Commit a6124a8b authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge remote-tracking branch 'ee-com/master' into ce-to-ee

Signed-off-by: default avatarDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>
parents 590b6365 e530453d
......@@ -124,7 +124,7 @@ import ApproversSelect from './approvers_select';
shortcut_handler = new ShortcutsNavigation();
new UsersSelect();
break;
case 'projects:builds:show':
case 'projects:jobs:show':
new Build();
break;
case 'projects:merge_requests:index':
......
......@@ -194,6 +194,7 @@ window.DropzoneInput = (function() {
$(child).val(beforeSelection + formattedText + afterSelection);
textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
textarea.style.height = `${textarea.scrollHeight}px`;
formTextarea.get(0).dispatchEvent(new Event('input'));
return formTextarea.trigger('input');
};
......
<script>
/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll';
import eventHub from '../event_hub';
import Service from '../services/index';
import Store from '../stores';
import titleComponent from './title.vue';
import descriptionComponent from './description.vue';
import formComponent from './form.vue';
import '../../lib/utils/url_utility';
export default {
props: {
......@@ -12,15 +16,27 @@ export default {
required: true,
type: String,
},
canMove: {
required: true,
type: Boolean,
},
canUpdate: {
required: true,
type: Boolean,
},
canDestroy: {
required: true,
type: Boolean,
},
issuableRef: {
type: String,
required: true,
},
initialTitle: {
initialTitleHtml: {
type: String,
required: true,
},
initialTitleText: {
type: String,
required: true,
},
......@@ -34,10 +50,40 @@ export default {
required: false,
default: '',
},
issuableTemplates: {
type: Array,
required: false,
default: () => [],
},
isConfidential: {
type: Boolean,
required: true,
},
markdownPreviewUrl: {
type: String,
required: true,
},
markdownDocs: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
projectNamespace: {
type: String,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
data() {
const store = new Store({
titleHtml: this.initialTitle,
titleHtml: this.initialTitleHtml,
titleText: this.initialTitleText,
descriptionHtml: this.initialDescriptionHtml,
descriptionText: this.initialDescriptionText,
});
......@@ -45,19 +91,97 @@ export default {
return {
store,
state: store.state,
showForm: false,
};
},
computed: {
formState() {
return this.store.formState;
},
},
components: {
descriptionComponent,
titleComponent,
formComponent,
},
methods: {
openForm() {
if (!this.showForm) {
this.showForm = true;
this.store.setFormState({
title: this.state.titleText,
confidential: this.isConfidential,
description: this.state.descriptionText,
lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false,
});
}
},
closeForm() {
this.showForm = false;
},
updateIssuable() {
const canPostUpdate = this.store.formState.move_to_project_id !== 0 ?
confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert
if (!canPostUpdate) {
this.store.setFormState({
updateLoading: false,
});
return;
}
this.service.updateIssuable(this.store.formState)
.then(res => res.json())
.then((data) => {
if (location.pathname !== data.web_url) {
gl.utils.visitUrl(data.web_url);
} else if (data.confidential !== this.isConfidential) {
gl.utils.visitUrl(location.pathname);
}
return this.service.getData();
})
.then(res => res.json())
.then((data) => {
this.store.updateState(data);
eventHub.$emit('close.form');
})
.catch(() => {
eventHub.$emit('close.form');
return new Flash('Error updating issue');
});
},
deleteIssuable() {
this.service.deleteIssuable()
.then(res => res.json())
.then((data) => {
// Stop the poll so we don't get 404's with the issue not existing
this.poll.stop();
gl.utils.visitUrl(data.web_url);
})
.catch(() => {
eventHub.$emit('close.form');
return new Flash('Error deleting issue');
});
},
},
created() {
const resource = new Service(this.endpoint);
const poll = new Poll({
resource,
this.service = new Service(this.endpoint);
this.poll = new Poll({
resource: this.service,
method: 'getData',
successCallback: (res) => {
this.store.updateState(res.json());
const data = res.json();
const shouldUpdate = this.store.stateShouldUpdate(data);
this.store.updateState(data);
if (this.showForm && (shouldUpdate.title || shouldUpdate.description)) {
this.store.formState.lockedWarningVisible = true;
}
},
errorCallback(err) {
throw new Error(err);
......@@ -65,32 +189,57 @@ export default {
});
if (!Visibility.hidden()) {
poll.makeRequest();
this.poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
poll.restart();
this.poll.restart();
} else {
poll.stop();
this.poll.stop();
}
});
eventHub.$on('delete.issuable', this.deleteIssuable);
eventHub.$on('update.issuable', this.updateIssuable);
eventHub.$on('close.form', this.closeForm);
eventHub.$on('open.form', this.openForm);
},
beforeDestroy() {
eventHub.$off('delete.issuable', this.deleteIssuable);
eventHub.$off('update.issuable', this.updateIssuable);
eventHub.$off('close.form', this.closeForm);
eventHub.$off('open.form', this.openForm);
},
};
</script>
<template>
<div>
<title-component
:issuable-ref="issuableRef"
:title-html="state.titleHtml"
:title-text="state.titleText" />
<description-component
v-if="state.descriptionHtml"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus" />
<form-component
v-if="canUpdate && showForm"
:form-state="formState"
:can-move="canMove"
:can-destroy="canDestroy"
:issuable-templates="issuableTemplates"
:markdown-docs="markdownDocs"
:markdown-preview-url="markdownPreviewUrl"
:project-path="projectPath"
:project-namespace="projectNamespace"
:projects-autocomplete-url="projectsAutocompleteUrl"
/>
<div v-else>
<title-component
:issuable-ref="issuableRef"
:title-html="state.titleHtml"
:title-text="state.titleText" />
<description-component
v-if="state.descriptionHtml"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus" />
</div>
</div>
</template>
......@@ -18,11 +18,13 @@
},
updatedAt: {
type: String,
required: true,
required: false,
default: '',
},
taskStatus: {
type: String,
required: true,
required: false,
default: '',
},
},
data() {
......@@ -83,6 +85,7 @@
<template>
<div
v-if="descriptionHtml"
class="description"
:class="{
'js-task-list-container': canUpdate
......
<script>
import updateMixin from '../mixins/update';
import eventHub from '../event_hub';
export default {
mixins: [updateMixin],
props: {
canDestroy: {
type: Boolean,
required: true,
},
formState: {
type: Object,
required: true,
},
},
data() {
return {
deleteLoading: false,
};
},
computed: {
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
},
methods: {
closeForm() {
eventHub.$emit('close.form');
},
deleteIssuable() {
// eslint-disable-next-line no-alert
if (confirm('Issue will be removed! Are you sure?')) {
this.deleteLoading = true;
eventHub.$emit('delete.issuable');
}
},
},
};
</script>
<template>
<div class="prepend-top-default append-bottom-default clearfix">
<button
class="btn btn-save pull-left"
:class="{ disabled: formState.updateLoading || !isSubmitEnabled }"
type="submit"
:disabled="formState.updateLoading || !isSubmitEnabled"
@click.prevent="updateIssuable">
Save changes
<i
class="fa fa-spinner fa-spin"
aria-hidden="true"
v-if="formState.updateLoading">
</i>
</button>
<button
class="btn btn-default pull-right"
type="button"
@click="closeForm">
Cancel
</button>
<button
v-if="canDestroy"
class="btn btn-danger pull-right append-right-default"
:class="{ disabled: deleteLoading }"
type="button"
:disabled="deleteLoading"
@click="deleteIssuable">
Delete
<i
class="fa fa-spinner fa-spin"
aria-hidden="true"
v-if="deleteLoading">
</i>
</button>
</div>
</template>
<script>
export default {
props: {
formState: {
type: Object,
required: true,
},
},
};
</script>
<template>
<fieldset class="checkbox">
<label for="issue-confidential">
<input
type="checkbox"
value="1"
id="issue-confidential"
v-model="formState.confidential" />
This issue is confidential and should only be visible to team members with at least Reporter access.
</label>
</fieldset>
</template>
<script>
/* global Flash */
import updateMixin from '../../mixins/update';
import markdownField from '../../../vue_shared/components/markdown/field.vue';
export default {
mixins: [updateMixin],
props: {
formState: {
type: Object,
required: true,
},
markdownPreviewUrl: {
type: String,
required: true,
},
markdownDocs: {
type: String,
required: true,
},
},
components: {
markdownField,
},
mounted() {
this.$refs.textarea.focus();
},
};
</script>
<template>
<div class="common-note-form">
<label
class="sr-only"
for="issue-description">
Description
</label>
<markdown-field
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs">
<textarea
id="issue-description"
class="note-textarea js-gfm-input js-autosize markdown-area"
data-supports-slash-commands="false"
aria-label="Description"
v-model="formState.description"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
@keydown.meta.enter="updateIssuable">
</textarea>
</markdown-field>
</div>
</template>
<script>
export default {
props: {
formState: {
type: Object,
required: true,
},
issuableTemplates: {
type: Array,
required: false,
default: () => [],
},
projectPath: {
type: String,
required: true,
},
projectNamespace: {
type: String,
required: true,
},
},
computed: {
issuableTemplatesJson() {
return JSON.stringify(this.issuableTemplates);
},
},
mounted() {
// Create the editor for the template
const editor = document.querySelector('.detail-page-description .note-textarea') || {};
editor.setValue = (val) => {
this.formState.description = val;
};
editor.getValue = () => this.formState.description;
this.issuableTemplate = new gl.IssuableTemplateSelectors({
$dropdowns: $(this.$refs.toggle),
editor,
});
},
};
</script>
<template>
<div
class="dropdown js-issuable-selector-wrap"
data-issuable-type="issue">
<button
class="dropdown-menu-toggle js-issuable-selector"
type="button"
ref="toggle"
data-field-name="issuable_template"
data-selected="null"
data-toggle="dropdown"
:data-namespace-path="projectNamespace"
:data-project-path="projectPath"
:data-data="issuableTemplatesJson">
<span class="dropdown-toggle-text">
Choose a template
</span>
<i
aria-hidden="true"
class="fa fa-chevron-down">
</i>
</button>
<div class="dropdown-menu dropdown-select">
<div class="dropdown-title">
Choose a template
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button">
<i
aria-hidden="true"
class="fa fa-times dropdown-menu-close-icon">
</i>
</button>
</div>
<div class="dropdown-input">
<input
type="search"
class="dropdown-input-field"
placeholder="Filter"
autocomplete="off" />
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search">
</i>
<i
role="button"
aria-label="Clear templates search input"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear">
</i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-footer">
<ul class="dropdown-footer-list">
<li>
<a class="no-template">
No template
</a>
</li>
<li>
<a class="reset-template">
Reset template
</a>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
export default {
mixins: [
tooltipMixin,
],
props: {
formState: {
type: Object,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
mounted() {
const $moveDropdown = $(this.$refs['move-dropdown']);
$moveDropdown.select2({
ajax: {
url: this.projectsAutocompleteUrl,
quietMillis: 125,
data(term, page, context) {
return {
search: term,
offset_id: context,
};
},
results(data) {
const more = data.length >= 50;
const context = data[data.length - 1] ? data[data.length - 1].id : null;
return {
results: data,
more,
context,
};
},
},
formatResult(project) {
return project.name_with_namespace;
},
formatSelection(project) {
return project.name_with_namespace;
},
})
.on('change', (e) => {
this.formState.move_to_project_id = parseInt(e.target.value, 10);
});
},
beforeDestroy() {
$(this.$refs['move-dropdown']).select2('destroy');
},
};
</script>
<template>
<fieldset>
<label
for="issuable-move"
class="sr-only">
Move
</label>
<div class="issuable-form-select-holder append-right-5">
<input
ref="move-dropdown"
type="hidden"
id="issuable-move"
data-placeholder="Move to a different project" />
</div>
<span
data-placement="auto top"
title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location."
ref="tooltip">
<i
class="fa fa-question-circle"
aria-hidden="true">
</i>
</span>
</fieldset>
</template>
<script>
import updateMixin from '../../mixins/update';
export default {
mixins: [updateMixin],
props: {
formState: {
type: Object,
required: true,
},
},
};
</script>
<template>
<fieldset>
<label
class="sr-only"
for="issue-title">
Title
</label>
<input
id="issue-title"
class="form-control"
type="text"
placeholder="Issue title"
aria-label="Issue title"
v-model="formState.title"
@keydown.meta.enter="updateIssuable" />
</fieldset>
</template>
<script>
import lockedWarning from './locked_warning.vue';
import titleField from './fields/title.vue';
import descriptionField from './fields/description.vue';
import editActions from './edit_actions.vue';
import descriptionTemplate from './fields/description_template.vue';
import projectMove from './fields/project_move.vue';
import confidentialCheckbox from './fields/confidential_checkbox.vue';
export default {
props: {
canMove: {
type: Boolean,
required: true,
},
canDestroy: {
type: Boolean,
required: true,
},
formState: {
type: Object,
required: true,
},
issuableTemplates: {
type: Array,
required: false,
default: () => [],
},
markdownPreviewUrl: {
type: String,
required: true,
},
markdownDocs: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
projectNamespace: {
type: String,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
components: {
lockedWarning,
titleField,
descriptionField,
descriptionTemplate,
editActions,
projectMove,
confidentialCheckbox,
},
computed: {
hasIssuableTemplates() {
return this.issuableTemplates.length;
},
},
};
</script>
<template>
<form>
<locked-warning v-if="formState.lockedWarningVisible" />
<div class="row">
<div
class="col-sm-4 col-lg-3"
v-if="hasIssuableTemplates">
<description-template
:form-state="formState"
:issuable-templates="issuableTemplates"
:project-path="projectPath"
:project-namespace="projectNamespace" />
</div>
<div
:class="{
'col-sm-8 col-lg-9': hasIssuableTemplates,
'col-xs-12': !hasIssuableTemplates,
}">
<title-field
:form-state="formState"
:issuable-templates="issuableTemplates" />
</div>
</div>
<description-field
:form-state="formState"
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs" />
<confidential-checkbox
:form-state="formState" />
<project-move
v-if="canMove"
:form-state="formState"
:projects-autocomplete-url="projectsAutocompleteUrl" />
<edit-actions
:form-state="formState"
:can-destroy="canDestroy" />
</form>
</template>
<script>
export default {
computed: {
currentPath() {
return location.pathname;
},
},
};
</script>
<template>
<div class="alert alert-danger">
Someone edited the issue at the same time you did. Please check out
<a
:href="currentPath"
target="_blank"
rel="nofollow">the issue</a>
and make sure your changes will not unintentionally remove theirs.
</div>
</template>
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import eventHub from './event_hub';
import issuableApp from './components/app.vue';
import '../vue_shared/vue_resource_interceptor';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: document.getElementById('js-issuable-app'),
components: {
issuableApp,
},
data() {
const issuableElement = this.$options.el;
const issuableTitleElement = issuableElement.querySelector('.title');
const issuableDescriptionElement = issuableElement.querySelector('.wiki');
const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field');
const {
canUpdate,
endpoint,
issuableRef,
} = issuableElement.dataset;
document.addEventListener('DOMContentLoaded', () => {
const initialDataEl = document.getElementById('js-issuable-app-initial-data');
const initialData = JSON.parse(initialDataEl.innerHTML.replace(/&quot;/g, '"'));
return {
canUpdate: gl.utils.convertPermissionToBoolean(canUpdate),
endpoint,
issuableRef,
initialTitle: issuableTitleElement.innerHTML,
initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '',
initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '',
};
},
render(createElement) {
return createElement('issuable-app', {
props: {
canUpdate: this.canUpdate,
endpoint: this.endpoint,
issuableRef: this.issuableRef,
initialTitle: this.initialTitle,
initialDescriptionHtml: this.initialDescriptionHtml,
initialDescriptionText: this.initialDescriptionText,
},
});
},
}));
$('.issuable-edit').on('click', (e) => {
e.preventDefault();
eventHub.$emit('open.form');
});
return new Vue({
el: document.getElementById('js-issuable-app'),
components: {
issuableApp,
},
data() {
return {
...initialData,
};
},
render(createElement) {
return createElement('issuable-app', {
props: {
canUpdate: this.canUpdate,
canDestroy: this.canDestroy,
canMove: this.canMove,
endpoint: this.endpoint,
issuableRef: this.issuableRef,
initialTitleHtml: this.initialTitleHtml,
initialTitleText: this.initialTitleText,
initialDescriptionHtml: this.initialDescriptionHtml,
initialDescriptionText: this.initialDescriptionText,
issuableTemplates: this.issuableTemplates,
isConfidential: this.isConfidential,
markdownPreviewUrl: this.markdownPreviewUrl,
markdownDocs: this.markdownDocs,
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
projectsAutocompleteUrl: this.projectsAutocompleteUrl,
},
});
},
});
});
......@@ -4,7 +4,7 @@ export default {
this.preAnimation = true;
this.pulseAnimation = false;
this.$nextTick(() => {
setTimeout(() => {
this.preAnimation = false;
this.pulseAnimation = true;
});
......
import eventHub from '../event_hub';
export default {
methods: {
updateIssuable() {
this.formState.updateLoading = true;
eventHub.$emit('update.issuable');
},
},
};
......@@ -7,10 +7,23 @@ export default class Service {
constructor(endpoint) {
this.endpoint = endpoint;
this.resource = Vue.resource(this.endpoint);
this.resource = Vue.resource(`${this.endpoint}.json`, {}, {
realtimeChanges: {
method: 'GET',
url: `${this.endpoint}/realtime_changes`,
},
});
}
getData() {
return this.resource.get();
return this.resource.realtimeChanges();
}
deleteIssuable() {
return this.resource.delete();
}
updateIssuable(data) {
return this.resource.update(data);
}
}
export default class Store {
constructor({
titleHtml,
titleText,
descriptionHtml,
descriptionText,
}) {
this.state = {
titleHtml,
titleText: '',
titleText,
descriptionHtml,
descriptionText,
taskStatus: '',
updatedAt: '',
};
this.formState = {
title: '',
confidential: false,
description: '',
lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false,
};
}
updateState(data) {
......@@ -22,4 +31,15 @@ export default class Store {
this.state.taskStatus = data.task_status;
this.state.updatedAt = data.updated_at;
}
stateShouldUpdate(data) {
return {
title: this.state.titleText !== data.title_text,
description: this.state.descriptionText !== data.description_text,
};
}
setFormState(state) {
this.formState = Object.assign(this.formState, state);
}
}
......@@ -170,7 +170,7 @@ gl.text.init = function(form) {
});
};
gl.text.removeListeners = function(form) {
return $('.js-md', form).off();
return $('.js-md', form).off('click');
};
gl.text.humanize = function(string) {
return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
......
......@@ -77,7 +77,9 @@ import './shortcuts_navigation';
ShortcutsIssuable.prototype.editIssue = function() {
var $editBtn;
$editBtn = $('.issuable-edit');
return gl.utils.visitUrl($editBtn.attr('href'));
// Need to click the element as on issues, editing is inline
// on merge request, editing is on a different page
$editBtn.get(0).click();
};
ShortcutsIssuable.prototype.openSidebarDropdown = function(name) {
......
<script>
/* global Flash */
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
export default {
props: {
markdownPreviewUrl: {
type: String,
required: false,
default: '',
},
markdownDocs: {
type: String,
required: true,
},
},
data() {
return {
markdownPreview: '',
markdownPreviewLoading: false,
previewMarkdown: false,
};
},
components: {
markdownHeader,
markdownToolbar,
},
methods: {
toggleMarkdownPreview() {
this.previewMarkdown = !this.previewMarkdown;
if (!this.previewMarkdown) {
this.markdownPreview = '';
} else {
this.markdownPreviewLoading = true;
this.$http.post(
this.markdownPreviewUrl,
{
/*
Can't use `$refs` as the component is technically in the parent component
so we access the VNode & then get the element
*/
text: this.$slots.textarea[0].elm.value,
},
)
.then((res) => {
const data = res.json();
this.markdownPreviewLoading = false;
this.markdownPreview = data.body;
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
})
.catch(() => new Flash('Error loading markdown preview'));
}
},
},
mounted() {
/*
GLForm class handles all the toolbar buttons
*/
return new gl.GLForm($(this.$refs['gl-form']), true);
},
};
</script>
<template>
<div
class="md-area prepend-top-default append-bottom-default js-vue-markdown-field"
ref="gl-form">
<markdown-header
:preview-markdown="previewMarkdown"
@toggle-markdown="toggleMarkdownPreview" />
<div
class="md-write-holder"
v-show="!previewMarkdown">
<div class="zen-backdrop">
<slot name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave"
href="#"
aria-label="Enter zen mode">
<i
class="fa fa-compress"
aria-hidden="true">
</i>
</a>
<markdown-toolbar
:markdown-docs="markdownDocs" />
</div>
</div>
<div
class="md md-preview-holder md-preview"
v-show="previewMarkdown">
<div
ref="markdown-preview"
v-html="markdownPreview">
</div>
<span v-if="markdownPreviewLoading">
Loading...
</span>
</div>
</div>
</template>
<script>
import tooltipMixin from '../../mixins/tooltip';
import toolbarButton from './toolbar_button.vue';
export default {
mixins: [
tooltipMixin,
],
props: {
previewMarkdown: {
type: Boolean,
required: true,
},
},
components: {
toolbarButton,
},
methods: {
toggleMarkdownPreview(e, form) {
if (form && !form.find('.js-vue-markdown-field').length) {
return;
} else if (e.target.blur) {
e.target.blur();
}
this.$emit('toggle-markdown');
},
},
mounted() {
$(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
$(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview);
},
beforeDestroy() {
$(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
$(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview);
},
};
</script>
<template>
<div class="md-header">
<ul class="nav-links clearfix">
<li :class="{ active: !previewMarkdown }">
<a
href="#md-write-holder"
tabindex="-1"
@click.prevent="toggleMarkdownPreview($event)">
Write
</a>
</li>
<li :class="{ active: previewMarkdown }">
<a
href="#md-preview-holder"
tabindex="-1"
@click.prevent="toggleMarkdownPreview($event)">
Preview
</a>
</li>
<li class="pull-right">
<div class="toolbar-group">
<toolbar-button
tag="**"
button-title="Add bold text"
icon="bold" />
<toolbar-button
tag="*"
button-title="Add italic text"
icon="italic" />
<toolbar-button
tag="> "
:prepend="true"
button-title="Insert a quote"
icon="quote-right" />
<toolbar-button
tag="`"
tag-block="```"
button-title="Insert code"
icon="code" />
<toolbar-button
tag="* "
:prepend="true"
button-title="Add a bullet list"
icon="list-ul" />
<toolbar-button
tag="1. "
:prepend="true"
button-title="Add a numbered list"
icon="list-ol" />
<toolbar-button
tag="* [ ] "
:prepend="true"
button-title="Add a task list"
icon="check-square-o" />
</div>
<div class="toolbar-group">
<button
aria-label="Go full screen"
class="toolbar-btn js-zen-enter"
data-container="body"
tabindex="-1"
title="Go full screen"
type="button"
ref="tooltip">
<i
aria-hidden="true"
class="fa fa-arrows-alt fa-fw">
</i>
</button>
</div>
</li>
</ul>
</div>
</template>
<script>
export default {
props: {
markdownDocs: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<a
:href="markdownDocs"
target="_blank"
tabindex="-1">
Markdown is supported
</a>
</div>
<button
class="toolbar-button markdown-selector"
type="button"
tabindex="-1">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true">
</i>
Attach a file
</button>
</div>
</template>
<script>
import tooltipMixin from '../../mixins/tooltip';
export default {
mixins: [
tooltipMixin,
],
props: {
buttonTitle: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
tag: {
type: String,
required: true,
},
tagBlock: {
type: String,
required: false,
default: '',
},
prepend: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
iconClass() {
return `fa-${this.icon}`;
},
},
};
</script>
<template>
<button
type="button"
class="toolbar-btn js-md hidden-xs"
tabindex="-1"
ref="tooltip"
data-container="body"
:data-md-tag="tag"
:data-md-block="tagBlock"
:data-md-prepend="prepend"
:title="buttonTitle"
:aria-label="buttonTitle">
<i
aria-hidden="true"
class="fa fa-fw"
:class="iconClass">
</i>
</button>
</template>
......@@ -6,4 +6,8 @@ export default {
updated() {
$(this.$refs.tooltip).tooltip('fixTitle');
},
beforeDestroy() {
$(this.$refs.tooltip).tooltip('destroy');
},
};
class Admin::BuildsController < Admin::ApplicationController
class Admin::JobsController < Admin::ApplicationController
def index
@scope = params[:scope]
@all_builds = Ci::Build
......@@ -20,6 +20,6 @@ class Admin::BuildsController < Admin::ApplicationController
def cancel_all
Ci::Build.running_or_pending.each(&:cancel)
redirect_to admin_builds_path
redirect_to admin_jobs_path
end
end
......@@ -14,7 +14,16 @@ module IssuableActions
name = issuable.human_class_name
flash[:notice] = "The #{name} was successfully deleted."
redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
index_path = polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
respond_to do |format|
format.html { redirect_to index_path }
format.json do
render json: {
web_url: index_path
}
end
end
end
def bulk_update
......
......@@ -46,7 +46,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
def keep
build.keep_artifacts!
redirect_to namespace_project_build_path(project.namespace, project, build)
redirect_to namespace_project_job_path(project.namespace, project, build)
end
def latest_succeeded
......@@ -79,7 +79,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
def build_from_id
project.builds.find_by(id: params[:build_id]) if params[:build_id]
project.builds.find_by(id: params[:job_id]) if params[:job_id]
end
def build_from_ref
......
class Projects::BuildArtifactsController < Projects::ApplicationController
include ExtractsPath
include RendersBlob
before_action :authorize_read_build!
before_action :extract_ref_name_and_path
before_action :validate_artifacts!
def download
redirect_to download_namespace_project_job_artifacts_path(project.namespace, project, job)
end
def browse
redirect_to browse_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
end
def file
redirect_to file_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
end
def raw
redirect_to raw_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
end
def latest_succeeded
redirect_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, job, ref_name_and_path: params[:ref_name_and_path], job: params[:job])
end
private
def validate_artifacts!
render_404 unless job && job.artifacts?
end
def extract_ref_name_and_path
return unless params[:ref_name_and_path]
@ref_name, @path = extract_ref(params[:ref_name_and_path])
end
def job
@job ||= job_from_id || job_from_ref
end
def job_from_id
project.builds.find_by(id: params[:build_id]) if params[:build_id]
end
def job_from_ref
return unless @ref_name
jobs = project.latest_successful_builds_for(@ref_name)
jobs.find_by(name: params[:job])
end
end
class Projects::BuildsController < Projects::ApplicationController
before_action :build, except: [:index, :cancel_all]
before_action :authorize_read_build!,
only: [:index, :show, :status, :raw, :trace]
before_action :authorize_update_build!,
except: [:index, :show, :status, :raw, :trace, :cancel_all]
layout 'project'
before_action :authorize_read_build!
def index
@scope = params[:scope]
@all_builds = project.builds.relevant
@builds = @all_builds.order('created_at DESC')
@builds =
case @scope
when 'pending'
@builds.pending.reverse_order
when 'running'
@builds.running.reverse_order
when 'finished'
@builds.finished
else
@builds
end
@builds = @builds.includes([
{ pipeline: :project },
:project,
:tags
])
@builds = @builds.page(params[:page]).per(30)
end
def cancel_all
return access_denied! unless can?(current_user, :update_build, project)
@project.builds.running_or_pending.each do |build|
build.cancel if can?(current_user, :update_build, build)
end
redirect_to namespace_project_builds_path(project.namespace, project)
redirect_to namespace_project_jobs_path(project.namespace, project)
end
def show
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline
end
def trace
build.trace.read do |stream|
respond_to do |format|
format.json do
result = {
id: @build.id, status: @build.status, complete: @build.complete?
}
if stream.valid?
stream.limit
state = params[:state].presence
trace = stream.html_with_state(state)
result.merge!(trace.to_h)
end
render json: result
end
end
end
end
def retry
return respond_422 unless @build.retryable?
build = Ci::Build.retry(@build, current_user)
redirect_to build_path(build)
end
def play
return respond_422 unless @build.playable?
build = @build.play(current_user)
redirect_to build_path(build)
end
def cancel
return respond_422 unless @build.cancelable?
@build.cancel
redirect_to build_path(@build)
end
def status
render json: BuildSerializer
.new(project: @project, current_user: @current_user)
.represent_status(@build)
end
def erase
if @build.erase(erased_by: current_user)
redirect_to namespace_project_build_path(project.namespace, project, @build),
notice: "Build has been successfully erased!"
else
respond_422
end
redirect_to namespace_project_job_path(project.namespace, project, job)
end
def raw
build.trace.read do |stream|
if stream.file?
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
render_404
end
end
redirect_to raw_namespace_project_job_path(project.namespace, project, job)
end
private
def authorize_update_build!
return access_denied! unless can?(current_user, :update_build, build)
end
def build
@build ||= project.builds.find(params[:id])
.present(current_user: current_user)
end
def build_path(build)
namespace_project_build_path(build.project.namespace, build.project, build)
def job
@job ||= project.builds.find(params[:id])
end
end
......@@ -149,10 +149,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do
if @issue.valid?
render json: @issue.to_json(methods: [:task_status, :task_status_short],
include: { milestone: {},
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
labels: { methods: :text_color } })
render json: IssueSerializer.new.represent(@issue)
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end
......
class Projects::JobsController < Projects::ApplicationController
before_action :build, except: [:index, :cancel_all]
before_action :authorize_read_build!,
only: [:index, :show, :status, :raw, :trace]
before_action :authorize_update_build!,
except: [:index, :show, :status, :raw, :trace, :cancel_all]
layout 'project'
def index
@scope = params[:scope]
@all_builds = project.builds.relevant
@builds = @all_builds.order('created_at DESC')
@builds =
case @scope
when 'pending'
@builds.pending.reverse_order
when 'running'
@builds.running.reverse_order
when 'finished'
@builds.finished
else
@builds
end
@builds = @builds.includes([
{ pipeline: :project },
:project,
:tags
])
@builds = @builds.page(params[:page]).per(30)
end
def cancel_all
return access_denied! unless can?(current_user, :update_build, project)
@project.builds.running_or_pending.each do |build|
build.cancel if can?(current_user, :update_build, build)
end
redirect_to namespace_project_jobs_path(project.namespace, project)
end
def show
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline
end
def trace
build.trace.read do |stream|
respond_to do |format|
format.json do
result = {
id: @build.id, status: @build.status, complete: @build.complete?
}
if stream.valid?
stream.limit
state = params[:state].presence
trace = stream.html_with_state(state)
result.merge!(trace.to_h)
end
render json: result
end
end
end
end
def retry
return respond_422 unless @build.retryable?
build = Ci::Build.retry(@build, current_user)
redirect_to build_path(build)
end
def play
return respond_422 unless @build.playable?
build = @build.play(current_user)
redirect_to build_path(build)
end
def cancel
return respond_422 unless @build.cancelable?
@build.cancel
redirect_to build_path(@build)
end
def status
render json: BuildSerializer
.new(project: @project, current_user: @current_user)
.represent_status(@build)
end
def erase
if @build.erase(erased_by: current_user)
redirect_to namespace_project_job_path(project.namespace, project, @build),
notice: "Build has been successfully erased!"
else
respond_422
end
end
def raw
build.trace.read do |stream|
if stream.file?
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
render_404
end
end
end
private
def authorize_update_build!
return access_denied! unless can?(current_user, :update_build, build)
end
def build
@build ||= project.builds.find(params[:id])
.present(current_user: current_user)
end
def build_path(build)
namespace_project_job_path(build.project.namespace, build.project, build)
end
end
......@@ -120,7 +120,7 @@ module BlobHelper
def blob_raw_url
if @build && @entry
raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path)
raw_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: @entry.path)
elsif @snippet
if @snippet.project_id
raw_namespace_project_snippet_path(@project.namespace, @project, @snippet)
......
......@@ -2,7 +2,7 @@ module BuildsHelper
def build_summary(build, skip: false)
if build.has_trace?
if skip
link_to "View job trace", pipeline_build_url(build.pipeline, build)
link_to "View job trace", pipeline_job_url(build.pipeline, build)
else
build.trace.html(last_lines: 10).html_safe
end
......@@ -20,8 +20,8 @@ module BuildsHelper
def javascript_build_options
{
page_url: namespace_project_build_url(@project.namespace, @project, @build),
build_url: namespace_project_build_url(@project.namespace, @project, @build, :json),
page_url: namespace_project_job_url(@project.namespace, @project, @build),
build_url: namespace_project_job_url(@project.namespace, @project, @build, :json),
build_status: @build.status,
build_stage: @build.stage,
log_state: ''
......@@ -31,7 +31,7 @@ module BuildsHelper
def build_failed_issue_options
{
title: "Build Failed ##{@build.id}",
description: namespace_project_build_url(@project.namespace, @project, @build)
description: namespace_project_job_url(@project.namespace, @project, @build)
}
end
end
......@@ -50,8 +50,8 @@ module GitlabRoutingHelper
namespace_project_cycle_analytics_path(project.namespace, project, *args)
end
def project_builds_path(project, *args)
namespace_project_builds_path(project.namespace, project, *args)
def project_jobs_path(project, *args)
namespace_project_jobs_path(project.namespace, project, *args)
end
def project_ref_path(project, ref_name, *args)
......@@ -110,8 +110,8 @@ module GitlabRoutingHelper
namespace_project_pipeline_url(pipeline.project.namespace, pipeline.project, pipeline.id, *args)
end
def pipeline_build_url(pipeline, build, *args)
namespace_project_build_url(pipeline.project.namespace, pipeline.project, build.id, *args)
def pipeline_job_url(pipeline, build, *args)
namespace_project_job_url(pipeline.project.namespace, pipeline.project, build.id, *args)
end
def commits_url(entity, *args)
......@@ -215,13 +215,13 @@ module GitlabRoutingHelper
case action
when 'download'
download_namespace_project_build_artifacts_path(*args)
download_namespace_project_job_artifacts_path(*args)
when 'browse'
browse_namespace_project_build_artifacts_path(*args)
browse_namespace_project_job_artifacts_path(*args)
when 'file'
file_namespace_project_build_artifacts_path(*args)
file_namespace_project_job_artifacts_path(*args)
when 'raw'
raw_namespace_project_build_artifacts_path(*args)
raw_namespace_project_job_artifacts_path(*args)
end
end
......
......@@ -210,6 +210,27 @@ module IssuablesHelper
issuable_filter_params.any? { |k| params.key?(k) }
end
def issuable_initial_data(issuable)
{
endpoint: namespace_project_issue_path(@project.namespace, @project, issuable),
canUpdate: can?(current_user, :update_issue, issuable),
canDestroy: can?(current_user, :destroy_issue, issuable),
canMove: current_user ? issuable.can_move?(current_user) : false,
issuableRef: issuable.to_reference,
isConfidential: issuable.confidential,
markdownPreviewUrl: preview_markdown_path(@project),
markdownDocs: help_page_path('user/markdown'),
projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id),
issuableTemplates: issuable_templates(issuable),
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path,
initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title,
initialDescriptionHtml: markdown_field(issuable, :description),
initialDescriptionText: issuable.description
}.to_json
end
private
def sidebar_gutter_collapsed?
......
......@@ -52,6 +52,12 @@ module Ci
after_destroy :update_project_statistics
class << self
# This is needed for url_for to work,
# as the controller is JobsController
def model_name
ActiveModel::Name.new(self, nil, 'job')
end
def first_pending
pending.unstarted.order('created_at ASC').first
end
......
......@@ -25,7 +25,7 @@ class AnalyticsBuildEntity < Grape::Entity
end
expose :url do |build|
url_to(:namespace_project_build, build)
url_to(:namespace_project_job, build)
end
expose :commit_url do |build|
......
......@@ -6,7 +6,7 @@ class BuildActionEntity < Grape::Entity
end
expose :path do |build|
play_namespace_project_build_path(
play_namespace_project_job_path(
build.project.namespace,
build.project,
build)
......
......@@ -6,7 +6,7 @@ class BuildArtifactEntity < Grape::Entity
end
expose :path do |build|
download_namespace_project_build_artifacts_path(
download_namespace_project_job_artifacts_path(
build.project.namespace,
build.project,
build)
......
......@@ -5,15 +5,15 @@ class BuildEntity < Grape::Entity
expose :name
expose :build_path do |build|
path_to(:namespace_project_build, build)
path_to(:namespace_project_job, build)
end
expose :retry_path do |build|
path_to(:retry_namespace_project_build, build)
path_to(:retry_namespace_project_job, build)
end
expose :play_path, if: -> (*) { playable? } do |build|
path_to(:play_namespace_project_build, build)
path_to(:play_namespace_project_job, build)
end
expose :playable?, as: :playable
......
class IssueEntity < IssuableEntity
include RequestAwareEntity
expose :branch_name
expose :confidential
expose :assignees, using: API::Entities::UserBasic
......@@ -8,4 +10,8 @@ class IssueEntity < IssuableEntity
expose :weight
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
expose :web_url do |issue|
namespace_project_issue_path(issue.project.namespace, issue.project, issue)
end
end
......@@ -20,7 +20,7 @@
%span
Groups
= nav_link path: 'builds#index' do
= link_to admin_builds_path, title: 'Jobs' do
= link_to admin_jobs_path, title: 'Jobs' do
%span
Jobs
= nav_link path: ['runners#index', 'runners#show'] do
......
......@@ -4,15 +4,15 @@
%div{ class: container_class }
.top-area
- build_path_proc = ->(scope) { admin_builds_path(scope: scope) }
- build_path_proc = ->(scope) { admin_jobs_path(scope: scope) }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
.nav-controls
- if @all_builds.running_or_pending.any?
= link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
= link_to 'Cancel all', cancel_all_admin_jobs_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
.row-content-block.second-block
#{(@scope || 'all').capitalize} jobs
%ul.content-list.builds-content-list.admin-builds-table
= render "projects/builds/table", builds: @builds, admin: true
= render "projects/jobs/table", builds: @builds, admin: true
......@@ -85,7 +85,7 @@
%tr.build
%td.id
- if project
= link_to namespace_project_build_path(project.namespace, project, build) do
= link_to namespace_project_job_path(project.namespace, project, build) do
%strong ##{build.id}
- else
%strong ##{build.id}
......
......@@ -92,7 +92,7 @@
-# Shortcut to Pipelines > Jobs
- if project_nav_tab? :builds
%li.hidden
= link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
= link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
Jobs
-# Shortcut to commits page
......
%a{ href: pipeline_build_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" }
%a{ href: pipeline_job_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" }
= build.name
Job #<%= build.id %> ( <%= pipeline_build_url(pipeline, build) %> )
Job #<%= build.id %> ( <%= pipeline_job_url(pipeline, build) %> )
- path_to_directory = browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: directory.path)
- path_to_directory = browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: directory.path)
%tr.tree-item{ 'data-link' => path_to_directory }
%td.tree-item-file-name
......
- path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path)
- path_to_file = file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: file.path)
%tr.tree-item{ 'data-link' => path_to_file }
- blob = file.blob
......
- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
= render "projects/pipelines/head"
= render "projects/builds/header", show_controls: false
= render "projects/jobs/header", show_controls: false
.tree-holder
.nav-block
.tree-controls
= link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build),
= link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build),
rel: 'nofollow', download: '', class: 'btn btn-default download' do
= icon('download')
Download artifacts archive
%ul.breadcrumb.repo-breadcrumb
%li
= link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build)
= link_to 'Artifacts', browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build)
- path_breadcrumbs do |title, path|
%li
= link_to truncate(title, length: 40), browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path)
= link_to truncate(title, length: 40), browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path)
.tree-content-holder
%table.table.tree-table
......
- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
= render "projects/pipelines/head"
= render "projects/builds/header", show_controls: false
= render "projects/jobs/header", show_controls: false
#tree-holder.tree-holder
.nav-block
%ul.breadcrumb.repo-breadcrumb
%li
= link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build)
= link_to 'Artifacts', browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build)
- path_breadcrumbs do |title, path|
- title = truncate(title, length: 40)
%li
- if path == @path
= link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do
= link_to file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) do
%strong= title
- else
= link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path)
= link_to title, browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path)
%article.file-holder
......
......@@ -14,7 +14,7 @@
%td.branch-commit
- if can?(current_user, :read_build, job)
= link_to namespace_project_build_url(job.project.namespace, job.project, job) do
= link_to namespace_project_job_url(job.project.namespace, job.project, job) do
%span.build-link ##{job.id}
- else
%span.build-link ##{job.id}
......@@ -95,16 +95,16 @@
%td
.pull-right
- if can?(current_user, :read_build, job) && job.artifacts?
= link_to download_namespace_project_build_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
= link_to download_namespace_project_job_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
= icon('download')
- if can?(current_user, :update_build, job)
- if job.active?
= link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
= link_to cancel_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
= icon('remove', class: 'cred')
- elsif allow_retry
- if job.playable? && !admin && can?(current_user, :update_build, job)
= link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
= link_to play_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
= custom_icon('icon_play')
- elsif job.retryable?
= link_to retry_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
= link_to retry_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
= icon('repeat')
......@@ -31,7 +31,7 @@
%ul
- if can_update_issue
%li
= link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue)
= link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'issuable-edit'
%li
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%li
......@@ -55,10 +55,8 @@
.issue-details.issuable-details
.detail-page-description.content-block
#js-issuable-app{ "data" => { "endpoint" => realtime_changes_namespace_project_issue_path(@project.namespace, @project, @issue),
"can-update" => can?(current_user, :update_issue, @issue).to_s,
"issuable-ref" => @issue.to_reference,
} }
%script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue)
#js-issuable-app
%h2.title= markdown_field(@issue, :title)
- if @issue.description.present?
.description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
......
......@@ -6,7 +6,7 @@
= render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title
%strong
Job
= link_to "##{@build.id}", namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id'
= link_to "##{@build.id}", namespace_project_job_path(@project.namespace, @project, @build), class: 'js-build-id'
in pipeline
%strong
= link_to "##{pipeline.id}", pipeline_path(pipeline)
......@@ -17,7 +17,7 @@
%strong
= link_to @build.ref, project_ref_path(@project, @build.ref), class: 'ref-name'
= render "projects/builds/user" if @build.user
= render "projects/jobs/user" if @build.user
= time_ago_with_tooltip(@build.created_at)
......@@ -26,6 +26,6 @@
- if can?(current_user, :create_issue, @project) && @build.failed?
= link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted'
- if can?(current_user, :update_build, @build) && @build.retryable?
= link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post
= link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post
%button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" }
= icon('angle-double-left')
......@@ -30,21 +30,21 @@
- if @build.artifacts?
.btn-group.btn-group-justified{ role: :group }
- if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build)
= link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
= link_to keep_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
Keep
= link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do
= link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do
Download
- if @build.artifacts_metadata?
= link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
= link_to browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
Browse
.block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) }
.title
Job details
- if can?(current_user, :update_build, @build) && @build.retryable?
= link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post
= link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post
- if @build.merge_request
%p.build-detail-row
%span.build-light-text Merge Request:
......@@ -69,11 +69,11 @@
\##{@build.runner.id}
.btn-group.btn-group-justified{ role: :group }
- if @build.has_trace?
= link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
= link_to 'Raw', raw_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
- if @build.active?
= link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
= link_to "Cancel", cancel_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
- if can?(current_user, :update_build, @project) && @build.erasable?
= link_to erase_namespace_project_build_path(@project.namespace, @project, @build),
= link_to erase_namespace_project_job_path(@project.namespace, @project, @build),
class: "btn btn-sm btn-default", method: :post,
data: { confirm: "Are you sure you want to erase this build?" } do
Erase
......@@ -126,7 +126,7 @@
- HasStatus::ORDERED_STATUSES.each do |build_status|
- builds.select{|build| build.status == build_status}.each do |build|
.build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } }
= link_to namespace_project_build_path(@project.namespace, @project, build) do
= link_to namespace_project_job_path(@project.namespace, @project, build) do
= icon('arrow-right')
%span{ class: "ci-status-icon-#{build.status}" }
= ci_icon_for_status(build.status)
......
......@@ -4,13 +4,13 @@
%div{ class: container_class }
.top-area
- build_path_proc = ->(scope) { project_builds_path(@project, scope: scope) }
- build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
.nav-controls
- if can?(current_user, :update_build, @project)
- if @all_builds.running_or_pending.any?
= link_to 'Cancel running', cancel_all_namespace_project_builds_path(@project.namespace, @project),
= link_to 'Cancel running', cancel_all_namespace_project_jobs_path(@project.namespace, @project),
data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
- unless @repository.gitlab_ci_yml
......
......@@ -77,7 +77,7 @@
Showing last
%span.js-truncated-info-size.truncated-info-size><
KiB of log -
%a.js-raw-link.raw-link{ :href => raw_namespace_project_build_path(@project.namespace, @project, @build) }>< Complete Raw
%a.js-raw-link.raw-link{ :href => raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw
%code.bash.js-build-output
.build-loader-animation.js-build-refresh
......
......@@ -11,7 +11,7 @@
- if project_nav_tab? :builds
= nav_link(controller: [:builds, :artifacts]) do
= link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
= link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
%span
Jobs
......
......@@ -51,5 +51,5 @@
%span.stage
= build.stage.titleize
%span.build-name
= link_to build.name, pipeline_build_url(pipeline, build)
= link_to build.name, pipeline_job_url(pipeline, build)
%pre.build-log= build_summary(build, skip: index >= 10)
---
title: Enables inline editing for an issues title & description
merge_request:
author:
---
title: Change /builds in the URL to /-/jobs. Backward URLs were also added
merge_request: 11407
author:
......@@ -4,7 +4,7 @@
# - [project.namespace, project, build]
#
# instead of:
# - namespace_project_build_path(project.namespace, project, build)
# - namespace_project_job_path(project.namespace, project, build)
#
# Without that, Ci:: namespace is used for resolving routes:
# - namespace_project_ci_build_path(project.namespace, project, build)
......
......@@ -142,7 +142,7 @@ namespace :admin do
resources :cohorts, only: :index
resources :builds, only: :index do
resources :jobs, only: :index do
collection do
post :cancel_all
end
......
require 'constraints/project_url_constrainer'
require 'gitlab/routes/legacy_builds'
resources :projects, only: [:index, :new, :create]
......@@ -215,38 +216,42 @@ constraints(ProjectUrlConstrainer.new) do
end
end
resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do
collection do
post :cancel_all
resources :artifacts, only: [] do
collection do
get :latest_succeeded,
path: '*ref_name_and_path',
format: false
scope '-' do
resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do
collection do
post :cancel_all
resources :artifacts, only: [] do
collection do
get :latest_succeeded,
path: '*ref_name_and_path',
format: false
end
end
end
end
member do
get :status
post :cancel
post :retry
post :play
post :erase
get :trace, defaults: { format: 'json' }
get :raw
end
member do
get :status
post :cancel
post :retry
post :play
post :erase
get :trace, defaults: { format: 'json' }
get :raw
end
resource :artifacts, only: [] do
get :download
get :browse, path: 'browse(/*path)', format: false
get :file, path: 'file/*path', format: false
get :raw, path: 'raw/*path', format: false
post :keep
resource :artifacts, only: [] do
get :download
get :browse, path: 'browse(/*path)', format: false
get :file, path: 'file/*path', format: false
get :raw, path: 'raw/*path', format: false
post :keep
end
end
end
Gitlab::Routes::LegacyBuilds.new(self).draw
resources :hooks, only: [:index, :create, :edit, :update, :destroy], constraints: { id: /\d+/ } do
member do
get :test
......
......@@ -233,10 +233,30 @@ The two most obvious issues that replication can have here are:
### Step 5. Replicating the repositories data
Getting a new secondary Geo node up and running, will also require the
repositories directory to be synced from the primary node.
repositories data to be synced.
With GitLab **8.14** you can start the syncing process by clicking the
"Backfill all repositories" button on `Admin > Geo Nodes` screen.
With GitLab **9.0** the syncing process starts automatically from the
secondary node after the **Add Node** button is pressed.
Currently, this is what is synced:
* Git repositories
* Wikis
* LFS objects
* Issue, merge request, and comment attachments
* User, group, and project avatars
You can monitor the status of the syncing process on a secondary node
by visiting the primary node's **Admin Area ➔ Geo Nodes** (`/admin/geo_nodes`)
in your browser.
![GitLab Geo dashboard](img/geo-node-dashboard.png)
Disabling a secondary node stops the syncing process.
With GitLab **8.14** this process is started manually from the primary node.
You can start the syncing process by clicking the "Backfill all repositories"
button on `Admin > Geo Nodes` screen.
On previous versions, you can use `rsync` for that:
......
......@@ -27,11 +27,11 @@ module SharedBuilds
end
step 'I visit recent build details page' do
visit namespace_project_build_path(@project.namespace, @project, @build)
visit namespace_project_job_path(@project.namespace, @project, @build)
end
step 'I visit project builds page' do
visit namespace_project_builds_path(@project.namespace, @project)
visit namespace_project_jobs_path(@project.namespace, @project)
end
step 'recent build has artifacts available' do
......@@ -56,7 +56,7 @@ module SharedBuilds
end
step 'I access artifacts download page' do
visit download_namespace_project_build_artifacts_path(@project.namespace, @project, @build)
visit download_namespace_project_job_artifacts_path(@project.namespace, @project, @build)
end
step 'I see details of a build' do
......
......@@ -30,7 +30,7 @@ module SharedMarkdown
end
step 'I should see the Markdown write tab' do
expect(find('.gfm-form')).to have_css('.js-md-write-button', visible: true)
expect(first('.gfm-form')).to have_link('Write', visible: true)
end
step 'I should see the Markdown preview' do
......@@ -49,9 +49,9 @@ module SharedMarkdown
end
step 'I preview a description text like "Bug fixed :smile:"' do
page.within('.gfm-form') do
page.within(first('.gfm-form')) do
fill_in 'Description', with: 'Bug fixed :smile:'
find('.js-md-preview-button').click
click_link 'Preview'
end
end
......
......@@ -12,7 +12,7 @@ module Gitlab
end
def action_path
cancel_namespace_project_build_path(subject.project.namespace,
cancel_namespace_project_job_path(subject.project.namespace,
subject.project,
subject)
end
......
......@@ -8,7 +8,7 @@ module Gitlab
end
def details_path
namespace_project_build_path(subject.project.namespace,
namespace_project_job_path(subject.project.namespace,
subject.project,
subject)
end
......
......@@ -20,7 +20,7 @@ module Gitlab
end
def action_path
play_namespace_project_build_path(subject.project.namespace,
play_namespace_project_job_path(subject.project.namespace,
subject.project,
subject)
end
......
......@@ -16,7 +16,7 @@ module Gitlab
end
def action_path
retry_namespace_project_build_path(subject.project.namespace,
retry_namespace_project_job_path(subject.project.namespace,
subject.project,
subject)
end
......
......@@ -20,7 +20,7 @@ module Gitlab
end
def action_path
play_namespace_project_build_path(subject.project.namespace,
play_namespace_project_job_path(subject.project.namespace,
subject.project,
subject)
end
......
......@@ -10,8 +10,8 @@ module Gitlab
# - Ending in `issues/id`/realtime_changes` for the `issue_title` route
USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes
commit pipelines merge_requests new].freeze
RESERVED_WORDS = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES
RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS)
RESERVED_WORDS = Gitlab::Regex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES
RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape)))
ROUTES = [
Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z),
......
module Gitlab
module Routes
class LegacyBuilds
def initialize(map)
@map = map
end
def draw
@map.instance_eval do
resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do
collection do
resources :artifacts, only: [], controller: 'build_artifacts' do
collection do
get :latest_succeeded,
path: '*ref_name_and_path',
format: false
end
end
end
member do
get :raw
end
resource :artifacts, only: [], controller: 'build_artifacts' do
get :download
get :browse, path: 'browse(/*path)', format: false
get :file, path: 'file/*path', format: false
get :raw, path: 'raw/*path', format: false
end
end
end
end
end
end
end
......@@ -12,7 +12,7 @@ describe Projects::ArtifactsController do
status: 'success')
end
let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
before do
project.team << [user, :developer]
......@@ -22,16 +22,16 @@ describe Projects::ArtifactsController do
describe 'GET download' do
it 'sends the artifacts file' do
expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original
expect(controller).to receive(:send_file).with(job.artifacts_file.path, disposition: 'attachment').and_call_original
get :download, namespace_id: project.namespace, project_id: project, build_id: build
get :download, namespace_id: project.namespace, project_id: project, job_id: job
end
end
describe 'GET browse' do
context 'when the directory exists' do
it 'renders the browse view' do
get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2'
get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'other_artifacts_0.1.2'
expect(response).to render_template('projects/artifacts/browse')
end
......@@ -39,7 +39,7 @@ describe Projects::ArtifactsController do
context 'when the directory does not exist' do
it 'responds Not Found' do
get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown'
get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown'
expect(response).to be_not_found
end
......@@ -49,7 +49,7 @@ describe Projects::ArtifactsController do
describe 'GET file' do
context 'when the file exists' do
it 'renders the file view' do
get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt'
get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt'
expect(response).to render_template('projects/artifacts/file')
end
......@@ -57,7 +57,7 @@ describe Projects::ArtifactsController do
context 'when the file does not exist' do
it 'responds Not Found' do
get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown'
get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown'
expect(response).to be_not_found
end
......@@ -67,7 +67,7 @@ describe Projects::ArtifactsController do
describe 'GET raw' do
context 'when the file exists' do
it 'serves the file using workhorse' do
get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt'
get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt'
send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]
......@@ -84,7 +84,7 @@ describe Projects::ArtifactsController do
context 'when the file does not exist' do
it 'responds Not Found' do
get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown'
get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown'
expect(response).to be_not_found
end
......@@ -92,29 +92,29 @@ describe Projects::ArtifactsController do
end
describe 'GET latest_succeeded' do
def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse')
def params_from_ref(ref = pipeline.ref, job_name = job.name, path = 'browse')
{
namespace_id: project.namespace,
project_id: project,
ref_name_and_path: File.join(ref, path),
job: job
job: job_name
}
end
context 'cannot find the build' do
context 'cannot find the job' do
shared_examples 'not found' do
it { expect(response).to have_http_status(:not_found) }
end
context 'has no such ref' do
before do
get :latest_succeeded, params_from_ref('TAIL', build.name)
get :latest_succeeded, params_from_ref('TAIL', job.name)
end
it_behaves_like 'not found'
end
context 'has no such build' do
context 'has no such job' do
before do
get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD')
end
......@@ -124,20 +124,20 @@ describe Projects::ArtifactsController do
context 'has no path' do
before do
get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '')
get :latest_succeeded, params_from_ref(pipeline.sha, job.name, '')
end
it_behaves_like 'not found'
end
end
context 'found the build and redirect' do
shared_examples 'redirect to the build' do
context 'found the job and redirect' do
shared_examples 'redirect to the job' do
it 'redirects' do
path = browse_namespace_project_build_artifacts_path(
path = browse_namespace_project_job_artifacts_path(
project.namespace,
project,
build)
job)
expect(response).to redirect_to(path)
end
......@@ -151,7 +151,7 @@ describe Projects::ArtifactsController do
get :latest_succeeded, params_from_ref('master')
end
it_behaves_like 'redirect to the build'
it_behaves_like 'redirect to the job'
end
context 'with branch name containing slash' do
......@@ -162,7 +162,7 @@ describe Projects::ArtifactsController do
get :latest_succeeded, params_from_ref('improve/awesome')
end
it_behaves_like 'redirect to the build'
it_behaves_like 'redirect to the job'
end
context 'with branch name and path containing slashes' do
......@@ -170,14 +170,14 @@ describe Projects::ArtifactsController do
pipeline.update(ref: 'improve/awesome',
sha: project.commit('improve/awesome').sha)
get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md')
get :latest_succeeded, params_from_ref('improve/awesome', job.name, 'file/README.md')
end
it 'redirects' do
path = file_namespace_project_build_artifacts_path(
path = file_namespace_project_job_artifacts_path(
project.namespace,
project,
build,
job,
'README.md')
expect(response).to redirect_to(path)
......
......@@ -198,7 +198,7 @@ describe Projects::EnvironmentsController do
expect(response).to have_http_status(200)
expect(json_response).to eq(
{ 'redirect_url' =>
"http://test.host/#{project.path_with_namespace}/builds/#{action.id}" })
namespace_project_job_url(project.namespace, project, action) })
end
end
......@@ -212,7 +212,7 @@ describe Projects::EnvironmentsController do
expect(response).to have_http_status(200)
expect(json_response).to eq(
{ 'redirect_url' =>
"http://test.host/#{project.path_with_namespace}/environments/#{environment.id}" })
namespace_project_environment_url(project.namespace, project, environment) })
end
end
end
......
......@@ -204,7 +204,7 @@ describe Projects::IssuesController do
body = JSON.parse(response.body)
expect(body['assignees'].first.keys)
.to match_array(%w(id name username avatar_url))
.to match_array(%w(id name username avatar_url state web_url))
end
end
......
require 'spec_helper'
describe Projects::BuildsController do
describe Projects::JobsController do
include ApiHelpers
let(:project) { create(:empty_project, :public) }
......@@ -213,7 +213,7 @@ describe Projects::BuildsController do
it 'redirects to the retried build page' do
expect(response).to have_http_status(:found)
expect(response).to redirect_to(namespace_project_build_path(id: Ci::Build.last.id))
expect(response).to redirect_to(namespace_project_job_path(id: Ci::Build.last.id))
end
end
......@@ -245,7 +245,7 @@ describe Projects::BuildsController do
it 'redirects to the played build page' do
expect(response).to have_http_status(:found)
expect(response).to redirect_to(namespace_project_build_path(id: build.id))
expect(response).to redirect_to(namespace_project_job_path(id: build.id))
end
it 'transits to pending' do
......@@ -281,7 +281,7 @@ describe Projects::BuildsController do
it 'redirects to the canceled build page' do
expect(response).to have_http_status(:found)
expect(response).to redirect_to(namespace_project_build_path(id: build.id))
expect(response).to redirect_to(namespace_project_job_path(id: build.id))
end
it 'transits to canceled' do
......@@ -319,7 +319,7 @@ describe Projects::BuildsController do
it 'redirects to a index page' do
expect(response).to have_http_status(:found)
expect(response).to redirect_to(namespace_project_builds_path)
expect(response).to redirect_to(namespace_project_jobs_path)
end
it 'transits to canceled' do
......@@ -336,7 +336,7 @@ describe Projects::BuildsController do
it 'redirects to a index page' do
expect(response).to have_http_status(:found)
expect(response).to redirect_to(namespace_project_builds_path)
expect(response).to redirect_to(namespace_project_jobs_path)
end
end
......@@ -359,7 +359,7 @@ describe Projects::BuildsController do
it 'redirects to the erased build page' do
expect(response).to have_http_status(:found)
expect(response).to redirect_to(namespace_project_build_path(id: build.id))
expect(response).to redirect_to(namespace_project_job_path(id: build.id))
end
it 'erases artifacts' do
......
......@@ -16,7 +16,7 @@ describe 'Admin Builds' do
create(:ci_build, pipeline: pipeline, status: :success)
create(:ci_build, pipeline: pipeline, status: :failed)
visit admin_builds_path
visit admin_jobs_path
expect(page).to have_selector('.nav-links li.active', text: 'All')
expect(page).to have_selector('.row-content-block', text: 'All jobs')
......@@ -27,7 +27,7 @@ describe 'Admin Builds' do
context 'when have no jobs' do
it 'shows a message' do
visit admin_builds_path
visit admin_jobs_path
expect(page).to have_selector('.nav-links li.active', text: 'All')
expect(page).to have_content 'No jobs to show'
......@@ -44,7 +44,7 @@ describe 'Admin Builds' do
build3 = create(:ci_build, pipeline: pipeline, status: :success)
build4 = create(:ci_build, pipeline: pipeline, status: :failed)
visit admin_builds_path(scope: :pending)
visit admin_jobs_path(scope: :pending)
expect(page).to have_selector('.nav-links li.active', text: 'Pending')
expect(page.find('.build-link')).to have_content(build1.id)
......@@ -59,7 +59,7 @@ describe 'Admin Builds' do
it 'shows a message' do
create(:ci_build, pipeline: pipeline, status: :success)
visit admin_builds_path(scope: :pending)
visit admin_jobs_path(scope: :pending)
expect(page).to have_selector('.nav-links li.active', text: 'Pending')
expect(page).to have_content 'No jobs to show'
......@@ -76,7 +76,7 @@ describe 'Admin Builds' do
build3 = create(:ci_build, pipeline: pipeline, status: :failed)
build4 = create(:ci_build, pipeline: pipeline, status: :pending)
visit admin_builds_path(scope: :running)
visit admin_jobs_path(scope: :running)
expect(page).to have_selector('.nav-links li.active', text: 'Running')
expect(page.find('.build-link')).to have_content(build1.id)
......@@ -91,7 +91,7 @@ describe 'Admin Builds' do
it 'shows a message' do
create(:ci_build, pipeline: pipeline, status: :success)
visit admin_builds_path(scope: :running)
visit admin_jobs_path(scope: :running)
expect(page).to have_selector('.nav-links li.active', text: 'Running')
expect(page).to have_content 'No jobs to show'
......@@ -107,7 +107,7 @@ describe 'Admin Builds' do
build2 = create(:ci_build, pipeline: pipeline, status: :running)
build3 = create(:ci_build, pipeline: pipeline, status: :success)
visit admin_builds_path(scope: :finished)
visit admin_jobs_path(scope: :finished)
expect(page).to have_selector('.nav-links li.active', text: 'Finished')
expect(page.find('.build-link')).not_to have_content(build1.id)
......@@ -121,7 +121,7 @@ describe 'Admin Builds' do
it 'shows a message' do
create(:ci_build, pipeline: pipeline, status: :running)
visit admin_builds_path(scope: :finished)
visit admin_jobs_path(scope: :finished)
expect(page).to have_selector('.nav-links li.active', text: 'Finished')
expect(page).to have_content 'No jobs to show'
......
......@@ -38,9 +38,11 @@ feature 'issue move to another project' do
end
scenario 'moving issue to another project', js: true do
find('#move_to_project_id', visible: false).set(new_project.id)
find('#issuable-move', visible: false).set(new_project.id)
click_button('Save changes')
wait_for_requests
expect(current_url).to include project_path(new_project)
expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}")
......@@ -51,7 +53,7 @@ feature 'issue move to another project' do
scenario 'searching project dropdown', js: true do
new_project_search.team << [user, :reporter]
page.within '.js-move-dropdown' do
page.within '.detail-page-description' do
first('.select2-choice').click
end
......@@ -69,7 +71,7 @@ feature 'issue move to another project' do
background { another_project.team << [user, :guest] }
scenario 'browsing projects in projects select' do
click_link 'Select project'
click_link 'Move to a different project'
page.within '.select2-results' do
expect(page).to have_content 'No project'
......
......@@ -85,7 +85,7 @@ feature 'Mini Pipeline Graph', :js, :feature do
build_item.click
find('.build-page')
expect(current_path).to eql(namespace_project_build_path(project.namespace, project, build))
expect(current_path).to eql(namespace_project_job_path(project.namespace, project, build))
end
it 'should show tooltip when hovered' do
......
require 'spec_helper'
feature 'Browse artifact', :js, feature: true do
let(:project) { create(:project, :public) }
let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') }
let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
def browse_path(path)
browse_namespace_project_job_artifacts_path(project.namespace, project, job, path)
end
context 'when visiting old URL' do
let(:browse_url) do
browse_path('other_artifacts_0.1.2')
end
before do
visit browse_url.sub('/-/jobs', '/builds')
end
it "redirects to new URL" do
expect(page.current_path).to eq(browse_url)
end
end
end
require 'spec_helper'
feature 'Download artifact', :js, feature: true do
let(:project) { create(:project, :public) }
let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project, sha: project.commit.sha, ref: 'master') }
let(:job) { create(:ci_build, :artifacts, :success, pipeline: pipeline) }
shared_examples 'downloading' do
it 'downloads the zip' do
expect(page.response_headers['Content-Disposition'])
.to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"})
# Check the content does match, but don't print this as error message
expect(page.source.b == job.artifacts_file.file.read.b)
end
end
context 'when downloading' do
before do
visit download_url
end
context 'via job id' do
let(:download_url) do
download_namespace_project_job_artifacts_path(project.namespace, project, job)
end
it_behaves_like 'downloading'
end
context 'via branch name and job name' do
let(:download_url) do
latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{pipeline.ref}/download", job: job.name)
end
it_behaves_like 'downloading'
end
end
context 'when visiting old URL' do
before do
visit download_url.sub('/-/jobs', '/builds')
end
context 'via job id' do
let(:download_url) do
download_namespace_project_job_artifacts_path(project.namespace, project, job)
end
it_behaves_like 'downloading'
end
context 'via branch name and job name' do
let(:download_url) do
latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{pipeline.ref}/download", job: job.name)
end
it_behaves_like 'downloading'
end
end
end
......@@ -6,7 +6,11 @@ feature 'Artifact file', :js, feature: true do
let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) }
def visit_file(path)
visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path)
visit file_path(path)
end
def file_path(path)
file_namespace_project_job_artifacts_path(project.namespace, project, build, path)
end
context 'Text file' do
......@@ -56,4 +60,18 @@ feature 'Artifact file', :js, feature: true do
end
end
end
context 'when visiting old URL' do
let(:file_url) do
file_path('other_artifacts_0.1.2/doc_sample.txt')
end
before do
visit file_url.sub('/-/jobs', '/builds')
end
it "redirects to new URL" do
expect(page.current_path).to eq(file_url)
end
end
end
require 'spec_helper'
feature 'Raw artifact', :js, feature: true do
let(:project) { create(:project, :public) }
let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') }
let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
def raw_path(path)
raw_namespace_project_job_artifacts_path(project.namespace, project, job, path)
end
context 'when visiting old URL' do
let(:raw_url) do
raw_path('other_artifacts_0.1.2/doc_sample.txt')
end
before do
visit raw_url.sub('/-/jobs', '/builds')
end
it "redirects to new URL" do
expect(page.current_path).to eq(raw_url)
end
end
end
......@@ -355,7 +355,7 @@ describe "Internal Project Access", feature: true do
end
describe "GET /:project_path/builds" do
subject { namespace_project_builds_path(project.namespace, project) }
subject { namespace_project_jobs_path(project.namespace, project) }
context "when allowed for public and internal" do
before { project.update(public_builds: true) }
......@@ -391,7 +391,7 @@ describe "Internal Project Access", feature: true do
describe "GET /:project_path/builds/:id" do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
subject { namespace_project_build_path(project.namespace, project, build.id) }
subject { namespace_project_job_path(project.namespace, project, build.id) }
context "when allowed for public and internal" do
before { project.update(public_builds: true) }
......@@ -427,7 +427,7 @@ describe "Internal Project Access", feature: true do
describe 'GET /:project_path/builds/:id/trace' do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
subject { trace_namespace_project_build_path(project.namespace, project, build.id) }
subject { trace_namespace_project_job_path(project.namespace, project, build.id) }
context 'when allowed for public and internal' do
before do
......
......@@ -362,7 +362,7 @@ describe "Private Project Access", feature: true do
end
describe "GET /:project_path/builds" do
subject { namespace_project_builds_path(project.namespace, project) }
subject { namespace_project_jobs_path(project.namespace, project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:auditor) }
......@@ -391,7 +391,7 @@ describe "Private Project Access", feature: true do
describe "GET /:project_path/builds/:id" do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
subject { namespace_project_build_path(project.namespace, project, build.id) }
subject { namespace_project_job_path(project.namespace, project, build.id) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:auditor) }
......@@ -425,7 +425,7 @@ describe "Private Project Access", feature: true do
describe 'GET /:project_path/builds/:id/trace' do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
subject { trace_namespace_project_build_path(project.namespace, project, build.id) }
subject { trace_namespace_project_job_path(project.namespace, project, build.id) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
......
......@@ -162,7 +162,7 @@ describe "Public Project Access", feature: true do
end
describe "GET /:project_path/builds" do
subject { namespace_project_builds_path(project.namespace, project) }
subject { namespace_project_jobs_path(project.namespace, project) }
context "when allowed for public" do
before { project.update(public_builds: true) }
......@@ -198,7 +198,7 @@ describe "Public Project Access", feature: true do
describe "GET /:project_path/builds/:id" do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
subject { namespace_project_build_path(project.namespace, project, build.id) }
subject { namespace_project_job_path(project.namespace, project, build.id) }
context "when allowed for public" do
before { project.update(public_builds: true) }
......@@ -234,7 +234,7 @@ describe "Public Project Access", feature: true do
describe 'GET /:project_path/builds/:id/trace' do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
subject { trace_namespace_project_build_path(project.namespace, project, build.id) }
subject { trace_namespace_project_job_path(project.namespace, project, build.id) }
context 'when allowed for public' do
before do
......
......@@ -8,7 +8,7 @@ import '~/breakpoints';
import 'vendor/jquery.nicescroll';
describe('Build', () => {
const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1`;
const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1`;
preloadFixtures('builds/build-with-artifacts.html.raw');
......
require 'spec_helper'
describe Projects::BuildsController, '(JavaScript fixtures)', type: :controller do
describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do
include JavaScriptFixturesHelpers
let(:admin) { create(:admin) }
......
......@@ -2,6 +2,7 @@ import Vue from 'vue';
import '~/render_math';
import '~/render_gfm';
import issuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
import issueShowData from '../mock_data';
const issueShowInterceptor = data => (request, next) => {
......@@ -22,14 +23,25 @@ describe('Issuable output', () => {
const IssuableDescriptionComponent = Vue.extend(issuableApp);
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
spyOn(eventHub, '$emit');
vm = new IssuableDescriptionComponent({
propsData: {
canUpdate: true,
canDestroy: true,
canMove: true,
endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes',
issuableRef: '#1',
initialTitle: '',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: '',
initialDescriptionText: '',
markdownPreviewUrl: '/',
markdownDocs: '/',
projectsAutocompleteUrl: '/',
isConfidential: false,
projectNamespace: '/',
projectPath: '/',
},
}).$mount();
});
......@@ -57,4 +69,296 @@ describe('Issuable output', () => {
});
});
});
it('shows actions if permissions are correct', (done) => {
vm.showForm = true;
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.btn'),
).not.toBeNull();
done();
});
});
it('does not show actions if permissions are incorrect', (done) => {
vm.showForm = true;
vm.canUpdate = false;
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.btn'),
).toBeNull();
done();
});
});
it('does not update formState if form is already open', (done) => {
vm.openForm();
vm.state.titleText = 'testing 123';
vm.openForm();
Vue.nextTick(() => {
expect(
vm.store.formState.title,
).not.toBe('testing 123');
done();
});
});
describe('updateIssuable', () => {
it('fetches new data after update', (done) => {
spyOn(vm.service, 'getData');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
return {
confidential: false,
web_url: location.pathname,
};
},
});
}));
vm.updateIssuable();
setTimeout(() => {
expect(
vm.service.getData,
).toHaveBeenCalled();
done();
});
});
it('reloads the page if the confidential status has changed', (done) => {
spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
return {
confidential: true,
web_url: location.pathname,
};
},
});
}));
vm.updateIssuable();
setTimeout(() => {
expect(
gl.utils.visitUrl,
).toHaveBeenCalledWith(location.pathname);
done();
});
});
it('correctly updates issuable data', (done) => {
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve();
}));
vm.updateIssuable();
setTimeout(() => {
expect(
vm.service.updateIssuable,
).toHaveBeenCalledWith(vm.formState);
expect(
eventHub.$emit,
).toHaveBeenCalledWith('close.form');
done();
});
});
it('does not redirect if issue has not moved', (done) => {
spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
return {
web_url: location.pathname,
confidential: vm.isConfidential,
};
},
});
}));
vm.updateIssuable();
setTimeout(() => {
expect(
gl.utils.visitUrl,
).not.toHaveBeenCalled();
done();
});
});
it('redirects if issue is moved', (done) => {
spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
return {
web_url: '/testing-issue-move',
confidential: vm.isConfidential,
};
},
});
}));
vm.updateIssuable();
setTimeout(() => {
expect(
gl.utils.visitUrl,
).toHaveBeenCalledWith('/testing-issue-move');
done();
});
});
it('does not update issuable if project move confirm is false', (done) => {
spyOn(window, 'confirm').and.returnValue(false);
spyOn(vm.service, 'updateIssuable');
vm.store.formState.move_to_project_id = 1;
vm.updateIssuable();
setTimeout(() => {
expect(
vm.service.updateIssuable,
).not.toHaveBeenCalled();
done();
});
});
it('closes form on error', (done) => {
spyOn(window, 'Flash').and.callThrough();
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => {
reject();
}));
vm.updateIssuable();
setTimeout(() => {
expect(
eventHub.$emit,
).toHaveBeenCalledWith('close.form');
expect(
window.Flash,
).toHaveBeenCalledWith('Error updating issue');
done();
});
});
});
describe('deleteIssuable', () => {
it('changes URL when deleted', (done) => {
spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
return { web_url: '/test' };
},
});
}));
vm.deleteIssuable();
setTimeout(() => {
expect(
gl.utils.visitUrl,
).toHaveBeenCalledWith('/test');
done();
});
});
it('stops polling when deleting', (done) => {
spyOn(gl.utils, 'visitUrl');
spyOn(vm.poll, 'stop');
spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
return { web_url: '/test' };
},
});
}));
vm.deleteIssuable();
setTimeout(() => {
expect(
vm.poll.stop,
).toHaveBeenCalledWith();
done();
});
});
it('closes form on error', (done) => {
spyOn(window, 'Flash').and.callThrough();
spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve, reject) => {
reject();
}));
vm.deleteIssuable();
setTimeout(() => {
expect(
eventHub.$emit,
).toHaveBeenCalledWith('close.form');
expect(
window.Flash,
).toHaveBeenCalledWith('Error deleting issue');
done();
});
});
});
describe('open form', () => {
it('shows locked warning if form is open & data is different', (done) => {
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
Vue.nextTick()
.then(() => new Promise((resolve) => {
setTimeout(resolve);
}))
.then(() => {
vm.openForm();
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
return new Promise((resolve) => {
setTimeout(resolve);
});
})
.then(() => {
expect(
vm.formState.lockedWarningVisible,
).toBeTruthy();
expect(
vm.$el.querySelector('.alert'),
).not.toBeNull();
done();
})
.catch(done.fail);
});
});
});
import Vue from 'vue';
import editActions from '~/issue_show/components/edit_actions.vue';
import eventHub from '~/issue_show/event_hub';
import Store from '~/issue_show/stores';
describe('Edit Actions components', () => {
let vm;
beforeEach((done) => {
const Component = Vue.extend(editActions);
const store = new Store({
titleHtml: '',
descriptionHtml: '',
issuableRef: '',
});
store.formState.title = 'test';
spyOn(eventHub, '$emit');
vm = new Component({
propsData: {
canDestroy: true,
formState: store.formState,
},
}).$mount();
Vue.nextTick(done);
});
it('renders all buttons as enabled', () => {
expect(
vm.$el.querySelectorAll('.disabled').length,
).toBe(0);
expect(
vm.$el.querySelectorAll('[disabled]').length,
).toBe(0);
});
it('does not render delete button if canUpdate is false', (done) => {
vm.canDestroy = false;
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.btn-danger'),
).toBeNull();
done();
});
});
it('disables submit button when title is blank', (done) => {
vm.formState.title = '';
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.btn-save').getAttribute('disabled'),
).toBe('disabled');
done();
});
});
describe('updateIssuable', () => {
it('sends update.issauble event when clicking save button', () => {
vm.$el.querySelector('.btn-save').click();
expect(
eventHub.$emit,
).toHaveBeenCalledWith('update.issuable');
});
it('shows loading icon after clicking save button', (done) => {
vm.$el.querySelector('.btn-save').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.btn-save .fa'),
).not.toBeNull();
done();
});
});
it('disabled button after clicking save button', (done) => {
vm.$el.querySelector('.btn-save').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.btn-save').getAttribute('disabled'),
).toBe('disabled');
done();
});
});
});
describe('closeForm', () => {
it('emits close.form when clicking cancel', () => {
vm.$el.querySelector('.btn-default').click();
expect(
eventHub.$emit,
).toHaveBeenCalledWith('close.form');
});
});
describe('deleteIssuable', () => {
it('sends delete.issuable event when clicking save button', () => {
spyOn(window, 'confirm').and.returnValue(true);
vm.$el.querySelector('.btn-danger').click();
expect(
eventHub.$emit,
).toHaveBeenCalledWith('delete.issuable');
});
it('shows loading icon after clicking delete button', (done) => {
spyOn(window, 'confirm').and.returnValue(true);
vm.$el.querySelector('.btn-danger').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.btn-danger .fa'),
).not.toBeNull();
done();
});
});
it('does no actions when confirm is false', (done) => {
spyOn(window, 'confirm').and.returnValue(false);
vm.$el.querySelector('.btn-danger').click();
Vue.nextTick(() => {
expect(
eventHub.$emit,
).not.toHaveBeenCalledWith('delete.issuable');
expect(
vm.$el.querySelector('.btn-danger .fa'),
).toBeNull();
done();
});
});
});
});
import Vue from 'vue';
import Store from '~/issue_show/stores';
import descriptionField from '~/issue_show/components/fields/description.vue';
describe('Description field component', () => {
let vm;
let store;
beforeEach((done) => {
const Component = Vue.extend(descriptionField);
const el = document.createElement('div');
store = new Store({
titleHtml: '',
descriptionHtml: '',
issuableRef: '',
});
store.formState.description = 'test';
document.body.appendChild(el);
vm = new Component({
el,
propsData: {
markdownPreviewUrl: '/',
markdownDocs: '/',
formState: store.formState,
},
}).$mount();
Vue.nextTick(done);
});
it('renders markdown field with description', () => {
expect(
vm.$el.querySelector('.md-area textarea').value,
).toBe('test');
});
it('renders markdown field with a markdown description', (done) => {
store.formState.description = '**test**';
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.md-area textarea').value,
).toBe('**test**');
done();
});
});
it('focuses field when mounted', () => {
expect(
document.activeElement,
).toBe(vm.$refs.textarea);
});
});
import Vue from 'vue';
import descriptionTemplate from '~/issue_show/components/fields/description_template.vue';
import '~/templates/issuable_template_selector';
import '~/templates/issuable_template_selectors';
describe('Issue description template component', () => {
let vm;
let formState;
beforeEach((done) => {
const Component = Vue.extend(descriptionTemplate);
formState = {
description: 'test',
};
vm = new Component({
propsData: {
formState,
issuableTemplates: [{ name: 'test' }],
projectPath: '/',
projectNamespace: '/',
},
}).$mount();
Vue.nextTick(done);
});
it('renders templates as JSON array in data attribute', () => {
expect(
vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data'),
).toBe('[{"name":"test"}]');
});
it('updates formState when changing template', () => {
vm.issuableTemplate.editor.setValue('test new template');
expect(
formState.description,
).toBe('test new template');
});
it('returns formState description with editor getValue', () => {
formState.description = 'testing new template';
expect(
vm.issuableTemplate.editor.getValue(),
).toBe('testing new template');
});
});
import Vue from 'vue';
import projectMove from '~/issue_show/components/fields/project_move.vue';
describe('Project move field component', () => {
let vm;
let formState;
beforeEach((done) => {
const Component = Vue.extend(projectMove);
formState = {
move_to_project_id: 0,
};
vm = new Component({
propsData: {
formState,
projectsAutocompleteUrl: '/autocomplete',
},
}).$mount();
Vue.nextTick(done);
});
it('mounts select2 element', () => {
expect(
vm.$el.querySelector('.select2-container'),
).not.toBeNull();
});
it('updates formState on change', () => {
$(vm.$refs['move-dropdown']).val(2).trigger('change');
expect(
formState.move_to_project_id,
).toBe(2);
});
});
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment