diff options
103 files changed, 1636 insertions, 1301 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 88d536fa9b3..7fbfda4a5a8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -408,9 +408,6 @@ rake gitlab:assets:compile: - webpack-report/ rake karma: - cache: - paths: - - vendor/ruby stage: test <<: *use-pg <<: *dedicated-runner diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 66e1e0e20b3..58af062e75e 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -40,6 +40,7 @@ logs, and code as it's very hard to read otherwise.) #### Results of GitLab environment info <details> +<summary>Expand for output related to GitLab environment info</summary> <pre> (For installations with omnibus-gitlab package run and paste the output of: @@ -54,6 +55,7 @@ logs, and code as it's very hard to read otherwise.) #### Results of GitLab application Check <details> +<summary>Expand for output related to the GitLab application check</summary> <pre> (For installations with omnibus-gitlab package run and paste the output of: diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 43ad127a4db..1a791395d6f 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -14,7 +14,6 @@ /* global NotificationsForm */ /* global TreeView */ /* global NotificationsDropdown */ -/* global UsersSelect */ /* global GroupAvatar */ /* global LineHighlighter */ /* global ProjectFork */ @@ -52,6 +51,7 @@ import ShortcutsWiki from './shortcuts_wiki'; import Pipelines from './pipelines'; import BlobViewer from './blob/viewer/index'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; +import UsersSelect from './users_select'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -113,6 +113,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:boards:show': case 'projects:boards:index': shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; case 'projects:builds:show': new Build(); @@ -127,6 +128,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', }); shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; case 'projects:issues:show': new Issue(); @@ -139,6 +141,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); new Milestone(); new Sidebar(); break; + case 'groups:issues': + case 'groups:merge_requests': + new UsersSelect(); + break; case 'dashboard:todos:index': new gl.Todos(); break; @@ -223,6 +229,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'dashboard:activity': new gl.Activities(); break; + case 'dashboard:issues': + case 'dashboard:merge_requests': + new UsersSelect(); + break; case 'projects:commit:show': new Commit(); new gl.Diff(); @@ -377,6 +387,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); new LineHighlighter(); new BlobViewer(); break; + case 'import:fogbugz:new_user_map': + new UsersSelect(); + break; } switch (path.first()) { case 'sessions': diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 834b98e8601..4520e990e6f 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -1,8 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new, comma-dangle, quotes, prefer-arrow-callback, consistent-return, one-var, no-var, one-var-declaration-per-line, no-underscore-dangle, max-len */ -/* global UsersSelect */ /* global bp */ import Cookies from 'js-cookie'; +import UsersSelect from './users_select'; (function() { this.IssuableContext = (function() { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 544fc91876a..4310663e0b6 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -1,11 +1,12 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */ /* global GitLab */ -/* global UsersSelect */ /* global ZenMode */ /* global Autosave */ /* global dateFormat */ /* global Pikaday */ +import UsersSelect from './users_select'; + (function() { this.IssuableForm = (function() { IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?'; diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js deleted file mode 100644 index 0740a9f559c..00000000000 --- a/app/assets/javascripts/issue_show/actions/tasks.js +++ /dev/null @@ -1,27 +0,0 @@ -export default (newStateData, tasks) => { - const $tasks = $('#task_status'); - const $tasksShort = $('#task_status_short'); - const $issueableHeader = $('.issuable-header'); - const tasksStates = { newState: null, currentState: null }; - - if ($tasks.length === 0) { - if (!(newStateData.task_status.indexOf('0 of 0') === 0)) { - $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); - } else { - $issueableHeader.append('<span id="task_status"></span>'); - } - } else { - tasksStates.newState = newStateData.task_status.indexOf('0 of 0') === 0; - tasksStates.currentState = tasks.indexOf('0 of 0') === 0; - } - - if ($tasks.length !== 0 && !tasksStates.newState) { - $tasks.text(newStateData.task_status); - $tasksShort.text(newStateData.task_status); - } else if (tasksStates.currentState) { - $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); - } else if (tasksStates.newState) { - $tasks.remove(); - $tasksShort.remove(); - } -}; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue new file mode 100644 index 00000000000..770a0dcd27e --- /dev/null +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -0,0 +1,96 @@ +<script> +import Visibility from 'visibilityjs'; +import Poll from '../../lib/utils/poll'; +import Service from '../services/index'; +import Store from '../stores'; +import titleComponent from './title.vue'; +import descriptionComponent from './description.vue'; + +export default { + props: { + endpoint: { + required: true, + type: String, + }, + canUpdate: { + required: true, + type: Boolean, + }, + issuableRef: { + type: String, + required: true, + }, + initialTitle: { + type: String, + required: true, + }, + initialDescriptionHtml: { + type: String, + required: false, + default: '', + }, + initialDescriptionText: { + type: String, + required: false, + default: '', + }, + }, + data() { + const store = new Store({ + titleHtml: this.initialTitle, + descriptionHtml: this.initialDescriptionHtml, + descriptionText: this.initialDescriptionText, + }); + + return { + store, + state: store.state, + }; + }, + components: { + descriptionComponent, + titleComponent, + }, + created() { + const resource = new Service(this.endpoint); + const poll = new Poll({ + resource, + method: 'getData', + successCallback: (res) => { + this.store.updateState(res.json()); + }, + errorCallback(err) { + throw new Error(err); + }, + }); + + if (!Visibility.hidden()) { + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + }, +}; +</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" /> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue new file mode 100644 index 00000000000..4ad3eb7dfd7 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -0,0 +1,105 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + props: { + canUpdate: { + type: Boolean, + required: true, + }, + descriptionHtml: { + type: String, + required: true, + }, + descriptionText: { + type: String, + required: true, + }, + updatedAt: { + type: String, + required: true, + }, + taskStatus: { + type: String, + required: true, + }, + }, + data() { + return { + preAnimation: false, + pulseAnimation: false, + timeAgoEl: $('.js-issue-edited-ago'), + }; + }, + watch: { + descriptionHtml() { + this.animateChange(); + + this.$nextTick(() => { + const toolTipTime = gl.utils.formatDate(this.updatedAt); + + this.timeAgoEl.attr('datetime', this.updatedAt) + .attr('title', toolTipTime) + .tooltip('fixTitle'); + + this.renderGFM(); + }); + }, + taskStatus() { + const taskRegexMatches = this.taskStatus.match(/(\d+) of (\d+)/); + const $issuableHeader = $('.issuable-meta'); + const $tasks = $('#task_status', $issuableHeader); + const $tasksShort = $('#task_status_short', $issuableHeader); + + if (taskRegexMatches) { + $tasks.text(this.taskStatus); + $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`); + } else { + $tasks.text(''); + $tasksShort.text(''); + } + }, + }, + methods: { + renderGFM() { + $(this.$refs['gfm-entry-content']).renderGFM(); + + if (this.canUpdate) { + // eslint-disable-next-line no-new + new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); + } + }, + }, + mounted() { + this.renderGFM(); + }, + }; +</script> + +<template> + <div + class="description" + :class="{ + 'js-task-list-container': canUpdate + }"> + <div + class="wiki" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="descriptionHtml" + ref="gfm-content"> + </div> + <textarea + class="hidden js-task-list-field" + v-if="descriptionText" + v-model="descriptionText"> + </textarea> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue new file mode 100644 index 00000000000..a9dabd4cff1 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -0,0 +1,53 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + data() { + return { + preAnimation: false, + pulseAnimation: false, + titleEl: document.querySelector('title'), + }; + }, + props: { + issuableRef: { + type: String, + required: true, + }, + titleHtml: { + type: String, + required: true, + }, + titleText: { + type: String, + required: true, + }, + }, + watch: { + titleHtml() { + this.setPageTitle(); + this.animateChange(); + }, + }, + methods: { + setPageTitle() { + const currentPageTitleScope = this.titleEl.innerText.split('·'); + currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; + this.titleEl.textContent = currentPageTitleScope.join('·'); + }, + }, + }; +</script> + +<template> + <h2 + class="title" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="titleHtml" + > + </h2> +</template> diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index eb20a597bb5..f06e33dee60 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,20 +1,42 @@ import Vue from 'vue'; -import IssueTitle from './issue_title_description.vue'; +import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; -(() => { - const issueTitleData = document.querySelector('.issue-title-data').dataset; - const { canUpdateTasksClass, endpoint } = issueTitleData; +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; - const vm = new Vue({ - el: '.issue-title-entrypoint', - render: createElement => createElement(IssueTitle, { + 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: { - canUpdateTasksClass, - endpoint, + canUpdate: this.canUpdate, + endpoint: this.endpoint, + issuableRef: this.issuableRef, + initialTitle: this.initialTitle, + initialDescriptionHtml: this.initialDescriptionHtml, + initialDescriptionText: this.initialDescriptionText, }, - }), - }); - - return vm; -})(); + }); + }, +})); diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue deleted file mode 100644 index dc3ba2550c5..00000000000 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ /dev/null @@ -1,180 +0,0 @@ -<script> -import Visibility from 'visibilityjs'; -import Poll from './../lib/utils/poll'; -import Service from './services/index'; -import tasks from './actions/tasks'; - -export default { - props: { - endpoint: { - required: true, - type: String, - }, - canUpdateTasksClass: { - required: true, - type: String, - }, - }, - data() { - const resource = new Service(this.$http, this.endpoint); - - const poll = new Poll({ - resource, - method: 'getTitle', - successCallback: (res) => { - this.renderResponse(res); - }, - errorCallback: (err) => { - throw new Error(err); - }, - }); - - return { - poll, - apiData: {}, - tasks: '0 of 0', - title: null, - titleText: '', - titleFlag: { - pre: true, - pulse: false, - }, - description: null, - descriptionText: '', - descriptionChange: false, - descriptionFlag: { - pre: true, - pulse: false, - }, - timeAgoEl: $('.issue_edited_ago'), - titleEl: document.querySelector('title'), - }; - }, - methods: { - updateFlag(key, toggle) { - this[key].pre = toggle; - this[key].pulse = !toggle; - }, - renderResponse(res) { - this.apiData = res.json(); - this.triggerAnimation(); - }, - updateTaskHTML() { - tasks(this.apiData, this.tasks); - }, - elementsToVisualize(noTitleChange, noDescriptionChange) { - if (!noTitleChange) { - this.titleText = this.apiData.title_text; - this.updateFlag('titleFlag', true); - } - - if (!noDescriptionChange) { - // only change to true when we need to bind TaskLists the html of description - this.descriptionChange = true; - this.updateTaskHTML(); - this.tasks = this.apiData.task_status; - this.updateFlag('descriptionFlag', true); - } - }, - setTabTitle() { - const currentTabTitleScope = this.titleEl.innerText.split('·'); - currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `; - this.titleEl.innerText = currentTabTitleScope.join('·'); - }, - animate(title, description) { - this.title = title; - this.description = description; - this.setTabTitle(); - - this.$nextTick(() => { - this.updateFlag('titleFlag', false); - this.updateFlag('descriptionFlag', false); - }); - }, - triggerAnimation() { - // always reset to false before checking the change - this.descriptionChange = false; - - const { title, description } = this.apiData; - this.descriptionText = this.apiData.description_text; - - const noTitleChange = this.title === title; - const noDescriptionChange = this.description === description; - - /** - * since opacity is changed, even if there is no diff for Vue to update - * we must check the title/description even on a 304 to ensure no visual change - */ - if (noTitleChange && noDescriptionChange) return; - - this.elementsToVisualize(noTitleChange, noDescriptionChange); - this.animate(title, description); - }, - updateEditedTimeAgo() { - const toolTipTime = gl.utils.formatDate(this.apiData.updated_at); - this.timeAgoEl.attr('datetime', this.apiData.updated_at); - this.timeAgoEl.attr('title', toolTipTime).tooltip('fixTitle'); - }, - }, - created() { - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - }, - updated() { - // if new html is injected (description changed) - bind TaskList and call renderGFM - if (this.descriptionChange) { - this.updateEditedTimeAgo(); - - $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); - - const tl = new gl.TaskList({ - dataType: 'issue', - fieldName: 'description', - selector: '.detail-page-description', - }); - - return tl && null; - } - - return null; - }, -}; -</script> - -<template> - <div> - <h2 - class="title" - :class="{ 'issue-realtime-pre-pulse': titleFlag.pre, 'issue-realtime-trigger-pulse': titleFlag.pulse }" - ref="issue-title" - v-html="title" - > - </h2> - <div - class="description is-task-list-enabled" - :class="canUpdateTasksClass" - v-if="description" - > - <div - class="wiki" - :class="{ 'issue-realtime-pre-pulse': descriptionFlag.pre, 'issue-realtime-trigger-pulse': descriptionFlag.pulse }" - v-html="description" - ref="issue-content-container-gfm-entry" - > - </div> - <textarea - class="hidden js-task-list-field" - v-if="descriptionText" - >{{descriptionText}}</textarea> - </div> - </div> -</template> diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js new file mode 100644 index 00000000000..eda6302aa8b --- /dev/null +++ b/app/assets/javascripts/issue_show/mixins/animate.js @@ -0,0 +1,13 @@ +export default { + methods: { + animateChange() { + this.preAnimation = true; + this.pulseAnimation = false; + + this.$nextTick(() => { + this.preAnimation = false; + this.pulseAnimation = true; + }); + }, + }, +}; diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index c4ab0b1e07a..348ad8d6813 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -1,10 +1,16 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + export default class Service { - constructor(resource, endpoint) { - this.resource = resource; + constructor(endpoint) { this.endpoint = endpoint; + + this.resource = Vue.resource(this.endpoint); } - getTitle() { - return this.resource.get(this.endpoint); + getData() { + return this.resource.get(); } } diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js new file mode 100644 index 00000000000..8e89a2b7730 --- /dev/null +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -0,0 +1,25 @@ +export default class Store { + constructor({ + titleHtml, + descriptionHtml, + descriptionText, + }) { + this.state = { + titleHtml, + titleText: '', + descriptionHtml, + descriptionText, + taskStatus: '', + updatedAt: '', + }; + } + + updateState(data) { + this.state.titleHtml = data.title; + this.state.titleText = data.title_text; + this.state.descriptionHtml = data.description; + this.state.descriptionText = data.description_text; + this.state.taskStatus = data.task_status; + this.state.updatedAt = data.updated_at; + } +} diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 93c30c54a8e..a3dff25c93b 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -358,6 +358,13 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; // So we dont affix the tabs on these if (Breakpoints.get().getBreakpointSize() === 'xs' || !$tabs.length) return; + /** + If the browser does not support position sticky, it returns the position as static. + If the browser does support sticky, then we allow the browser to handle it, if not + then we default back to Bootstraps affix + **/ + if ($tabs.css('position') !== 'static') return; + const $diffTabs = $('#diff-notes-app'); $tabs.off('affix.bs.affix affix-top.bs.affix') diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js index 27ffe6ea304..5109b110b31 100644 --- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js +++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js @@ -4,8 +4,10 @@ import illustrationSvg from '../icons/intro_illustration.svg'; const cookieKey = 'pipeline_schedules_callout_dismissed'; export default { + name: 'PipelineSchedulesCallout', data() { return { + docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl, illustrationSvg, calloutDismissed: Cookies.get(cookieKey) === 'true', }; @@ -28,13 +30,15 @@ export default { <div class="svg-container" v-html="illustrationSvg"></div> <div class="user-callout-copy"> <h4>Scheduling Pipelines</h4> - <p> - The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. + <p> + The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user. </p> <p> Learn more in the - <!-- FIXME --> - <a href="random.com">pipeline schedules documentation</a>. + <a + :href="docsUrl" + target="_blank" + rel="nofollow">pipeline schedules documentation</a>. <!-- oneline to prevent extra space before period --> </p> </div> </div> diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js index e36dc5db2ab..6584549ad06 100644 --- a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js +++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js @@ -1,9 +1,12 @@ import Vue from 'vue'; import PipelineSchedulesCallout from './components/pipeline_schedules_callout'; -const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); - -document.addEventListener('DOMContentLoaded', () => { - new PipelineSchedulesCalloutComponent() - .$mount('#scheduling-pipelines-callout'); -}); +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#pipeline-schedules-callout', + components: { + 'pipeline-schedules-callout': PipelineSchedulesCallout, + }, + render(createElement) { + return createElement('pipeline-schedules-callout'); + }, +})); diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js index 8be58023c84..7230946b484 100644 --- a/app/assets/javascripts/todos.js +++ b/app/assets/javascripts/todos.js @@ -1,5 +1,6 @@ /* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */ -/* global UsersSelect */ + +import UsersSelect from './users_select'; class Todos { constructor() { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 452578d5b98..8119a8cd000 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -5,655 +5,649 @@ // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; -(function() { - const slice = [].slice; - - this.UsersSelect = (function() { - function UsersSelect(currentUser, els) { - var $els; - this.users = this.users.bind(this); - this.user = this.user.bind(this); - this.usersPath = "/autocomplete/users.json"; - this.userPath = "/autocomplete/users/:id.json"; - if (currentUser != null) { - if (typeof currentUser === 'object') { - this.currentUser = currentUser; - } else { - this.currentUser = JSON.parse(currentUser); +function UsersSelect(currentUser, els) { + var $els; + this.users = this.users.bind(this); + this.user = this.user.bind(this); + this.usersPath = "/autocomplete/users.json"; + this.userPath = "/autocomplete/users/:id.json"; + if (currentUser != null) { + if (typeof currentUser === 'object') { + this.currentUser = currentUser; + } else { + this.currentUser = JSON.parse(currentUser); + } + } + + $els = $(els); + + if (!els) { + $els = $('.js-user-search'); + } + + $els.each((function(_this) { + return function(i, dropdown) { + var options = {}; + var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove; + $dropdown = $(dropdown); + options.projectId = $dropdown.data('project-id'); + options.groupId = $dropdown.data('group-id'); + options.showCurrentUser = $dropdown.data('current-user'); + options.todoFilter = $dropdown.data('todo-filter'); + options.todoStateFilter = $dropdown.data('todo-state-filter'); + showNullUser = $dropdown.data('null-user'); + defaultNullUser = $dropdown.data('null-user-default'); + showMenuAbove = $dropdown.data('showMenuAbove'); + showAnyUser = $dropdown.data('any-user'); + firstUser = $dropdown.data('first-user'); + options.authorId = $dropdown.data('author-id'); + defaultLabel = $dropdown.data('default-label'); + issueURL = $dropdown.data('issueUpdate'); + $selectbox = $dropdown.closest('.selectbox'); + $block = $selectbox.closest('.block'); + abilityName = $dropdown.data('ability-name'); + $value = $block.find('.value'); + $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + $loading = $block.find('.block-loading').fadeOut(); + selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; + selectedId = $dropdown.data('selected') || selectedIdDefault; + + const assignYourself = function () { + const unassignedSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); } - } - $els = $(els); + // Save current selected user to the DOM + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = $dropdown.data('field-name'); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + + if (currentUserInfo) { + input.value = currentUserInfo.id; + input.dataset.meta = currentUserInfo.name; + } else if (_this.currentUser) { + input.value = _this.currentUser.id; + } - if (!els) { - $els = $('.js-user-search'); + if ($selectbox) { + $dropdown.parent().before(input); + } else { + $dropdown.after(input); + } + }; + + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); } - $els.each((function(_this) { - return function(i, dropdown) { - var options = {}; - var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove; - $dropdown = $(dropdown); - options.projectId = $dropdown.data('project-id'); - options.groupId = $dropdown.data('group-id'); - options.showCurrentUser = $dropdown.data('current-user'); - options.todoFilter = $dropdown.data('todo-filter'); - options.todoStateFilter = $dropdown.data('todo-state-filter'); - showNullUser = $dropdown.data('null-user'); - defaultNullUser = $dropdown.data('null-user-default'); - showMenuAbove = $dropdown.data('showMenuAbove'); - showAnyUser = $dropdown.data('any-user'); - firstUser = $dropdown.data('first-user'); - options.authorId = $dropdown.data('author-id'); - defaultLabel = $dropdown.data('default-label'); - issueURL = $dropdown.data('issueUpdate'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - abilityName = $dropdown.data('ability-name'); - $value = $block.find('.value'); - $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - $loading = $block.find('.block-loading').fadeOut(); - selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; - selectedId = $dropdown.data('selected') || selectedIdDefault; - - const assignYourself = function () { - const unassignedSelected = $dropdown.closest('.selectbox') - .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); - - if (unassignedSelected) { - unassignedSelected.remove(); - } + const getSelectedUserInputs = function() { + return $selectbox + .find(`input[name="${$dropdown.data('field-name')}"]`); + }; + + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; + + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('max-select'); + if (maxSelect) { + const selected = getSelected(); + + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); + + firstSelected.remove(); + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + }; + + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected() + .filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return 'Unassigned'; + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return `${selectedUser.name} + ${otherSelected.length} more`; + } else { + return `${firstUser.name} + ${selectedUsers.length - 1} more`; + } + }; - // Save current selected user to the DOM - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = $dropdown.data('field-name'); + $('.assign-to-me-link').on('click', (e) => { + e.preventDefault(); + $(e.currentTarget).hide(); - const currentUserInfo = $dropdown.data('currentUserInfo'); + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); - if (currentUserInfo) { - input.value = currentUserInfo.id; - input.dataset.meta = currentUserInfo.name; - } else if (_this.currentUser) { - input.value = _this.currentUser.id; - } + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); + } else { + const $input = $(`input[name="${$dropdown.data('field-name')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); + } + }); - if ($selectbox) { - $dropdown.parent().before(input); - } else { - $dropdown.after(input); - } - }; + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); - if ($block[0]) { - $block[0].addEventListener('assignYourself', assignYourself); + assignTo = function(selected) { + var data; + data = {}; + data[abilityName] = {}; + data[abilityName].assignee_id = selected != null ? selected : null; + $loading.removeClass('hidden').fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + + return $.ajax({ + type: 'PUT', + dataType: 'json', + url: issueURL, + data: data + }).done(function(data) { + var user; + $dropdown.trigger('loaded.gl.dropdown'); + $loading.fadeOut(); + if (data.assignee) { + user = { + name: data.assignee.name, + username: data.assignee.username, + avatar: data.assignee.avatar_url + }; + } else { + user = { + name: 'Unassigned', + username: '', + avatar: '' + }; } - - const getSelectedUserInputs = function() { - return $selectbox - .find(`input[name="${$dropdown.data('field-name')}"]`); - }; - - const getSelected = function() { - return getSelectedUserInputs() - .map((index, input) => parseInt(input.value, 10)) - .get(); - }; - - const checkMaxSelect = function() { - const maxSelect = $dropdown.data('max-select'); - if (maxSelect) { - const selected = getSelected(); - - if (selected.length > maxSelect) { - const firstSelectedId = selected[0]; - const firstSelected = $dropdown.closest('.selectbox') - .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); - - firstSelected.remove(); - emitSidebarEvent('sidebar.removeAssignee', { - id: firstSelectedId, - }); + $value.html(assigneeTemplate(user)); + $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + }); + }; + collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); + assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); + return $dropdown.glDropdown({ + showMenuAbove: showMenuAbove, + data: function(term, callback) { + var isAuthorFilter; + isAuthorFilter = $('.js-author-search'); + return _this.users(term, options, function(users) { + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }.bind(this)); + }, + processData: function(term, users, callback) { + let anyUser; + let index; + let j; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = j = 0, len = users.length; j < len; index = (j += 1)) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; + } } } - }; - - const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { - const selectedUsers = getSelected() - .filter(u => u !== 0); - - const firstUser = getSelectedUserInputs() - .map((index, input) => ({ - name: input.dataset.meta, - value: parseInt(input.value, 10), - })) - .filter(u => u.id !== 0) - .get(0); - - if (selectedUsers.length === 0) { - return 'Unassigned'; - } else if (selectedUsers.length === 1) { - return firstUser.name; - } else if (isSelected) { - const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); - return `${selectedUser.name} + ${otherSelected.length} more`; - } else { - return `${firstUser.name} + ${selectedUsers.length - 1} more`; + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: 'Unassigned', + id: 0 + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + beforeDivider: true, + name: name, + id: null + }; + users.unshift(anyUser); } - }; - - $('.assign-to-me-link').on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).hide(); - - if ($dropdown.data('multiSelect')) { - assignYourself(); - checkMaxSelect(); - const currentUserInfo = $dropdown.data('currentUserInfo'); - $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); - } else { - const $input = $(`input[name="${$dropdown.data('field-name')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); + if (showDivider) { + users.splice(showDivider, 0, 'divider'); } - }); - $block.on('click', '.js-assign-yourself', (e) => { - e.preventDefault(); - return assignTo(_this.currentUser.id); - }); + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); - assignTo = function(selected) { - var data; - data = {}; - data[abilityName] = {}; - data[abilityName].assignee_id = selected != null ? selected : null; - $loading.removeClass('hidden').fadeIn(); - $dropdown.trigger('loading.gl.dropdown'); - - return $.ajax({ - type: 'PUT', - dataType: 'json', - url: issueURL, - data: data - }).done(function(data) { - var user; - $dropdown.trigger('loaded.gl.dropdown'); - $loading.fadeOut(); - if (data.assignee) { - user = { - name: data.assignee.name, - username: data.assignee.username, - avatar: data.assignee.avatar_url - }; - } else { - user = { - name: 'Unassigned', - username: '', - avatar: '' - }; - } - $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); - return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); - }); - }; - collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); - assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); - return $dropdown.glDropdown({ - showMenuAbove: showMenuAbove, - data: function(term, callback) { - var isAuthorFilter; - isAuthorFilter = $('.js-author-search'); - return _this.users(term, options, function(users) { - // GitLabDropdownFilter returns this.instance - // GitLabDropdownRemote returns this.options.instance - const glDropdown = this.instance || this.options.instance; - glDropdown.options.processData(term, users, callback); - }.bind(this)); - }, - processData: function(term, users, callback) { - let anyUser; - let index; - let j; - let len; - let name; - let obj; - let showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = j = 0, len = users.length; j < len; index = (j += 1)) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } - } - } - if (showNullUser) { + if (selected.length > 0) { + if ($dropdown.data('dropdown-header')) { showDivider += 1; - users.unshift({ - beforeDivider: true, - name: 'Unassigned', - id: 0 + users.splice(showDivider, 0, { + header: $dropdown.data('dropdown-header'), }); } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - beforeDivider: true, - name: name, - id: null - }; - users.unshift(anyUser); - } - if (showDivider) { - users.splice(showDivider, 0, 'divider'); - } + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); - if ($dropdown.hasClass('js-multiselect')) { - const selected = getSelected().filter(i => i !== 0); + users = users.filter(u => selected.indexOf(u.id) === -1); - if (selected.length > 0) { - if ($dropdown.data('dropdown-header')) { - showDivider += 1; - users.splice(showDivider, 0, { - header: $dropdown.data('dropdown-header'), - }); - } - - const selectedUsers = users - .filter(u => selected.indexOf(u.id) !== -1) - .sort((a, b) => a.name > b.name); + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); - users = users.filter(u => selected.indexOf(u.id) === -1); + users.splice(showDivider + 1, 0, 'divider'); + } + } + } - selectedUsers.forEach((selectedUser) => { - showDivider += 1; - users.splice(showDivider, 0, selectedUser); - }); + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } + }, + filterable: true, + filterRemote: true, + search: { + fields: ['name', 'username'] + }, + selectable: true, + fieldName: $dropdown.data('field-name'), + toggleLabel: function(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } - users.splice(showDivider + 1, 0, 'divider'); - } - } - } + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); - } - }, - filterable: true, - filterRemote: true, - search: { - fields: ['name', 'username'] - }, - selectable: true, - fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected, el, glDropdown) { - const inputValue = glDropdown.filterInput.val(); - - if (this.multiSelect && inputValue === '') { - // Remove non-users from the fullData array - const users = glDropdown.filteredFullData(); - const callback = glDropdown.parseData.bind(glDropdown); - - // Update the data model - this.processData(inputValue, users, callback); - } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); + if (selected.text) { + return selected.text; + } else { + return selected.name; + } + } else { + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); + return defaultLabel; + } + }, + defaultLabel: defaultLabel, + hidden: function(e) { + if ($dropdown.hasClass('js-multiselect')) { + emitSidebarEvent('sidebar.saveAssignees'); + } - if (this.multiSelect) { - return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); - } + if (!$dropdown.data('always-show-selectbox')) { + $selectbox.hide(); - if (selected && 'id' in selected && $(el).hasClass('is-active')) { - $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); - if (selected.text) { - return selected.text; - } else { - return selected.name; - } - } else { - $dropdown.find('.dropdown-toggle-text').addClass('is-default'); - return defaultLabel; - } - }, - defaultLabel: defaultLabel, - hidden: function(e) { - if ($dropdown.hasClass('js-multiselect')) { - emitSidebarEvent('sidebar.saveAssignees'); - } + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('input-meta'), + clicked: function(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); + + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + const id = parseInt(element.value, 10); + element.remove(); + }); + emitSidebarEvent('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + emitSidebarEvent('sidebar.addAssignee', user); - if (!$dropdown.data('always-show-selectbox')) { - $selectbox.hide(); + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); - // Recalculate where .value is because vue might have changed it - $block = $selectbox.closest('.block'); - $value = $block.find('.value'); - // display:block overrides the hide-collapse rule - $value.css('display', ''); + if (unassignedSelected) { + unassignedSelected.remove(); + } + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('field-name'), 0, {}); } - }, - multiSelect: $dropdown.hasClass('js-multiselect'), - inputMeta: $dropdown.data('input-meta'), - clicked: function(options) { - const { $el, e, isMarking } = options; - const user = options.selectedObj; - - if ($dropdown.hasClass('js-multiselect')) { - const isActive = $el.hasClass('is-active'); - const previouslySelected = $dropdown.closest('.selectbox') - .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); - - // Enables support for limiting the number of users selected - // Automatically removes the first on the list if more users are selected - checkMaxSelect(); - - if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { - // Unassigned selected - previouslySelected.each((index, element) => { - const id = parseInt(element.value, 10); - element.remove(); - }); - emitSidebarEvent('sidebar.removeAllAssignees'); - } else if (isActive) { - // user selected - emitSidebarEvent('sidebar.addAssignee', user); - // Remove unassigned selection (if it was previously selected) - const unassignedSelected = $dropdown.closest('.selectbox') - .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); + // User unselected + emitSidebarEvent('sidebar.removeAssignee', user); + } - if (unassignedSelected) { - unassignedSelected.remove(); - } - } else { - if (previouslySelected.length === 0) { - // Select unassigned because there is no more selected users - this.addInput($dropdown.data('field-name'), 0, {}); - } + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } - // User unselected - emitSidebarEvent('sidebar.removeAssignee', user); - } + var isIssueIndex, isMRIndex, page, selected; + page = $('body').data('page'); + isIssueIndex = page === 'projects:issues:index'; + isMRIndex = (page === page && page === 'projects:merge_requests:index'); + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { + e.preventDefault(); - if (getSelected().find(u => u === gon.current_user_id)) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - } + const isSelecting = (user.id !== selectedId); + selectedId = isSelecting ? user.id : selectedIdDefault; - var isIssueIndex, isMRIndex, page, selected; - page = $('body').data('page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = (page === page && page === 'projects:merge_requests:index'); - if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { - e.preventDefault(); + if (selectedId === gon.current_user_id) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + return; + } + if ($el.closest('.add-issues-modal').length) { + gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else if (!$dropdown.hasClass('js-multiselect')) { + selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); + return assignTo(selected); + } + }, + id: function (user) { + return user.id; + }, + opened: function(e) { + const $el = $(e.currentTarget); + if ($dropdown.hasClass('js-issue-board-sidebar')) { + selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; + } + $el.find('.is-active').removeClass('is-active'); - const isSelecting = (user.id !== selectedId); - selectedId = isSelecting ? user.id : selectedIdDefault; + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } - if (selectedId === gon.current_user_id) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - return; - } - if ($el.closest('.add-issues-modal').length) { - gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; - } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - return Issuable.filterResults($dropdown.closest('form')); - } else if ($dropdown.hasClass('js-filter-submit')) { - return $dropdown.closest('form').submit(); - } else if (!$dropdown.hasClass('js-multiselect')) { - selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); - return assignTo(selected); - } - }, - id: function (user) { - return user.id; - }, - opened: function(e) { - const $el = $(e.currentTarget); - if ($dropdown.hasClass('js-issue-board-sidebar')) { - selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; - } - $el.find('.is-active').removeClass('is-active'); + if ($selectbox[0]) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else { + highlightSelected(selectedId); + } + }, + updateLabel: $dropdown.data('dropdown-title'), + renderRow: function(user) { + var avatar, img, listClosingTags, listWithName, listWithUserName, username; + username = user.username ? "@" + user.username : ""; + avatar = user.avatar_url ? user.avatar_url : false; - function highlightSelected(id) { - $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); - } + let selected = user.id === parseInt(selectedId, 10); - if ($selectbox[0]) { - getSelected().forEach(selectedId => highlightSelected(selectedId)); - } else { - highlightSelected(selectedId); - } - }, - updateLabel: $dropdown.data('dropdown-title'), - renderRow: function(user) { - var avatar, img, listClosingTags, listWithName, listWithUserName, username; - username = user.username ? "@" + user.username : ""; - avatar = user.avatar_url ? user.avatar_url : false; + if (this.multiSelect) { + const fieldName = this.fieldName; + const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); - let selected = user.id === parseInt(selectedId, 10); + if (field.length) { + selected = true; + } + } - if (this.multiSelect) { - const fieldName = this.fieldName; - const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); + img = ""; + if (user.beforeDivider != null) { + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; + } else { + if (avatar) { + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + } + } - if (field.length) { - selected = true; + return ` + <li data-user-id=${user.id}> + <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> + ${img} + <strong class='dropdown-menu-user-full-name'> + ${user.name} + </strong> + ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} + </a> + </li> + `; + } + }); + }; + })(this)); + $('.ajax-users-select').each((function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('project-id'); + options.groupId = $(select).data('group-id'); + options.showCurrentUser = $(select).data('current-user'); + options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); + options.authorId = $(select).data('author-id'); + options.skipUsers = $(select).data('skip-users'); + showNullUser = $(select).data('null-user'); + showAnyUser = $(select).data('any-user'); + showEmailUser = $(select).data('email-user'); + firstUser = $(select).data('first-user'); + return $(select).select2({ + placeholder: "Search for a user", + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return _this.users(query.term, options, function(users) { + var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; + data = { + results: users + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + ref = data.results; + for (index = j = 0, len = ref.length; j < len; index = (j += 1)) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } } } - - img = ""; - if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; - } else { - if (avatar) { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0 + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; } + anyUser = { + name: name, + id: null + }; + data.results.unshift(anyUser); } - - return ` - <li data-user-id=${user.id}> - <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> - ${img} - <strong class='dropdown-menu-user-full-name'> - ${user.name} - </strong> - ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} - </a> - </li> - `; } - }); - }; - })(this)); - $('.ajax-users-select').each((function(_this) { - return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('project-id'); - options.groupId = $(select).data('group-id'); - options.showCurrentUser = $(select).data('current-user'); - options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); - options.authorId = $(select).data('author-id'); - options.skipUsers = $(select).data('skip-users'); - showNullUser = $(select).data('null-user'); - showAnyUser = $(select).data('any-user'); - showEmailUser = $(select).data('email-user'); - firstUser = $(select).data('first-user'); - return $(select).select2({ - placeholder: "Search for a user", - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - return _this.users(query.term, options, function(users) { - var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; - data = { - results: users - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - ref = data.results; - for (index = j = 0, len = ref.length; j < len; index = (j += 1)) { - obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; - } - } - } - if (showNullUser) { - nullUser = { - name: 'Unassigned', - id: 0 - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - name: name, - id: null - }; - data.results.unshift(anyUser); - } - } - if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { - var trimmed = query.term.trim(); - emailUser = { - name: "Invite \"" + query.term + "\"", - username: trimmed, - id: trimmed - }; - data.results.unshift(emailUser); - } - return query.callback(data); - }); - }, - initSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: "ajax-users-dropdown", - // we do not want to escape markup since we are displaying html in results - escapeMarkup: function(m) { - return m; + if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { + var trimmed = query.term.trim(); + emailUser = { + name: "Invite \"" + query.term + "\"", + username: trimmed, + id: trimmed + }; + data.results.unshift(emailUser); } + return query.callback(data); }); - }; - })(this)); - } - - UsersSelect.prototype.initSelection = function(element, callback) { - var id, nullUser; - id = $(element).val(); - if (id === "0") { - nullUser = { - name: 'Unassigned' - }; - return callback(nullUser); - } else if (id !== "") { - return this.user(id, callback); - } - }; - - UsersSelect.prototype.formatResult = function(user) { - var avatar; - if (user.avatar_url) { - avatar = user.avatar_url; - } else { - avatar = gon.default_avatar_url; - } - return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; - }; - - UsersSelect.prototype.formatSelection = function(user) { - return user.name; - }; - - UsersSelect.prototype.user = function(user_id, callback) { - if (!/^\d+$/.test(user_id)) { - return false; - } - - var url; - url = this.buildUrl(this.userPath); - url = url.replace(':id', user_id); - return $.ajax({ - url: url, - dataType: "json" - }).done(function(user) { - return callback(user); - }); - }; - - // Return users list. Filtered by query - // Only active users retrieved - UsersSelect.prototype.users = function(query, options, callback) { - var url; - url = this.buildUrl(this.usersPath); - return $.ajax({ - url: url, - data: { - search: query, - per_page: 20, - active: true, - project_id: options.projectId || null, - group_id: options.groupId || null, - skip_ldap: options.skipLdap || null, - todo_filter: options.todoFilter || null, - todo_state_filter: options.todoStateFilter || null, - current_user: options.showCurrentUser || null, - push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, - author_id: options.authorId || null, - skip_users: options.skipUsers || null }, - dataType: "json" - }).done(function(users) { - return callback(users); + initSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: "ajax-users-dropdown", + // we do not want to escape markup since we are displaying html in results + escapeMarkup: function(m) { + return m; + } }); }; - - UsersSelect.prototype.buildUrl = function(url) { - if (gon.relative_url_root != null) { - url = gon.relative_url_root.replace(/\/$/, '') + url; - } - return url; + })(this)); +} + +UsersSelect.prototype.initSelection = function(element, callback) { + var id, nullUser; + id = $(element).val(); + if (id === "0") { + nullUser = { + name: 'Unassigned' }; - - return UsersSelect; - })(); -}).call(window); + return callback(nullUser); + } else if (id !== "") { + return this.user(id, callback); + } +}; + +UsersSelect.prototype.formatResult = function(user) { + var avatar; + if (user.avatar_url) { + avatar = user.avatar_url; + } else { + avatar = gon.default_avatar_url; + } + return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; +}; + +UsersSelect.prototype.formatSelection = function(user) { + return user.name; +}; + +UsersSelect.prototype.user = function(user_id, callback) { + if (!/^\d+$/.test(user_id)) { + return false; + } + + var url; + url = this.buildUrl(this.userPath); + url = url.replace(':id', user_id); + return $.ajax({ + url: url, + dataType: "json" + }).done(function(user) { + return callback(user); + }); +}; + +// Return users list. Filtered by query +// Only active users retrieved +UsersSelect.prototype.users = function(query, options, callback) { + var url; + url = this.buildUrl(this.usersPath); + return $.ajax({ + url: url, + data: { + search: query, + per_page: 20, + active: true, + project_id: options.projectId || null, + group_id: options.groupId || null, + skip_ldap: options.skipLdap || null, + todo_filter: options.todoFilter || null, + todo_state_filter: options.todoStateFilter || null, + current_user: options.showCurrentUser || null, + push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, + author_id: options.authorId || null, + skip_users: options.skipUsers || null + }, + dataType: "json" + }).done(function(users) { + return callback(users); + }); +}; + +UsersSelect.prototype.buildUrl = function(url) { + if (gon.relative_url_root != null) { + url = gon.relative_url_root.replace(/\/$/, '') + url; + } + return url; +}; + +export default UsersSelect; diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index fbae85c85f6..7ac7ceaa4e5 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -62,10 +62,12 @@ export default { commitAuthor() { let commitAuthorInformation; + if (!this.pipeline || !this.pipeline.commit) { + return null; + } + // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline && - this.pipeline.commit && - this.pipeline.commit.author) { + if (this.pipeline.commit.author) { // 2. if person who is an author of a commit is a GitLab user // he/she can have a GitLab avatar if (this.pipeline.commit.author.avatar_url) { @@ -77,11 +79,8 @@ export default { avatar_url: this.pipeline.commit.author_gravatar_url, }); } - } - - // 4. If committer is not a GitLab User he/she can have a Gravatar - if (this.pipeline && - this.pipeline.commit) { + // 4. If committer is not a GitLab User he/she can have a Gravatar + } else { commitAuthorInformation = { avatar_url: this.pipeline.commit.author_gravatar_url, web_url: `mailto:${this.pipeline.commit.author_email}`, diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index eb73f7cc794..678af978edd 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -112,7 +112,7 @@ } } - .issue_edited_ago, + .issue-edited-ago, .note_edited_ago { display: none; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 75c57e369e7..87592926930 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -693,12 +693,17 @@ } .merge-request-tabs-holder { + top: 0; + z-index: 10; background-color: $white-light; + @media(min-width: $screen-sm-min) { + position: sticky; + position: -webkit-sticky; + } + &.affix { - top: 0; left: 0; - z-index: 10; transition: right .15s; @media (max-width: $screen-xs-max) { diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 6cc1cc8e263..136d0c79467 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -28,9 +28,6 @@ nav.navbar-collapse.collapse, .profiler-results, .tree-ref-holder, .tree-holder .breadcrumb, -.blob-commit-info, -.file-title, -.file-holder, .nav, .btn, ul.notes-form, @@ -43,6 +40,11 @@ ul.notes-form, display: none!important; } +pre { + page-break-before: avoid; + page-break-inside: auto; +} + .page-gutter { padding-top: 0; padding-left: 0; diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index b199f18da1e..4cf645d6341 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -60,17 +60,24 @@ module IssuableActions end def bulk_update_params - params.require(:update).permit( + permitted_keys = [ :issuable_ids, :assignee_id, :milestone_id, :state_event, :subscription_event, - assignee_ids: [], label_ids: [], add_label_ids: [], remove_label_ids: [] - ) + ] + + if resource_name == 'issue' + permitted_keys << { assignee_ids: [] } + else + permitted_keys.unshift(:assignee_id) + end + + params.require(:update).permit(permitted_keys) end def resource_name diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index d4ab6782444..afd110adcad 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -4,7 +4,7 @@ module RoutableActions def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil) routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?) - if routable_authorized?(routable_klass, routable, extra_authorization_proc) + if routable_authorized?(routable, extra_authorization_proc) ensure_canonical_path(routable, requested_full_path) routable else @@ -13,8 +13,8 @@ module RoutableActions end end - def routable_authorized?(routable_klass, routable, extra_authorization_proc) - action = :"read_#{routable_klass.to_s.underscore}" + def routable_authorized?(routable, extra_authorization_proc) + action = :"read_#{routable.class.to_s.underscore}" return false unless can?(current_user, action, routable) if extra_authorization_proc @@ -30,7 +30,7 @@ module RoutableActions canonical_path = routable.full_path if canonical_path != requested_path if canonical_path.casecmp(requested_path) != 0 - flash[:notice] = "Project '#{requested_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." + flash[:notice] = "#{routable.class.to_s.titleize} '#{requested_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." end redirect_to request.original_url.sub(requested_path, canonical_path) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 7c0459648ef..760ba246e3e 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -208,7 +208,6 @@ class Projects::IssuesController < Projects::ApplicationController description: view_context.markdown_field(@issue, :description), description_text: @issue.description, task_status: @issue.task_status, - issue_number: @issue.iid, updated_at: @issue.updated_at } end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index c85e96cf78d..206d0753f08 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -42,7 +42,10 @@ module ButtonHelper class: "btn #{css_class}", data: data, type: :button, - title: title + title: title, + aria: { + label: title + } end def http_clone_button(project, placement = 'right', append_link: true) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index fbbce6876c2..9290e4ec133 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -67,9 +67,10 @@ module IssuablesHelper end def users_dropdown_label(selected_users) - if selected_users.length == 0 + case selected_users.length + when 0 "Unassigned" - elsif selected_users.length == 1 + when 1 selected_users[0].name else "#{selected_users[0].name} + #{selected_users.length - 1} more" @@ -136,11 +137,9 @@ module IssuablesHelper author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "hidden-sm hidden-md hidden-lg") end - if issuable.tasks? - output << " ".html_safe - output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") - output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") - end + output << " ".html_safe + output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") + output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") output end diff --git a/app/models/project.rb b/app/models/project.rb index be4960aef8a..65745fd6d37 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -175,7 +175,7 @@ class Project < ActiveRecord::Base has_many :builds, class_name: 'Ci::Build' # the builds are created from the commit_statuses has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' - has_many :variables, dependent: :destroy, class_name: 'Ci::Variable' + has_many :variables, class_name: 'Ci::Variable' has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger' has_many :environments, dependent: :destroy has_many :deployments, dependent: :destroy diff --git a/app/models/repository.rb b/app/models/repository.rb index 11163ec77e9..b1563bfba8b 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -517,8 +517,8 @@ class Repository cache_method :avatar def readme - if head = tree(:head) - ReadmeBlob.new(head.readme, self) + if readme = tree(:head)&.readme + ReadmeBlob.new(readme, self) end end diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 40ff9b8b867..5d42a89fced 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -7,7 +7,7 @@ module Issuable ids = params.delete(:issuable_ids).split(",") items = model_class.where(id: ids) - %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key| + permitted_attrs(type).each do |key| params.delete(key) unless params[key].present? end @@ -26,5 +26,17 @@ module Issuable success: !items.count.zero? } end + + private + + def permitted_attrs(type) + attrs = %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event) + + if type == 'issue' + attrs.push(:assignee_ids) + else + attrs.push(:assignee_id) + end + end end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 174e7c6e95b..0766df50ed2 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -79,7 +79,7 @@ module SystemNoteService text_parts.join(' and ') elsif old_assignees.any? - "removed all assignees" + "removed assignee" elsif issue.assignees.any? "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" end diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 9999a4362c6..c52a515226e 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -46,6 +46,3 @@ .form-actions = submit_tag 'Continue to the next step', class: 'btn btn-create' - -:javascript - new UsersSelect(); diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 0e64ebd71b8..b689991bb6d 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -13,7 +13,7 @@ .location-badge= label .search-input-wrap .dropdown{ data: { url: search_autocomplete_path } } - = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url } + = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }, aria: { label: 'Search' } .dropdown-menu.dropdown-select = dropdown_content do %ul diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index 7ca0ec8ed2b..efec69662f3 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -3,9 +3,9 @@ - page_title "Boards" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('filtered_search') - = page_specific_javascript_bundle_tag('boards') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'filtered_search' + = webpack_bundle_tag 'boards' %script#js-board-template{ type: "text/x-template" }= render "projects/boards/components/board" %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index 796ecdfd014..55575c5e412 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -17,11 +17,13 @@ .help-block.text-danger.js-branch-name-error .form-group = label_tag :ref, 'Create from', class: 'control-label' - .col-sm-10.dropdown.create-from - = hidden_field_tag :ref, default_ref - = button_tag type: 'button', title: default_ref, class: 'dropdown-toggle form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do - .text-left.dropdown-toggle-text= default_ref - = render 'shared/ref_dropdown', dropdown_class: 'wide' + .col-sm-10.create-from + .dropdown + = hidden_field_tag :ref, default_ref + = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do + .text-left.dropdown-toggle-text= default_ref + = icon('chevron-down') + = render 'shared/ref_dropdown', dropdown_class: 'wide' .help-block Existing branch name, tag, or commit SHA .form-actions = button_tag 'Create branch', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 8f32d2b72e5..24153b8b59a 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -37,6 +37,6 @@ .commit-actions.flex-row.hidden-xs - if commit.status(ref) = render_commit_status(commit, ref: ref) - = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard") = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" + = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard") = link_to_browse_code(project, commit) diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 160345cfaa5..d9643dc7957 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -40,8 +40,8 @@ .form_group.prepend-top-20.sharing-and-permissions .row.js-visibility-select .col-md-9 - %label.label-light - = label_tag :project_visibility, 'Project Visibility', class: 'label-light' + .label-light + = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level = link_to "(?)", help_page_path("public_access/public_access") %span.help-block .col-md-3.visibility-select-container diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 6bc6bf76e18..dba092c8844 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -17,7 +17,7 @@ .description %strong Create a merge request %span - Creates a branch named after this issue and a merge request. The source branch is '#{@project.default_branch}' by default. + Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'. %li.divider.droplab-item-ignore %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } .menu-item @@ -26,4 +26,4 @@ .description %strong Create a branch %span - Creates a branch named after this issue. The source branch is '#{@project.default_branch}' by default. + Creates a branch named after this issue, from '#{@project.default_branch}'. diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 4ac0bc1d028..60900e9d660 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -7,7 +7,8 @@ = render "projects/issues/head" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('filtered_search') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'filtered_search' = content_for :meta_tags do = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues") diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 9084883eb3e..f66724900de 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -51,12 +51,17 @@ .issue-details.issuable-details .detail-page-description.content-block - .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), - "can-update-tasks-class" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', + #js-issuable-app{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), + "can-update" => can?(current_user, :update_issue, @issue).to_s, + "issuable-ref" => @issue.to_reference, } } - .issue-title-entrypoint + %h2.title= markdown_field(@issue, :title) + - if @issue.description.present? + .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } + .wiki= markdown_field(@issue, :description) + %textarea.hidden.js-task-list-field= @issue.description - = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } // This element is filled in using JavaScript. diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 6bf0035e051..502220232a1 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -8,7 +8,8 @@ = render 'projects/last_push' - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('filtered_search') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'filtered_search' - if @project.merge_requests.exists? %div{ class: container_class } diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 9e292729425..e180cb8bad1 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -30,7 +30,7 @@ #{root_url}#{current_user.username}/ = f.hidden_field :namespace_id, value: current_user.namespace_id .form-group.col-xs-12.col-sm-6.project-path - = f.label :namespace_id, class: 'label-light' do + = f.label :path, class: 'label-light' do %span Project name = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index dd35c3055f2..a597d745e33 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -6,7 +6,7 @@ = render "projects/pipelines/head" %div{ class: container_class } - #scheduling-pipelines-callout + #pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipeline_schedules') } } .top-area - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) } = render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 34a4d7398bc..0992a65f7cd 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -17,7 +17,7 @@ %li = http_clone_button(project) - = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true + = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } .input-group-btn = clipboard_button(target: '#project_clone', title: "Copy URL to clipboard") diff --git a/app/views/shared/empty_states/icons/_pipelines_empty.svg b/app/views/shared/empty_states/icons/_pipelines_empty.svg index 8119d5bebe0..7c672538097 100644 --- a/app/views/shared/empty_states/icons/_pipelines_empty.svg +++ b/app/views/shared/empty_states/icons/_pipelines_empty.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd" transform="translate(0-3)"><g transform="translate(0 105)"><g fill="#e5e5e5"><rect width="78" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g transform="translate(0 4)"><path fill="#98d7b2" fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path fill="#31af64" d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(69 3)"><path fill="#e5e5e5" fill-rule="nonzero" d="m4 11.99v60.02c0 4.413 3.583 7.99 8 7.99h89.991c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99m-4 0c0-6.622 5.378-11.99 12-11.99h89.991c6.629 0 12 5.367 12 11.99v60.02c0 6.622-5.378 11.99-12 11.99h-89.991c-6.629 0-12-5.367-12-11.99v-60.02m52.874 80.3l-13.253-15.292h34.76l-13.253 15.292c-2.237 2.582-6.01 2.585-8.253 0m3.02-2.62c.644.743 1.564.743 2.207 0l7.516-8.673h-17.24l7.516 8.673"/><rect width="18" height="6" x="15" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="39" y="39" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="33" y="55" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="39" y="23" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="57" y="55" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="15" y="55" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="81" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="15" y="39" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="57" y="23" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="69" y="23" rx="3"/><rect width="6" height="6" x="75" y="39" rx="3"/></g><rect width="6" height="6" x="63" y="39" fill="#e52c5a" rx="3"/></g><g transform="matrix(.70711-.70711.70711.70711 84.34 52.5)"><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26"/><path fill="#fff" fill-opacity=".3" stroke="#6b4fbb" stroke-width="8" d="m31 71c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30" transform="matrix(.86603.5-.5.86603 26.663-17.507)"/></g></g></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd"><g transform="translate(0 102)"><g fill="#e5e5e5"><rect width="74" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g fill="#31af64" transform="translate(0 4)"><path fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(73 4)"><path stroke="#e5e5e5" stroke-width="4" d="m64.82 76h33.18c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99v60.02c0 4.413 3.583 7.99 8 7.99h31.935l9.263 9.855c1.725 1.835 4.631 1.833 6.354 0l9.263-9.855"/><rect width="18" height="6" x="11" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="35" y="35" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="29" y="51" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="35" y="19" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="53" y="51" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="11" y="51" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="77" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="11" y="35" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="53" y="19" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="65" y="19" rx="3"/><rect width="6" height="6" x="71" y="35" rx="3"/></g><rect width="6" height="6" x="59" y="35" fill="#e52c5a" rx="3"/></g><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26" transform="matrix(.70711-.70711.70711.70711 84.34 49.5)"/></g></svg> diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml index 36bbb1148d4..217af7c9fac 100644 --- a/app/views/shared/issuable/_assignees.html.haml +++ b/app/views/shared/issuable/_assignees.html.haml @@ -1,9 +1,8 @@ - max_render = 3 - max = [max_render, issue.assignees.length].min -- issue.assignees.each_with_index do |assignee, index| - - if index < max - = link_to_member(@project, assignee, name: false, title: "Assigned to :name") +- issue.assignees.take(max).each do |assignee| + = link_to_member(@project, assignee, name: false, title: "Assigned to :name") - if issue.assignees.length > max_render - counter = issue.assignees.length - max_render diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 1a12f110945..6cd03f028a9 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -71,7 +71,6 @@ = render 'shared/labels_row', labels: @labels :javascript - new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); new IssueStatusSelect(); diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index f7b87171573..622e2f33eea 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -150,7 +150,6 @@ - unless type === :boards_modal :javascript - new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); new IssueStatusSelect(); diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index c36a45098a8..e9ce7b7ce9c 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,4 +1,4 @@ -- if issuable.instance_of?(Issue) +- if issuable.is_a?(Issue) #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } - else .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } @@ -33,17 +33,17 @@ - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } - - if issuable.instance_of?(Issue) - - if issuable.assignees.length == 0 + - title = 'Select assignee' + + - if issuable.is_a?(Issue) + - unless issuable.assignees.any? = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil - - title = 'Select assignee' - options[:toggle_class] += ' js-multiselect js-save-user-data' - - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]" - - options[:data][:multi_select] = true - - options[:data]['dropdown-title'] = title - - options[:data]['dropdown-header'] = 'Assignee' - - options[:data]['max-select'] = 1 - - else - - title = 'Select assignee' + - data = { field_name: "#{issuable.to_ability_name}[assignee_ids][]" } + - data[:multi_select] = true + - data['dropdown-title'] = title + - data['dropdown-header'] = 'Assignee' + - data['max-select'] = 1 + - options[:data].merge!(data) = dropdown_tag(title, options: options) diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml index c33474ac3b4..66091d95a91 100644 --- a/app/views/shared/issuable/form/_issue_assignee.html.haml +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -1,8 +1,9 @@ - issue = issuable +- assignees = issue.assignees .block.assignee .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) } - - if issue.assignees.any? - - issue.assignees.each do |assignee| + - if assignees.any? + - assignees.each do |assignee| = link_to_member(@project, assignee, size: 24) - else = icon('user', 'aria-hidden': 'true') @@ -12,8 +13,8 @@ - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' .value.hide-collapsed - - if issue.assignees.any? - - issue.assignees.each do |assignee| + - if assignees.any? + - assignees.each do |assignee| = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do %span.username = assignee.to_reference diff --git a/changelogs/unreleased/31781-print-rendered-files-not-possible.yml b/changelogs/unreleased/31781-print-rendered-files-not-possible.yml new file mode 100644 index 00000000000..14915823ff7 --- /dev/null +++ b/changelogs/unreleased/31781-print-rendered-files-not-possible.yml @@ -0,0 +1,4 @@ +--- +title: Include the blob content when printing a blob page +merge_request: 11247 +author: diff --git a/changelogs/unreleased/issue-templates-summary-lines.yml b/changelogs/unreleased/issue-templates-summary-lines.yml new file mode 100644 index 00000000000..0c8c3d884ce --- /dev/null +++ b/changelogs/unreleased/issue-templates-summary-lines.yml @@ -0,0 +1,4 @@ +--- +title: Add summary lines for collapsed details in the bug report template +merge_request: +author: diff --git a/changelogs/unreleased/pipeline-schedules-callout-docs-url.yml b/changelogs/unreleased/pipeline-schedules-callout-docs-url.yml new file mode 100644 index 00000000000..b21bb162380 --- /dev/null +++ b/changelogs/unreleased/pipeline-schedules-callout-docs-url.yml @@ -0,0 +1,4 @@ +--- +title: Pass docsUrl to pipeline schedules callout component. +merge_request: !1126 +author: diff --git a/changelogs/unreleased/winh-pipeline-author-link.yml b/changelogs/unreleased/winh-pipeline-author-link.yml new file mode 100644 index 00000000000..1b903d1e357 --- /dev/null +++ b/changelogs/unreleased/winh-pipeline-author-link.yml @@ -0,0 +1,4 @@ +--- +title: Link to commit author user page from pipelines +merge_request: 11100 +author: diff --git a/changelogs/unreleased/zj-clean-up-ci-variables-table.yml b/changelogs/unreleased/zj-clean-up-ci-variables-table.yml new file mode 100644 index 00000000000..ea2db40d590 --- /dev/null +++ b/changelogs/unreleased/zj-clean-up-ci-variables-table.yml @@ -0,0 +1,4 @@ +--- +title: Cleanup ci_variables schema and table +merge_request: +author: diff --git a/config/webpack.config.js b/config/webpack.config.js index 7e413c8493e..32064138ae4 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -141,13 +141,13 @@ var config = { 'diff_notes', 'environments', 'environments_folder', + 'filtered_search', 'sidebar', 'issue_show', 'merge_conflicts', 'notebook_viewer', 'pdf_viewer', 'pipelines', - 'balsamiq_viewer', 'pipelines_graph', ], minChunks: function(module, count) { diff --git a/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb b/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb new file mode 100644 index 00000000000..41c687a4f6e --- /dev/null +++ b/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb @@ -0,0 +1,12 @@ +class AddNotNullContraintsToCiVariables < ActiveRecord::Migration + DOWNTIME = false + + def up + change_column(:ci_variables, :key, :string, null: false) + change_column(:ci_variables, :project_id, :integer, null: false) + end + + def down + # no op + end +end diff --git a/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb b/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb new file mode 100644 index 00000000000..20ecaa2c36c --- /dev/null +++ b/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb @@ -0,0 +1,24 @@ +class AddForeignKeyToCiVariables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + execute <<~SQL + DELETE FROM ci_variables + WHERE NOT EXISTS ( + SELECT true + FROM projects + WHERE projects.id = ci_variables.project_id + ) + SQL + + add_concurrent_foreign_key(:ci_variables, :projects, column: :project_id) + end + + def down + remove_foreign_key(:ci_variables, column: :project_id) + end +end diff --git a/db/schema.rb b/db/schema.rb index 6eaab4832e9..60077ffd812 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170508170547) do +ActiveRecord::Schema.define(version: 20170508190732) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -347,12 +347,12 @@ ActiveRecord::Schema.define(version: 20170508170547) do add_index "ci_triggers", ["project_id"], name: "index_ci_triggers_on_project_id", using: :btree create_table "ci_variables", force: :cascade do |t| - t.string "key" + t.string "key", null: false t.text "value" t.text "encrypted_value" t.string "encrypted_value_salt" t.string "encrypted_value_iv" - t.integer "project_id" + t.integer "project_id", null: false end add_index "ci_variables", ["project_id"], name: "index_ci_variables_on_project_id", using: :btree @@ -1417,6 +1417,7 @@ ActiveRecord::Schema.define(version: 20170508170547) do add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade + add_foreign_key "ci_variables", "projects", name: "fk_ada5eb64b3", on_delete: :cascade add_foreign_key "container_repositories", "projects" add_foreign_key "issue_assignees", "issues", on_delete: :cascade add_foreign_key "issue_assignees", "users", on_delete: :cascade diff --git a/doc/api/README.md b/doc/api/README.md index d444ce94573..1b0f6470b13 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -61,8 +61,9 @@ The following documentation is for the [internal CI API](ci/README.md): ## Authentication -All API requests require authentication via a session cookie or token. There are -three types of tokens available: private tokens, OAuth 2 tokens, and personal +Most API requests require authentication via a session cookie or token. For those cases where it is not required, this will be mentioned in the documentation +for each individual endpoint. For example, the [`/projects/:id` endpoint](projects.md). +There are three types of tokens available: private tokens, OAuth 2 tokens, and personal access tokens. If authentication information is invalid or omitted, an error message will be diff --git a/doc/api/issues.md b/doc/api/issues.md index 75794cc8d04..3f949ca5667 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -100,7 +100,7 @@ Example response: ] ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## List group issues @@ -192,7 +192,7 @@ Example response: ] ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## List project issues @@ -284,7 +284,7 @@ Example response: ] ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Single issue @@ -359,7 +359,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## New issue @@ -375,7 +375,7 @@ POST /projects/:id/issues | `title` | string | yes | The title of an issue | | `description` | string | no | The description of an issue | | `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | -| `assignee_ids` | Array[integer] | no | The ID of a user to assign issue | +| `assignee_ids` | Array[integer] | no | The ID of the users to assign issue | | `milestone_id` | integer | no | The ID of a milestone to assign issue | | `labels` | string | no | Comma-separated label names for an issue | | `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | @@ -421,7 +421,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Edit issue @@ -439,7 +439,7 @@ PUT /projects/:id/issues/:issue_iid | `title` | string | no | The title of an issue | | `description` | string | no | The description of an issue | | `confidential` | boolean | no | Updates an issue to be confidential | -| `assignee_ids` | Array[integer] | no | The ID of a user to assign the issue to | +| `assignee_ids` | Array[integer] | no | The ID of the users to assign the issue to | | `milestone_id` | integer | no | The ID of a milestone to assign the issue to | | `labels` | string | no | Comma-separated label names for an issue | | `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it | @@ -484,7 +484,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Delete an issue @@ -570,7 +570,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Subscribe to an issue @@ -635,7 +635,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Unsubscribe from an issue @@ -757,7 +757,7 @@ Example response: } ``` -**Note**: `assignee` column is deprecated, it shows the first assignee only. +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. ## Set a time estimate for an issue diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md index 1702c2184f2..6892905dd94 100644 --- a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md +++ b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md @@ -1,6 +1,6 @@ # How to configure LDAP with GitLab CE -> **Type:** admin guide || +> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** admin guide || > **Level:** intermediary || > **Author:** [Chris Wilson](https://gitlab.com/MrChrisW) || > **Publication date:** 2017/05/03 diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 1e81905c081..5b09f79f143 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -198,10 +198,17 @@ You can combine one or more of the following: the `.md` document that you're working on is located. Always prepend their names with the name of the document that they will be included in. For example, if there is a document called `twitter.md`, then a valid image name - could be `twitter_login_screen.png`. + could be `twitter_login_screen.png`. [**Exception**: images for + [articles](writing_documentation.md#technical-articles) should be + put in a directory called `img` underneath `/articles/article_title/img/`, therefore, + there's no need to prepend the document name to their filenames.] - Images should have a specific, non-generic name that will differentiate them. - Keep all file names in lower case. - Consider using PNG images instead of JPEG. +- Compress all images with <https://tinypng.com/> or similar tool. +- Compress gifs with <https://ezgif.com/optimize> or similar toll. +- Images should be used (only when necessary) to _illustrate_ the description +of a process, not to _replace_ it. Inside the document: diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md index 2814c18e0b6..657a826d7ee 100644 --- a/doc/development/writing_documentation.md +++ b/doc/development/writing_documentation.md @@ -52,11 +52,13 @@ Every **Technical Article** contains, in the very beginning, a blockquote with t - A reference to the **type of article** (user guide, admin guide, tech overview, tutorial) - A reference to the **knowledge level** expected from the reader to be able to follow through (beginner, intermediate, advanced) - A reference to the **author's name** and **GitLab.com handle** +- A reference of the **publication date** ```md -> **Type:** tutorial || +> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** tutorial || > **Level:** intermediary || -> **Author:** [Name Surname](https://gitlab.com/username) +> **Author:** [Name Surname](https://gitlab.com/username) || +> **Publication date:** AAAA/MM/DD ``` #### Technical Articles - Writing Method diff --git a/doc/install/installation.md b/doc/install/installation.md index 5615b2a534b..5bba405f159 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -109,14 +109,19 @@ Then select 'Internet Site' and press enter to confirm the hostname. ## 2. Ruby -**Note:** The current supported Ruby version is 2.3.x. GitLab 9.0 dropped support -for Ruby 2.1.x. +The Ruby interpreter is required to run GitLab. + +**Note:** The current supported Ruby (MRI) version is 2.3.x. GitLab 9.0 dropped +support for Ruby 2.1.x. The use of Ruby version managers such as [RVM], [rbenv] or [chruby] with GitLab in production, frequently leads to hard to diagnose problems. For example, GitLab Shell is called from OpenSSH, and having a version manager can prevent pushing and pulling over SSH. Version managers are not supported and we strongly -advise everyone to follow the instructions below to use a system Ruby. +advise everyone to follow the instructions below to use a system Ruby. + +Linux distributions generally have older versions of Ruby available, so these +instructions are designed to install Ruby from the official source code. Remove the old Ruby 1.8 if present: @@ -132,7 +137,7 @@ Download Ruby and compile it: make sudo make install -Install the Bundler Gem: +Then install the Bundler Gem: sudo gem install bundler --no-ri --no-rdoc diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 35586091f74..148796b73d4 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -122,15 +122,22 @@ To change the Unicorn workers when you have the Omnibus package please see [the We currently support the following databases: -- PostgreSQL (recommended) +- PostgreSQL - MySQL/MariaDB -If you want to run the database separately, expect a size of about 1 MB per user. +We _highly_ recommend the use of PostgreSQL instead of MySQL/MariaDB as not all +features of GitLab may work with MySQL/MariaDB. Existing users using GitLab with +MySQL/MariaDB are advised to migrate to PostgreSQL instead. + +The server running the database should have _at least_ 5-10 GB of storage +available, though the exact requirements depend on the size of the GitLab +installation (e.g. the number of users, projects, etc). ### PostgreSQL Requirements -As of GitLab 9.0, PostgreSQL 9.6 is recommended. Lower versions of PostgreSQL -may work but primary testing and developement takes place using PostgreSQL 9.6. +As of GitLab 9.0, PostgreSQL 9.2 or newer is required, and earlier versions are +not supported. We highly recommend users to use at least PostgreSQL 9.6 as this +is the PostgreSQL version used for development and testing. Users using PostgreSQL must ensure the `pg_trgm` extension is loaded into every GitLab database. This extension can be enabled (using a PostgreSQL super user) @@ -165,4 +172,4 @@ about it, check the [Prometheus documentation](../administration/monitoring/prom We support the current and the previous major release of Firefox, Chrome/Chromium, Safari and Microsoft browsers (Microsoft Edge and Internet Explorer 11). -Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version.
\ No newline at end of file +Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version. diff --git a/doc/topics/authentication/index.md b/doc/topics/authentication/index.md index 3e756d96ed2..0c0d482499a 100644 --- a/doc/topics/authentication/index.md +++ b/doc/topics/authentication/index.md @@ -19,7 +19,7 @@ This page gathers all the resources for the topic **Authentication** within GitL - [Enforce Two-factor Authentication (2FA)](../../security/two_factor_authentication.md#enforce-two-factor-authentication-2fa) - **Articles:** - [How to Configure LDAP with GitLab CE](../../articles/how_to_configure_ldap_gitlab_ce/index.md) - - [How to Configure LDAP with GitLab EE](https://docs.gitlab.com/articles/how_to_configure_ldap_gitlab_ee/) + - [How to Configure LDAP with GitLab EE](https://docs.gitlab.com/ee/articles/how_to_configure_ldap_gitlab_ee/) - [Feature Highlight: LDAP Integration](https://about.gitlab.com/2014/07/10/feature-highlight-ldap-sync/) - [Debugging LDAP](https://about.gitlab.com/handbook/support/workflows/ldap/debugging_ldap.html) - **Integrations:** diff --git a/doc/user/project/new_ci_build_permissions_model.md b/doc/user/project/new_ci_build_permissions_model.md index f846736028f..051a28efea6 100644 --- a/doc/user/project/new_ci_build_permissions_model.md +++ b/doc/user/project/new_ci_build_permissions_model.md @@ -89,7 +89,7 @@ to steal the tokens of other jobs. ## Pipeline triggers -Since 9.0 [pipelnie triggers][triggers] do support the new permission model. +Since 9.0 [pipeline triggers][triggers] do support the new permission model. The new triggers do impersonate their associated user including their access to projects and their project permissions. To migrate trigger to use new permisison model use **Take ownership**. diff --git a/doc/user/project/pages/getting_started_part_four.md b/doc/user/project/pages/getting_started_part_four.md index 50767095aa0..bd0cb437924 100644 --- a/doc/user/project/pages/getting_started_part_four.md +++ b/doc/user/project/pages/getting_started_part_four.md @@ -1,8 +1,9 @@ # GitLab Pages from A to Z: Part 4 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: intermediate || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) diff --git a/doc/user/project/pages/getting_started_part_one.md b/doc/user/project/pages/getting_started_part_one.md index e92549aa0df..2f104c7becc 100644 --- a/doc/user/project/pages/getting_started_part_one.md +++ b/doc/user/project/pages/getting_started_part_one.md @@ -1,15 +1,16 @@ # GitLab Pages from A to Z: Part 1 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: beginner || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - **Part 1: Static sites and GitLab Pages domains** - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) - [Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](getting_started_part_three.md) - [Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](getting_started_part_four.md) -## GitLab Pages form A to Z +## GitLab Pages from A to Z This is a comprehensive guide, made for those who want to publish a website with GitLab Pages but aren't familiar with diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md index 80f16e43e20..53fd1786cfa 100644 --- a/doc/user/project/pages/getting_started_part_three.md +++ b/doc/user/project/pages/getting_started_part_three.md @@ -1,8 +1,9 @@ # GitLab Pages from A to Z: Part 3 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: beginner || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md index 578ad13f5df..c91e2d8c261 100644 --- a/doc/user/project/pages/getting_started_part_two.md +++ b/doc/user/project/pages/getting_started_part_two.md @@ -1,8 +1,9 @@ # GitLab Pages from A to Z: Part 2 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: beginner || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - **Part 2: Quick start guide - Setting up GitLab Pages** diff --git a/lib/api/helpers/common_helpers.rb b/lib/api/helpers/common_helpers.rb index 6236fdd43ca..322624c6092 100644 --- a/lib/api/helpers/common_helpers.rb +++ b/lib/api/helpers/common_helpers.rb @@ -2,11 +2,11 @@ module API module Helpers module CommonHelpers def convert_parameters_from_legacy_format(params) - if params[:assignee_id].present? - params[:assignee_ids] = [params.delete(:assignee_id)] + params.tap do |params| + if params[:assignee_id].present? + params[:assignee_ids] = [params.delete(:assignee_id)] + end end - - params end end end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 298b1a1f4e6..f04a907004c 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -278,6 +278,16 @@ module Gitlab raise 'rename_column_concurrently can not be run inside a transaction' end + old_col = column_for(table, old) + new_type = type || old_col.type + + add_column(table, new, new_type, + limit: old_col.limit, + default: old_col.default, + null: old_col.null, + precision: old_col.precision, + scale: old_col.scale) + trigger_name = rename_trigger_name(table, old, new) quoted_table = quote_table_name(table) quoted_old = quote_column_name(old) @@ -291,16 +301,6 @@ module Gitlab quoted_old, quoted_new) end - old_col = column_for(table, old) - new_type = type || old_col.type - - add_column(table, new, new_type, - limit: old_col.limit, - default: old_col.default, - null: old_col.null, - precision: old_col.precision, - scale: old_col.scale) - update_column_in_batches(table, new, Arel::Table.new(table)[old]) copy_indexes(table, old, new) diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index df8ea225814..15dae3231ca 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -101,7 +101,7 @@ describe GroupsController do get :issues, id: redirect_route.path expect(response).to redirect_to(issues_group_path(group.to_param)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group)) end end end @@ -146,7 +146,7 @@ describe GroupsController do get :merge_requests, id: redirect_route.path expect(response).to redirect_to(merge_requests_group_path(group.to_param)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group)) end end end @@ -249,4 +249,8 @@ describe GroupsController do end end end + + def group_moved_message(redirect_route, group) + "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path." + end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index e46ef447df2..e230944d52e 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -227,7 +227,7 @@ describe ProjectsController do get :show, namespace_id: 'foo', id: 'bar' expect(response).to redirect_to(public_project) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(project_moved_message(redirect_route, public_project)) end end end @@ -473,7 +473,7 @@ describe ProjectsController do get :refs, namespace_id: 'foo', id: 'bar' expect(response).to redirect_to(refs_namespace_project_path(namespace_id: public_project.namespace, id: public_project)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(project_moved_message(redirect_route, public_project)) end end end @@ -487,4 +487,8 @@ describe ProjectsController do expect(JSON.parse(response.body).keys).to match_array(%w(body references)) end end + + def project_moved_message(redirect_route, project) + "Project '#{redirect_route.path}' was moved to '#{project.full_path}'. Please update any links and bookmarks that may still have the old path." + end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 74c5aa44ba9..1d61719f1d0 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -83,7 +83,7 @@ describe UsersController do get :show, username: redirect_route.path expect(response).to redirect_to(user) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user)) end end @@ -162,7 +162,7 @@ describe UsersController do get :calendar, username: redirect_route.path expect(response).to redirect_to(user_calendar_path(user)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user)) end end end @@ -216,7 +216,7 @@ describe UsersController do get :calendar_activities, username: redirect_route.path expect(response).to redirect_to(user_calendar_activities_path(user)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user)) end end end @@ -270,7 +270,7 @@ describe UsersController do get :snippets, username: redirect_route.path expect(response).to redirect_to(user_snippets_path(user)) - expect(controller).to set_flash[:notice].to(/moved/) + expect(controller).to set_flash[:notice].to(user_moved_message(redirect_route, user)) end end end @@ -320,4 +320,8 @@ describe UsersController do end end end + + def user_moved_message(redirect_route, user) + "User '#{redirect_route.path}' was moved to '#{user.full_path}'. Please update any links and bookmarks that may still have the old path." + end end diff --git a/spec/factories/ci/variables.rb b/spec/factories/ci/variables.rb index 6653f0bb5c3..c5fba597c1c 100644 --- a/spec/factories/ci/variables.rb +++ b/spec/factories/ci/variables.rb @@ -2,5 +2,7 @@ FactoryGirl.define do factory :ci_variable, class: Ci::Variable do sequence(:key) { |n| "VARIABLE_#{n}" } value 'VARIABLE_VALUE' + + project factory: :empty_project end end diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 11ef8e1f61b..1238647d3f3 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -158,7 +158,7 @@ describe 'Issue Boards', feature: true, js: true do end page.within(first('.board')) do - find('.card:nth-child(2)').click + find('.card:nth-child(2)').trigger('click') end page.within('.assignee') do diff --git a/spec/features/dashboard/milestone_filter_spec.rb b/spec/features/dashboard/milestone_filter_spec.rb index 628627f70d4..e9ca7fd50e7 100644 --- a/spec/features/dashboard/milestone_filter_spec.rb +++ b/spec/features/dashboard/milestone_filter_spec.rb @@ -43,7 +43,7 @@ describe 'Dashboard > milestone filter', feature: true, js: true do end it 'should not change active Milestone unless clicked' do - find(milestone_select).click + find(milestone_select).trigger('click') expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1) diff --git a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb index cfc782c98ad..c5e0a0f0517 100644 --- a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb +++ b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe 'New Branch Ref Dropdown', :js, :feature do let(:user) { create(:user) } let(:project) { create(:project, :public) } - let(:toggle) { find('.create-from .dropdown-toggle') } + let(:toggle) { find('.create-from .dropdown-menu-toggle') } before do project.add_master(user) diff --git a/spec/javascripts/commit/pipelines/mock_data.js b/spec/javascripts/commit/pipelines/mock_data.js deleted file mode 100644 index 10a60620f49..00000000000 --- a/spec/javascripts/commit/pipelines/mock_data.js +++ /dev/null @@ -1,90 +0,0 @@ -export default { - id: 73, - user: { - name: 'Administrator', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://localhost:3000/root', - }, - path: '/root/review-app/pipelines/73', - details: { - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/73', - }, - duration: null, - finished_at: '2017-01-25T00:00:17.130Z', - stages: [{ - name: 'build', - title: 'build: failed', - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/73#build', - }, - path: '/root/review-app/pipelines/73#build', - dropdown_path: '/root/review-app/pipelines/73/stage.json?stage=build', - }], - artifacts: [], - manual_actions: [ - { - name: 'stop_review', - path: '/root/review-app/builds/1463/play', - }, - { - name: 'name', - path: '/root/review-app/builds/1490/play', - }, - ], - }, - flags: { - latest: true, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: false, - }, - ref: - { - name: 'master', - path: '/root/review-app/tree/master', - tag: false, - branch: true, - }, - coverage: '42.21', - commit: { - id: 'fbd79f04fa98717641deaaeb092a4d417237c2e4', - short_id: 'fbd79f04', - title: 'Update .gitlab-ci.yml', - author_name: 'Administrator', - author_email: 'admin@example.com', - created_at: '2017-01-16T12:13:57.000-05:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - message: 'Update .gitlab-ci.yml', - author: { - name: 'Administrator', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://localhost:3000/root', - }, - author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - commit_url: 'http://localhost:3000/root/review-app/commit/fbd79f04fa98717641deaaeb092a4d417237c2e4', - commit_path: '/root/review-app/commit/fbd79f04fa98717641deaaeb092a4d417237c2e4', - }, - retry_path: '/root/review-app/pipelines/73/retry', - created_at: '2017-01-16T17:13:59.800Z', - updated_at: '2017-01-25T00:00:17.132Z', -}; diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js index ad31448f81c..398c593eec2 100644 --- a/spec/javascripts/commit/pipelines/pipelines_spec.js +++ b/spec/javascripts/commit/pipelines/pipelines_spec.js @@ -1,12 +1,17 @@ import Vue from 'vue'; import PipelinesTable from '~/commit/pipelines/pipelines_table'; -import pipeline from './mock_data'; describe('Pipelines table in Commits and Merge requests', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + let pipeline; + preloadFixtures('static/pipelines_table.html.raw'); + preloadFixtures(jsonFixtureName); beforeEach(() => { loadFixtures('static/pipelines_table.html.raw'); + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); }); describe('successful request', () => { diff --git a/spec/javascripts/fixtures/pipelines.rb b/spec/javascripts/fixtures/pipelines.rb new file mode 100644 index 00000000000..daafbac86db --- /dev/null +++ b/spec/javascripts/fixtures/pipelines.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') } + let(:commit) { create(:commit, project: project) } + let(:commit_without_author) { RepoHelpers.another_sample_commit } + let!(:user) { create(:user, email: commit.author_email) } + let!(:pipeline) { create(:ci_pipeline, project: project, sha: commit.id, user: user) } + let!(:pipeline_without_author) { create(:ci_pipeline, project: project, sha: commit_without_author.id) } + let!(:pipeline_without_commit) { create(:ci_pipeline, project: project, sha: '0000') } + + render_views + + before(:all) do + clean_frontend_fixtures('pipelines/') + end + + before(:each) do + sign_in(admin) + end + + it 'pipelines/pipelines.json' do |example| + get :index, + namespace_id: namespace, + project_id: project, + format: :json + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 1ec4fe58b08..09bca2c3680 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -1,11 +1,8 @@ import Vue from 'vue'; -import $ from 'jquery'; import '~/render_math'; import '~/render_gfm'; -import issueTitleDescription from '~/issue_show/issue_title_description.vue'; -import issueShowData from './mock_data'; - -window.$ = $; +import issuableApp from '~/issue_show/components/app.vue'; +import issueShowData from '../mock_data'; const issueShowInterceptor = data => (request, next) => { next(request.respondWith(JSON.stringify(data), { @@ -16,42 +13,45 @@ const issueShowInterceptor = data => (request, next) => { })); }; -describe('Issue Title', () => { +describe('Issuable output', () => { document.body.innerHTML = '<span id="task_status"></span>'; - let IssueTitleDescriptionComponent; + let vm; beforeEach(() => { - IssueTitleDescriptionComponent = Vue.extend(issueTitleDescription); - }); - - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); - }); - - it('should render a title/description and update title/description on update', (done) => { + const IssuableDescriptionComponent = Vue.extend(issuableApp); Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); - const issueShowComponent = new IssueTitleDescriptionComponent({ + vm = new IssuableDescriptionComponent({ propsData: { - canUpdateIssue: '.css-stuff', + canUpdate: true, endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', + issuableRef: '#1', + initialTitle: '', + initialDescriptionHtml: '', + initialDescriptionText: '', }, }).$mount(); + }); + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); + }); + + it('should render a title/description and update title/description on update', (done) => { setTimeout(() => { expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); - expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); - expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); - expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('this is a description'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); + expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain('this is a description'); Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); setTimeout(() => { expect(document.querySelector('title').innerText).toContain('2 (#1)'); - expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); - expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); - expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('42'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); + expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42'); done(); }); diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js new file mode 100644 index 00000000000..408349cc42d --- /dev/null +++ b/spec/javascripts/issue_show/components/description_spec.js @@ -0,0 +1,99 @@ +import Vue from 'vue'; +import descriptionComponent from '~/issue_show/components/description.vue'; + +describe('Description component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(descriptionComponent); + + if (!document.querySelector('.issuable-meta')) { + const metaData = document.createElement('div'); + metaData.classList.add('issuable-meta'); + metaData.innerHTML = '<span id="task_status"></span><span id="task_status_short"></span>'; + + document.body.appendChild(metaData); + } + + vm = new Component({ + propsData: { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + updatedAt: new Date().toString(), + taskStatus: '', + }, + }).$mount(); + }); + + it('animates description changes', (done) => { + vm.descriptionHtml = 'changed'; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.wiki').classList.contains('issue-realtime-pre-pulse'), + ).toBeTruthy(); + + setTimeout(() => { + expect( + vm.$el.querySelector('.wiki').classList.contains('issue-realtime-trigger-pulse'), + ).toBeTruthy(); + + done(); + }); + }); + }); + + it('re-inits the TaskList when description changed', (done) => { + spyOn(gl, 'TaskList'); + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect( + gl.TaskList, + ).toHaveBeenCalled(); + + done(); + }); + }); + + it('does not re-init the TaskList when canUpdate is false', (done) => { + spyOn(gl, 'TaskList'); + vm.canUpdate = false; + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect( + gl.TaskList, + ).not.toHaveBeenCalled(); + + done(); + }); + }); + + describe('taskStatus', () => { + it('adds full taskStatus', (done) => { + vm.taskStatus = '1 of 1'; + + setTimeout(() => { + expect( + document.querySelector('.issuable-meta #task_status').textContent.trim(), + ).toBe('1 of 1'); + + done(); + }); + }); + + it('adds short taskStatus', (done) => { + vm.taskStatus = '1 of 1'; + + setTimeout(() => { + expect( + document.querySelector('.issuable-meta #task_status_short').textContent.trim(), + ).toBe('1/1 task'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js new file mode 100644 index 00000000000..2f953e7e92e --- /dev/null +++ b/spec/javascripts/issue_show/components/title_spec.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import titleComponent from '~/issue_show/components/title.vue'; + +describe('Title component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(titleComponent); + vm = new Component({ + propsData: { + issuableRef: '#1', + titleHtml: 'Testing <img />', + titleText: 'Testing', + }, + }).$mount(); + }); + + it('renders title HTML', () => { + expect( + vm.$el.innerHTML.trim(), + ).toBe('Testing <img>'); + }); + + it('updates page title when changing titleHtml', (done) => { + spyOn(vm, 'setPageTitle'); + vm.titleHtml = 'test'; + + Vue.nextTick(() => { + expect( + vm.setPageTitle, + ).toHaveBeenCalled(); + + done(); + }); + }); + + it('animates title changes', (done) => { + vm.titleHtml = 'test'; + + Vue.nextTick(() => { + expect( + vm.$el.classList.contains('issue-realtime-pre-pulse'), + ).toBeTruthy(); + + setTimeout(() => { + expect( + vm.$el.classList.contains('issue-realtime-trigger-pulse'), + ).toBeTruthy(); + + done(); + }); + }); + }); + + it('updates page title after changing title', (done) => { + vm.titleHtml = 'changed'; + vm.titleText = 'changed'; + + Vue.nextTick(() => { + expect( + document.querySelector('title').textContent.trim(), + ).toContain('changed'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js index ad5a7b63470..6683d581bc5 100644 --- a/spec/javascripts/issue_show/mock_data.js +++ b/spec/javascripts/issue_show/mock_data.js @@ -4,23 +4,23 @@ export default { title_text: 'this is a title', description: '<p>this is a description!</p>', description_text: 'this is a description', - issue_number: 1, task_status: '2 of 4 completed', + updated_at: new Date().toString(), }, secondRequest: { title: '<p>2</p>', title_text: '2', description: '<p>42</p>', description_text: '42', - issue_number: 1, task_status: '0 of 0 completed', + updated_at: new Date().toString(), }, issueSpecRequest: { title: '<p>this is a title</p>', title_text: 'this is a title', description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>', description_text: '- [ ] Task List Item', - issue_number: 1, task_status: '0 of 1 completed', + updated_at: new Date().toString(), }, }; diff --git a/spec/javascripts/lib/utils/poll_spec.js b/spec/javascripts/lib/utils/poll_spec.js index 918b6d32c43..22f30191ab9 100644 --- a/spec/javascripts/lib/utils/poll_spec.js +++ b/spec/javascripts/lib/utils/poll_spec.js @@ -1,9 +1,5 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; import Poll from '~/lib/utils/poll'; -Vue.use(VueResource); - const waitForAllCallsToFinish = (service, waitForCount, successCallback) => { const timer = () => { setTimeout(() => { @@ -12,45 +8,33 @@ const waitForAllCallsToFinish = (service, waitForCount, successCallback) => { } else { timer(); } - }, 5); + }, 0); }; timer(); }; -class ServiceMock { - constructor(endpoint) { - this.service = Vue.resource(endpoint); - } +function mockServiceCall(service, response, shouldFail = false) { + const action = shouldFail ? Promise.reject : Promise.resolve; + const responseObject = response; + + if (!responseObject.headers) responseObject.headers = {}; - fetch() { - return this.service.get(); - } + service.fetch.and.callFake(action.bind(Promise, responseObject)); } describe('Poll', () => { - let callbacks; - let service; + const service = jasmine.createSpyObj('service', ['fetch']); + const callbacks = jasmine.createSpyObj('callbacks', ['success', 'error']); - beforeEach(() => { - callbacks = { - success: () => {}, - error: () => {}, - }; - - service = new ServiceMock('endpoint'); - - spyOn(callbacks, 'success'); - spyOn(callbacks, 'error'); - spyOn(service, 'fetch').and.callThrough(); + afterEach(() => { + callbacks.success.calls.reset(); + callbacks.error.calls.reset(); + service.fetch.calls.reset(); }); it('calls the success callback when no header for interval is provided', (done) => { - const successInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200 })); - }; - - Vue.http.interceptors.push(successInterceptor); + mockServiceCall(service, { status: 200 }); new Poll({ resource: service, @@ -63,18 +47,12 @@ describe('Poll', () => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, successInterceptor); - done(); - }, 0); + }); }); it('calls the error callback whe the http request returns an error', (done) => { - const errorInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 500 })); - }; - - Vue.http.interceptors.push(errorInterceptor); + mockServiceCall(service, { status: 500 }, true); new Poll({ resource: service, @@ -86,42 +64,29 @@ describe('Poll', () => { waitForAllCallsToFinish(service, 1, () => { expect(callbacks.success).not.toHaveBeenCalled(); expect(callbacks.error).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor); done(); }); }); it('should call the success callback when the interval header is -1', (done) => { - const intervalInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': -1 } })); - }; - - Vue.http.interceptors.push(intervalInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': -1 } }); new Poll({ resource: service, method: 'fetch', successCallback: callbacks.success, errorCallback: callbacks.error, - }).makeRequest(); - - setTimeout(() => { + }).makeRequest().then(() => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, intervalInterceptor); - done(); - }, 0); + }).catch(done.fail); }); it('starts polling when http status is 200 and interval header is provided', (done) => { - const pollInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } })); - }; - - Vue.http.interceptors.push(pollInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ resource: service, @@ -141,19 +106,13 @@ describe('Poll', () => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); - done(); }); }); describe('stop', () => { it('stops polling when method is called', (done) => { - const pollInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } })); - }; - - Vue.http.interceptors.push(pollInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ resource: service, @@ -174,8 +133,6 @@ describe('Poll', () => { expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(Polling.stop).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); - done(); }); }); @@ -183,11 +140,7 @@ describe('Poll', () => { describe('restart', () => { it('should restart polling when its called', (done) => { - const pollInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } })); - }; - - Vue.http.interceptors.push(pollInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ resource: service, @@ -215,8 +168,6 @@ describe('Poll', () => { expect(Polling.stop).toHaveBeenCalled(); expect(Polling.restart).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); - done(); }); }); diff --git a/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js b/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js index 1d05f37cb36..6120d224ac0 100644 --- a/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js +++ b/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js @@ -4,8 +4,15 @@ import PipelineSchedulesCallout from '~/pipeline_schedules/components/pipeline_s const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); const cookieKey = 'pipeline_schedules_callout_dismissed'; +const docsUrl = 'help/ci/scheduled_pipelines'; describe('Pipeline Schedule Callout', () => { + beforeEach(() => { + setFixtures(` + <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div> + `); + }); + describe('independent of cookies', () => { beforeEach(() => { this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); @@ -18,6 +25,10 @@ describe('Pipeline Schedule Callout', () => { it('correctly sets illustrationSvg', () => { expect(this.calloutComponent.illustrationSvg).toContain('<svg'); }); + + it('correctly sets docsUrl', () => { + expect(this.calloutComponent.docsUrl).toContain(docsUrl); + }); }); describe(`when ${cookieKey} cookie is set`, () => { @@ -61,6 +72,10 @@ describe('Pipeline Schedule Callout', () => { expect(this.calloutComponent.$el.outerHTML).toContain('runs pipelines in the future'); }); + it('renders the documentation url', () => { + expect(this.calloutComponent.$el.outerHTML).toContain(docsUrl); + }); + it('updates calloutDismissed when close button is clicked', (done) => { this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click(); diff --git a/spec/javascripts/pipelines/mock_data.js b/spec/javascripts/pipelines/mock_data.js deleted file mode 100644 index 2365a662b9f..00000000000 --- a/spec/javascripts/pipelines/mock_data.js +++ /dev/null @@ -1,107 +0,0 @@ -export default { - pipelines: [{ - id: 115, - user: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://localhost:3000/root', - }, - path: '/root/review-app/pipelines/115', - details: { - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/115', - }, - duration: null, - finished_at: '2017-03-17T19:00:15.996Z', - stages: [{ - name: 'build', - title: 'build: failed', - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/115#build', - }, - path: '/root/review-app/pipelines/115#build', - dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=build', - }, - { - name: 'review', - title: 'review: skipped', - status: { - icon: 'icon_status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - has_details: true, - details_path: '/root/review-app/pipelines/115#review', - }, - path: '/root/review-app/pipelines/115#review', - dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=review', - }], - artifacts: [], - manual_actions: [{ - name: 'stop_review', - path: '/root/review-app/builds/3766/play', - }], - }, - flags: { - latest: true, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: false, - }, - ref: { - name: 'thisisabranch', - path: '/root/review-app/tree/thisisabranch', - tag: false, - branch: true, - }, - commit: { - id: '9e87f87625b26c42c59a2ee0398f81d20cdfe600', - short_id: '9e87f876', - title: 'Update README.md', - created_at: '2017-03-15T22:58:28.000+00:00', - parent_ids: ['3744f9226e699faec2662a8b267e5d3fd0bfff0e'], - message: 'Update README.md', - author_name: 'Root', - author_email: 'admin@example.com', - authored_date: '2017-03-15T22:58:28.000+00:00', - committer_name: 'Root', - committer_email: 'admin@example.com', - committed_date: '2017-03-15T22:58:28.000+00:00', - author: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://localhost:3000/root', - }, - author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - commit_url: 'http://localhost:3000/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600', - commit_path: '/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600', - }, - retry_path: '/root/review-app/pipelines/115/retry', - created_at: '2017-03-15T22:58:33.436Z', - updated_at: '2017-03-17T19:00:15.997Z', - }], - count: { - all: 52, - running: 0, - pending: 0, - finished: 52, - }, -}; diff --git a/spec/javascripts/pipelines/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js index e9c05f74ce6..3a56156358b 100644 --- a/spec/javascripts/pipelines/pipelines_spec.js +++ b/spec/javascripts/pipelines/pipelines_spec.js @@ -1,15 +1,20 @@ import Vue from 'vue'; import pipelinesComp from '~/pipelines/pipelines'; import Store from '~/pipelines/stores/pipelines_store'; -import pipelinesData from './mock_data'; describe('Pipelines', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + preloadFixtures('static/pipelines.html.raw'); + preloadFixtures(jsonFixtureName); let PipelinesComponent; + let pipeline; beforeEach(() => { loadFixtures('static/pipelines.html.raw'); + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); PipelinesComponent = Vue.extend(pipelinesComp); }); @@ -17,7 +22,7 @@ describe('Pipelines', () => { describe('successfull request', () => { describe('with pipelines', () => { const pipelinesInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify(pipelinesData), { + next(request.respondWith(JSON.stringify(pipeline), { status: 200, })); }; diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js index e0df0a3228f..865951b2ad7 100644 --- a/spec/javascripts/sidebar/sidebar_assignees_spec.js +++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js @@ -24,6 +24,7 @@ describe('sidebar assignees', () => { SidebarService.singleton = null; SidebarStore.singleton = null; SidebarMediator.singleton = null; + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); }); it('calls the mediator when saves the assignees', () => { diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js index 2b00fa17334..e246f41ee82 100644 --- a/spec/javascripts/sidebar/sidebar_mediator_spec.js +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -14,6 +14,7 @@ describe('Sidebar mediator', () => { SidebarService.singleton = null; SidebarStore.singleton = null; SidebarMediator.singleton = null; + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); }); it('assigns yourself ', () => { diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js index d41162096a6..91a4dd669a7 100644 --- a/spec/javascripts/sidebar/sidebar_service_spec.js +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -10,6 +10,7 @@ describe('Sidebar service', () => { afterEach(() => { SidebarService.singleton = null; + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); }); it('gets the data', (done) => { diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js index 699625cdbb7..0a2c66e72d7 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js +++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js @@ -1,27 +1,47 @@ import Vue from 'vue'; import tableRowComp from '~/vue_shared/components/pipelines_table_row'; -import pipeline from '../../commit/pipelines/mock_data'; describe('Pipelines Table Row', () => { - let component; - - beforeEach(() => { + const jsonFixtureName = 'pipelines/pipelines.json'; + const buildComponent = (pipeline) => { const PipelinesTableRowComponent = Vue.extend(tableRowComp); - - component = new PipelinesTableRowComponent({ + return new PipelinesTableRowComponent({ el: document.querySelector('.test-dom-element'), propsData: { pipeline, service: {}, }, }).$mount(); + }; + + let component; + let pipeline; + let pipelineWithoutAuthor; + let pipelineWithoutCommit; + + preloadFixtures(jsonFixtureName); + + beforeEach(() => { + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); + pipelineWithoutAuthor = pipelines.find(p => p.id === 2); + pipelineWithoutCommit = pipelines.find(p => p.id === 3); + }); + + afterEach(() => { + component.$destroy(); }); it('should render a table row', () => { + component = buildComponent(pipeline); expect(component.$el).toEqual('TR'); }); describe('status column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render a pipeline link', () => { expect( component.$el.querySelector('td.commit-link a').getAttribute('href'), @@ -36,6 +56,10 @@ describe('Pipelines Table Row', () => { }); describe('information column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render a pipeline link', () => { expect( component.$el.querySelector('td:nth-child(2) a').getAttribute('href'), @@ -63,13 +87,59 @@ describe('Pipelines Table Row', () => { describe('commit column', () => { it('should render link to commit', () => { - expect( - component.$el.querySelector('td:nth-child(3) .commit-id').getAttribute('href'), - ).toEqual(pipeline.commit.commit_path); + component = buildComponent(pipeline); + + const commitLink = component.$el.querySelector('.branch-commit .commit-id'); + expect(commitLink.getAttribute('href')).toEqual(pipeline.commit.commit_path); + }); + + const findElements = () => { + const commitTitleElement = component.$el.querySelector('.branch-commit .commit-title'); + const commitAuthorElement = commitTitleElement.querySelector('a.avatar-image-container'); + + if (!commitAuthorElement) { + return { commitAuthorElement }; + } + + const commitAuthorLink = commitAuthorElement.getAttribute('href'); + const commitAuthorName = commitAuthorElement.querySelector('img.avatar').getAttribute('title'); + + return { commitAuthorElement, commitAuthorLink, commitAuthorName }; + }; + + it('renders nothing without commit', () => { + expect(pipelineWithoutCommit.commit).toBe(null); + component = buildComponent(pipelineWithoutCommit); + + const { commitAuthorElement } = findElements(); + + expect(commitAuthorElement).toBe(null); + }); + + it('renders commit author', () => { + component = buildComponent(pipeline); + const { commitAuthorLink, commitAuthorName } = findElements(); + + expect(commitAuthorLink).toEqual(pipeline.commit.author.web_url); + expect(commitAuthorName).toEqual(pipeline.commit.author.username); + }); + + it('renders commit with unregistered author', () => { + expect(pipelineWithoutAuthor.commit.author).toBe(null); + component = buildComponent(pipelineWithoutAuthor); + + const { commitAuthorLink, commitAuthorName } = findElements(); + + expect(commitAuthorLink).toEqual(`mailto:${pipelineWithoutAuthor.commit.author_email}`); + expect(commitAuthorName).toEqual(pipelineWithoutAuthor.commit.author_name); }); }); describe('stages column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render an icon for each stage', () => { expect( component.$el.querySelectorAll('td:nth-child(4) .js-builds-dropdown-button').length, @@ -78,6 +148,10 @@ describe('Pipelines Table Row', () => { }); describe('actions column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render the provided actions', () => { expect( component.$el.querySelectorAll('td:nth-child(6) ul li').length, diff --git a/spec/javascripts/vue_shared/components/pipelines_table_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_spec.js index 4d3ced944d7..6cc178b8f1d 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_spec.js +++ b/spec/javascripts/vue_shared/components/pipelines_table_spec.js @@ -1,13 +1,19 @@ import Vue from 'vue'; import pipelinesTableComp from '~/vue_shared/components/pipelines_table'; import '~/lib/utils/datetime_utility'; -import pipeline from '../../commit/pipelines/mock_data'; describe('Pipelines Table', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + + let pipeline; let PipelinesTableComponent; + preloadFixtures(jsonFixtureName); + beforeEach(() => { PipelinesTableComponent = Vue.extend(pipelinesTableComp); + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); }); describe('table', () => { diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index 048d25869bc..fe8c52d5353 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Ci::Variable, models: true do - subject { Ci::Variable.new } + subject { build(:ci_variable) } let(:secret_value) { 'secret' } diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 00a9f2abeb9..61b748429d7 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1659,15 +1659,25 @@ describe Repository, models: true do describe '#readme', caching: true do context 'with a non-existing repository' do it 'returns nil' do - expect(repository).to receive(:tree).with(:head).and_return(nil) + allow(repository).to receive(:tree).with(:head).and_return(nil) expect(repository.readme).to be_nil end end context 'with an existing repository' do - it 'returns the README' do - expect(repository.readme).to be_an_instance_of(ReadmeBlob) + context 'when no README exists' do + it 'returns nil' do + allow_any_instance_of(Tree).to receive(:readme).and_return(nil) + + expect(repository.readme).to be_nil + end + end + + context 'when a README exists' do + it 'returns the README' do + expect(repository.readme).to be_an_instance_of(ReadmeBlob) + end end end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index da2b56c040b..79cac721202 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -1124,7 +1124,7 @@ describe API::Issues do end context 'CE restrictions' do - it 'updates an issue with several assignee but only one has been applied' do + it 'updates an issue with several assignees but only one has been applied' do put api("/projects/#{project.id}/issues/#{issue.iid}", user), assignee_ids: [user2.id, guest.id] diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 3ddd0badd39..6437d00e451 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -62,7 +62,7 @@ describe Issuable::BulkUpdateService, services: true do expect(result[:count]).to eq(1) end - it 'updates the assignee to the use ID passed' do + it 'updates the assignee to the user ID passed' do assignee = create(:user) project.team << [assignee, :developer] @@ -100,7 +100,7 @@ describe Issuable::BulkUpdateService, services: true do expect(result[:count]).to eq(1) end - it 'updates the assignee to the use ID passed' do + it 'updates the assignee to the user ID passed' do assignee = create(:user) project.team << [assignee, :developer] expect { bulk_update(issue, assignee_ids: [assignee.id]) } diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 516566eddef..7a9cd7553b1 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -178,7 +178,7 @@ describe SystemNoteService, services: true do end it 'builds a correct phrase when assignee removed' do - expect(build_note([assignee1], [])).to eq 'removed all assignees' + expect(build_note([assignee1], [])).to eq 'removed assignee' end it 'builds a correct phrase when assignees changed' do |