diff options
author | Jacob Schatz <jschatz@gitlab.com> | 2017-05-05 19:17:28 +0000 |
---|---|---|
committer | Jacob Schatz <jschatz@gitlab.com> | 2017-05-05 19:17:28 +0000 |
commit | aa874a1cd6fc94f6291a59fb414f3b09b3337c0d (patch) | |
tree | 1fbc687d9732273715115d0b945f329ce1d41a17 | |
parent | 0c5b7f8475926a5c97d42649dd12f4b4a32071d4 (diff) | |
parent | 933447e0da19f9d0be8574185500cabb5d7ab012 (diff) | |
download | gitlab-ce-aa874a1cd6fc94f6291a59fb414f3b09b3337c0d.tar.gz |
Merge branch 'mia_backort' into 'master'
Backport of Multiple Assignees feature
See merge request !11089
220 files changed, 3966 insertions, 1590 deletions
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 3062cd51ee3..a20c6ca7a21 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -99,7 +99,7 @@ export default class FileTemplateMediator { }); } - selectTemplateType(item, el, e) { + selectTemplateType(item, e) { if (e) { e.preventDefault(); } @@ -117,6 +117,10 @@ export default class FileTemplateMediator { this.cacheToggleText(); } + selectTemplateTypeOptions(options) { + this.selectTemplateType(options.selectedObj, options.e); + } + selectTemplateFile(selector, query, data) { selector.renderLoading(); // in case undo menu is already already there diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index 31dd45fac89..ab5b3751c4e 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -52,9 +52,17 @@ export default class FileTemplateSelector { .removeClass('fa-spinner fa-spin'); } - reportSelection(query, el, e, data) { + reportSelection(options) { + const { query, e, data } = options; e.preventDefault(); return this.mediator.selectTemplateFile(this, query, data); } + + reportSelectionName(options) { + const opts = options; + opts.query = options.selectedObj.name; + + this.reportSelection(opts); + } } diff --git a/app/assets/javascripts/blob/target_branch_dropdown.js b/app/assets/javascripts/blob/target_branch_dropdown.js index 216f069ef71..d52d69b1274 100644 --- a/app/assets/javascripts/blob/target_branch_dropdown.js +++ b/app/assets/javascripts/blob/target_branch_dropdown.js @@ -37,8 +37,8 @@ class TargetBranchDropDown { } return SELECT_ITEM_MSG; }, - clicked(item, el, e) { - e.preventDefault(); + clicked(options) { + options.e.preventDefault(); self.onClick.call(self); }, fieldName: self.fieldName, diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index d7c1c32efbd..888883163c5 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -24,7 +24,7 @@ export default class TemplateSelector { search: { fields: ['name'], }, - clicked: (item, el, e) => this.fetchFileTemplate(item, el, e), + clicked: options => this.fetchFileTemplate(options), text: item => item.name, }); } @@ -51,7 +51,10 @@ export default class TemplateSelector { return this.$dropdownContainer.removeClass('hidden'); } - fetchFileTemplate(item, el, e) { + fetchFileTemplate(options) { + const { e } = options; + const item = options.selectedObj; + e.preventDefault(); return this.requestFile(item); } diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js index 935df07677c..f2f81af137b 100644 --- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js +++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js @@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js index b4b4d09c315..3cb7b960aaa 100644 --- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js +++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js @@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js index aefae54ae71..7efda8e7f50 100644 --- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js +++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js @@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js index c8abd689ab4..1d757332f6c 100644 --- a/app/assets/javascripts/blob/template_selectors/license_selector.js +++ b/app/assets/javascripts/blob/template_selectors/license_selector.js @@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => { + clicked: (options) => { + const { e } = options; + const el = options.$el; + const query = options.selectedObj; + const data = { project: this.$dropdown.data('project'), fullname: this.$dropdown.data('fullname'), }; - this.reportSelection(query.id, el, e, data); + this.reportSelection({ + query: query.id, + el, + e, + data, + }); }, text: item => item.name, }); diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js index 56f23ef0568..a09381014a7 100644 --- a/app/assets/javascripts/blob/template_selectors/type_selector.js +++ b/app/assets/javascripts/blob/template_selectors/type_selector.js @@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector { filterable: false, selectable: true, toggleLabel: item => item.name, - clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e), + clicked: options => this.mediator.selectTemplateTypeOptions(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 8c08b2d4db3..88eb4251339 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -11,7 +11,7 @@ require('./models/issue'); require('./models/label'); require('./models/list'); require('./models/milestone'); -require('./models/user'); +require('./models/assignee'); require('./stores/boards_store'); require('./stores/modal_store'); require('./services/board_service'); diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js index 0fa85b6fe14..1ce95b62138 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.js @@ -26,6 +26,7 @@ export default { title: this.title, labels, subscribed: true, + assignees: [], }); this.list.newIssue(issue) diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index f0066d4ec5d..317cef9f227 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -3,8 +3,13 @@ /* global MilestoneSelect */ /* global LabelsSelect */ /* global Sidebar */ +/* global Flash */ import Vue from 'vue'; +import eventHub from '../../sidebar/event_hub'; + +import AssigneeTitle from '../../sidebar/components/assignees/assignee_title'; +import Assignees from '../../sidebar/components/assignees/assignees'; require('./sidebar/remove_issue'); @@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ detail: Store.detail, issue: {}, list: {}, + loadingAssignees: false, }; }, computed: { @@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({ this.issue = this.detail.issue; this.list = this.detail.list; + + this.$nextTick(() => { + this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate; + }); }, deep: true }, @@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({ $('.right-sidebar').getNiceScroll().resize(); }); } - } + + this.issue = this.detail.issue; + this.list = this.detail.list; + }, + deep: true }, methods: { closeSidebar () { this.detail.issue = {}; - } + }, + assignSelf () { + // Notify gl dropdown that we are now assigning to current user + this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself')); + + this.addAssignee(this.currentUser); + this.saveAssignees(); + }, + removeAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a); + }, + addAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.addAssignee(a); + }, + removeAllAssignees () { + gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees(); + }, + saveAssignees () { + this.loadingAssignees = true; + + gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint) + .then(() => { + this.loadingAssignees = false; + }) + .catch(() => { + this.loadingAssignees = false; + return new Flash('An error occurred while saving assignees'); + }); + }, + }, + created () { + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); }, mounted () { new IssuableContext(this.currentUser); @@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }, components: { removeBtn: gl.issueBoards.RemoveIssueBtn, + 'assignee-title': AssigneeTitle, + assignees: Assignees, }, }); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index fc154ee7b8b..710207db0c7 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({ default: false, }, }, + data() { + return { + limitBeforeCounter: 3, + maxRender: 4, + maxCounter: 99, + }; + }, computed: { - cardUrl() { - return `${this.issueLinkBase}/${this.issue.id}`; + numberOverLimit() { + return this.issue.assignees.length - this.limitBeforeCounter; }, - assigneeUrl() { - return `${this.rootPath}${this.issue.assignee.username}`; + assigneeCounterTooltip() { + return `${this.assigneeCounterLabel} more`; + }, + assigneeCounterLabel() { + if (this.numberOverLimit > this.maxCounter) { + return `${this.maxCounter}+`; + } + + return `+${this.numberOverLimit}`; }, - assigneeUrlTitle() { - return `Assigned to ${this.issue.assignee.name}`; + shouldRenderCounter() { + if (this.issue.assignees.length <= this.maxRender) { + return false; + } + + return this.issue.assignees.length > this.numberOverLimit; }, - avatarUrlTitle() { - return `Avatar for ${this.issue.assignee.name}`; + cardUrl() { + return `${this.issueLinkBase}/${this.issue.id}`; }, issueId() { return `#${this.issue.id}`; @@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({ }, }, methods: { + isIndexLessThanlimit(index) { + return index < this.limitBeforeCounter; + }, + shouldRenderAssignee(index) { + // Eg. maxRender is 4, + // Render up to all 4 assignees if there are only 4 assigness + // Otherwise render up to the limitBeforeCounter + if (this.issue.assignees.length <= this.maxRender) { + return index < this.maxRender; + } + + return index < this.limitBeforeCounter; + }, + assigneeUrl(assignee) { + return `${this.rootPath}${assignee.username}`; + }, + assigneeUrlTitle(assignee) { + return `Assigned to ${assignee.name}`; + }, + avatarUrlTitle(assignee) { + return `Avatar for ${assignee.name}`; + }, showLabel(label) { if (!this.list) return true; @@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({ {{ issueId }} </span> </h4> - <a - class="card-assignee has-tooltip js-no-trigger" - :href="assigneeUrl" - :title="assigneeUrlTitle" - v-if="issue.assignee" - data-container="body" - > - <img - class="avatar avatar-inline s20 js-no-trigger" - :src="issue.assignee.avatar" - width="20" - height="20" - :alt="avatarUrlTitle" - /> - </a> + <div class="card-assignee"> + <a + class="has-tooltip js-no-trigger" + :href="assigneeUrl(assignee)" + :title="assigneeUrlTitle(assignee)" + v-for="(assignee, index) in issue.assignees" + v-if="shouldRenderAssignee(index)" + data-container="body" + data-placement="bottom" + > + <img + class="avatar avatar-inline s20" + :src="assignee.avatar" + width="20" + height="20" + :alt="avatarUrlTitle(assignee)" + /> + </a> + <span + class="avatar-counter has-tooltip" + :title="assigneeCounterTooltip" + v-if="shouldRenderCounter" + > + {{ assigneeCounterLabel }} + </span> + </div> </div> - <div class="card-footer" v-if="showLabelFooter"> + <div + class="card-footer" + v-if="showLabelFooter" + > <button - class="label color-label has-tooltip js-no-trigger" + class="label color-label has-tooltip" v-for="label in issue.labels" type="button" v-if="showLabel(label)" diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 7e3bb79af1d..f29b6caa1ac 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => { filterable: true, selectable: true, multiSelect: true, - clicked (label, $el, e) { + clicked (options) { + const { e } = options; + const label = options.selectedObj; e.preventDefault(); if (!Store.findList('title', label.title)) { diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/assignee.js index 2af583c3279..05dd449e4fd 100644 --- a/app/assets/javascripts/boards/models/user.js +++ b/app/assets/javascripts/boards/models/assignee.js @@ -1,4 +1,6 @@ -class ListUser { +/* eslint-disable no-unused-vars */ + +class ListAssignee { constructor(user, defaultAvatar) { this.id = user.id; this.name = user.name; @@ -7,4 +9,4 @@ class ListUser { } } -window.ListUser = ListUser; +window.ListAssignee = ListAssignee; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index db783467f87..6c2d8a3781b 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -1,7 +1,7 @@ /* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ /* global ListLabel */ /* global ListMilestone */ -/* global ListUser */ +/* global ListAssignee */ import Vue from 'vue'; @@ -14,14 +14,10 @@ class ListIssue { this.dueDate = obj.due_date; this.subscribed = obj.subscribed; this.labels = []; + this.assignees = []; this.selected = false; - this.assignee = false; this.position = obj.relative_position || Infinity; - if (obj.assignee) { - this.assignee = new ListUser(obj.assignee, defaultAvatar); - } - if (obj.milestone) { this.milestone = new ListMilestone(obj.milestone); } @@ -29,6 +25,8 @@ class ListIssue { obj.labels.forEach((label) => { this.labels.push(new ListLabel(label)); }); + + this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar)); } addLabel (label) { @@ -51,6 +49,26 @@ class ListIssue { labels.forEach(this.removeLabel.bind(this)); } + addAssignee (assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(new ListAssignee(assignee)); + } + } + + findAssignee (findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee (removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees () { + this.assignees = []; + } + getLists () { return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); } @@ -60,7 +78,7 @@ class ListIssue { issue: { milestone_id: this.milestone ? this.milestone.id : null, due_date: this.dueDate, - assignee_id: this.assignee ? this.assignee.id : null, + assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0], label_ids: this.labels.map((label) => label.id) } }; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index a03f1202a6d..0c9eb84f0eb 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -255,7 +255,8 @@ GitLabDropdown = (function() { } }; // Remote data - })(this) + })(this), + instance: this, }); } } @@ -269,6 +270,7 @@ GitLabDropdown = (function() { remote: this.options.filterRemote, query: this.options.data, keys: searchFields, + instance: this, elements: (function(_this) { return function() { selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; @@ -343,21 +345,26 @@ GitLabDropdown = (function() { } this.dropdown.on("click", selector, function(e) { var $el, selected, selectedObj, isMarking; - $el = $(this); + $el = $(e.currentTarget); selected = self.rowClicked($el); selectedObj = selected ? selected[0] : null; isMarking = selected ? selected[1] : null; - if (self.options.clicked) { - self.options.clicked(selectedObj, $el, e, isMarking); + if (this.options.clicked) { + this.options.clicked.call(this, { + selectedObj, + $el, + e, + isMarking, + }); } // Update label right after all modifications in dropdown has been done - if (self.options.toggleLabel) { - self.updateLabel(selectedObj, $el, self); + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); } $el.trigger('blur'); - }); + }.bind(this)); } } @@ -439,15 +446,34 @@ GitLabDropdown = (function() { } }; + GitLabDropdown.prototype.filteredFullData = function() { + return this.fullData.filter(r => typeof r === 'object' + && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') + && !Object.prototype.hasOwnProperty.call(r, 'header') + ); + }; + GitLabDropdown.prototype.opened = function(e) { var contentHtml; this.resetRows(); this.addArrowKeyEvent(); + const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); + const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); + const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); + // Makes indeterminate items effective - if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { + if (this.fullData && hasFilterBulkUpdate) { this.parseData(this.fullData); } + + // Process the data to make sure rendered data + // matches the correct layout + if (this.fullData && hasMultiSelect && this.options.processData) { + const inputValue = this.filterInput.val(); + this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this)); + } + contentHtml = $('.dropdown-content', this.dropdown).html(); if (this.remote && contentHtml === "") { this.remote.execute(); @@ -709,6 +735,11 @@ GitLabDropdown = (function() { if (this.options.inputId != null) { $input.attr('id', this.options.inputId); } + + if (this.options.inputMeta) { + $input.attr('data-meta', selectedObject[this.options.inputMeta]); + } + return this.dropdown.before($input); }; @@ -829,7 +860,14 @@ GitLabDropdown = (function() { if (instance == null) { instance = null; } - return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance)); + + let toggleText = this.options.toggleLabel(selected, el, instance); + if (this.options.updateLabel) { + // Option to override the dropdown label text + toggleText = this.options.updateLabel; + } + + return $(this.el).find(".dropdown-toggle-text").text(toggleText); }; GitLabDropdown.prototype.clearField = function(field, isInput) { diff --git a/app/assets/javascripts/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js deleted file mode 100644 index e927cc0077c..00000000000 --- a/app/assets/javascripts/issuable/issuable_bundle.js +++ /dev/null @@ -1 +0,0 @@ -require('./time_tracking/time_tracking_bundle'); diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js deleted file mode 100644 index aec13e78f42..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import stopwatchSvg from 'icons/_icon_stopwatch.svg'; - -require('../../../lib/utils/pretty_time'); - -(() => { - Vue.component('time-tracking-collapsed-state', { - name: 'time-tracking-collapsed-state', - props: [ - 'showComparisonState', - 'showSpentOnlyState', - 'showEstimateOnlyState', - 'showNoTimeTrackingState', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - methods: { - abbreviateTime(timeStr) { - return gl.utils.prettyTime.abbreviateTime(timeStr); - }, - }, - template: ` - <div class='sidebar-collapsed-icon'> - ${stopwatchSvg} - <div class='time-tracking-collapsed-summary'> - <div class='compare' v-if='showComparisonState'> - <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='estimate-only' v-if='showEstimateOnlyState'> - <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='spend-only' v-if='showSpentOnlyState'> - <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span> - </div> - <div class='no-tracking' v-if='showNoTimeTrackingState'> - <span class='no-value'>None</span> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js deleted file mode 100644 index c55e263f6f4..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js +++ /dev/null @@ -1,70 +0,0 @@ -import Vue from 'vue'; - -require('../../../lib/utils/pretty_time'); - -(() => { - const prettyTime = gl.utils.prettyTime; - - Vue.component('time-tracking-comparison-pane', { - name: 'time-tracking-comparison-pane', - props: [ - 'timeSpent', - 'timeEstimate', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - computed: { - parsedRemaining() { - const diffSeconds = this.timeEstimate - this.timeSpent; - return prettyTime.parseSeconds(diffSeconds); - }, - timeRemainingHumanReadable() { - return prettyTime.stringifyTime(this.parsedRemaining); - }, - timeRemainingTooltip() { - const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; - return `${prefix} ${this.timeRemainingHumanReadable}`; - }, - /* Diff values for comparison meter */ - timeRemainingMinutes() { - return this.timeEstimate - this.timeSpent; - }, - timeRemainingPercent() { - return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; - }, - timeRemainingStatusClass() { - return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; - }, - /* Parsed time values */ - parsedEstimate() { - return prettyTime.parseSeconds(this.timeEstimate); - }, - parsedSpent() { - return prettyTime.parseSeconds(this.timeSpent); - }, - }, - template: ` - <div class='time-tracking-comparison-pane'> - <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay' - :aria-valuenow='timeRemainingTooltip' - :title='timeRemainingTooltip' - :data-original-title='timeRemainingTooltip' - :class='timeRemainingStatusClass'> - <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'> - <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div> - </div> - <div class='compare-display-container'> - <div class='compare-display pull-left'> - <span class='compare-label'>Spent</span> - <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span> - </div> - <div class='compare-display estimated pull-right'> - <span class='compare-label'>Est</span> - <span class='compare-value'>{{ timeEstimateHumanReadable }}</span> - </div> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js deleted file mode 100644 index a7fbd704c40..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-estimate-only-pane', { - name: 'time-tracking-estimate-only-pane', - props: ['timeEstimateHumanReadable'], - template: ` - <div class='time-tracking-estimate-only-pane'> - <span class='bold'>Estimated:</span> - {{ timeEstimateHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js b/app/assets/javascripts/issuable/time_tracking/components/help_state.js deleted file mode 100644 index 344b29ebea4..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/help_state.js +++ /dev/null @@ -1,25 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-help-state', { - name: 'time-tracking-help-state', - props: ['docsUrl'], - template: ` - <div class='time-tracking-help-state'> - <div class='time-tracking-info'> - <h4>Track time with slash commands</h4> - <p>Slash commands can be used in the issues description and comment boxes.</p> - <p> - <code>/estimate</code> - will update the estimated time with the latest command. - </p> - <p> - <code>/spend</code> - will update the sum of the time spent. - </p> - <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js deleted file mode 100644 index b081adf5e64..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-no-tracking-pane', { - name: 'time-tracking-no-tracking-pane', - template: ` - <div class='time-tracking-no-tracking-pane'> - <span class='no-value'>No estimate or time spent</span> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js deleted file mode 100644 index edb9169112f..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-spent-only-pane', { - name: 'time-tracking-spent-only-pane', - props: ['timeSpentHumanReadable'], - template: ` - <div class='time-tracking-spend-only-pane'> - <span class='bold'>Spent:</span> - {{ timeSpentHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js deleted file mode 100644 index 0213522f551..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js +++ /dev/null @@ -1,117 +0,0 @@ -import Vue from 'vue'; - -require('./help_state'); -require('./collapsed_state'); -require('./spent_only_pane'); -require('./no_tracking_pane'); -require('./estimate_only_pane'); -require('./comparison_pane'); - -(() => { - Vue.component('issuable-time-tracker', { - name: 'issuable-time-tracker', - props: [ - 'time_estimate', - 'time_spent', - 'human_time_estimate', - 'human_time_spent', - 'docsUrl', - ], - data() { - return { - showHelp: false, - }; - }, - computed: { - timeSpent() { - return this.time_spent; - }, - timeEstimate() { - return this.time_estimate; - }, - timeEstimateHumanReadable() { - return this.human_time_estimate; - }, - timeSpentHumanReadable() { - return this.human_time_spent; - }, - hasTimeSpent() { - return !!this.timeSpent; - }, - hasTimeEstimate() { - return !!this.timeEstimate; - }, - showComparisonState() { - return this.hasTimeEstimate && this.hasTimeSpent; - }, - showEstimateOnlyState() { - return this.hasTimeEstimate && !this.hasTimeSpent; - }, - showSpentOnlyState() { - return this.hasTimeSpent && !this.hasTimeEstimate; - }, - showNoTimeTrackingState() { - return !this.hasTimeEstimate && !this.hasTimeSpent; - }, - showHelpState() { - return !!this.showHelp; - }, - }, - methods: { - toggleHelpState(show) { - this.showHelp = show; - }, - }, - template: ` - <div class='time_tracker time-tracking-component-wrap' v-cloak> - <time-tracking-collapsed-state - :show-comparison-state='showComparisonState' - :show-help-state='showHelpState' - :show-spent-only-state='showSpentOnlyState' - :show-estimate-only-state='showEstimateOnlyState' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-collapsed-state> - <div class='title hide-collapsed'> - Time tracking - <div class='help-button pull-right' - v-if='!showHelpState' - @click='toggleHelpState(true)'> - <i class='fa fa-question-circle' aria-hidden='true'></i> - </div> - <div class='close-help-button pull-right' - v-if='showHelpState' - @click='toggleHelpState(false)'> - <i class='fa fa-close' aria-hidden='true'></i> - </div> - </div> - <div class='time-tracking-content hide-collapsed'> - <time-tracking-estimate-only-pane - v-if='showEstimateOnlyState' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-estimate-only-pane> - <time-tracking-spent-only-pane - v-if='showSpentOnlyState' - :time-spent-human-readable='timeSpentHumanReadable'> - </time-tracking-spent-only-pane> - <time-tracking-no-tracking-pane - v-if='showNoTimeTrackingState'> - </time-tracking-no-tracking-pane> - <time-tracking-comparison-pane - v-if='showComparisonState' - :time-estimate='timeEstimate' - :time-spent='timeSpent' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-comparison-pane> - <transition name='help-state-toggle'> - <time-tracking-help-state - v-if='showHelpState' - :docs-url='docsUrl'> - </time-tracking-help-state> - </transition> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js deleted file mode 100644 index 1689a69e1ed..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js +++ /dev/null @@ -1,66 +0,0 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -require('./components/time_tracker'); -require('../../smart_interval'); -require('../../subbable_resource'); - -Vue.use(VueResource); - -(() => { - /* This Vue instance represents what will become the parent instance for the - * sidebar. It will be responsible for managing `issuable` state and propagating - * changes to sidebar components. We will want to create a separate service to - * interface with the server at that point. - */ - - class IssuableTimeTracking { - constructor(issuableJSON) { - const parsedIssuable = JSON.parse(issuableJSON); - return this.initComponent(parsedIssuable); - } - - initComponent(parsedIssuable) { - this.parentInstance = new Vue({ - el: '#issuable-time-tracker', - data: { - issuable: parsedIssuable, - }, - methods: { - fetchIssuable() { - return gl.IssuableResource.get.call(gl.IssuableResource, { - type: 'GET', - url: gl.IssuableResource.endpoint, - }); - }, - updateState(data) { - this.issuable = data; - }, - subscribeToUpdates() { - gl.IssuableResource.subscribe(data => this.updateState(data)); - }, - listenForSlashCommands() { - $(document).on('ajax:success', '.gfm-form', (e, data) => { - const subscribedCommands = ['spend_time', 'time_estimate']; - const changedCommands = data.commands_changes - ? Object.keys(data.commands_changes) - : []; - if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { - this.fetchIssuable(); - } - }); - }, - }, - created() { - this.fetchIssuable(); - }, - mounted() { - this.subscribeToUpdates(); - this.listenForSlashCommands(); - }, - }); - } - } - - gl.IssuableTimeTracking = IssuableTimeTracking; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index b2cfd3ef2a3..56cb536dcde 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js index e0ebd36a65c..fee3429e2b8 100644 --- a/app/assets/javascripts/issues_bulk_assignment.js +++ b/app/assets/javascripts/issues_bulk_assignment.js @@ -88,7 +88,10 @@ const formData = { update: { state_event: this.form.find('input[name="update[state_event]"]').val(), + // For Merge Requests assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + // For Issues + assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 9a60f5464df..ac5ce84e31b 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -330,7 +330,10 @@ }, multiSelect: $dropdown.hasClass('js-multiselect'), vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(label, $el, e, isMarking) { + clicked: function(options) { + const { $el, e, isMarking } = options; + const label = options.selectedObj; + var isIssueIndex, isMRIndex, page, boardsModel; var fadeOutLoader = () => { $loading.fadeOut(); @@ -352,7 +355,7 @@ if ($dropdown.hasClass('js-filter-bulk-update')) { _this.enableBulkLabelDropdown(); - _this.setDropdownData($dropdown, isMarking, this.id(label)); + _this.setDropdownData($dropdown, isMarking, label.id); return; } diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index be3c2c9fbb1..1b0d5fc92e3 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -158,7 +158,6 @@ import './single_file_diff'; import './smart_interval'; import './snippets_list'; import './star'; -import './subbable_resource'; import './subscription'; import './subscription_select'; import './syntax_highlight'; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index e3f367a11eb..8291b8c4a70 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -31,8 +31,8 @@ toggleLabel(selected, $el) { return $el.text(); }, - clicked: (selected, $link) => { - this.formSubmit(null, $link); + clicked: (options) => { + this.formSubmit(null, options.$el); }, }); }); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index bebd0aa357e..11e68c0a3be 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -121,7 +121,10 @@ return $value.css('display', ''); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(selected, $el, e) { + clicked: function(options) { + const { $el, e } = options; + let selected = options.selectedObj; + var data, isIssueIndex, isMRIndex, page, boardsStore; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index b98e6121967..36bc1257cef 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -58,7 +58,8 @@ }); } - NamespaceSelect.prototype.onSelectItem = function(item, el, e) { + NamespaceSelect.prototype.onSelectItem = function(options) { + const { e } = options; return e.preventDefault(); }; diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index f944fcc5a58..738e710deb9 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -112,7 +112,8 @@ import Cookies from 'js-cookie'; toggleLabel: function(obj, $el) { return $el.text().trim(); }, - clicked: function(selected, $el, e) { + clicked: function(options) { + const { e } = options; e.preventDefault(); if ($('input[name="ref"]').length) { var $form = $dropdown.closest('form'); diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js index e7fff57ff45..42993a252c3 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js @@ -19,7 +19,9 @@ return 'Select'; } }, - clicked(item, $el, e) { + clicked(opts) { + const { e } = opts; + e.preventDefault(); onSelect(); } diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js index 1d4bb8a13d6..bc6110fcd4e 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js @@ -35,7 +35,8 @@ class ProtectedBranchDropdown { return _.escape(protectedBranch.id); }, onFilter: this.toggleCreateNewButton.bind(this), - clicked: (item, $el, e) => { + clicked: (options) => { + const { $el, e } = options; e.preventDefault(); this.onSelect(); } diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js index fff83f3af3b..d4c9a91a74a 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js @@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown { } return 'Select'; }, - clicked(item, $el, e) { - e.preventDefault(); + clicked(options) { + options.e.preventDefault(); onSelect(); }, }); diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js index 5ff4e443262..068e9698e1d 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js @@ -39,8 +39,8 @@ export default class ProtectedTagDropdown { return _.escape(protectedTag.id); }, onFilter: this.toggleCreateNewButton.bind(this), - clicked: (item, $el, e) => { - e.preventDefault(); + clicked: (options) => { + options.e.preventDefault(); this.onSelect(); }, }); diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js new file mode 100644 index 00000000000..a9ad3708514 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js @@ -0,0 +1,41 @@ +export default { + name: 'AssigneeTitle', + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + numberOfAssignees: { + type: Number, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + assigneeTitle() { + const assignees = this.numberOfAssignees; + return assignees > 1 ? `${assignees} Assignees` : 'Assignee'; + }, + }, + template: ` + <div class="title hide-collapsed"> + {{assigneeTitle}} + <i + v-if="loading" + aria-hidden="true" + class="fa fa-spinner fa-spin block-loading" + /> + <a + v-if="editable" + class="edit-link pull-right" + href="#" + > + Edit + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js new file mode 100644 index 00000000000..7e5feac622c --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js @@ -0,0 +1,224 @@ +export default { + name: 'Assignees', + data() { + return { + defaultRenderCount: 5, + defaultMaxCounter: 99, + showLess: true, + }; + }, + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasMoreThanTwoAssignees() { + return this.users.length > 2; + }, + hasMoreThanOneAssignee() { + return this.users.length > 1; + }, + hasAssignees() { + return this.users.length > 0; + }, + hasNoUsers() { + return !this.users.length; + }, + hasOneUser() { + return this.users.length === 1; + }, + renderShowMoreSection() { + return this.users.length > this.defaultRenderCount; + }, + numberOfHiddenAssignees() { + return this.users.length - this.defaultRenderCount; + }, + isHiddenAssignees() { + return this.numberOfHiddenAssignees > 0; + }, + hiddenAssigneesLabel() { + return `+ ${this.numberOfHiddenAssignees} more`; + }, + collapsedTooltipTitle() { + const maxRender = Math.min(this.defaultRenderCount, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (this.users.length > maxRender) { + names.push(`+ ${this.users.length - maxRender} more`); + } + + return names.join(', '); + }, + sidebarAvatarCounter() { + let counter = `+${this.users.length - 1}`; + + if (this.users.length > this.defaultMaxCounter) { + counter = `${this.defaultMaxCounter}+`; + } + + return counter; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + toggleShowLess() { + this.showLess = !this.showLess; + }, + renderAssignee(index) { + return !this.showLess || (index < this.defaultRenderCount && this.showLess); + }, + avatarUrl(user) { + return user.avatar || user.avatar_url; + }, + assigneeUrl(user) { + return `${this.rootPath}${user.username}`; + }, + assigneeAlt(user) { + return `${user.name}'s avatar`; + }, + assigneeUsername(user) { + return `@${user.username}`; + }, + shouldRenderCollapsedAssignee(index) { + const firstTwo = this.users.length <= 2 && index <= 2; + + return index === 0 || firstTwo; + }, + }, + template: ` + <div> + <div + class="sidebar-collapsed-icon sidebar-collapsed-user" + :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + data-container="body" + data-placement="left" + :title="collapsedTooltipTitle" + > + <i + v-if="hasNoUsers" + aria-label="No Assignee" + class="fa fa-user" + /> + <button + type="button" + class="btn-link" + v-for="(user, index) in users" + v-if="shouldRenderCollapsedAssignee(index)" + > + <img + width="24" + class="avatar avatar-inline s24" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + <span class="author"> + {{ user.name }} + </span> + </button> + <button + v-if="hasMoreThanTwoAssignees" + class="btn-link" + type="button" + > + <span + class="avatar-counter sidebar-avatar-counter" + > + {{ sidebarAvatarCounter }} + </span> + </button> + </div> + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value"> + No assignee + <template v-if="editable"> + - + <button + type="button" + class="btn-link" + @click="assignSelf" + > + assign yourself + </button> + </template> + </span> + </template> + <template v-else-if="hasOneUser"> + <a + class="author_link bold" + :href="assigneeUrl(firstUser)" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(firstUser)" + :src="avatarUrl(firstUser)" + /> + <span class="author"> + {{ firstUser.name }} + </span> + <span class="username"> + {{ assigneeUsername(firstUser) }} + </span> + </a> + </template> + <template v-else> + <div class="user-list"> + <div + class="user-item" + v-for="(user, index) in users" + v-if="renderAssignee(index)" + > + <a + class="user-link has-tooltip" + data-placement="bottom" + :href="assigneeUrl(user)" + :data-title="user.name" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + </a> + </div> + </div> + <div + v-if="renderShowMoreSection" + class="user-list-more" + > + <button + type="button" + class="btn-link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenAssigneesLabel }} + </template> + <template v-else> + - show less + </template> + </button> + </div> + </template> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js new file mode 100644 index 00000000000..1488a66c695 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js @@ -0,0 +1,84 @@ +/* global Flash */ + +import AssigneeTitle from './assignee_title'; +import Assignees from './assignees'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +import eventHub from '../../event_hub'; + +export default { + name: 'SidebarAssignees', + data() { + return { + mediator: new Mediator(), + store: new Store(), + loading: false, + field: '', + }; + }, + components: { + 'assignee-title': AssigneeTitle, + assignees: Assignees, + }, + methods: { + assignSelf() { + // Notify gl dropdown that we are now assigning to current user + this.$el.parentElement.dispatchEvent(new Event('assignYourself')); + + this.mediator.assignYourself(); + this.saveAssignees(); + }, + saveAssignees() { + this.loading = true; + + function setLoadingFalse() { + this.loading = false; + } + + this.mediator.saveAssignees(this.field) + .then(setLoadingFalse.bind(this)) + .catch(() => { + setLoadingFalse(); + return new Flash('Error occurred when saving assignees'); + }); + }, + }, + created() { + this.removeAssignee = this.store.removeAssignee.bind(this.store); + this.addAssignee = this.store.addAssignee.bind(this.store); + this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store); + + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); + }, + beforeMount() { + this.field = this.$el.dataset.field; + }, + template: ` + <div> + <assignee-title + :number-of-assignees="store.assignees.length" + :loading="loading" + :editable="store.editable" + /> + <assignees + class="value" + :root-path="store.rootPath" + :users="store.assignees" + :editable="store.editable" + @assign-self="assignSelf" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js new file mode 100644 index 00000000000..0da265053bd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js @@ -0,0 +1,97 @@ +import stopwatchSvg from 'icons/_icon_stopwatch.svg'; + +import '../../../lib/utils/pretty_time'; + +export default { + name: 'time-tracking-collapsed-state', + props: { + showComparisonState: { + type: Boolean, + required: true, + }, + showSpentOnlyState: { + type: Boolean, + required: true, + }, + showEstimateOnlyState: { + type: Boolean, + required: true, + }, + showNoTimeTrackingState: { + type: Boolean, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: false, + default: '', + }, + timeEstimateHumanReadable: { + type: String, + required: false, + default: '', + }, + }, + computed: { + timeSpent() { + return this.abbreviateTime(this.timeSpentHumanReadable); + }, + timeEstimate() { + return this.abbreviateTime(this.timeEstimateHumanReadable); + }, + divClass() { + if (this.showComparisonState) { + return 'compare'; + } else if (this.showEstimateOnlyState) { + return 'estimate-only'; + } else if (this.showSpentOnlyState) { + return 'spend-only'; + } else if (this.showNoTimeTrackingState) { + return 'no-tracking'; + } + + return ''; + }, + spanClass() { + if (this.showComparisonState) { + return ''; + } else if (this.showEstimateOnlyState || this.showSpentOnlyState) { + return 'bold'; + } else if (this.showNoTimeTrackingState) { + return 'no-value'; + } + + return ''; + }, + text() { + if (this.showComparisonState) { + return `${this.timeSpent} / ${this.timeEstimate}`; + } else if (this.showEstimateOnlyState) { + return `-- / ${this.timeEstimate}`; + } else if (this.showSpentOnlyState) { + return `${this.timeSpent} / --`; + } else if (this.showNoTimeTrackingState) { + return 'None'; + } + + return ''; + }, + }, + methods: { + abbreviateTime(timeStr) { + return gl.utils.prettyTime.abbreviateTime(timeStr); + }, + }, + template: ` + <div class="sidebar-collapsed-icon"> + ${stopwatchSvg} + <div class="time-tracking-collapsed-summary"> + <div :class="divClass"> + <span :class="spanClass"> + {{ text }} + </span> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js new file mode 100644 index 00000000000..40f5c89c5bb --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js @@ -0,0 +1,98 @@ +import '../../../lib/utils/pretty_time'; + +const prettyTime = gl.utils.prettyTime; + +export default { + name: 'time-tracking-comparison-pane', + props: { + timeSpent: { + type: Number, + required: true, + }, + timeEstimate: { + type: Number, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: true, + }, + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + computed: { + parsedRemaining() { + const diffSeconds = this.timeEstimate - this.timeSpent; + return prettyTime.parseSeconds(diffSeconds); + }, + timeRemainingHumanReadable() { + return prettyTime.stringifyTime(this.parsedRemaining); + }, + timeRemainingTooltip() { + const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; + return `${prefix} ${this.timeRemainingHumanReadable}`; + }, + /* Diff values for comparison meter */ + timeRemainingMinutes() { + return this.timeEstimate - this.timeSpent; + }, + timeRemainingPercent() { + return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; + }, + timeRemainingStatusClass() { + return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; + }, + /* Parsed time values */ + parsedEstimate() { + return prettyTime.parseSeconds(this.timeEstimate); + }, + parsedSpent() { + return prettyTime.parseSeconds(this.timeSpent); + }, + }, + template: ` + <div class="time-tracking-comparison-pane"> + <div + class="compare-meter" + data-toggle="tooltip" + data-placement="top" + role="timeRemainingDisplay" + :aria-valuenow="timeRemainingTooltip" + :title="timeRemainingTooltip" + :data-original-title="timeRemainingTooltip" + :class="timeRemainingStatusClass" + > + <div + class="meter-container" + role="timeSpentPercent" + :aria-valuenow="timeRemainingPercent" + > + <div + :style="{ width: timeRemainingPercent }" + class="meter-fill" + /> + </div> + <div class="compare-display-container"> + <div class="compare-display pull-left"> + <span class="compare-label"> + Spent + </span> + <span class="compare-value spent"> + {{ timeSpentHumanReadable }} + </span> + </div> + <div class="compare-display estimated pull-right"> + <span class="compare-label"> + Est + </span> + <span class="compare-value"> + {{ timeEstimateHumanReadable }} + </span> + </div> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js new file mode 100644 index 00000000000..ad1b9179db0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js @@ -0,0 +1,17 @@ +export default { + name: 'time-tracking-estimate-only-pane', + props: { + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-estimate-only-pane"> + <span class="bold"> + Estimated: + </span> + {{ timeEstimateHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js new file mode 100644 index 00000000000..b2a77462fe0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js @@ -0,0 +1,44 @@ +export default { + name: 'time-tracking-help-state', + props: { + rootPath: { + type: String, + required: true, + }, + }, + computed: { + href() { + return `${this.rootPath}help/workflow/time_tracking.md`; + }, + }, + template: ` + <div class="time-tracking-help-state"> + <div class="time-tracking-info"> + <h4> + Track time with slash commands + </h4> + <p> + Slash commands can be used in the issues description and comment boxes. + </p> + <p> + <code> + /estimate + </code> + will update the estimated time with the latest command. + </p> + <p> + <code> + /spend + </code> + will update the sum of the time spent. + </p> + <a + class="btn btn-default learn-more-button" + :href="href" + > + Learn more + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js new file mode 100644 index 00000000000..d1dd1dcdd27 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js @@ -0,0 +1,10 @@ +export default { + name: 'time-tracking-no-tracking-pane', + template: ` + <div class="time-tracking-no-tracking-pane"> + <span class="no-value"> + No estimate or time spent + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js new file mode 100644 index 00000000000..e2dba1fb0c2 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -0,0 +1,45 @@ +import '~/smart_interval'; + +import timeTracker from './time_tracker'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + components: { + 'issuable-time-tracker': timeTracker, + }, + methods: { + listenForSlashCommands() { + $(document).on('ajax:success', '.gfm-form', (e, data) => { + const subscribedCommands = ['spend_time', 'time_estimate']; + const changedCommands = data.commands_changes + ? Object.keys(data.commands_changes) + : []; + if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { + this.mediator.fetch(); + } + }); + }, + }, + mounted() { + this.listenForSlashCommands(); + }, + template: ` + <div class="block"> + <issuable-time-tracker + :time_estimate="store.timeEstimate" + :time_spent="store.totalTimeSpent" + :human_time_estimate="store.humanTimeEstimate" + :human_time_spent="store.humanTotalTimeSpent" + :rootPath="store.rootPath" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js new file mode 100644 index 00000000000..bf987562647 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js @@ -0,0 +1,15 @@ +export default { + name: 'time-tracking-spent-only-pane', + props: { + timeSpentHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-spend-only-pane"> + <span class="bold">Spent:</span> + {{ timeSpentHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js new file mode 100644 index 00000000000..ed0d71a4f79 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js @@ -0,0 +1,163 @@ +import timeTrackingHelpState from './help_state'; +import timeTrackingCollapsedState from './collapsed_state'; +import timeTrackingSpentOnlyPane from './spent_only_pane'; +import timeTrackingNoTrackingPane from './no_tracking_pane'; +import timeTrackingEstimateOnlyPane from './estimate_only_pane'; +import timeTrackingComparisonPane from './comparison_pane'; + +import eventHub from '../../event_hub'; + +export default { + name: 'issuable-time-tracker', + props: { + time_estimate: { + type: Number, + required: true, + }, + time_spent: { + type: Number, + required: true, + }, + human_time_estimate: { + type: String, + required: false, + default: '', + }, + human_time_spent: { + type: String, + required: false, + default: '', + }, + rootPath: { + type: String, + required: true, + }, + }, + data() { + return { + showHelp: false, + }; + }, + components: { + 'time-tracking-collapsed-state': timeTrackingCollapsedState, + 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane, + 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, + 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane, + 'time-tracking-comparison-pane': timeTrackingComparisonPane, + 'time-tracking-help-state': timeTrackingHelpState, + }, + computed: { + timeSpent() { + return this.time_spent; + }, + timeEstimate() { + return this.time_estimate; + }, + timeEstimateHumanReadable() { + return this.human_time_estimate; + }, + timeSpentHumanReadable() { + return this.human_time_spent; + }, + hasTimeSpent() { + return !!this.timeSpent; + }, + hasTimeEstimate() { + return !!this.timeEstimate; + }, + showComparisonState() { + return this.hasTimeEstimate && this.hasTimeSpent; + }, + showEstimateOnlyState() { + return this.hasTimeEstimate && !this.hasTimeSpent; + }, + showSpentOnlyState() { + return this.hasTimeSpent && !this.hasTimeEstimate; + }, + showNoTimeTrackingState() { + return !this.hasTimeEstimate && !this.hasTimeSpent; + }, + showHelpState() { + return !!this.showHelp; + }, + }, + methods: { + toggleHelpState(show) { + this.showHelp = show; + }, + update(data) { + this.time_estimate = data.time_estimate; + this.time_spent = data.time_spent; + this.human_time_estimate = data.human_time_estimate; + this.human_time_spent = data.human_time_spent; + }, + }, + created() { + eventHub.$on('timeTracker:updateData', this.update); + }, + template: ` + <div + class="time_tracker time-tracking-component-wrap" + v-cloak + > + <time-tracking-collapsed-state + :show-comparison-state="showComparisonState" + :show-no-time-tracking-state="showNoTimeTrackingState" + :show-help-state="showHelpState" + :show-spent-only-state="showSpentOnlyState" + :show-estimate-only-state="showEstimateOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <div class="title hide-collapsed"> + Time tracking + <div + class="help-button pull-right" + v-if="!showHelpState" + @click="toggleHelpState(true)" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + /> + </div> + <div + class="close-help-button pull-right" + v-if="showHelpState" + @click="toggleHelpState(false)" + > + <i + class="fa fa-close" + aria-hidden="true" + /> + </div> + </div> + <div class="time-tracking-content hide-collapsed"> + <time-tracking-estimate-only-pane + v-if="showEstimateOnlyState" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <time-tracking-spent-only-pane + v-if="showSpentOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + /> + <time-tracking-no-tracking-pane + v-if="showNoTimeTrackingState" + /> + <time-tracking-comparison-pane + v-if="showComparisonState" + :time-estimate="timeEstimate" + :time-spent="timeSpent" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <transition name="help-state-toggle"> + <time-tracking-help-state + v-if="showHelpState" + :rootPath="rootPath" + /> + </transition> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/sidebar/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js new file mode 100644 index 00000000000..5a82d01dc41 --- /dev/null +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class SidebarService { + constructor(endpoint) { + if (!SidebarService.singleton) { + this.endpoint = endpoint; + + SidebarService.singleton = this; + } + + return SidebarService.singleton; + } + + get() { + return Vue.http.get(this.endpoint); + } + + update(key, data) { + return Vue.http.put(this.endpoint, { + [key]: data, + }, { + emulateJSON: true, + }); + } +} diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js new file mode 100644 index 00000000000..2ce53c2ed30 --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; +import sidebarAssignees from './components/assignees/sidebar_assignees'; + +import Mediator from './sidebar_mediator'; + +document.addEventListener('DOMContentLoaded', () => { + const mediator = new Mediator(gl.sidebarOptions); + mediator.fetch(); + + const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); + + // Only create the sidebarAssignees vue app if it is found in the DOM + // We currently do not use sidebarAssignees for the MR page + if (sidebarAssigneesEl) { + new Vue(sidebarAssignees).$mount(sidebarAssigneesEl); + } + + new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); +}); + diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js new file mode 100644 index 00000000000..c13f3391f0d --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -0,0 +1,38 @@ +/* global Flash */ + +import Service from './services/sidebar_service'; +import Store from './stores/sidebar_store'; + +export default class SidebarMediator { + constructor(options) { + if (!SidebarMediator.singleton) { + this.store = new Store(options); + this.service = new Service(options.endpoint); + SidebarMediator.singleton = this; + } + + return SidebarMediator.singleton; + } + + assignYourself() { + this.store.addAssignee(this.store.currentUser); + } + + saveAssignees(field) { + const selected = this.store.assignees.map(u => u.id); + + // If there are no ids, that means we have to unassign (which is id = 0) + // And it only accepts an array, hence [0] + return this.service.update(field, selected.length === 0 ? [0] : selected); + } + + fetch() { + this.service.get() + .then((response) => { + const data = response.json(); + this.store.processAssigneeData(data); + this.store.processTimeTrackingData(data); + }) + .catch(() => new Flash('Error occured when fetching sidebar data')); + } +} diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js new file mode 100644 index 00000000000..94408c4d715 --- /dev/null +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -0,0 +1,52 @@ +export default class SidebarStore { + constructor(store) { + if (!SidebarStore.singleton) { + const { currentUser, rootPath, editable } = store; + this.currentUser = currentUser; + this.rootPath = rootPath; + this.editable = editable; + this.timeEstimate = 0; + this.totalTimeSpent = 0; + this.humanTimeEstimate = ''; + this.humanTimeSpent = ''; + this.assignees = []; + + SidebarStore.singleton = this; + } + + return SidebarStore.singleton; + } + + processAssigneeData(data) { + if (data.assignees) { + this.assignees = data.assignees; + } + } + + processTimeTrackingData(data) { + this.timeEstimate = data.time_estimate; + this.totalTimeSpent = data.total_time_spent; + this.humanTimeEstimate = data.human_time_estimate; + this.humanTotalTimeSpent = data.human_total_time_spent; + } + + addAssignee(assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(assignee); + } + } + + findAssignee(findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee(removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees() { + this.assignees = []; + } +} diff --git a/app/assets/javascripts/subbable_resource.js b/app/assets/javascripts/subbable_resource.js deleted file mode 100644 index d8191605128..00000000000 --- a/app/assets/javascripts/subbable_resource.js +++ /dev/null @@ -1,51 +0,0 @@ -(() => { -/* -* SubbableResource can be extended to provide a pubsub-style service for one-off REST -* calls. Subscribe by passing a callback or render method you will use to handle responses. - * -* */ - - class SubbableResource { - constructor(resourcePath) { - this.endpoint = resourcePath; - - // TODO: Switch to axios.create - this.resource = $.ajax; - this.subscribers = []; - } - - subscribe(callback) { - this.subscribers.push(callback); - } - - publish(newResponse) { - const responseCopy = _.extend({}, newResponse); - this.subscribers.forEach((fn) => { - fn(responseCopy); - }); - return newResponse; - } - - get(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - post(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - put(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - delete(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - } - - gl.SubbableResource = SubbableResource; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js index 8b25f43ffc7..0cd591c7320 100644 --- a/app/assets/javascripts/subscription_select.js +++ b/app/assets/javascripts/subscription_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 68cf9ced3ef..be29b08c343 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ /* global Issuable */ -/* global ListUser */ + +import eventHub from './sidebar/event_hub'; (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, @@ -54,42 +55,115 @@ selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; selectedId = $dropdown.data('selected') || selectedIdDefault; - var updateIssueBoardsIssue = function () { - $loading.removeClass('hidden').fadeIn(); - gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) - .then(function () { - $loading.fadeOut(); - }) - .catch(function () { - $loading.fadeOut(); - }); + const assignYourself = function () { + const unassignedSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); + } + + // 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 ($selectbox) { + $dropdown.parent().before(input); + } else { + $dropdown.after(input); + } + }; + + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); + } + + 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(); + eventHub.$emit('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`; + } }; $('.assign-to-me-link').on('click', (e) => { e.preventDefault(); $(e.currentTarget).hide(); - 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'); - }); - $block.on('click', '.js-assign-yourself', function(e) { - e.preventDefault(); - - if ($dropdown.hasClass('js-issue-board-sidebar')) { - gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ - id: _this.currentUser.id, - username: _this.currentUser.username, - name: _this.currentUser.name, - avatar_url: _this.currentUser.avatar_url - })); + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); - updateIssueBoardsIssue(); + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); } else { - return assignTo(_this.currentUser.id); + 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'); } }); + + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); + assignTo = function(selected) { var data; data = {}; @@ -97,6 +171,7 @@ data[abilityName].assignee_id = selected != null ? selected : null; $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); + return $.ajax({ type: 'PUT', dataType: 'json', @@ -106,7 +181,6 @@ var user; $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); - $selectbox.hide(); if (data.assignee) { user = { name: data.assignee.name, @@ -133,51 +207,90 @@ var isAuthorFilter; isAuthorFilter = $('.js-author-search'); return _this.users(term, options, function(users) { - var anyUser, index, j, len, name, obj, 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; - } + // 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) { - 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); + } + 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); } + if (showDivider) { - users.splice(showDivider, 0, "divider"); + users.splice(showDivider, 0, 'divider'); } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); + + 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); + + users = users.filter(u => selected.indexOf(u.id) === -1); + + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); + + users.splice(showDivider + 1, 0, 'divider'); + } } - }); + } + + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } }, filterable: true, filterRemote: true, @@ -186,7 +299,22 @@ }, selectable: true, fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected, el) { + 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 (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); if (selected.text) { @@ -200,22 +328,81 @@ } }, defaultLabel: defaultLabel, - inputId: 'issue_assignee_id', hidden: function(e) { - $selectbox.hide(); - // display:block overrides the hide-collapse rule - return $value.css('display', ''); + if ($dropdown.hasClass('js-multiselect')) { + eventHub.$emit('sidebar.saveAssignees'); + } + + if (!$dropdown.data('always-show-selectbox')) { + $selectbox.hide(); + + // 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', ''); + } }, - vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(user, $el, e) { - var isIssueIndex, isMRIndex, page, selected, isSelecting; + 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(); + }); + eventHub.$emit('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + eventHub.$emit('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]"); + + 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, {}); + } + + // User unselected + eventHub.$emit('sidebar.removeAssignee', user); + } + + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } + + var isIssueIndex, isMRIndex, page, selected; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); - isSelecting = (user.id !== selectedId); - selectedId = isSelecting ? user.id : selectedIdDefault; if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); + + const isSelecting = (user.id !== selectedId); + selectedId = isSelecting ? user.id : selectedIdDefault; + if (selectedId === gon.current_user_id) { $('.assign-to-me-link').hide(); } else { @@ -229,20 +416,7 @@ return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); - } else if ($dropdown.hasClass('js-issue-board-sidebar')) { - if (user.id && isSelecting) { - gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ - id: user.id, - username: user.username, - name: user.name, - avatar_url: user.avatar_url - })); - } else { - gl.issueBoards.boardStoreIssueDelete('assignee'); - } - - updateIssueBoardsIssue(); - } else { + } else if (!$dropdown.hasClass('js-multiselect')) { selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); return assignTo(selected); } @@ -256,29 +430,54 @@ selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; } $el.find('.is-active').removeClass('is-active'); - $el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active'); + + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('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, selected, username; + var avatar, img, listClosingTags, listWithName, listWithUserName, username; username = user.username ? "@" + user.username : ""; avatar = user.avatar_url ? user.avatar_url : false; - selected = user.id === parseInt(selectedId, 10) ? "is-active" : ""; + + let selected = user.id === parseInt(selectedId, 10); + + if (this.multiSelect) { + const fieldName = this.fieldName; + const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); + + if (field.length) { + selected = true; + } + } + img = ""; if (user.beforeDivider != null) { - "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>"; + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; } else { if (avatar) { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />"; + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; } } - // split into three parts so we can remove the username section if nessesary - listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>"; - listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>"; - listClosingTags = "</a> </li>"; - if (username === '') { - listWithUserName = ''; - } - return listWithName + listWithUserName + listClosingTags; + + 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> + `; } }); }; diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 3f5b78ed445..91c1ebd5a7d 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -93,3 +93,14 @@ align-self: center; } } + +.avatar-counter { + background-color: $gray-darkest; + color: $white-light; + border: 1px solid $border-color; + border-radius: 1em; + font-family: $regular_font; + font-size: 9px; + line-height: 16px; + text-align: center; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 73ded9f30d4..5c9b71a452c 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -251,14 +251,16 @@ } .dropdown-header { - color: $gl-text-color; + color: $gl-text-color-secondary; font-size: 13px; - font-weight: 600; line-height: 22px; - text-transform: capitalize; padding: 0 16px; } + &.capitalize-header .dropdown-header { + text-transform: capitalize; + } + .separator + .dropdown-header { padding-top: 2px; } @@ -337,8 +339,8 @@ .dropdown-menu-user { .avatar { float: left; - width: 30px; - height: 30px; + width: 2 * $gl-padding; + height: 2 * $gl-padding; margin: 0 10px 0 0; } } @@ -381,6 +383,7 @@ .dropdown-menu-selectable { a { padding-left: 26px; + position: relative; &.is-indeterminate, &.is-active { @@ -406,6 +409,9 @@ &.is-active::before { content: "\f00c"; + position: absolute; + top: 50%; + transform: translateY(-50%); } } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 15dc0aa6a52..c9a25946ffd 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -255,6 +255,7 @@ ul.controls { .avatar-inline { margin-left: 0; margin-right: 0; + margin-bottom: 0; } } } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 0be1c215959..68d7ab4bf84 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -207,8 +207,13 @@ margin-bottom: 5px; } - &.is-active { + &.is-active, + &.is-active .card-assignee:hover a { background-color: $row-hover; + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $row-hover; + } } .label { @@ -224,7 +229,7 @@ } .card-title { - margin: 0; + margin: 0 30px 0 0; font-size: 1em; line-height: inherit; @@ -240,10 +245,69 @@ min-height: 20px; .card-assignee { - margin-left: auto; - margin-right: 5px; - padding-left: 10px; + display: flex; + justify-content: flex-end; + position: absolute; + right: 15px; height: 20px; + width: 20px; + + .avatar-counter { + display: none; + vertical-align: middle; + min-width: 20px; + line-height: 19px; + height: 20px; + padding-left: 2px; + padding-right: 2px; + border-radius: 2em; + } + + img { + vertical-align: top; + } + + a { + position: relative; + margin-left: -15px; + } + + a:nth-child(1) { + z-index: 3; + } + + a:nth-child(2) { + z-index: 2; + } + + a:nth-child(3) { + z-index: 1; + } + + a:nth-child(4) { + display: none; + } + + &:hover { + .avatar-counter { + display: inline-block; + } + + a { + position: static; + background-color: $white-light; + transition: background-color 0s; + margin-left: auto; + + &:nth-child(4) { + display: block; + } + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $white-light; + } + } + } } .avatar { diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index feefaad8a15..77f2638683a 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -570,14 +570,7 @@ .diff-comments-more-count, .diff-notes-collapse { - background-color: $gray-darkest; - color: $white-light; - border: 1px solid $white-light; - border-radius: 1em; - font-family: $regular_font; - font-size: 9px; - line-height: 17px; - text-align: center; + @extend .avatar-counter; } .diff-notes-collapse { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index ad6eb9f6fe0..c4210ffd823 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -95,10 +95,15 @@ } .right-sidebar { - a { + a, + .btn-link { color: inherit; } + .btn-link { + outline: none; + } + .issuable-header-text { margin-top: 7px; } @@ -215,6 +220,10 @@ } } + .assign-yourself .btn-link { + padding-left: 0; + } + .light { font-weight: normal; } @@ -239,6 +248,10 @@ margin-left: 0; } + .assignee .user-list .avatar { + margin: 0; + } + .username { display: block; margin-top: 4px; @@ -301,6 +314,10 @@ margin-top: 0; } + .sidebar-avatar-counter { + padding-top: 2px; + } + .todo-undone { color: $gl-link-color; } @@ -309,10 +326,15 @@ display: none; } - .avatar:hover { + .avatar:hover, + .avatar-counter:hover { border-color: $issuable-sidebar-color; } + .avatar-counter:hover { + color: $issuable-sidebar-color; + } + .btn-clipboard { border: none; color: $issuable-sidebar-color; @@ -322,6 +344,17 @@ color: $gl-text-color; } } + + &.multiple-users { + display: flex; + justify-content: center; + } + } + + .sidebar-avatar-counter { + width: 24px; + height: 24px; + border-radius: 12px; } .sidebar-collapsed-user { @@ -332,6 +365,37 @@ .issuable-header-btn { display: none; } + + .multiple-users { + height: 24px; + margin-bottom: 17px; + margin-top: 4px; + padding-bottom: 4px; + + .btn-link { + padding: 0; + border: 0; + + .avatar { + margin: 0; + } + } + + .btn-link:first-child { + position: absolute; + left: 10px; + z-index: 1; + } + + .btn-link:last-child { + position: absolute; + right: 10px; + + &:hover { + text-decoration: none; + } + } + } } a { @@ -383,6 +447,12 @@ margin: -5px; } + +.user-list { + display: flex; + flex-wrap: wrap; +} + .participants-author { display: inline-block; padding: 5px; @@ -400,13 +470,39 @@ } } -.participants-more { +.user-item { + display: inline-block; + padding: 5px; + flex-basis: 20%; + + .user-link { + display: inline-block; + } +} + +.participants-more, +.user-list-more { margin-top: 5px; margin-left: 5px; - a { + a, + .btn-link { color: $gl-text-color-secondary; } + + .btn-link { + outline: none; + padding: 0; + } + + .btn-link:hover { + @extend a:hover; + text-decoration: none; + } + + .btn-link:focus { + text-decoration: none; + } } .issuable-form-padding-top { @@ -499,6 +595,19 @@ } } +.issuable-list li, +.issue-info-container .controls { + .avatar-counter { + display: inline-block; + vertical-align: middle; + min-width: 16px; + line-height: 14px; + height: 16px; + padding-left: 2px; + padding-right: 2px; + } +} + .time_tracker { padding-bottom: 0; border-bottom: 0; diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 3ccf2a9ce33..b199f18da1e 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -66,6 +66,7 @@ module IssuableActions :milestone_id, :state_event, :subscription_event, + assignee_ids: [], label_ids: [], add_label_ids: [], remove_label_ids: [] diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c8a501d7319..6df2c068745 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -43,7 +43,7 @@ module IssuableCollections end def issues_collection - issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace) + issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace) end def merge_requests_collection diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb index 28c9646910d..da9b789d617 100644 --- a/app/controllers/projects/boards/issues_controller.rb +++ b/app/controllers/projects/boards/issues_controller.rb @@ -82,7 +82,7 @@ module Projects labels: true, only: [:id, :iid, :title, :confidential, :due_date, :relative_position], include: { - assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, milestone: { only: [:id, :title] } }, user: current_user diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index b7a55a623b7..bcd23d61519 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -67,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController def new params[:issue] ||= ActionController::Parameters.new( - assignee_id: "" + assignee_ids: "" ) build_params = issue_params.merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], @@ -150,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController if @issue.valid? render json: @issue.to_json(methods: [:task_status, :task_status_short], include: { milestone: {}, - assignee: { only: [:name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, labels: { methods: :text_color } }) else render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity @@ -284,7 +284,7 @@ class Projects::IssuesController < Projects::ApplicationController def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, - :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [] + :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [], ) end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 4cc42b88a2a..957ad875858 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -231,7 +231,7 @@ class IssuableFinder when 'created-by-me', 'authored' items.where(author_id: current_user.id) when 'assigned-to-me' - items.where(assignee_id: current_user.id) + items.assigned_to(current_user) else items end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 76715e5970d..b4c074bc69c 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder IssuesFinder.not_restricted_by_confidentiality(current_user) end + def by_assignee(items) + if assignee + items.assigned_to(assignee) + elsif no_assignee? + items.unassigned + elsif assignee_id? || assignee_username? # assignee not found + items.none + else + items + end + end + def self.not_restricted_by_confidentiality(user) - return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank? + return Issue.where('issues.confidential IS NOT TRUE') if user.blank? return Issue.all if user.admin? Issue.where(' - issues.confidential IS NULL - OR issues.confidential IS FALSE + issues.confidential IS NOT TRUE OR (issues.confidential = TRUE AND (issues.author_id = :user_id - OR issues.assignee_id = :user_id + OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id) OR issues.project_id IN(:project_ids)))', user_id: user.id, project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 1182939f656..53962b84618 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -15,4 +15,36 @@ module FormHelper end end end + + def issue_dropdown_options(issuable, has_multiple_assignees = true) + options = { + toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data', + title: 'Select assignee', + filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', + placeholder: 'Search users', + data: { + first_user: current_user&.username, + null_user: true, + current_user: true, + project_id: issuable.project.try(:id), + field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]", + default_label: 'Assignee', + 'max-select': 1, + 'dropdown-header': 'Assignee', + multi_select: true, + 'input-meta': 'name', + 'always-show-selectbox': true, + current_user_info: current_user.to_json(only: [:id, :name]) + } + } + + if has_multiple_assignees + options[:title] = 'Select assignee(s)' + options[:data][:'dropdown-header'] = 'Assignee(s)' + options[:data].delete(:'max-select') + end + + options + end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 0b13dbf5f8d..7656929efe7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -63,6 +63,16 @@ module IssuablesHelper end end + def users_dropdown_label(selected_users) + if selected_users.length == 0 + "Unassigned" + elsif selected_users.length == 1 + selected_users[0].name + else + "#{selected_users[0].name} + #{selected_users.length - 1} more" + end + end + def user_dropdown_label(user_id, default_label) return default_label if user_id.nil? return "Unassigned" if user_id == "0" diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index d64e48f774b..0f847841295 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -11,10 +11,12 @@ module Emails mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end - def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id) + def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) - @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id + @previous_assignees = [] + @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 26dbf4d9570..16f04305a43 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -26,7 +26,6 @@ module Issuable cache_markdown_field :description, issuable_state_filter_enabled: true belongs_to :author, class_name: "User" - belongs_to :assignee, class_name: "User" belongs_to :updated_by, class_name: "User" belongs_to :milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do @@ -65,11 +64,8 @@ module Issuable validates :title, presence: true, length: { maximum: 255 } scope :authored, ->(user) { where(author_id: user) } - scope :assigned_to, ->(u) { where(assignee_id: u.id)} scope :recent, -> { reorder(id: :desc) } scope :order_position_asc, -> { reorder(position: :asc) } - scope :assigned, -> { where("assignee_id IS NOT NULL") } - scope :unassigned, -> { where("assignee_id IS NULL") } scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } @@ -92,23 +88,14 @@ module Issuable attr_mentionable :description participant :author - participant :assignee participant :notes_with_associations strip_attributes :title acts_as_paranoid - after_save :update_assignee_cache_counts, if: :assignee_id_changed? after_save :record_metrics, unless: :imported? - def update_assignee_cache_counts - # make sure we flush the cache for both the old *and* new assignees(if they exist) - previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was - previous_assignee&.update_cache_counts - assignee&.update_cache_counts - end - # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html def locking_enabled? @@ -237,10 +224,6 @@ module Issuable today? && created_at == updated_at end - def is_being_reassigned? - assignee_id_changed? - end - def open? opened? || reopened? end @@ -269,7 +252,11 @@ module Issuable # DEPRECATED repository: project.hook_attrs.slice(:name, :url, :description, :homepage) } - hook_data[:assignee] = assignee.hook_attrs if assignee + if self.is_a?(Issue) + hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any? + else + hook_data[:assignee] = assignee.hook_attrs if assignee + end hook_data end @@ -331,11 +318,6 @@ module Issuable false end - def assignee_or_author?(user) - # We're comparing IDs here so we don't need to load any associations. - author_id == user.id || assignee_id == user.id - end - def record_metrics metrics = self.metrics || create_metrics metrics.record! diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index f449229864d..a3472af5c55 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -40,7 +40,7 @@ module Milestoneish def issues_visible_to_user(user) memoize_per_user(user, :issues_visible_to_user) do IssuesFinder.new(user, issues_finder_params) - .execute.where(milestone_id: milestoneish_ids) + .execute.includes(:assignees).where(milestone_id: milestoneish_ids) end end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 0afbca2cb32..538615130a7 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -36,7 +36,7 @@ class GlobalMilestone closed = count_by_state(milestones_by_state_and_title, 'closed') all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count - { + { opened: opened, closed: closed, all: all @@ -86,7 +86,7 @@ class GlobalMilestone end def issues - @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels) + @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels) end def merge_requests @@ -94,7 +94,7 @@ class GlobalMilestone end def participants - @participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq + @participants ||= milestones.map(&:participants).flatten.uniq end def labels diff --git a/app/models/issue.rb b/app/models/issue.rb index 78bde6820da..27e3ed9bc7f 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + has_many :issue_assignees + has_many :assignees, class_name: "User", through: :issue_assignees + validates :project, presence: true scope :in_projects, ->(project_ids) { where(project_id: project_ids) } + scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)} + scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } @@ -37,13 +44,15 @@ class Issue < ActiveRecord::Base scope :created_after, -> (datetime) { where("created_at >= ?", datetime) } - scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) } + scope :include_associations, -> { includes(:labels, project: :namespace) } after_save :expire_etag_cache attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true + participant :assignees + state_machine :state, initial: :opened do event :close do transition [:reopened, :opened] => :closed @@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base end def hook_attrs + assignee_ids = self.assignee_ids + attrs = { total_time_spent: total_time_spent, human_total_time_spent: human_total_time_spent, - human_time_estimate: human_time_estimate + human_time_estimate: human_time_estimate, + assignee_ids: assignee_ids, + assignee_id: assignee_ids.first # This key is deprecated } attributes.merge!(attrs) @@ -114,6 +127,22 @@ class Issue < ActiveRecord::Base "id DESC") end + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee_list + } + end + + def assignee_or_author?(user) + author_id == user.id || assignees.exists?(user.id) + end + + def assignee_list + assignees.map(&:name).to_sentence + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" @@ -248,7 +277,7 @@ class Issue < ActiveRecord::Base true elsif confidential? author == user || - assignee == user || + assignees.include?(user) || project.team.member?(user, Gitlab::Access::REPORTER) else project.public? || diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb new file mode 100644 index 00000000000..0663d3aaef8 --- /dev/null +++ b/app/models/issue_assignee.rb @@ -0,0 +1,13 @@ +class IssueAssignee < ActiveRecord::Base + extend Gitlab::CurrentSettings + + belongs_to :issue + belongs_to :assignee, class_name: "User", foreign_key: :user_id + + after_create :update_assignee_cache_counts + after_destroy :update_assignee_cache_counts + + def update_assignee_cache_counts + assignee&.update_cache_counts + end +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 12c5481cd6d..35231bab12e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -17,6 +17,8 @@ class MergeRequest < ActiveRecord::Base has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + belongs_to :assignee, class_name: "User" + serialize :merge_params, Hash after_create :ensure_merge_request_diff, unless: :importing? @@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } + scope :assigned, -> { where("assignee_id IS NOT NULL") } + scope :unassigned, -> { where("assignee_id IS NULL") } + scope :assigned_to, ->(u) { where(assignee_id: u.id)} + + participant :assignee after_save :keep_around_commit + after_save :update_assignee_cache_counts, if: :assignee_id_changed? def self.reference_prefix '!' @@ -177,6 +185,30 @@ class MergeRequest < ActiveRecord::Base work_in_progress?(title) ? title : "WIP: #{title}" end + def update_assignee_cache_counts + # make sure we flush the cache for both the old *and* new assignees(if they exist) + previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was + previous_assignee&.update_cache_counts + assignee&.update_cache_counts + end + + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee.try(:name) + } + end + + # This method is needed for compatibility with issues to not mess view and other code + def assignees + Array(assignee) + end + + def assignee_or_author?(user) + author_id == user.id || assignee_id == user.id + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 652b1551928..c06bfe0ccdd 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base has_many :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests - has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee has_many :events, as: :target, dependent: :destroy scope :active, -> { with_state(:active) } @@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base end end + def participants + User.joins(assigned_issues: :milestone).where("milestones.id = ?", id) + end + def self.sort(method) case method.to_s when 'due_date_asc' diff --git a/app/models/user.rb b/app/models/user.rb index 43c5fdc038d..59f2be3ba9d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -100,6 +100,10 @@ class User < ActiveRecord::Base has_many :award_emoji, dependent: :destroy has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id + has_many :issue_assignees + has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue + has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" + # Issues that a user owns are expected to be moved to the "ghost" user before # the user is destroyed. If the user owns any issues during deletion, this # should be treated as an exceptional condition. diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index 29aecb50849..65b204d4dd2 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -1,7 +1,6 @@ class IssuableEntity < Grape::Entity expose :id expose :iid - expose :assignee_id expose :author_id expose :description expose :lock_version diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 6429159ebe1..bc4f68710b2 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -1,6 +1,7 @@ class IssueEntity < IssuableEntity expose :branch_name expose :confidential + expose :assignees, using: API::Entities::UserBasic expose :due_date expose :moved_to_id expose :project_id diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 5f80ab397a9..453ba52b892 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,4 +1,5 @@ class MergeRequestEntity < IssuableEntity + expose :assignee_id expose :in_progress_merge_commit_sha expose :locked_at expose :merge_commit_sha diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 60891cbb255..40ff9b8b867 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -7,10 +7,14 @@ module Issuable ids = params.delete(:issuable_ids).split(",") items = model_class.where(id: ids) - %i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key| + %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key| params.delete(key) unless params[key].present? end + if params[:assignee_ids] == [IssuableFinder::NONE.to_s] + params[:assignee_ids] = [] + end + items.each do |issuable| next unless can?(current_user, :"update_#{type}", issuable) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index b071a398481..6c2777a8d2d 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -1,11 +1,6 @@ class IssuableBaseService < BaseService private - def create_assignee_note(issuable) - SystemNoteService.change_assignee( - issuable, issuable.project, current_user, issuable.assignee) - end - def create_milestone_note(issuable) SystemNoteService.change_milestone( issuable, issuable.project, current_user, issuable.milestone) @@ -53,6 +48,7 @@ class IssuableBaseService < BaseService params.delete(:add_label_ids) params.delete(:remove_label_ids) params.delete(:label_ids) + params.delete(:assignee_ids) params.delete(:assignee_id) params.delete(:due_date) end @@ -77,7 +73,7 @@ class IssuableBaseService < BaseService def assignee_can_read?(issuable, assignee_id) new_assignee = User.find_by_id(assignee_id) - return false unless new_assignee.present? + return false unless new_assignee ability_name = :"read_#{issuable.to_ability_name}" resource = issuable.persisted? ? issuable : project @@ -207,6 +203,7 @@ class IssuableBaseService < BaseService filter_params(issuable) old_labels = issuable.labels.to_a old_mentioned_users = issuable.mentioned_users.to_a + old_assignees = issuable.assignees.to_a label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) @@ -222,7 +219,13 @@ class IssuableBaseService < BaseService handle_common_system_notes(issuable, old_labels: old_labels) end - handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users) + handle_changes( + issuable, + old_labels: old_labels, + old_mentioned_users: old_mentioned_users, + old_assignees: old_assignees + ) + after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') @@ -272,7 +275,7 @@ class IssuableBaseService < BaseService end end - def has_changes?(issuable, old_labels: []) + def has_changes?(issuable, old_labels: [], old_assignees: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| @@ -281,7 +284,9 @@ class IssuableBaseService < BaseService labels_changed = issuable.labels != old_labels - attrs_changed || labels_changed + assignees_changed = issuable.assignees != old_assignees + + attrs_changed || labels_changed || assignees_changed end def handle_common_system_notes(issuable, old_labels: []) diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index ee1b40db718..34199eb5d13 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -9,11 +9,33 @@ module Issues private + def create_assignee_note(issue, old_assignees) + SystemNoteService.change_issue_assignees( + issue, issue.project, current_user, old_assignees) + end + def execute_hooks(issue, action = 'open') issue_data = hook_data(issue, action) hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks issue.project.execute_hooks(issue_data, hooks_scope) issue.project.execute_services(issue_data, hooks_scope) end + + def filter_assignee(issuable) + return if params[:assignee_ids].blank? + + # The number of assignees is limited by one for GitLab CE + params[:assignee_ids] = params[:assignee_ids][0, 1] + + assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } + + if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE] + params[:assignee_ids] = [] + elsif assignee_ids.any? + params[:assignee_ids] = assignee_ids + else + params.delete(:assignee_ids) + end + end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b7fe5cb168b..cd9f9a4a16e 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -12,8 +12,12 @@ module Issues spam_check(issue, current_user) end - def handle_changes(issue, old_labels: [], old_mentioned_users: []) - if has_changes?(issue, old_labels: old_labels) + def handle_changes(issue, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + old_assignees = options[:old_assignees] || [] + + if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -26,9 +30,9 @@ module Issues create_milestone_note(issue) end - if issue.previous_changes.include?('assignee_id') - create_assignee_note(issue) - notification_service.reassigned_issue(issue, current_user) + if issue.assignees != old_assignees + create_assignee_note(issue, old_assignees) + notification_service.reassigned_issue(issue, current_user, old_assignees) todo_service.reassigned_issue(issue, current_user) end diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb index 1711be7211c..a85b9465c84 100644 --- a/app/services/members/authorized_destroy_service.rb +++ b/app/services/members/authorized_destroy_service.rb @@ -26,15 +26,22 @@ module Members def unassign_issues_and_merge_requests(member) if member.is_a?(GroupMember) - IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). - execute. - update_all(assignee_id: nil) + issue_ids = IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). + execute.pluck(:id) + + IssueAssignee.destroy_all(issue_id: issue_ids, user_id: member.user_id) + MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). execute. update_all(assignee_id: nil) else project = member.source - project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil) + + IssueAssignee.destroy_all( + user_id: member.user_id, + issue_id: project.issues.opened.assigned_to(member.user).select(:id) + ) + project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) member.user.update_cache_counts end diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb index 066efa1acc3..8c6c4841020 100644 --- a/app/services/merge_requests/assign_issues_service.rb +++ b/app/services/merge_requests/assign_issues_service.rb @@ -4,7 +4,7 @@ module MergeRequests @assignable_issues ||= begin if current_user == merge_request.author closes_issues.select do |issue| - !issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue) + !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue) end else [] @@ -14,7 +14,7 @@ module MergeRequests def execute assignable_issues.each do |issue| - Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue) + Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue) end { diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 582d5c47b66..3542a41ac83 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -38,6 +38,11 @@ module MergeRequests private + def create_assignee_note(merge_request) + SystemNoteService.change_assignee( + merge_request, merge_request.project, current_user, merge_request.assignee) + end + # Returns all origin and fork merge requests from `@project` satisfying passed arguments. def merge_requests_for(source_branch, mr_states: [:opened, :reopened]) MergeRequest diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index ab7fcf3b6e2..5c843a258fb 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -21,7 +21,10 @@ module MergeRequests update(merge_request) end - def handle_changes(merge_request, old_labels: [], old_mentioned_users: []) + def handle_changes(merge_request, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 8bb995158de..988bd0a7cdb 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -19,9 +19,14 @@ class NotificationRecipientService # Re-assign is considered as a mention of the new assignee so we add the # new assignee to the list of recipients after we rejected users with # the "on mention" notification level - if [:reassign_merge_request, :reassign_issue].include?(custom_action) + case custom_action + when :reassign_merge_request recipients << previous_assignee if previous_assignee recipients << target.assignee + when :reassign_issue + previous_assignees = Array(previous_assignee) + recipients.concat(previous_assignees) + recipients.concat(target.assignees) end recipients = reject_muted_users(recipients) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 6b186263bd1..c65c66d7150 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -66,8 +66,25 @@ class NotificationService # * issue new assignee if their notification level is not Disabled # * users with custom level checked with "reassign issue" # - def reassigned_issue(issue, current_user) - reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email) + def reassigned_issue(issue, current_user, previous_assignees = []) + recipients = NotificationRecipientService.new(issue.project).build_recipients( + issue, + current_user, + action: "reassign", + previous_assignee: previous_assignees + ) + + previous_assignee_ids = previous_assignees.map(&:id) + + recipients.each do |recipient| + mailer.send( + :reassigned_issue_email, + recipient.id, + issue.id, + previous_assignee_ids, + current_user.id + ).deliver_later + end end # When we add labels to an issue we should send an email to: @@ -367,10 +384,10 @@ class NotificationService end def previous_record(object, attribute) - if object && attribute - if object.previous_changes.include?(attribute) - object.previous_changes[attribute].first - end + return unless object && attribute + + if object.previous_changes.include?(attribute) + object.previous_changes[attribute].first end end end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index f1bbc7032d5..a7e13648b54 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -91,32 +91,47 @@ module SlashCommands end desc 'Assign' - explanation do |user| - "Assigns #{user.to_reference}." if user + explanation do |users| + "Assigns #{users.map(&:to_reference).to_sentence}." if users.any? end params '@user' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end parse_params do |assignee_param| - extract_references(assignee_param, :user).first || - User.find_by(username: assignee_param) + users = extract_references(assignee_param, :user) + + if users.empty? + users = User.where(username: assignee_param.split(' ').map(&:strip)) + end + + users end - command :assign do |user| - @updates[:assignee_id] = user.id if user + command :assign do |users| + next if users.empty? + + if issuable.is_a?(Issue) + @updates[:assignee_ids] = users.map(&:id) + else + @updates[:assignee_id] = users.last.id + end end desc 'Remove assignee' explanation do - "Removes assignee #{issuable.assignee.to_reference}." + "Removes assignee #{issuable.assignees.first.to_reference}." end condition do issuable.persisted? && - issuable.assignee_id? && + issuable.assignees.any? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :unassign do - @updates[:assignee_id] = nil + if issuable.is_a?(Issue) + @updates[:assignee_ids] = [] + else + @updates[:assignee_id] = nil + end end desc 'Set milestone' diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index c9e25c7aaa2..fb1f56c9cc6 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -49,6 +49,44 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) end + # Called when the assignees of an Issue is changed or removed + # + # issue - Issue object + # project - Project owning noteable + # author - User performing the change + # assignees - Users being assigned, or nil + # + # Example Note text: + # + # "removed all assignees" + # + # "assigned to @user1 additionally to @user2" + # + # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5" + # + # "assigned to @user1 and @user2" + # + # Returns the created Note object + def change_issue_assignees(issue, project, author, old_assignees) + body = + if issue.assignees.any? && old_assignees.any? + unassigned_users = old_assignees - issue.assignees + added_users = issue.assignees.to_a - old_assignees + + text_parts = [] + text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any? + text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? + + text_parts.join(' and ') + elsif old_assignees.any? + "removed all assignees" + elsif issue.assignees.any? + "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" + end + + create_note(NoteSummary.new(issue, project, author, body, action: 'assignee')) + end + # Called when one or more labels on a Noteable are added and/or removed # # noteable - Noteable object diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 8ae61694b50..322c6286365 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -251,9 +251,9 @@ class TodoService end def create_assignment_todo(issuable, author) - if issuable.assignee + if issuable.assignees.any? attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED) - create_todos(issuable.assignee, attributes) + create_todos(issuable.assignees, attributes) end end diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 23a88448055..2ed78bb3b65 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -23,10 +23,19 @@ xml.entry do end end - if issue.assignee + if issue.assignees.any? + xml.assignees do + issue.assignees.each do |assignee| + xml.assignee do + xml.name assignee.name + xml.email assignee.public_email + end + end + end + xml.assignee do - xml.name issue.assignee.name - xml.email issue.assignee_public_email + xml.name issue.assignees.first.name + xml.email issue.assignees.first.public_email end end end diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb deleted file mode 100644 index daf20a226dd..00000000000 --- a/app/views/notify/_reassigned_issuable_email.text.erb +++ /dev/null @@ -1,6 +0,0 @@ -Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> - -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> - -Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> - to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index c762578971a..eb5157ccac9 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -2,9 +2,9 @@ %p.details #{link_to @issue.author_name, user_url(@issue.author)} created an issue: -- if @issue.assignee_id.present? +- if @issue.assignees.any? %p - Assignee: #{@issue.assignee_name} + Assignee: #{@issue.assignee_list} - if @issue.description %div diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index ca5c2f2688c..13f1ac08e94 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -2,6 +2,6 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb index 457e94b4800..f19ac3adfc7 100644 --- a/app/views/notify/new_mention_in_issue_email.text.erb +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -2,6 +2,6 @@ You have been mentioned in an issue. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml index 498ba8b8365..ee2f40e1683 100644 --- a/app/views/notify/reassigned_issue_email.html.haml +++ b/app/views/notify/reassigned_issue_email.html.haml @@ -1 +1,10 @@ -= render 'reassigned_issuable_email', issuable: @issue +%p + Assignee changed + - if @previous_assignees.any? + from + %strong= @previous_assignees.map(&:name).to_sentence + to + - if @issue.assignees.any? + %strong= @issue.assignee_list + - else + %strong Unassigned diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index 710253be984..6c357f1074a 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @issue %> +Reassigned Issue <%= @issue.iid %> + +<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%> + to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index 2a650130f59..841df872857 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -1 +1,9 @@ -= render 'reassigned_issuable_email', issuable: @merge_request +Reassigned Merge Request #{ @merge_request.iid } + += url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) + +Assignee changed +- if @previous_assignee + from #{@previous_assignee.name} +to += @merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned' diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index b5b4f1ff99a..998a40fefde 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @merge_request %> +Reassigned Merge Request <%= @merge_request.iid %> + +<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> + to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %> diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml index 0f424334521..642da679f97 100644 --- a/app/views/projects/boards/components/sidebar/_assignee.html.haml +++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml @@ -1,40 +1,28 @@ -.block.assignee - .title.hide-collapsed - Assignee - - if can?(current_user, :admin_issue, @project) - = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" - .value.hide-collapsed - %span.assign-yourself.no-value{ "v-if" => "!issue.assignee" } - No assignee - - if can?(current_user, :admin_issue, @project) - \- - %a.js-assign-yourself{ href: "#" } - assign yourself - %a.author_link.bold{ ":href" => "'#{root_url}' + issue.assignee.username", - "v-if" => "issue.assignee" } - %img.avatar.avatar-inline.s32{ ":src" => "issue.assignee.avatar", - width: "32", alt: "Avatar" } - %span.author - {{ issue.assignee.name }} - %span.username - = precede "@" do - {{ issue.assignee.username }} +.block.assignee{ ref: "assigneeBlock" } + %template{ "v-if" => "issue.assignees" } + %assignee-title{ ":number-of-assignees" => "issue.assignees.length", + ":loading" => "loadingAssignees", + ":editable" => can?(current_user, :admin_issue, @project) } + %assignees.value{ "root-path" => "#{root_url}", + ":users" => "issue.assignees", + ":editable" => can?(current_user, :admin_issue, @project), + "@assign-self" => "assignSelf" } + - if can?(current_user, :admin_issue, @project) .selectbox.hide-collapsed %input{ type: "hidden", - name: "issue[assignee_id]", - id: "issue_assignee_id", - ":value" => "issue.assignee.id", - "v-if" => "issue.assignee" } + name: "issue[assignee_ids][]", + ":value" => "assignee.id", + "v-if" => "issue.assignees", + "v-for" => "assignee in issue.assignees" } .dropdown - %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", null_user_default: "true" }, + %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", 'max-select' => 1, dropdown: { header: 'Assignee' } }, ":data-issuable-id" => "issue.id", ":data-selected" => "assigneeId", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } Select assignee = icon("chevron-down") - .dropdown-menu.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author + .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author = dropdown_title("Assign to") = dropdown_filter("Search users") = dropdown_content diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 0e3902c066a..c184e0e0022 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -13,9 +13,9 @@ %li CLOSED - - if issue.assignee + - if issue.assignees.any? %li - = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") + = render 'shared/issuable/assignees', project: @project, issue: issue = render 'shared/issuable_meta_data', issuable: issue diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index b8e885b4d9a..99bc2516366 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -25,7 +25,7 @@ .merge_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-merge wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }}) .form-group %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } @@ -34,7 +34,7 @@ .push_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-push wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) .panel-footer diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml index d6044aacaec..c61b2951e1e 100644 --- a/app/views/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -1,10 +1,10 @@ %td = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) %td = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml index 74851519077..c50515cfe06 100644 --- a/app/views/projects/protected_tags/_dropdown.html.haml +++ b/app/views/projects/protected_tags/_dropdown.html.haml @@ -2,7 +2,7 @@ = dropdown_tag('Select tag or create wildcard', options: { toggle_class: 'js-protected-tag-select js-filter-submit wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected tag", + filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header", placeholder: "Search protected tag", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_tag_name], diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml index 62823bee46e..cc80bd04dd0 100644 --- a/app/views/projects/protected_tags/_update_protected_tag.haml +++ b/app/views/projects/protected_tags/_update_protected_tag.haml @@ -1,5 +1,5 @@ %td = hidden_field_tag "allowed_to_create_#{protected_tag.id}", protected_tag.create_access_levels.first.access_level = dropdown_tag( (protected_tag.create_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable js-allowed-to-create-container', + options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container', data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: protected_tag.create_access_levels.first.id }}) diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml new file mode 100644 index 00000000000..36bbb1148d4 --- /dev/null +++ b/app/views/shared/issuable/_assignees.html.haml @@ -0,0 +1,15 @@ +- 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") + +- if issue.assignees.length > max_render + - counter = issue.assignees.length - max_render + + %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } } + - if counter < 99 + = "+#{counter}" + - else + 99+ diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml index 171da899937..db407363a09 100644 --- a/app/views/shared/issuable/_participants.html.haml +++ b/app/views/shared/issuable/_participants.html.haml @@ -12,9 +12,9 @@ - participants.each do |participant| .participants-author.js-participants-author = link_to_member(@project, participant, name: false, size: 24) - - if participants_extra > 0 - .participants-more - %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } - + #{participants_extra} more + - if participants_extra > 0 + .hide-collapsed.participants-more + %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } + + #{participants_extra} more :javascript IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row}; diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index b6fce5e3cd4..f7b87171573 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -124,8 +124,13 @@ %li %a{ href: "#", data: { id: "close" } } Closed .filter-item.inline + - if type == :issues + - field_name = "update[assignee_ids][]" + - else + - field_name = "update[assignee_id]" + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } }) .filter-item.inline.labels-filter diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index bc638e994f3..44e624c15a7 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,10 +1,10 @@ - todo = issuable_todo(issuable) - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('issuable') + = page_specific_javascript_bundle_tag('sidebar') %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } - .issuable-sidebar + .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - if current_user @@ -20,36 +20,55 @@ .block.todo.hide-expanded = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true .block.assignee - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 24) - - else - = icon('user', 'aria-hidden': 'true') - .title.hide-collapsed - Assignee - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.hide-collapsed - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) - %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } - = icon('exclamation-triangle', 'aria-hidden': 'true') - %span.username - = issuable.assignee.to_reference - - else - %span.assign-yourself.no-value - No assignee - - if can_edit_issuable - \- - %a.js-assign-yourself{ href: '#' } - assign yourself + - if issuable.instance_of?(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) } + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do + - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = issuable.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself .selectbox.hide-collapsed - = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' - = dropdown_tag('Select assignee', 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_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, null_user_default: true, selected: issuable.assignee_id } }) + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil + - 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 + = 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' + + = dropdown_tag(title, options: options) .block.milestone .sidebar-collapsed-icon = icon('clock-o', 'aria-hidden': 'true') @@ -75,11 +94,10 @@ = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block - %issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'docs-url' => help_page_path('workflow/time_tracking.md') } - // Fallback while content is loading - .title.hide-collapsed - Time tracking - = icon('spinner spin', 'aria-hidden': 'true') + // Fallback while content is loading + .title.hide-collapsed + Time tracking + = icon('spinner spin', 'aria-hidden': 'true') - if issuable.has_attribute?(:due_date) .block.due_date .sidebar-collapsed-icon @@ -169,8 +187,13 @@ = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") :javascript - gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}'); - new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}"); + gl.sidebarOptions = { + endpoint: "#{issuable_json_path(issuable)}", + editable: #{can_edit_issuable ? true : false}, + currentUser: #{current_user.to_json(only: [:username, :id, :name], methods: :avatar_url)}, + rootPath: "#{root_path}" + }; + new MilestoneSelect('{"full_path":"#{@project.full_path}"}'); new LabelsSelect(); new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}'); diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml new file mode 100644 index 00000000000..c33474ac3b4 --- /dev/null +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -0,0 +1,30 @@ +- issue = issuable +.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| + = link_to_member(@project, assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issue.assignees.any? + - issue.assignees.each do |assignee| + = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do + %span.username + = assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_ids', value: issuable.assignee_ids, id: 'issue_assignee_ids' + = dropdown_tag('Select assignee', 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_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }) diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml new file mode 100644 index 00000000000..18011d528a0 --- /dev/null +++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml @@ -0,0 +1,31 @@ +- merge_request = issuable +.block.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) } + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do + - unless merge_request.can_be_merged_by?(merge_request.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = merge_request.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id' + = dropdown_tag('Select assignee', 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, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } }) diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 9dbfedb84f1..9281a515744 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -10,13 +10,27 @@ .row %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } .form-group.issue-assignee - = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - = form.hidden_field :assignee_id - = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" + - if issuable.is_a?(Issue) + = form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder.selectbox + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } + + - if issuable.assignees.length === 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } + + = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false)) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" + - else + = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + = form.hidden_field :assignee_id + + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" .form-group.issue-milestone = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index 5247d6a51e6..22547a30cdf 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -1,7 +1,7 @@ -# @project is present when viewing Project's milestone - project = @project || issuable.project - namespace = @project_namespace || project.namespace.becomes(Namespace) -- assignee = issuable.assignee +- assignees = issuable.assignees - issuable_type = issuable.class.table_name - base_url_args = [namespace, project] - issuable_type_args = base_url_args + [issuable_type] @@ -26,7 +26,7 @@ - render_colored_label(label) %span.assignee-icon - - if assignee - = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }), + - assignees.each do |assignee| + = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }), class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do - - image_tag(avatar_icon(issuable.assignee, 16), class: "avatar s16", alt: '') + - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '') diff --git a/config/webpack.config.js b/config/webpack.config.js index 160dc9057f2..119b1ea9d2e 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -38,8 +38,6 @@ var config = { graphs: './graphs/graphs_bundle.js', group: './group.js', groups_list: './groups_list.js', - issuable: './issuable/issuable_bundle.js', - locale: './locale/index.js', issue_show: './issue_show/index.js', locale: './locale/index.js', main: './main.js', @@ -54,6 +52,7 @@ var config = { profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', + sidebar: './sidebar/sidebar_bundle.js', snippet: './snippet/snippet_bundle.js', sketch_viewer: './blob/sketch_viewer.js', stl_viewer: './blob/stl_viewer.js', @@ -139,7 +138,7 @@ var config = { 'diff_notes', 'environments', 'environments_folder', - 'issuable', + 'sidebar', 'issue_show', 'merge_conflicts', 'notebook_viewer', diff --git a/db/fixtures/development/09_issues.rb b/db/fixtures/development/09_issues.rb index d93d133d157..0b32a461d56 100644 --- a/db/fixtures/development/09_issues.rb +++ b/db/fixtures/development/09_issues.rb @@ -8,7 +8,7 @@ Gitlab::Seeder.quiet do description: FFaker::Lorem.sentence, state: ['opened', 'closed'].sample, milestone: project.milestones.sample, - assignee: project.team.users.sample + assignees: [project.team.users.sample] } Issues::CreateService.new(project, project.team.users.sample, issue_params).execute diff --git a/db/migrate/20170320171632_create_issue_assignees_table.rb b/db/migrate/20170320171632_create_issue_assignees_table.rb new file mode 100644 index 00000000000..23b8da37b6d --- /dev/null +++ b/db/migrate/20170320171632_create_issue_assignees_table.rb @@ -0,0 +1,40 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class CreateIssueAssigneesTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + INDEX_NAME = 'index_issue_assignees_on_issue_id_and_user_id' + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def up + create_table :issue_assignees do |t| + t.references :user, foreign_key: { on_delete: :cascade }, index: true, null: false + t.references :issue, foreign_key: { on_delete: :cascade }, null: false + end + + add_index :issue_assignees, [:issue_id, :user_id], unique: true, name: INDEX_NAME + end + + def down + drop_table :issue_assignees + end +end diff --git a/db/migrate/20170320173259_migrate_assignees.rb b/db/migrate/20170320173259_migrate_assignees.rb new file mode 100644 index 00000000000..ba8edbd7d32 --- /dev/null +++ b/db/migrate/20170320173259_migrate_assignees.rb @@ -0,0 +1,52 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class MigrateAssignees < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + # Optimisation: this accounts for most of the invalid assignee IDs on GitLab.com + update_column_in_batches(:issues, :assignee_id, nil) do |table, query| + query.where(table[:assignee_id].eq(0)) + end + + users = Arel::Table.new(:users) + + update_column_in_batches(:issues, :assignee_id, nil) do |table, query| + query.where(table[:assignee_id].not_eq(nil)\ + .and( + users.project("true").where(users[:id].eq(table[:assignee_id])).exists.not + )) + end + + execute <<-EOF + INSERT INTO issue_assignees(issue_id, user_id) + SELECT id, assignee_id FROM issues WHERE assignee_id IS NOT NULL + EOF + end + + def down + execute <<-EOF + DELETE FROM issue_assignees + EOF + end +end diff --git a/db/schema.rb b/db/schema.rb index 588e2082ae0..aad97d5f810 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -454,6 +454,14 @@ ActiveRecord::Schema.define(version: 20170504102911) do add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree + create_table "issue_assignees", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "issue_id", null: false + end + + add_index "issue_assignees", ["issue_id", "user_id"], name: "index_issue_assignees_on_issue_id_and_user_id", unique: true, using: :btree + add_index "issue_assignees", ["user_id"], name: "index_issue_assignees_on_user_id", using: :btree + create_table "issue_metrics", force: :cascade do |t| t.integer "issue_id", null: false t.datetime "first_mentioned_in_commit_at" @@ -1388,6 +1396,8 @@ ActiveRecord::Schema.define(version: 20170504102911) do add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade + add_foreign_key "issue_assignees", "issues", on_delete: :cascade + add_foreign_key "issue_assignees", "users", on_delete: :cascade add_foreign_key "container_repositories", "projects" add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "label_priorities", "labels", on_delete: :cascade diff --git a/doc/api/issues.md b/doc/api/issues.md index 6c10b5ab0e7..1d43b1298b9 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -70,6 +70,14 @@ Example response: "updated_at" : "2016-01-04T15:31:39.996Z" }, "project_id" : 1, + "assignees" : [{ + "state" : "active", + "id" : 1, + "name" : "Administrator", + "web_url" : "https://gitlab.example.com/root", + "avatar_url" : null, + "username" : "root" + }], "assignee" : { "state" : "active", "id" : 1, @@ -92,6 +100,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## List group issues Get a list of a group's issues. @@ -153,6 +163,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -174,6 +192,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## List project issues Get a list of a project's issues. @@ -235,6 +255,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -256,6 +284,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Single issue Get a single project issue. @@ -300,6 +330,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -321,6 +359,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## New issue Creates a new project issue. @@ -329,13 +369,13 @@ Creates a new project issue. POST /projects/:id/issues ``` -| Attribute | Type | Required | Description | -|-------------------------------------------|---------|----------|--------------| +| Attribute | Type | Required | Description | +|-------------------------------------------|----------------|----------|--------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `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_id` | integer | no | The ID of a user to assign issue | +| `assignee_ids` | Array[integer] | no | The ID of a user 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) | @@ -357,6 +397,7 @@ Example response: "iid" : 14, "title" : "Issues with auth", "state" : "opened", + "assignees" : [], "assignee" : null, "labels" : [ "bug" @@ -380,6 +421,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Edit issue Updates an existing project issue. This call is also used to mark an issue as @@ -396,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_id` | integer | no | The ID of a user to assign the issue to | +| `assignee_ids` | Array[integer] | no | The ID of a user 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 | @@ -430,6 +473,7 @@ Example response: "bug" ], "id" : 85, + "assignees" : [], "assignee" : null, "milestone" : null, "subscribed" : true, @@ -440,6 +484,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Delete an issue Only for admins and project owners. Soft deletes the issue in question. @@ -494,6 +540,14 @@ Example response: "updated_at": "2016-04-07T12:20:17.596Z", "labels": [], "milestone": null, + "assignees": [{ + "name": "Miss Monserrate Beier", + "username": "axel.block", + "id": 12, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", + "web_url": "https://gitlab.example.com/axel.block" + }], "assignee": { "name": "Miss Monserrate Beier", "username": "axel.block", @@ -516,6 +570,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Subscribe to an issue Subscribes the authenticated user to an issue to receive notifications. @@ -549,6 +605,14 @@ Example response: "updated_at": "2016-04-07T12:20:17.596Z", "labels": [], "milestone": null, + "assignees": [{ + "name": "Miss Monserrate Beier", + "username": "axel.block", + "id": 12, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", + "web_url": "https://gitlab.example.com/axel.block" + }], "assignee": { "name": "Miss Monserrate Beier", "username": "axel.block", @@ -571,6 +635,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Unsubscribe from an issue Unsubscribes the authenticated user from the issue to not receive notifications @@ -652,6 +718,14 @@ Example response: "updated_at": "2016-06-17T07:47:33.832Z", "due_date": null }, + "assignees": [{ + "name": "Jarret O'Keefe", + "username": "francisca", + "id": 14, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/a7fa515d53450023c83d62986d0658a8?s=80&d=identicon", + "web_url": "https://gitlab.example.com/francisca" + }], "assignee": { "name": "Jarret O'Keefe", "username": "francisca", @@ -683,6 +757,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Set a time estimate for an issue Sets an estimated time of work for this issue. diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index dbdc93a77a8..e15daa2feae 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -232,6 +232,7 @@ X-Gitlab-Event: Issue Hook "object_attributes": { "id": 301, "title": "New API: create/update/delete file", + "assignee_ids": [51], "assignee_id": 51, "author_id": 51, "project_id": 14, @@ -246,6 +247,11 @@ X-Gitlab-Event: Issue Hook "url": "http://example.com/diaspora/issues/23", "action": "open" }, + "assignees": [{ + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }], "assignee": { "name": "User1", "username": "user1", @@ -265,6 +271,9 @@ X-Gitlab-Event: Issue Hook }] } ``` + +**Note**: `assignee` and `assignee_id` keys are deprecated and now show the first assignee only. + ### Comment events Triggered when a new comment is made on commits, merge requests, issues, and code snippets. @@ -544,6 +553,7 @@ X-Gitlab-Event: Note Hook "issue": { "id": 92, "title": "test", + "assignee_ids": [], "assignee_id": null, "author_id": 1, "project_id": 5, @@ -559,6 +569,8 @@ X-Gitlab-Event: Note Hook } ``` +**Note**: `assignee_id` field is deprecated and now shows the first assignee only. + #### Comment on code snippet **Request header**: diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index c715c85c43c..bf09d7b7114 100644 --- a/features/steps/dashboard/dashboard.rb +++ b/features/steps/dashboard/dashboard.rb @@ -77,7 +77,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps step 'project "Shop" has issue "Bugfix1" with label "feature"' do project = Project.find_by(name: "Shop") - issue = create(:issue, title: "Bugfix1", project: project, assignee: current_user) + issue = create(:issue, title: "Bugfix1", project: project, assignees: [current_user]) issue.labels << project.labels.find_by(title: 'feature') end end diff --git a/features/steps/dashboard/todos.rb b/features/steps/dashboard/todos.rb index 3225e19995b..b56558ba0d2 100644 --- a/features/steps/dashboard/todos.rb +++ b/features/steps/dashboard/todos.rb @@ -182,7 +182,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps end def issue - @issue ||= create(:issue, assignee: current_user, project: project) + @issue ||= create(:issue, assignees: [current_user], project: project) end def merge_request diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb index 49fcd6f1201..0b0983f0d06 100644 --- a/features/steps/group/milestones.rb +++ b/features/steps/group/milestones.rb @@ -113,7 +113,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user, milestone: milestone @@ -125,7 +125,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps issue = create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user, milestone: milestone diff --git a/features/steps/groups.rb b/features/steps/groups.rb index 4dc87dc4d9c..83d8abbab1f 100644 --- a/features/steps/groups.rb +++ b/features/steps/groups.rb @@ -61,7 +61,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps step 'project from group "Owned" has issues assigned to me' do create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user end @@ -123,7 +123,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps step 'the archived project have some issues' do create :issue, project: @archived_project, - assignee: current_user, + assignees: [current_user], author: current_user end diff --git a/lib/api/api.rb b/lib/api/api.rb index 302ccd3e532..52cd7cbe3db 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -6,6 +6,7 @@ module API version 'v3', using: :path do helpers ::API::V3::Helpers + helpers ::API::Helpers::CommonHelpers mount ::API::V3::AwardEmoji mount ::API::V3::Boards @@ -80,6 +81,7 @@ module API # Ensure the namespace is right, otherwise we might load Grape::API::Helpers helpers ::SentryHelper helpers ::API::Helpers + helpers ::API::Helpers::CommonHelpers # Keep in alphabetical order mount ::API::AccessRequests diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 6d6ccefe877..f8f5548d23d 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -256,7 +256,11 @@ module API class IssueBasic < ProjectEntity expose :label_names, as: :labels expose :milestone, using: Entities::Milestone - expose :assignee, :author, using: Entities::UserBasic + expose :assignees, :author, using: Entities::UserBasic + + expose :assignee, using: ::API::Entities::UserBasic do |issue, options| + issue.assignees.first + end expose :user_notes_count expose :upvotes, :downvotes diff --git a/lib/api/helpers/common_helpers.rb b/lib/api/helpers/common_helpers.rb new file mode 100644 index 00000000000..6236fdd43ca --- /dev/null +++ b/lib/api/helpers/common_helpers.rb @@ -0,0 +1,13 @@ +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)] + end + + params + end + end + end +end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 522f0f3be92..78db960ae28 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -32,7 +32,8 @@ module API params :issue_params_ce do optional :description, type: String, desc: 'The description of an issue' - optional :assignee_id, type: Integer, desc: 'The ID of a user to assign issue' + optional :assignee_ids, type: Array[Integer], desc: 'The array of user IDs to assign issue' + optional :assignee_id, type: Integer, desc: '[Deprecated] The ID of a user to assign issue' optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign issue' optional :labels, type: String, desc: 'Comma-separated list of label names' optional :due_date, type: String, desc: 'Date string in the format YEAR-MONTH-DAY' @@ -135,6 +136,8 @@ module API issue_params = declared_params(include_missing: false) + issue_params = convert_parameters_from_legacy_format(issue_params) + issue = ::Issues::CreateService.new(user_project, current_user, issue_params.merge(request: request, api: true)).execute @@ -159,7 +162,7 @@ module API desc: 'Date time when the issue was updated. Available only for admins and project owners.' optional :state_event, type: String, values: %w[reopen close], desc: 'State of the issue' use :issue_params - at_least_one_of :title, :description, :assignee_id, :milestone_id, + at_least_one_of :title, :description, :assignee_ids, :assignee_id, :milestone_id, :labels, :created_at, :due_date, :confidential, :state_event end put ':id/issues/:issue_iid' do @@ -173,6 +176,8 @@ module API update_params = declared_params(include_missing: false).merge(request: request, api: true) + update_params = convert_parameters_from_legacy_format(update_params) + issue = ::Issues::UpdateService.new(user_project, current_user, update_params).execute(issue) diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 832b4bdeb4f..7c8be7e51db 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -248,6 +248,13 @@ module API expose :project_id, :issues_events, :merge_requests_events expose :note_events, :build_events, :pipeline_events, :wiki_page_events end + + class Issue < ::API::Entities::Issue + unexpose :assignees + expose :assignee do |issue, options| + ::API::Entities::UserBasic.represent(issue.assignees.first, options) + end + end end end end diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb index 715083fc4f8..cb371fdbab8 100644 --- a/lib/api/v3/issues.rb +++ b/lib/api/v3/issues.rb @@ -8,6 +8,7 @@ module API helpers do def find_issues(args = {}) args = params.merge(args) + args = convert_parameters_from_legacy_format(args) args.delete(:id) args[:milestone_title] = args.delete(:milestone) @@ -51,7 +52,7 @@ module API resource :issues do desc "Get currently authenticated user's issues" do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -61,7 +62,7 @@ module API get do issues = find_issues(scope: 'authored') - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user end end @@ -70,7 +71,7 @@ module API end resource :groups, requirements: { id: %r{[^/]+} } do desc 'Get a list of group issues' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -82,7 +83,7 @@ module API issues = find_issues(group_id: group.id, match_all_labels: true) - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user end end @@ -94,7 +95,7 @@ module API desc 'Get a list of project issues' do detail 'iid filter is deprecated have been removed on V4' - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -107,22 +108,22 @@ module API issues = find_issues(project_id: project.id) - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end desc 'Get a single project issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' end get ":id/issues/:issue_id" do issue = find_project_issue(params[:issue_id]) - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end desc 'Create a new project issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :title, type: String, desc: 'The title of an issue' @@ -140,6 +141,7 @@ module API issue_params = declared_params(include_missing: false) issue_params = issue_params.merge(merge_request_to_resolve_discussions_of: issue_params.delete(:merge_request_for_resolving_discussions)) + issue_params = convert_parameters_from_legacy_format(issue_params) issue = ::Issues::CreateService.new(user_project, current_user, @@ -147,14 +149,14 @@ module API render_spam_error! if issue.spam? if issue.valid? - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end end desc 'Update an existing issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' @@ -176,6 +178,7 @@ module API end update_params = declared_params(include_missing: false).merge(request: request, api: true) + update_params = convert_parameters_from_legacy_format(update_params) issue = ::Issues::UpdateService.new(user_project, current_user, @@ -184,14 +187,14 @@ module API render_spam_error! if issue.spam? if issue.valid? - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end end desc 'Move an existing issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' @@ -206,7 +209,7 @@ module API begin issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project) - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project rescue ::Issues::MoveService::MoveError => error render_api_error!(error.message, 400) end diff --git a/lib/api/v3/merge_requests.rb b/lib/api/v3/merge_requests.rb index 1616142a619..b6b7254ae29 100644 --- a/lib/api/v3/merge_requests.rb +++ b/lib/api/v3/merge_requests.rb @@ -34,7 +34,7 @@ module API if project.has_external_issue_tracker? ::API::Entities::ExternalIssue else - ::API::Entities::Issue + ::API::V3::Entities::Issue end end diff --git a/lib/api/v3/milestones.rb b/lib/api/v3/milestones.rb index be90cec4afc..4c7061d4939 100644 --- a/lib/api/v3/milestones.rb +++ b/lib/api/v3/milestones.rb @@ -39,7 +39,7 @@ module API end desc 'Get all issues for a single project milestone' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :milestone_id, type: Integer, desc: 'The ID of a project milestone' @@ -56,7 +56,7 @@ module API } issues = IssuesFinder.new(current_user, finder_params).execute - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end end end diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb index e02b360924a..89ec715ddf6 100644 --- a/lib/banzai/reference_parser/issue_parser.rb +++ b/lib/banzai/reference_parser/issue_parser.rb @@ -28,7 +28,7 @@ module Banzai nodes, Issue.all.includes( :author, - :assignee, + :assignees, { # These associations are primarily used for checking permissions. # Eager loading these ensures we don't end up running dozens of diff --git a/lib/github/import.rb b/lib/github/import.rb index d49761fd6c6..06beb607a3e 100644 --- a/lib/github/import.rb +++ b/lib/github/import.rb @@ -245,7 +245,7 @@ module Github issue.label_ids = label_ids(representation.labels) issue.milestone_id = milestone_id(representation.milestone) issue.author_id = author_id - issue.assignee_id = user_id(representation.assignee) + issue.assignee_ids = [user_id(representation.assignee)] issue.created_at = representation.created_at issue.updated_at = representation.updated_at issue.save!(validate: false) diff --git a/lib/gitlab/chat_commands/presenters/issue_base.rb b/lib/gitlab/chat_commands/presenters/issue_base.rb index 054f7f4be0c..25bc82994ba 100644 --- a/lib/gitlab/chat_commands/presenters/issue_base.rb +++ b/lib/gitlab/chat_commands/presenters/issue_base.rb @@ -22,7 +22,7 @@ module Gitlab [ { title: "Assignee", - value: @resource.assignee ? @resource.assignee.name : "_None_", + value: @resource.assignees.any? ? @resource.assignees.first.name : "_None_", short: true }, { diff --git a/lib/gitlab/fogbugz_import/importer.rb b/lib/gitlab/fogbugz_import/importer.rb index 222bcdcbf9c..3dcee681c72 100644 --- a/lib/gitlab/fogbugz_import/importer.rb +++ b/lib/gitlab/fogbugz_import/importer.rb @@ -122,15 +122,15 @@ module Gitlab author_id = user_info(bug['ixPersonOpenedBy'])[:gitlab_id] || project.creator_id issue = Issue.create!( - iid: bug['ixBug'], - project_id: project.id, - title: bug['sTitle'], - description: body, - author_id: author_id, - assignee_id: assignee_id, - state: bug['fOpen'] == 'true' ? 'opened' : 'closed', - created_at: date, - updated_at: DateTime.parse(bug['dtLastUpdated']) + iid: bug['ixBug'], + project_id: project.id, + title: bug['sTitle'], + description: body, + author_id: author_id, + assignee_ids: [assignee_id], + state: bug['fOpen'] == 'true' ? 'opened' : 'closed', + created_at: date, + updated_at: DateTime.parse(bug['dtLastUpdated']) ) issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true) diff --git a/lib/gitlab/github_import/issue_formatter.rb b/lib/gitlab/github_import/issue_formatter.rb index 6f5ac4dac0d..977cd0423ba 100644 --- a/lib/gitlab/github_import/issue_formatter.rb +++ b/lib/gitlab/github_import/issue_formatter.rb @@ -10,7 +10,7 @@ module Gitlab description: description, state: state, author_id: author_id, - assignee_id: assignee_id, + assignee_ids: Array(assignee_id), created_at: raw_data.created_at, updated_at: raw_data.updated_at } diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 5ca3e6a95ca..1b43440673c 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -108,13 +108,13 @@ module Gitlab end issue = Issue.create!( - iid: raw_issue['id'], - project_id: project.id, - title: raw_issue['title'], - description: body, - author_id: project.creator_id, - assignee_id: assignee_id, - state: raw_issue['state'] == 'closed' ? 'closed' : 'opened' + iid: raw_issue['id'], + project_id: project.id, + title: raw_issue['title'], + description: body, + author_id: project.creator_id, + assignee_ids: [assignee_id], + state: raw_issue['state'] == 'closed' ? 'closed' : 'opened' ) issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true) diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb index 762e90f4a16..085f3fd8543 100644 --- a/spec/controllers/dashboard/todos_controller_spec.rb +++ b/spec/controllers/dashboard/todos_controller_spec.rb @@ -14,7 +14,7 @@ describe Dashboard::TodosController do describe 'GET #index' do context 'when using pagination' do let(:last_page) { user.todos.page.total_pages } - let!(:issues) { create_list(:issue, 2, project: project, assignee: user) } + let!(:issues) { create_list(:issue, 2, project: project, assignees: [user]) } before do issues.each { |issue| todo_service.new_issue(issue, user) } diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb index 15667e8d4b1..dc3b72c6de4 100644 --- a/spec/controllers/projects/boards/issues_controller_spec.rb +++ b/spec/controllers/projects/boards/issues_controller_spec.rb @@ -34,7 +34,7 @@ describe Projects::Boards::IssuesController do issue = create(:labeled_issue, project: project, labels: [planning]) create(:labeled_issue, project: project, labels: [planning]) create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow) - create(:labeled_issue, project: project, labels: [development], assignee: johndoe) + create(:labeled_issue, project: project, labels: [development], assignees: [johndoe]) issue.subscribe(johndoe, project) list_issues user: user, board: board, list: list2 diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 5f1f892821a..1f79e72495a 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -173,12 +173,12 @@ describe Projects::IssuesController do namespace_id: project.namespace.to_param, project_id: project, id: issue.iid, - issue: { assignee_id: assignee.id }, + issue: { assignee_ids: [assignee.id] }, format: :json body = JSON.parse(response.body) - expect(body['assignee'].keys) - .to match_array(%w(name username avatar_url)) + expect(body['assignees'].first.keys) + .to match_array(%w(id name username avatar_url)) end end @@ -348,7 +348,7 @@ describe Projects::IssuesController do let(:admin) { create(:admin) } let!(:issue) { create(:issue, project: project) } let!(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) } - let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignee: assignee) } + let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignees: [assignee]) } describe 'GET #index' do it 'does not list confidential issues for guests' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index a793da4162a..0483c6b7879 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -1067,7 +1067,7 @@ describe Projects::MergeRequestsController do end it 'correctly pluralizes flash message on success' do - issue2.update!(assignee: user) + issue2.assignees = [user] post_assign_issues diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 58b14e09740..9ea325ab41b 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -32,7 +32,7 @@ describe "Dashboard Issues Feed", feature: true do end context "issue with basic fields" do - let!(:issue2) { create(:issue, author: user, assignee: assignee, project: project2, description: 'test desc') } + let!(:issue2) { create(:issue, author: user, assignees: [assignee], project: project2, description: 'test desc') } it "renders issue fields" do visit issues_dashboard_path(:atom, private_token: user.private_token) @@ -41,7 +41,7 @@ describe "Dashboard Issues Feed", feature: true do expect(entry).to be_present expect(entry).to have_selector('author email', text: issue2.author_public_email) - expect(entry).to have_selector('assignee email', text: issue2.assignee_public_email) + expect(entry).to have_selector('assignees email', text: assignee.public_email) expect(entry).not_to have_selector('labels') expect(entry).not_to have_selector('milestone') expect(entry).to have_selector('description', text: issue2.description) @@ -51,7 +51,7 @@ describe "Dashboard Issues Feed", feature: true do context "issue with label and milestone" do let!(:milestone1) { create(:milestone, project: project1, title: 'v1') } let!(:label1) { create(:label, project: project1, title: 'label1') } - let!(:issue1) { create(:issue, author: user, assignee: assignee, project: project1, milestone: milestone1) } + let!(:issue1) { create(:issue, author: user, assignees: [assignee], project: project1, milestone: milestone1) } before do issue1.labels << label1 @@ -64,7 +64,7 @@ describe "Dashboard Issues Feed", feature: true do expect(entry).to be_present expect(entry).to have_selector('author email', text: issue1.author_public_email) - expect(entry).to have_selector('assignee email', text: issue1.assignee_public_email) + expect(entry).to have_selector('assignees email', text: assignee.public_email) expect(entry).to have_selector('labels label', text: label1.title) expect(entry).to have_selector('milestone', text: milestone1.title) expect(entry).not_to have_selector('description') diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index b3903ec2faf..4f6754ad541 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -6,7 +6,7 @@ describe 'Issues Feed', feature: true do let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } let!(:group) { create(:group) } let!(:project) { create(:project) } - let!(:issue) { create(:issue, author: user, assignee: assignee, project: project) } + let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) } before do project.team << [user, :developer] @@ -22,7 +22,8 @@ describe 'Issues Feed', feature: true do to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignee email', text: issue.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) expect(body).to have_selector('entry summary', text: issue.title) end end @@ -36,7 +37,8 @@ describe 'Issues Feed', feature: true do to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignee email', text: issue.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) expect(body).to have_selector('entry summary', text: issue.title) end end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index a172ce1e8c0..18585488e26 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -71,7 +71,7 @@ describe 'Issue Boards', feature: true, js: true do let!(:list2) { create(:list, board: board, label: development, position: 1) } let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, labels: [planning], relative_position: 8) } + let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], labels: [planning], relative_position: 8) } let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning], relative_position: 7) } let!(:issue3) { create(:labeled_issue, project: project, labels: [planning], relative_position: 6) } let!(:issue4) { create(:labeled_issue, project: project, labels: [planning], relative_position: 5) } diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index 4a4c13e79c8..e1367c675e5 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -98,7 +98,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do end context 'assignee' do - let!(:issue) { create(:issue, project: project, assignee: user2) } + let!(:issue) { create(:issue, project: project, assignees: [user2]) } before do project.team << [user2, :developer] diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index bafa4f05937..7c53d2b47d9 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -4,13 +4,14 @@ describe 'Issue Boards', feature: true, js: true do include WaitForVueResource let(:user) { create(:user) } + let(:user2) { create(:user) } let(:project) { create(:empty_project, :public) } let!(:milestone) { create(:milestone, project: project) } let!(:development) { create(:label, project: project, name: 'Development') } let!(:bug) { create(:label, project: project, name: 'Bug') } let!(:regression) { create(:label, project: project, name: 'Regression') } let!(:stretch) { create(:label, project: project, name: 'Stretch') } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [development], relative_position: 2) } + let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], milestone: milestone, labels: [development], relative_position: 2) } let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) } let(:board) { create(:board, project: project) } let!(:list) { create(:list, board: board, label: development, position: 0) } @@ -112,10 +113,11 @@ describe 'Issue Boards', feature: true, js: true do page.within('.dropdown-menu-user') do click_link 'Unassigned' - - wait_for_vue_resource end + find('.dropdown-menu-toggle').click + wait_for_vue_resource + expect(page).to have_content('No assignee') end @@ -128,7 +130,7 @@ describe 'Issue Boards', feature: true, js: true do page.within(find('.assignee')) do expect(page).to have_content('No assignee') - click_link 'assign yourself' + click_button 'assign yourself' wait_for_vue_resource @@ -138,7 +140,7 @@ describe 'Issue Boards', feature: true, js: true do expect(card).to have_selector('.avatar') end - it 'resets assignee dropdown' do + it 'updates assignee dropdown' do click_card(card) page.within('.assignee') do @@ -162,7 +164,7 @@ describe 'Issue Boards', feature: true, js: true do page.within('.assignee') do click_link 'Edit' - expect(page).not_to have_selector('.is-active') + expect(page).to have_selector('.is-active') end end end diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index 4fca7577e74..6f7bf0eba6e 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -7,7 +7,7 @@ describe 'Navigation bar counter', feature: true, caching: true do let(:merge_request) { create(:merge_request, source_project: project) } before do - issue.update(assignee: user) + issue.assignees = [user] merge_request.update(assignee: user) login_as(user) end @@ -17,7 +17,9 @@ describe 'Navigation bar counter', feature: true, caching: true do expect_counters('issues', '1') - issue.update(assignee: nil) + issue.assignees = [] + + user.update_cache_counts Timecop.travel(3.minutes.from_now) do visit issues_path diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index f4420814c3a..86c7954e60c 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -11,7 +11,7 @@ RSpec.describe 'Dashboard Issues', feature: true do let!(:authored_issue) { create :issue, author: current_user, project: project } let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project } - let!(:assigned_issue) { create :issue, assignee: current_user, project: project } + let!(:assigned_issue) { create :issue, assignees: [current_user], project: project } let!(:other_issue) { create :issue, project: project } before do @@ -30,6 +30,11 @@ RSpec.describe 'Dashboard Issues', feature: true do find('#assignee_id', visible: false).set('') find('.js-author-search', match: :first).click find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click + find('.js-author-search', match: :first).click + + page.within '.dropdown-menu-user' do + expect(find('.dropdown-menu-author li a.is-active', match: :first, text: current_user.to_reference)).to be_visible + end expect(page).to have_content(authored_issue.title) expect(page).to have_content(authored_issue_on_public_project.title) diff --git a/spec/features/dashboard_issues_spec.rb b/spec/features/dashboard_issues_spec.rb index b6b87905231..ad60fb2c74f 100644 --- a/spec/features/dashboard_issues_spec.rb +++ b/spec/features/dashboard_issues_spec.rb @@ -10,8 +10,8 @@ describe "Dashboard Issues filtering", feature: true, js: true do project.team << [user, :master] login_as(user) - create(:issue, project: project, author: user, assignee: user) - create(:issue, project: project, author: user, assignee: user, milestone: milestone) + create(:issue, project: project, author: user, assignees: [user]) + create(:issue, project: project, author: user, assignees: [user], milestone: milestone) visit_issues end diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb index f5b54463df8..005a029a393 100644 --- a/spec/features/gitlab_flavored_markdown_spec.rb +++ b/spec/features/gitlab_flavored_markdown_spec.rb @@ -54,11 +54,11 @@ describe "GitLab Flavored Markdown", feature: true do before do @other_issue = create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) @issue = create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project, title: "fix #{@other_issue.to_reference}", description: "ask #{fred.to_reference} for details") diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 71df3c949db..853632614c4 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -7,7 +7,7 @@ describe 'Awards Emoji', feature: true do let!(:user) { create(:user) } let(:issue) do create(:issue, - assignee: @user, + assignees: [user], project: project) end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index c824aa6a414..a8f4e2d7e10 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -51,15 +51,15 @@ describe 'Filter issues', js: true, feature: true do create(:issue, project: project, title: "issue with 'single quotes'") create(:issue, project: project, title: "issue with \"double quotes\"") create(:issue, project: project, title: "issue with !@\#{$%^&*()-+") - create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignee: user) - create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignee: user) + create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignees: [user]) + create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignees: [user]) issue = create(:issue, title: "Bug 2", project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue.labels << bug_label issue_with_caps_label = create(:issue, @@ -67,7 +67,7 @@ describe 'Filter issues', js: true, feature: true do project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue_with_caps_label.labels << caps_sensitive_label issue_with_everything = create(:issue, @@ -75,7 +75,7 @@ describe 'Filter issues', js: true, feature: true do project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue_with_everything.labels << bug_label issue_with_everything.labels << caps_sensitive_label diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 21b8cf3add5..87adce3cddd 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -10,7 +10,7 @@ describe 'New/edit issue', feature: true, js: true do let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } let!(:label2) { create(:label, project: project) } - let!(:issue) { create(:issue, project: project, assignee: user, milestone: milestone) } + let!(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) } before do project.team << [user, :master] @@ -23,23 +23,62 @@ describe 'New/edit issue', feature: true, js: true do visit new_namespace_project_issue_path(project.namespace, project) end + describe 'multiple assignees' do + before do + click_button 'Unassigned' + end + + it 'unselects other assignees when unassigned is selected' do + page.within '.dropdown-menu-user' do + click_link user2.name + end + + page.within '.dropdown-menu-user' do + click_link 'Unassigned' + end + + page.within '.js-assignee-search' do + expect(page).to have_content 'Unassigned' + end + + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match('0') + end + + it 'toggles assign to me when current user is selected and unselected' do + page.within '.dropdown-menu-user' do + click_link user.name + end + + expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible + + page.within '.dropdown-menu-user' do + click_link user.name + end + + expect(find('a', text: 'Assign to me')).to be_visible + end + end + it 'allows user to create new issue' do fill_in 'issue_title', with: 'title' fill_in 'issue_description', with: 'title' expect(find('a', text: 'Assign to me')).to be_visible - click_button 'Assignee' + click_button 'Unassigned' page.within '.dropdown-menu-user' do click_link user2.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user2.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s) page.within '.js-assignee-search' do expect(page).to have_content user2.name end expect(find('a', text: 'Assign to me')).to be_visible click_link 'Assign to me' - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + assignee_ids = page.all('input[name="issue[assignee_ids][]"]', visible: false) + + expect(assignee_ids[0].value).to match(user.id.to_s) + page.within '.js-assignee-search' do expect(page).to have_content user.name end @@ -69,7 +108,7 @@ describe 'New/edit issue', feature: true, js: true do page.within '.issuable-sidebar' do page.within '.assignee' do - expect(page).to have_content user.name + expect(page).to have_content "Assignee" end page.within '.milestone' do @@ -108,12 +147,12 @@ describe 'New/edit issue', feature: true, js: true do end it 'correctly updates the selected user when changing assignee' do - click_button 'Assignee' + click_button 'Unassigned' page.within '.dropdown-menu-user' do click_link user.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) click_button user.name @@ -127,7 +166,7 @@ describe 'New/edit issue', feature: true, js: true do click_link user2.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user2.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s) click_button user2.name @@ -141,7 +180,7 @@ describe 'New/edit issue', feature: true, js: true do end it 'allows user to update issue' do - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 82b80a69bed..e9a05f56543 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -42,6 +42,21 @@ feature 'Issue Sidebar', feature: true do expect(page).to have_content(user2.name) end end + + it 'assigns yourself' do + find('.block.assignee .dropdown-menu-toggle').click + + click_button 'assign yourself' + + wait_for_ajax + + find('.block.assignee .edit-link').click + + page.within '.dropdown-menu-user' do + expect(page.find('.dropdown-header')).to be_visible + expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name) + end + end end context 'as a allowed user' do diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb index 7fa83c1fcf7..b250fa2ed3c 100644 --- a/spec/features/issues/update_issues_spec.rb +++ b/spec/features/issues/update_issues_spec.rb @@ -99,7 +99,7 @@ feature 'Multiple issue updating from issues#index', feature: true do end def create_assigned - create(:issue, project: project, assignee: user) + create(:issue, project: project, assignees: [user]) end def create_with_milestone diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 81cc8513454..5285dda361b 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -18,7 +18,7 @@ describe 'Issues', feature: true do let!(:issue) do create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) end @@ -43,7 +43,7 @@ describe 'Issues', feature: true do let!(:issue) do create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) end @@ -61,7 +61,7 @@ describe 'Issues', feature: true do expect(page).to have_content 'No assignee - assign yourself' end - expect(issue.reload.assignee).to be_nil + expect(issue.reload.assignees).to be_empty end end @@ -138,7 +138,7 @@ describe 'Issues', feature: true do describe 'Issue info' do it 'excludes award_emoji from comment count' do - issue = create(:issue, author: @user, assignee: @user, project: project, title: 'foobar') + issue = create(:issue, author: @user, assignees: [@user], project: project, title: 'foobar') create(:award_emoji, awardable: issue) visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id) @@ -153,14 +153,14 @@ describe 'Issues', feature: true do %w(foobar barbaz gitlab).each do |title| create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project, title: title) end @issue = Issue.find_by(title: 'foobar') @issue.milestone = create(:milestone, project: project) - @issue.assignee = nil + @issue.assignees = [] @issue.save end @@ -351,9 +351,9 @@ describe 'Issues', feature: true do let(:user2) { create(:user) } before do - foo.assignee = user2 + foo.assignees << user2 foo.save - bar.assignee = user2 + bar.assignees << user2 bar.save end @@ -396,7 +396,7 @@ describe 'Issues', feature: true do end describe 'update labels from issue#show', js: true do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } let!(:label) { create(:label, project: project) } before do @@ -415,7 +415,7 @@ describe 'Issues', feature: true do end describe 'update assignee from issue#show' do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } context 'by authorized user' do it 'allows user to select unassigned', js: true do @@ -426,10 +426,14 @@ describe 'Issues', feature: true do click_link 'Edit' click_link 'Unassigned' + first('.title').click expect(page).to have_content 'No assignee' end - expect(issue.reload.assignee).to be_nil + # wait_for_ajax does not work with vue-resource at the moment + sleep 1 + + expect(issue.reload.assignees).to be_empty end it 'allows user to select an assignee', js: true do @@ -461,14 +465,18 @@ describe 'Issues', feature: true do click_link 'Edit' click_link @user.name - page.within '.value' do + find('.dropdown-menu-toggle').click + + page.within '.value .author' do expect(page).to have_content @user.name end click_link 'Edit' click_link @user.name - page.within '.value' do + find('.dropdown-menu-toggle').click + + page.within '.value .assign-yourself' do expect(page).to have_content "No assignee" end end @@ -487,7 +495,7 @@ describe 'Issues', feature: true do login_with guest visit namespace_project_issue_path(project.namespace, project, issue) - expect(page).to have_content issue.assignee.name + expect(page).to have_content issue.assignees.first.name end end end @@ -558,7 +566,7 @@ describe 'Issues', feature: true do let(:user2) { create(:user) } before do - issue.assignee = user2 + issue.assignees << user2 issue.save end end @@ -655,7 +663,7 @@ describe 'Issues', feature: true do describe 'due date' do context 'update due on issue#show', js: true do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } before do visit namespace_project_issue_path(project.namespace, project, issue) @@ -702,7 +710,7 @@ describe 'Issues', feature: true do include WaitForVueResource it 'updates the title', js: true do - issue = create(:issue, author: @user, assignee: @user, project: project, title: 'new title') + issue = create(:issue, author: @user, assignees: [@user], project: project, title: 'new title') visit namespace_project_issue_path(project.namespace, project, issue) diff --git a/spec/features/merge_requests/assign_issues_spec.rb b/spec/features/merge_requests/assign_issues_spec.rb index 43cc6f2a2a7..ec49003772b 100644 --- a/spec/features/merge_requests/assign_issues_spec.rb +++ b/spec/features/merge_requests/assign_issues_spec.rb @@ -33,7 +33,7 @@ feature 'Merge request issue assignment', js: true, feature: true do end it "doesn't display if related issues are already assigned" do - [issue1, issue2].each { |issue| issue.update!(assignee: user) } + [issue1, issue2].each { |issue| issue.update!(assignees: [user]) } visit_merge_request diff --git a/spec/features/milestones/show_spec.rb b/spec/features/milestones/show_spec.rb index 40b4dc63697..227eb04ba72 100644 --- a/spec/features/milestones/show_spec.rb +++ b/spec/features/milestones/show_spec.rb @@ -5,7 +5,7 @@ describe 'Milestone show', feature: true do let(:project) { create(:empty_project) } let(:milestone) { create(:milestone, project: project) } let(:labels) { create_list(:label, 2, project: project) } - let(:issue_params) { { project: project, assignee: user, author: user, milestone: milestone, labels: labels } } + let(:issue_params) { { project: project, assignees: [user], author: user, milestone: milestone, labels: labels } } before do project.add_user(user, :developer) diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb index d28a853bbc2..fa5e30075e3 100644 --- a/spec/features/projects/issuable_templates_spec.rb +++ b/spec/features/projects/issuable_templates_spec.rb @@ -12,7 +12,7 @@ feature 'issuable templates', feature: true, js: true do context 'user creates an issue using templates' do let(:template_content) { 'this is a test "bug" template' } let(:longtemplate_content) { %Q(this\n\n\n\n\nis\n\n\n\n\na\n\n\n\n\nbug\n\n\n\n\ntemplate) } - let(:issue) { create(:issue, author: user, assignee: user, project: project) } + let(:issue) { create(:issue, author: user, assignees: [user], project: project) } let(:description_addition) { ' appending to description' } background do @@ -72,7 +72,7 @@ feature 'issuable templates', feature: true, js: true do context 'user creates an issue using templates, with a prior description' do let(:prior_description) { 'test issue description' } let(:template_content) { 'this is a test "bug" template' } - let(:issue) { create(:issue, author: user, assignee: user, project: project) } + let(:issue) { create(:issue, author: user, assignees: [user], project: project) } background do project.repository.create_file( diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index da6388dcdf2..498a4a5cba0 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -5,7 +5,7 @@ describe "Search", feature: true do let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } - let!(:issue) { create(:issue, project: project, assignee: user) } + let!(:issue) { create(:issue, project: project, assignees: [user]) } let!(:issue2) { create(:issue, project: project, author: user) } before do diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb index e2d9cfdd0b0..a23c4ca2b92 100644 --- a/spec/features/unsubscribe_links_spec.rb +++ b/spec/features/unsubscribe_links_spec.rb @@ -6,7 +6,7 @@ describe 'Unsubscribe links', feature: true do let(:recipient) { create(:user) } let(:author) { create(:user) } let(:project) { create(:empty_project, :public) } - let(:params) { { title: 'A bug!', description: 'Fix it!', assignee: recipient } } + let(:params) { { title: 'A bug!', description: 'Fix it!', assignees: [recipient] } } let(:issue) { Issues::CreateService.new(project, author, params).execute } let(:mail) { ActionMailer::Base.deliveries.last } diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index a5f717e6233..96151689359 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -7,12 +7,12 @@ describe IssuesFinder do set(:project2) { create(:empty_project) } set(:milestone) { create(:milestone, project: project1) } set(:label) { create(:label, project: project2) } - set(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone, title: 'gitlab') } - set(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'gitlab') } - set(:issue3) { create(:issue, author: user2, assignee: user2, project: project2, title: 'tanuki', description: 'tanuki') } + set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab') } + set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') } + set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki') } describe '#execute' do - set(:closed_issue) { create(:issue, author: user2, assignee: user2, project: project2, state: 'closed') } + set(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } set(:label_link) { create(:label_link, label: label, target: issue2) } let(:search_user) { user } let(:params) { {} } @@ -91,7 +91,7 @@ describe IssuesFinder do before do milestones.each do |milestone| - create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user) + create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) end end @@ -126,7 +126,7 @@ describe IssuesFinder do before do milestones.each do |milestone| - create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user) + create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) end end diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index 21c078e0f44..ff86437fdd5 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -46,6 +46,24 @@ "username": { "type": "string" }, "avatar_url": { "type": "uri" } }, + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "required": [ + "id", + "name", + "username", + "avatar_url" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "avatar_url": { "type": "uri" } + } + } + }, "subscribed": { "type": ["boolean", "null"] } }, "additionalProperties": false diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json index 52199e75734..2d1c84ee93d 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issues.json +++ b/spec/fixtures/api/schemas/public_api/v4/issues.json @@ -33,6 +33,21 @@ }, "additionalProperties": false }, + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + } + }, "assignee": { "type": ["object", "null"], "properties": { @@ -67,7 +82,7 @@ "required": [ "id", "iid", "project_id", "title", "description", "state", "created_at", "updated_at", "labels", - "milestone", "assignee", "author", "user_notes_count", + "milestone", "assignees", "author", "user_notes_count", "upvotes", "downvotes", "due_date", "confidential", "web_url" ], diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 93bb711f29a..c1ecb46aece 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -4,6 +4,23 @@ describe IssuablesHelper do let(:label) { build_stubbed(:label) } let(:label2) { build_stubbed(:label) } + describe '#users_dropdown_label' do + let(:user) { build_stubbed(:user) } + let(:user2) { build_stubbed(:user) } + + it 'returns unassigned' do + expect(users_dropdown_label([])).to eq('Unassigned') + end + + it 'returns selected user\'s name' do + expect(users_dropdown_label([user])).to eq(user.name) + end + + it 'returns selected user\'s name and counter' do + expect(users_dropdown_label([user, user2])).to eq("#{user.name} + 1 more") + end + end + describe '#issuable_labels_tooltip' do it 'returns label text' do expect(issuable_labels_tooltip([label])).to eq(label.title) diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js index de072e7e470..376e706d1db 100644 --- a/spec/javascripts/boards/board_card_spec.js +++ b/spec/javascripts/boards/board_card_spec.js @@ -1,12 +1,12 @@ /* global List */ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global boardsMockInterceptor */ /* global BoardService */ import Vue from 'vue'; -import '~/boards/models/user'; +import '~/boards/models/assignee'; require('~/boards/models/list'); require('~/boards/models/label'); @@ -133,12 +133,12 @@ describe('Issue card', () => { }); it('does not set detail issue if img is clicked', (done) => { - vm.issue.assignee = new ListUser({ + vm.issue.assignees = [new ListAssignee({ id: 1, name: 'testing 123', username: 'test', avatar: 'test_image', - }); + })]; Vue.nextTick(() => { triggerEvent('mouseup', vm.$el.querySelector('img')); diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js index 3f598887603..a89be911667 100644 --- a/spec/javascripts/boards/board_list_spec.js +++ b/spec/javascripts/boards/board_list_spec.js @@ -35,6 +35,7 @@ describe('Board list component', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); list.issuesSize = 1; list.issues.push(issue); diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js index b55ff2f473a..5ea160b7790 100644 --- a/spec/javascripts/boards/boards_store_spec.js +++ b/spec/javascripts/boards/boards_store_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Store', () => { beforeEach(() => { @@ -212,7 +212,8 @@ describe('Store', () => { title: 'Testing', iid: 2, confidential: false, - labels: [] + labels: [], + assignees: [], }); const list = gl.issueBoards.BoardsStore.addList(listObj); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index ef567635d48..fddde799d01 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -1,20 +1,20 @@ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global ListIssue */ import Vue from 'vue'; -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/boards_store'); -require('~/boards/components/issue_card_inner'); -require('./mock_data'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/boards_store'; +import '~/boards/components/issue_card_inner'; +import './mock_data'; describe('Issue card component', () => { - const user = new ListUser({ + const user = new ListAssignee({ id: 1, name: 'testing 123', username: 'test', @@ -40,6 +40,7 @@ describe('Issue card component', () => { iid: 1, confidential: false, labels: [list.label], + assignees: [], }); component = new Vue({ @@ -92,12 +93,12 @@ describe('Issue card component', () => { it('renders confidential icon', (done) => { component.issue.confidential = true; - setTimeout(() => { + Vue.nextTick(() => { expect( component.$el.querySelector('.confidential-icon'), ).not.toBeNull(); done(); - }, 0); + }); }); it('renders issue ID with #', () => { @@ -109,34 +110,32 @@ describe('Issue card component', () => { describe('assignee', () => { it('does not render assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).toBeNull(); }); describe('exists', () => { beforeEach((done) => { - component.issue.assignee = user; + component.issue.assignees = [user]; - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('renders assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).not.toBeNull(); }); it('sets title', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('title'), + component.$el.querySelector('.card-assignee a').getAttribute('title'), ).toContain(`Assigned to ${user.name}`); }); it('sets users path', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('href'), + component.$el.querySelector('.card-assignee a').getAttribute('href'), ).toBe('/test'); }); @@ -149,11 +148,11 @@ describe('Issue card component', () => { describe('assignee default avatar', () => { beforeEach((done) => { - component.issue.assignee = new ListUser({ + component.issue.assignees = [new ListAssignee({ id: 1, name: 'testing 123', username: 'test', - }, 'default_avatar'); + }, 'default_avatar')]; Vue.nextTick(done); }); @@ -169,6 +168,75 @@ describe('Issue card component', () => { }); }); + describe('multiple assignees', () => { + beforeEach((done) => { + component.issue.assignees = [ + user, + new ListAssignee({ + id: 2, + name: 'user2', + username: 'user2', + avatar: 'test_image', + }), + new ListAssignee({ + id: 3, + name: 'user3', + username: 'user3', + avatar: 'test_image', + }), + new ListAssignee({ + id: 4, + name: 'user4', + username: 'user4', + avatar: 'test_image', + })]; + + Vue.nextTick(() => done()); + }); + + it('renders all four assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(4); + }); + + describe('more than four assignees', () => { + beforeEach((done) => { + component.issue.assignees.push(new ListAssignee({ + id: 5, + name: 'user5', + username: 'user5', + avatar: 'test_image', + })); + + Vue.nextTick(() => done()); + }); + + it('renders more avatar counter', () => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('+2'); + }); + + it('renders three assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(3); + }); + + it('renders 99+ avatar counter', (done) => { + for (let i = 5; i < 104; i += 1) { + const u = new ListAssignee({ + id: i, + name: 'name', + username: 'username', + avatar: 'test_image', + }); + component.issue.assignees.push(u); + } + + Vue.nextTick(() => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('99+'); + done(); + }); + }); + }); + }); + describe('labels', () => { it('does not render any', () => { expect( @@ -180,9 +248,7 @@ describe('Issue card component', () => { beforeEach((done) => { component.issue.addLabel(label1); - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('does not render list label', () => { diff --git a/spec/javascripts/boards/issue_spec.js b/spec/javascripts/boards/issue_spec.js index c96dfe94a4a..cd1497bc5e6 100644 --- a/spec/javascripts/boards/issue_spec.js +++ b/spec/javascripts/boards/issue_spec.js @@ -2,14 +2,15 @@ /* global BoardService */ /* global ListIssue */ -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import Vue from 'vue'; +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Issue model', () => { let issue; @@ -27,7 +28,13 @@ describe('Issue model', () => { title: 'test', color: 'red', description: 'testing' - }] + }], + assignees: [{ + id: 1, + name: 'name', + username: 'username', + avatar_url: 'http://avatar_url', + }], }); }); @@ -80,6 +87,33 @@ describe('Issue model', () => { expect(issue.labels.length).toBe(0); }); + it('adds assignee', () => { + issue.addAssignee({ + id: 2, + name: 'Bruce Wayne', + username: 'batman', + avatar_url: 'http://batman', + }); + + expect(issue.assignees.length).toBe(2); + }); + + it('finds assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + expect(assignee).toBeDefined(); + }); + + it('removes assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + issue.removeAssignee(assignee); + expect(issue.assignees.length).toBe(0); + }); + + it('removes all assignees', () => { + issue.removeAllAssignees(); + expect(issue.assignees.length).toBe(0); + }); + it('sets position to infinity if no position is stored', () => { expect(issue.position).toBe(Infinity); }); @@ -90,9 +124,31 @@ describe('Issue model', () => { iid: 1, confidential: false, relative_position: 1, - labels: [] + labels: [], + assignees: [], }); expect(relativePositionIssue.position).toBe(1); }); + + describe('update', () => { + it('passes assignee ids when there are assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([1]); + done(); + }); + + issue.update('url'); + }); + + it('passes assignee ids of [0] when there are no assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([0]); + done(); + }); + + issue.removeAllAssignees(); + issue.update('url'); + }); + }); }); diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js index 24a2da9f6b6..8e3d9fd77a0 100644 --- a/spec/javascripts/boards/list_spec.js +++ b/spec/javascripts/boards/list_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('List model', () => { let list; @@ -94,7 +94,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label, listDup.label] + labels: [list.label, listDup.label], + assignees: [], }); list.issues.push(issue); @@ -119,7 +120,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000) + i, confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); } list.issuesSize = 50; @@ -137,7 +139,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); list.issuesSize = 2; diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js index a4fa694eebe..a64c3964ee3 100644 --- a/spec/javascripts/boards/mock_data.js +++ b/spec/javascripts/boards/mock_data.js @@ -33,7 +33,8 @@ const BoardsMockData = { title: 'Testing', iid: 1, confidential: false, - labels: [] + labels: [], + assignees: [], }], size: 1 } diff --git a/spec/javascripts/boards/modal_store_spec.js b/spec/javascripts/boards/modal_store_spec.js index 80db816aff8..32e6d04df9f 100644 --- a/spec/javascripts/boards/modal_store_spec.js +++ b/spec/javascripts/boards/modal_store_spec.js @@ -1,10 +1,10 @@ /* global ListIssue */ -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/modal_store'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/modal_store'; describe('Modal store', () => { let issue; @@ -21,12 +21,14 @@ describe('Modal store', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); issue2 = new ListIssue({ title: 'Testing', iid: 2, confidential: false, labels: [], + assignees: [], }); Store.store.issues.push(issue); Store.store.issues.push(issue2); diff --git a/spec/javascripts/issuable_time_tracker_spec.js b/spec/javascripts/issuable_time_tracker_spec.js index 0a830f25e29..8ff93c4f918 100644 --- a/spec/javascripts/issuable_time_tracker_spec.js +++ b/spec/javascripts/issuable_time_tracker_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; -require('~/issuable/time_tracking/components/time_tracker'); +import timeTracker from '~/sidebar/components/time_tracking/time_tracker'; function initTimeTrackingComponent(opts) { setFixtures(` @@ -16,187 +16,185 @@ function initTimeTrackingComponent(opts) { time_spent: opts.timeSpent, human_time_estimate: opts.timeEstimateHumanReadable, human_time_spent: opts.timeSpentHumanReadable, - docsUrl: '/help/workflow/time_tracking.md', + rootPath: '/', }; - const TimeTrackingComponent = Vue.component('issuable-time-tracker'); + const TimeTrackingComponent = Vue.extend(timeTracker); this.timeTracker = new TimeTrackingComponent({ el: '#mock-container', propsData: this.initialData, }); } -((gl) => { - describe('Issuable Time Tracker', function() { - describe('Initialization', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); +describe('Issuable Time Tracker', function() { + describe('Initialization', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should return something defined', function() { - expect(this.timeTracker).toBeDefined(); - }); + it('should return something defined', function() { + expect(this.timeTracker).toBeDefined(); + }); - it ('should correctly set timeEstimate', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); - done(); - }); + it ('should correctly set timeEstimate', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); + done(); }); - it ('should correctly set time_spent', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); - done(); - }); + }); + it ('should correctly set time_spent', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); + done(); }); }); + }); - describe('Content Display', function() { - describe('Panes', function() { - describe('Comparison pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + describe('Content Display', function() { + describe('Panes', function() { + describe('Comparison pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); + + it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + Vue.nextTick(() => { + const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); + expect(this.timeTracker.showComparisonState).toBe(true); + done(); }); + }); - it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + describe('Remaining meter', function() { + it('should display the remaining meter with the correct width', function(done) { Vue.nextTick(() => { - const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); - expect(this.timeTracker.showComparisonState).toBe(true); + const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; + const correctWidth = '5%'; + + expect(meterWidth).toBe(correctWidth); done(); - }); + }) }); - describe('Remaining meter', function() { - it('should display the remaining meter with the correct width', function(done) { - Vue.nextTick(() => { - const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; - const correctWidth = '5%'; - - expect(meterWidth).toBe(correctWidth); - done(); - }) - }); - - it('should display the remaining meter with the correct background color when within estimate', function(done) { - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done() - }); + it('should display the remaining meter with the correct background color when within estimate', function(done) { + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done() }); + }); - it('should display the remaining meter with the correct background color when over estimate', function(done) { - this.timeTracker.time_estimate = 100000; - this.timeTracker.time_spent = 20000000; - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done(); - }); + it('should display the remaining meter with the correct background color when over estimate', function(done) { + this.timeTracker.time_estimate = 100000; + this.timeTracker.time_spent = 20000000; + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done(); }); }); }); + }); - describe("Estimate only pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); - }); + describe("Estimate only pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); + }); - it('should display the human readable version of time estimated', function(done) { - Vue.nextTick(() => { - const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; - const correctText = 'Estimated: 2h 46m'; + it('should display the human readable version of time estimated', function(done) { + Vue.nextTick(() => { + const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; + const correctText = 'Estimated: 2h 46m'; - expect(estimateText).toBe(correctText); - done(); - }); + expect(estimateText).toBe(correctText); + done(); }); }); + }); - describe('Spent only pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); + describe('Spent only pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should display the human readable version of time spent', function(done) { - Vue.nextTick(() => { - const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; - const correctText = 'Spent: 1h 23m'; + it('should display the human readable version of time spent', function(done) { + Vue.nextTick(() => { + const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; + const correctText = 'Spent: 1h 23m'; - expect(spentText).toBe(correctText); - done(); - }); + expect(spentText).toBe(correctText); + done(); }); }); + }); - describe('No time tracking pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 }); - }); + describe('No time tracking pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); - it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { - Vue.nextTick(() => { - const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); - const noTrackingText =$noTrackingPane.innerText; - const correctText = 'No estimate or time spent'; + it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { + Vue.nextTick(() => { + const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); + const noTrackingText =$noTrackingPane.innerText; + const correctText = 'No estimate or time spent'; - expect(this.timeTracker.showNoTimeTrackingState).toBe(true); - expect($noTrackingPane).toBeVisible(); - expect(noTrackingText).toBe(correctText); - done(); - }); + expect(this.timeTracker.showNoTimeTrackingState).toBe(true); + expect($noTrackingPane).toBeVisible(); + expect(noTrackingText).toBe(correctText); + done(); }); }); + }); - describe("Help pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); - }); + describe("Help pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); + }); - it('should not show the "Help" pane by default', function(done) { - Vue.nextTick(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + it('should not show the "Help" pane by default', function(done) { + Vue.nextTick(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); - done(); - }); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); + done(); }); + }); - it('should show the "Help" pane when help button is clicked', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should show the "Help" pane when help button is clicked', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(true); - expect($helpPane).toBeVisible(); - done(); - }, 10); - }); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + expect(this.timeTracker.showHelpState).toBe(true); + expect($helpPane).toBeVisible(); + done(); + }, 10); }); + }); - it('should not show the "Help" pane when help button is clicked and then closed', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should not show the "Help" pane when help button is clicked and then closed', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { + setTimeout(() => { - $(this.timeTracker.$el).find('.close-help-button').click(); + $(this.timeTracker.$el).find('.close-help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); - done(); - }, 1000); + done(); }, 1000); - }); + }, 1000); }); }); }); }); }); -})(window.gl || (window.gl = {})); +}); diff --git a/spec/javascripts/subbable_resource_spec.js b/spec/javascripts/subbable_resource_spec.js deleted file mode 100644 index 454386697f5..00000000000 --- a/spec/javascripts/subbable_resource_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable max-len, arrow-parens, comma-dangle */ - -require('~/subbable_resource'); - -/* -* Test that each rest verb calls the publish and subscribe function and passes the correct value back -* -* -* */ -((global) => { - describe('Subbable Resource', function () { - describe('PubSub', function () { - beforeEach(function () { - this.MockResource = new global.SubbableResource('https://example.com'); - }); - it('should successfully add a single subscriber', function () { - const callback = () => {}; - this.MockResource.subscribe(callback); - - expect(this.MockResource.subscribers.length).toBe(1); - expect(this.MockResource.subscribers[0]).toBe(callback); - }); - - it('should successfully add multiple subscribers', function () { - const callbackOne = () => {}; - const callbackTwo = () => {}; - const callbackThree = () => {}; - - this.MockResource.subscribe(callbackOne); - this.MockResource.subscribe(callbackTwo); - this.MockResource.subscribe(callbackThree); - - expect(this.MockResource.subscribers.length).toBe(3); - }); - - it('should successfully publish an update to a single subscriber', function () { - const state = { myprop: 1 }; - - const callbacks = { - one: (data) => expect(data.myprop).toBe(2), - two: (data) => expect(data.myprop).toBe(2), - three: (data) => expect(data.myprop).toBe(2) - }; - - const spyOne = spyOn(callbacks, 'one'); - const spyTwo = spyOn(callbacks, 'two'); - const spyThree = spyOn(callbacks, 'three'); - - this.MockResource.subscribe(callbacks.one); - this.MockResource.subscribe(callbacks.two); - this.MockResource.subscribe(callbacks.three); - - state.myprop += 1; - - this.MockResource.publish(state); - - expect(spyOne).toHaveBeenCalled(); - expect(spyTwo).toHaveBeenCalled(); - expect(spyThree).toHaveBeenCalled(); - }); - }); - }); -})(window.gl || (window.gl = {})); diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb index 8a6fe1ad6a3..7c4a0f32c7b 100644 --- a/spec/lib/banzai/filter/redactor_filter_spec.rb +++ b/spec/lib/banzai/filter/redactor_filter_spec.rb @@ -113,7 +113,7 @@ describe Banzai::Filter::RedactorFilter, lib: true do it 'allows references for assignee' do assignee = create(:user) project = create(:empty_project, :public) - issue = create(:issue, :confidential, project: project, assignee: assignee) + issue = create(:issue, :confidential, project: project, assignees: [assignee]) link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue') doc = filter(link, current_user: assignee) diff --git a/spec/lib/gitlab/github_import/issue_formatter_spec.rb b/spec/lib/gitlab/github_import/issue_formatter_spec.rb index f34d09f2c1d..a4089592cf2 100644 --- a/spec/lib/gitlab/github_import/issue_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/issue_formatter_spec.rb @@ -43,7 +43,7 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do description: "*Created by: octocat*\n\nI'm having a problem with this.", state: 'opened', author_id: project.creator_id, - assignee_id: nil, + assignee_ids: [], created_at: created_at, updated_at: updated_at } @@ -64,7 +64,7 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do description: "*Created by: octocat*\n\nI'm having a problem with this.", state: 'closed', author_id: project.creator_id, - assignee_id: nil, + assignee_ids: [], created_at: created_at, updated_at: updated_at } @@ -77,19 +77,19 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do let(:raw_data) { double(base_data.merge(assignee: octocat)) } it 'returns nil as assignee_id when is not a GitLab user' do - expect(issue.attributes.fetch(:assignee_id)).to be_nil + expect(issue.attributes.fetch(:assignee_ids)).to be_empty end it 'returns GitLab user id associated with GitHub id as assignee_id' do gl_user = create(:omniauth_user, extern_uid: octocat.id, provider: 'github') - expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id + expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id] end it 'returns GitLab user id associated with GitHub email as assignee_id' do gl_user = create(:user, email: octocat.email) - expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id + expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id] end end diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb index ccaa88a5c79..622a0f513f4 100644 --- a/spec/lib/gitlab/google_code_import/importer_spec.rb +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -49,7 +49,7 @@ describe Gitlab::GoogleCodeImport::Importer, lib: true do expect(issue).not_to be_nil expect(issue.iid).to eq(169) expect(issue.author).to eq(project.creator) - expect(issue.assignee).to eq(mapped_user) + expect(issue.assignees).to eq([mapped_user]) expect(issue.state).to eq("closed") expect(issue.label_names).to include("Priority: Medium") expect(issue.label_names).to include("Status: Fixed") diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 0abf89d060c..cd2fa27bb9f 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -3,7 +3,7 @@ issues: - subscriptions - award_emoji - author -- assignee +- assignees - updated_by - milestone - notes @@ -16,6 +16,7 @@ issues: - merge_requests_closing_issues - metrics - timelogs +- issue_assignees events: - author - project diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 1035428b2e7..5aeb29b7fec 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -203,7 +203,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do end def setup_project - issue = create(:issue, assignee: user) + issue = create(:issue, assignees: [user]) snippet = create(:project_snippet) release = create(:release) group = create(:group) diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index e0ebea63eb4..a7c8e7f1f57 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -89,7 +89,7 @@ describe Gitlab::ProjectSearchResults, lib: true do let(:project) { create(:empty_project, :internal) } let!(:issue) { create(:issue, project: project, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } it 'does not list project confidential issues for non project members' do results = described_class.new(non_member, project, query) diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 847fb977400..31c3cd4d53c 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -72,9 +72,9 @@ describe Gitlab::SearchResults do let(:admin) { create(:admin) } let!(:issue) { create(:issue, project: project_1, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project_1, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignees: [assignee]) } let!(:security_issue_3) { create(:issue, :confidential, project: project_2, title: 'Security issue 3', author: author) } - let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignee: assignee) } + let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignees: [assignee]) } let!(:security_issue_5) { create(:issue, :confidential, project: project_4, title: 'Security issue 5') } it 'does not list confidential issues for non project members' do diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 9f12e40d808..1e6260270fe 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -36,11 +36,11 @@ describe Notify do end context 'for issues' do - let(:issue) { create(:issue, author: current_user, assignee: assignee, project: project) } - let(:issue_with_description) { create(:issue, author: current_user, assignee: assignee, project: project, description: 'My awesome description') } + let(:issue) { create(:issue, author: current_user, assignees: [assignee], project: project) } + let(:issue_with_description) { create(:issue, author: current_user, assignees: [assignee], project: project, description: 'My awesome description') } describe 'that are new' do - subject { described_class.new_issue_email(issue.assignee_id, issue.id) } + subject { described_class.new_issue_email(issue.assignees.first.id, issue.id) } it_behaves_like 'an assignee email' it_behaves_like 'an email starting a new thread with reply-by-email enabled' do @@ -69,7 +69,7 @@ describe Notify do end describe 'that are new with a description' do - subject { described_class.new_issue_email(issue_with_description.assignee_id, issue_with_description.id) } + subject { described_class.new_issue_email(issue_with_description.assignees.first.id, issue_with_description.id) } it_behaves_like 'it should show Gmail Actions View Issue link' @@ -79,7 +79,7 @@ describe Notify do end describe 'that have been reassigned' do - subject { described_class.reassigned_issue_email(recipient.id, issue.id, previous_assignee.id, current_user.id) } + subject { described_class.reassigned_issue_email(recipient.id, issue.id, [previous_assignee.id], current_user.id) } it_behaves_like 'a multiple recipients email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 3ecba2e9687..27890e33b49 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -10,7 +10,6 @@ describe Issuable do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:author) } - it { is_expected.to belong_to(:assignee) } it { is_expected.to have_many(:notes).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } @@ -66,60 +65,6 @@ describe Issuable do end end - describe 'assignee_name' do - it 'is delegated to assignee' do - issue.update!(assignee: create(:user)) - - expect(issue.assignee_name).to eq issue.assignee.name - end - - it 'returns nil when assignee is nil' do - issue.assignee_id = nil - issue.save(validate: false) - - expect(issue.assignee_name).to eq nil - end - end - - describe "before_save" do - describe "#update_cache_counts" do - context "when previous assignee exists" do - before do - assignee = create(:user) - issue.project.team << [assignee, :developer] - issue.update(assignee: assignee) - end - - it "updates cache counts for new assignee" do - user = create(:user) - - expect(user).to receive(:update_cache_counts) - - issue.update(assignee: user) - end - - it "updates cache counts for previous assignee" do - old_assignee = issue.assignee - allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee) - - expect(old_assignee).to receive(:update_cache_counts) - - issue.update(assignee: nil) - end - end - - context "when previous assignee does not exist" do - before{ issue.update(assignee: nil) } - - it "updates cache count for the new assignee" do - expect_any_instance_of(User).to receive(:update_cache_counts) - - issue.update(assignee: user) - end - end - end - end - describe ".search" do let!(:searchable_issue) { create(:issue, title: "Searchable issue") } @@ -307,7 +252,20 @@ describe Issuable do end context "issue is assigned" do - before { issue.update_attribute(:assignee, user) } + before { issue.assignees << user } + + it "returns correct hook data" do + expect(data[:assignees].first).to eq(user.hook_attrs) + end + end + + context "merge_request is assigned" do + let(:merge_request) { create(:merge_request) } + let(:data) { merge_request.to_hook_data(user) } + + before do + merge_request.update_attribute(:assignee, user) + end it "returns correct hook data" do expect(data[:object_attributes]['assignee_id']).to eq(user.id) @@ -329,24 +287,6 @@ describe Issuable do include_examples 'deprecated repository hook data' end - describe '#card_attributes' do - it 'includes the author name' do - allow(issue).to receive(:author).and_return(double(name: 'Robert')) - allow(issue).to receive(:assignee).and_return(nil) - - expect(issue.card_attributes). - to eq({ 'Author' => 'Robert', 'Assignee' => nil }) - end - - it 'includes the assignee name' do - allow(issue).to receive(:author).and_return(double(name: 'Robert')) - allow(issue).to receive(:assignee).and_return(double(name: 'Douwe')) - - expect(issue.card_attributes). - to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) - end - end - describe '#labels_array' do let(:project) { create(:empty_project) } let(:bug) { create(:label, project: project, title: 'bug') } @@ -475,27 +415,6 @@ describe Issuable do end end - describe '#assignee_or_author?' do - let(:user) { build(:user, id: 1) } - let(:issue) { build(:issue) } - - it 'returns true for a user that is assigned to an issue' do - issue.assignee = user - - expect(issue.assignee_or_author?(user)).to eq(true) - end - - it 'returns true for a user that is the author of an issue' do - issue.author = user - - expect(issue.assignee_or_author?(user)).to eq(true) - end - - it 'returns false for a user that is not the assignee or author' do - expect(issue.assignee_or_author?(user)).to eq(false) - end - end - describe '#spend_time' do let(:user) { create(:user) } let(:issue) { create(:issue) } diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index 68e4c0a522b..675b730c557 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -11,13 +11,13 @@ describe Milestone, 'Milestoneish' do let(:milestone) { create(:milestone, project: project) } let!(:issue) { create(:issue, project: project, milestone: milestone) } let!(:security_issue_1) { create(:issue, :confidential, project: project, author: author, milestone: milestone) } - let!(:security_issue_2) { create(:issue, :confidential, project: project, assignee: assignee, milestone: milestone) } + let!(:security_issue_2) { create(:issue, :confidential, project: project, assignees: [assignee], milestone: milestone) } let!(:closed_issue_1) { create(:issue, :closed, project: project, milestone: milestone) } let!(:closed_issue_2) { create(:issue, :closed, project: project, milestone: milestone) } let!(:closed_security_issue_1) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } - let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) } let!(:closed_security_issue_3) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } - let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) } let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: milestone) } before do diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index a9c5b604268..b8cb967c4cc 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -118,8 +118,8 @@ describe Event, models: true do let(:author) { create(:author) } let(:assignee) { create(:user) } let(:admin) { create(:admin) } - let(:issue) { create(:issue, project: project, author: author, assignee: assignee) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:issue) { create(:issue, project: project, author: author, assignees: [assignee]) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note_on_commit) { create(:note_on_commit, project: project) } let(:note_on_issue) { create(:note_on_issue, noteable: issue, project: project) } let(:note_on_confidential_issue) { create(:note_on_issue, noteable: confidential_issue, project: project) } diff --git a/spec/models/issue_collection_spec.rb b/spec/models/issue_collection_spec.rb index d8aed25c041..93c2c538e10 100644 --- a/spec/models/issue_collection_spec.rb +++ b/spec/models/issue_collection_spec.rb @@ -28,7 +28,7 @@ describe IssueCollection do end it 'returns the issues the user is assigned to' do - issue1.assignee = user + issue1.assignees << user expect(collection.updatable_by_user(user)).to eq([issue1]) end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 8748b98a4e3..725f5c2311f 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe Issue, models: true do describe "Associations" do it { is_expected.to belong_to(:milestone) } + it { is_expected.to have_many(:assignees) } end describe 'modules' do @@ -37,6 +38,64 @@ describe Issue, models: true do end end + describe "before_save" do + describe "#update_cache_counts when an issue is reassigned" do + let(:issue) { create(:issue) } + let(:assignee) { create(:user) } + + context "when previous assignee exists" do + before do + issue.project.team << [assignee, :developer] + issue.assignees << assignee + end + + it "updates cache counts for new assignee" do + user = create(:user) + + expect(user).to receive(:update_cache_counts) + + issue.assignees << user + end + + it "updates cache counts for previous assignee" do + issue.assignees.first + + expect_any_instance_of(User).to receive(:update_cache_counts) + + issue.assignees.destroy_all + end + end + + context "when previous assignee does not exist" do + it "updates cache count for the new assignee" do + issue.assignees = [] + + expect_any_instance_of(User).to receive(:update_cache_counts) + + issue.assignees << assignee + end + end + end + end + + describe '#card_attributes' do + it 'includes the author name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignees).and_return([]) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => '' }) + end + + it 'includes the assignee name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignees).and_return([double(name: 'Douwe')]) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) + end + end + describe '#closed_at' do after do Timecop.return @@ -124,13 +183,24 @@ describe Issue, models: true do end end - describe '#is_being_reassigned?' do - it 'returns true if the issue assignee has changed' do - subject.assignee = create(:user) - expect(subject.is_being_reassigned?).to be_truthy + describe '#assignee_or_author?' do + let(:user) { create(:user) } + let(:issue) { create(:issue) } + + it 'returns true for a user that is assigned to an issue' do + issue.assignees << user + + expect(issue.assignee_or_author?(user)).to be_truthy end - it 'returns false if the issue assignee has not changed' do - expect(subject.is_being_reassigned?).to be_falsey + + it 'returns true for a user that is the author of an issue' do + issue.update(author: user) + + expect(issue.assignee_or_author?(user)).to be_truthy + end + + it 'returns false for a user that is not the assignee or author' do + expect(issue.assignee_or_author?(user)).to be_falsey end end @@ -383,14 +453,14 @@ describe Issue, models: true do user1 = create(:user) user2 = create(:user) project = create(:empty_project) - issue = create(:issue, assignee: user1, project: project) + issue = create(:issue, assignees: [user1], project: project) project.add_developer(user1) project.add_developer(user2) expect(user1.assigned_open_issues_count).to eq(1) expect(user2.assigned_open_issues_count).to eq(0) - issue.assignee = user2 + issue.assignees = [user2] issue.save expect(user1.assigned_open_issues_count).to eq(0) @@ -676,6 +746,11 @@ describe Issue, models: true do expect(attrs_hash).to include(:human_total_time_spent) expect(attrs_hash).to include('time_estimate') end + + it 'includes assignee_ids and deprecated assignee_id' do + expect(attrs_hash).to include(:assignee_id) + expect(attrs_hash).to include(:assignee_ids) + end end describe '#check_for_spam' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 8b72125dd5d..6cf3dd30ead 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -9,6 +9,7 @@ describe MergeRequest, models: true do it { is_expected.to belong_to(:target_project).class_name('Project') } it { is_expected.to belong_to(:source_project).class_name('Project') } it { is_expected.to belong_to(:merge_user).class_name("User") } + it { is_expected.to belong_to(:assignee) } it { is_expected.to have_many(:merge_request_diffs).dependent(:destroy) } end @@ -86,6 +87,86 @@ describe MergeRequest, models: true do end end + describe "before_save" do + describe "#update_cache_counts when a merge request is reassigned" do + let(:project) { create :project } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:assignee) { create :user } + + context "when previous assignee exists" do + before do + project.team << [assignee, :developer] + merge_request.update(assignee: assignee) + end + + it "updates cache counts for new assignee" do + user = create(:user) + + expect(user).to receive(:update_cache_counts) + + merge_request.update(assignee: user) + end + + it "updates cache counts for previous assignee" do + old_assignee = merge_request.assignee + allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee) + + expect(old_assignee).to receive(:update_cache_counts) + + merge_request.update(assignee: nil) + end + end + + context "when previous assignee does not exist" do + it "updates cache count for the new assignee" do + merge_request.update(assignee: nil) + + expect_any_instance_of(User).to receive(:update_cache_counts) + + merge_request.update(assignee: assignee) + end + end + end + end + + describe '#card_attributes' do + it 'includes the author name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignee).and_return(nil) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => nil }) + end + + it 'includes the assignee name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignee).and_return(double(name: 'Douwe')) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) + end + end + + describe '#assignee_or_author?' do + let(:user) { create(:user) } + + it 'returns true for a user that is assigned to a merge request' do + subject.assignee = user + + expect(subject.assignee_or_author?(user)).to eq(true) + end + + it 'returns true for a user that is the author of a merge request' do + subject.author = user + + expect(subject.assignee_or_author?(user)).to eq(true) + end + + it 'returns false for a user that is not the assignee or author' do + expect(subject.assignee_or_author?(user)).to eq(false) + end + end + describe '#cache_merge_request_closes_issues!' do before do subject.project.team << [subject.author, :developer] @@ -295,16 +376,6 @@ describe MergeRequest, models: true do end end - describe '#is_being_reassigned?' do - it 'returns true if the merge_request assignee has changed' do - subject.assignee = create(:user) - expect(subject.is_being_reassigned?).to be_truthy - end - it 'returns false if the merge request assignee has not changed' do - expect(subject.is_being_reassigned?).to be_falsey - end - end - describe '#for_fork?' do it 'returns true if the merge request is for a fork' do subject.source_project = build_stubbed(:empty_project, namespace: create(:group)) diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb index 9a870b7fda1..4a07c864428 100644 --- a/spec/policies/issue_policy_spec.rb +++ b/spec/policies/issue_policy_spec.rb @@ -15,7 +15,7 @@ describe IssuePolicy, models: true do context 'a private project' do let(:non_member) { create(:user) } let(:project) { create(:empty_project, :private) } - let(:issue) { create(:issue, project: project, assignee: assignee, author: author) } + let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) } let(:issue_no_assignee) { create(:issue, project: project) } before do @@ -69,7 +69,7 @@ describe IssuePolicy, models: true do end context 'with confidential issues' do - let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) } + let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) } let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow non-members to read confidential issues' do @@ -110,7 +110,7 @@ describe IssuePolicy, models: true do context 'a public project' do let(:project) { create(:empty_project, :public) } - let(:issue) { create(:issue, project: project, assignee: assignee, author: author) } + let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) } let(:issue_no_assignee) { create(:issue, project: project) } before do @@ -157,7 +157,7 @@ describe IssuePolicy, models: true do end context 'with confidential issues' do - let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) } + let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) } let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow guests to read confidential issues' do diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 3ca13111acb..da2b56c040b 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -19,7 +19,7 @@ describe API::Issues do let!(:closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: project, state: :closed, milestone: milestone, @@ -31,14 +31,14 @@ describe API::Issues do :confidential, project: project, author: author, - assignee: assignee, + assignees: [assignee], created_at: generate(:past_time), updated_at: 2.hours.ago end let!(:issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: project, milestone: milestone, created_at: generate(:past_time), @@ -265,7 +265,7 @@ describe API::Issues do let!(:group_closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: group_project, state: :closed, milestone: group_milestone, @@ -276,13 +276,13 @@ describe API::Issues do :confidential, project: group_project, author: author, - assignee: assignee, + assignees: [assignee], updated_at: 2.hours.ago end let!(:group_issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: group_project, milestone: group_milestone, updated_at: 1.hour.ago, @@ -687,6 +687,7 @@ describe API::Issues do expect(json_response['updated_at']).to be_present expect(json_response['labels']).to eq(issue.label_names) expect(json_response['milestone']).to be_a Hash + expect(json_response['assignees']).to be_a Array expect(json_response['assignee']).to be_a Hash expect(json_response['author']).to be_a Hash expect(json_response['confidential']).to be_falsy @@ -759,15 +760,41 @@ describe API::Issues do end describe "POST /projects/:id/issues" do + context 'support for deprecated assignee_id' do + it 'creates a new project issue' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', assignee_id: user2.id + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + end + + context 'CE restrictions' do + it 'creates a new project issue with no more than one assignee' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', assignee_ids: [user2.id, guest.id] + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignees'].count).to eq(1) + end + end + it 'creates a new project issue' do post api("/projects/#{project.id}/issues", user), - title: 'new issue', labels: 'label, label2' + title: 'new issue', labels: 'label, label2', weight: 3, + assignee_ids: [user2.id] expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) end it 'creates a new confidential project issue' do @@ -1057,6 +1084,57 @@ describe API::Issues do end end + describe 'PUT /projects/:id/issues/:issue_iid to update assignee' do + context 'support for deprecated assignee_id' do + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_id: 0 + + expect(response).to have_http_status(200) + + expect(json_response['assignee']).to be_nil + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_id: user2.id + + expect(response).to have_http_status(200) + + expect(json_response['assignee']['name']).to eq(user2.name) + end + end + + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [0] + + expect(response).to have_http_status(200) + + expect(json_response['assignees']).to be_empty + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [user2.id] + + expect(response).to have_http_status(200) + + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + + context 'CE restrictions' do + it 'updates an issue with several assignee but only one has been applied' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [user2.id, guest.id] + + expect(response).to have_http_status(200) + + expect(json_response['assignees'].size).to eq(1) + end + end + end + describe 'PUT /projects/:id/issues/:issue_iid to update labels' do let!(:label) { create(:label, title: 'dummy', project: project) } let!(:label_link) { create(:label_link, label: label, target: issue) } diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index ef5b10a1615..cc81922697a 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -14,7 +14,7 @@ describe API::V3::Issues do let!(:closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: project, state: :closed, milestone: milestone, @@ -26,14 +26,14 @@ describe API::V3::Issues do :confidential, project: project, author: author, - assignee: assignee, + assignees: [assignee], created_at: generate(:past_time), updated_at: 2.hours.ago end let!(:issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: project, milestone: milestone, created_at: generate(:past_time), @@ -247,7 +247,7 @@ describe API::V3::Issues do let!(:group_closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: group_project, state: :closed, milestone: group_milestone, @@ -258,13 +258,13 @@ describe API::V3::Issues do :confidential, project: group_project, author: author, - assignee: assignee, + assignees: [assignee], updated_at: 2.hours.ago end let!(:group_issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: group_project, milestone: group_milestone, updated_at: 1.hour.ago @@ -737,13 +737,14 @@ describe API::V3::Issues do describe "POST /projects/:id/issues" do it 'creates a new project issue' do post v3_api("/projects/#{project.id}/issues", user), - title: 'new issue', labels: 'label, label2' + title: 'new issue', labels: 'label, label2', assignee_id: assignee.id expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy + expect(json_response['assignee']['name']).to eq(assignee.name) end it 'creates a new confidential project issue' do @@ -1140,6 +1141,22 @@ describe API::V3::Issues do end end + describe 'PUT /projects/:id/issues/:issue_id to update assignee' do + it 'updates an issue with no assignee' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: 0 + + expect(response).to have_http_status(200) + expect(json_response['assignee']).to eq(nil) + end + + it 'updates an issue with assignee' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: user2.id + + expect(response).to have_http_status(200) + expect(json_response['assignee']['name']).to eq(user2.name) + end + end + describe "DELETE /projects/:id/issues/:issue_id" do it "rejects a non member from deleting an issue" do delete v3_api("/projects/#{project.id}/issues/#{issue.id}", non_member) diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 7a1ac027310..5b1639ca0d6 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -4,11 +4,12 @@ describe Issuable::BulkUpdateService, services: true do let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } - def bulk_update(issues, extra_params = {}) + def bulk_update(issuables, extra_params = {}) bulk_update_params = extra_params - .reverse_merge(issuable_ids: Array(issues).map(&:id).join(',')) + .reverse_merge(issuable_ids: Array(issuables).map(&:id).join(',')) - Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute('issue') + type = Array(issuables).first.model_name.param_key + Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute(type) end describe 'close issues' do @@ -47,15 +48,15 @@ describe Issuable::BulkUpdateService, services: true do end end - describe 'updating assignee' do - let(:issue) { create(:issue, project: project, assignee: user) } + describe 'updating merge request assignee' do + let(:merge_request) { create(:merge_request, target_project: project, source_project: project, assignee: user) } context 'when the new assignee ID is a valid user' do it 'succeeds' do new_assignee = create(:user) project.team << [new_assignee, :developer] - result = bulk_update(issue, assignee_id: new_assignee.id) + result = bulk_update(merge_request, assignee_id: new_assignee.id) expect(result[:success]).to be_truthy expect(result[:count]).to eq(1) @@ -65,22 +66,59 @@ describe Issuable::BulkUpdateService, services: true do assignee = create(:user) project.team << [assignee, :developer] - expect { bulk_update(issue, assignee_id: assignee.id) } - .to change { issue.reload.assignee }.from(user).to(assignee) + expect { bulk_update(merge_request, assignee_id: assignee.id) } + .to change { merge_request.reload.assignee }.from(user).to(assignee) end end context "when the new assignee ID is #{IssuableFinder::NONE}" do it "unassigns the issues" do - expect { bulk_update(issue, assignee_id: IssuableFinder::NONE) } - .to change { issue.reload.assignee }.to(nil) + expect { bulk_update(merge_request, assignee_id: IssuableFinder::NONE) } + .to change { merge_request.reload.assignee }.to(nil) end end context 'when the new assignee ID is not present' do it 'does not unassign' do - expect { bulk_update(issue, assignee_id: nil) } - .not_to change { issue.reload.assignee } + expect { bulk_update(merge_request, assignee_id: nil) } + .not_to change { merge_request.reload.assignee } + end + end + end + + describe 'updating issue assignee' do + let(:issue) { create(:issue, project: project, assignees: [user]) } + + context 'when the new assignee ID is a valid user' do + it 'succeeds' do + new_assignee = create(:user) + project.team << [new_assignee, :developer] + + result = bulk_update(issue, assignee_ids: [new_assignee.id]) + + expect(result[:success]).to be_truthy + expect(result[:count]).to eq(1) + end + + it 'updates the assignee to the use ID passed' do + assignee = create(:user) + project.team << [assignee, :developer] + expect { bulk_update(issue, assignee_ids: [assignee.id]) } + .to change { issue.reload.assignees.first }.from(user).to(assignee) + end + end + + context "when the new assignee ID is #{IssuableFinder::NONE}" do + it "unassigns the issues" do + expect { bulk_update(issue, assignee_ids: [IssuableFinder::NONE.to_s]) } + .to change { issue.reload.assignees.count }.from(1).to(0) + end + end + + context 'when the new assignee ID is not present' do + it 'does not unassign' do + expect { bulk_update(issue, assignee_ids: []) } + .not_to change{ issue.reload.assignees } end end end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 7a54373963e..51840531711 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -4,7 +4,7 @@ describe Issues::CloseService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } let(:guest) { create(:user) } - let(:issue) { create(:issue, assignee: user2) } + let(:issue) { create(:issue, assignees: [user2]) } let(:project) { issue.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 80bfb731550..01edc46496d 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -6,10 +6,10 @@ describe Issues::CreateService, services: true do describe '#execute' do let(:issue) { described_class.new(project, user, opts).execute } + let(:assignee) { create(:user) } + let(:milestone) { create(:milestone, project: project) } context 'when params are valid' do - let(:assignee) { create(:user) } - let(:milestone) { create(:milestone, project: project) } let(:labels) { create_pair(:label, project: project) } before do @@ -20,7 +20,7 @@ describe Issues::CreateService, services: true do let(:opts) do { title: 'Awesome issue', description: 'please fix', - assignee_id: assignee.id, + assignee_ids: [assignee.id], label_ids: labels.map(&:id), milestone_id: milestone.id, due_date: Date.tomorrow } @@ -29,7 +29,7 @@ describe Issues::CreateService, services: true do it 'creates the issue with the given params' do expect(issue).to be_persisted expect(issue.title).to eq('Awesome issue') - expect(issue.assignee).to eq assignee + expect(issue.assignees).to eq [assignee] expect(issue.labels).to match_array labels expect(issue.milestone).to eq milestone expect(issue.due_date).to eq Date.tomorrow @@ -37,6 +37,7 @@ describe Issues::CreateService, services: true do context 'when current user cannot admin issues in the project' do let(:guest) { create(:user) } + before do project.team << [guest, :guest] end @@ -47,7 +48,7 @@ describe Issues::CreateService, services: true do expect(issue).to be_persisted expect(issue.title).to eq('Awesome issue') expect(issue.description).to eq('please fix') - expect(issue.assignee).to be_nil + expect(issue.assignees).to be_empty expect(issue.labels).to be_empty expect(issue.milestone).to be_nil expect(issue.due_date).to be_nil @@ -136,10 +137,83 @@ describe Issues::CreateService, services: true do end end - it_behaves_like 'issuable create service' + context 'issue create service' do + context 'assignees' do + before { project.team << [user, :master] } + + it 'removes assignee when user id is invalid' do + opts = { title: 'Title', description: 'Description', assignee_ids: [-1] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + + it 'removes assignee when user id is 0' do + opts = { title: 'Title', description: 'Description', assignee_ids: [0] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + + it 'saves assignee when user id is valid' do + project.team << [assignee, :master] + opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to eq([assignee]) + end + + context "when issuable feature is private" do + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, + merge_requests_access_level: ProjectFeature::PRIVATE) + end + + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + project.update(visibility_level: level) + opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + end + end + end + end it_behaves_like 'new issuable record that supports slash commands' + context 'Slash commands' do + context 'with assignee and milestone in params and command' do + let(:opts) do + { + assignee_ids: [create(:user).id], + milestone_id: 1, + title: 'Title', + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + } + end + + before do + project.team << [user, :master] + project.team << [assignee, :master] + end + + it 'assigns and sets milestone to issuable from command' do + expect(issue).to be_persisted + expect(issue.assignees).to eq([assignee]) + expect(issue.milestone).to eq(milestone) + end + end + end + context 'resolving discussions' do let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 5b324f3c706..6633ac10236 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -14,7 +14,7 @@ describe Issues::UpdateService, services: true do let(:issue) do create(:issue, title: 'Old title', description: "for #{user2.to_reference}", - assignee_id: user3.id, + assignee_ids: [user3.id], project: project) end @@ -40,7 +40,7 @@ describe Issues::UpdateService, services: true do { title: 'New title', description: 'Also please fix', - assignee_id: user2.id, + assignee_ids: [user2.id], state_event: 'close', label_ids: [label.id], due_date: Date.tomorrow @@ -53,15 +53,15 @@ describe Issues::UpdateService, services: true do expect(issue).to be_valid expect(issue.title).to eq 'New title' expect(issue.description).to eq 'Also please fix' - expect(issue.assignee).to eq user2 + expect(issue.assignees).to match_array([user2]) expect(issue).to be_closed expect(issue.labels).to match_array [label] expect(issue.due_date).to eq Date.tomorrow end it 'sorts issues as specified by parameters' do - issue1 = create(:issue, project: project, assignee_id: user3.id) - issue2 = create(:issue, project: project, assignee_id: user3.id) + issue1 = create(:issue, project: project, assignees: [user3]) + issue2 = create(:issue, project: project, assignees: [user3]) [issue, issue1, issue2].each do |issue| issue.move_to_end @@ -87,7 +87,7 @@ describe Issues::UpdateService, services: true do expect(issue).to be_valid expect(issue.title).to eq 'New title' expect(issue.description).to eq 'Also please fix' - expect(issue.assignee).to eq user3 + expect(issue.assignees).to match_array [user3] expect(issue.labels).to be_empty expect(issue.milestone).to be_nil expect(issue.due_date).to be_nil @@ -137,7 +137,7 @@ describe Issues::UpdateService, services: true do { title: 'New title', description: 'Also please fix', - assignee_id: user2.id, + assignee_ids: [user2], state_event: 'close', label_ids: [label.id], confidential: true @@ -163,12 +163,12 @@ describe Issues::UpdateService, services: true do it 'does not update assignee_id with unauthorized users' do project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC) update_issue(confidential: true) - non_member = create(:user) - original_assignee = issue.assignee + non_member = create(:user) + original_assignees = issue.assignees - update_issue(assignee_id: non_member.id) + update_issue(assignee_ids: [non_member.id]) - expect(issue.reload.assignee_id).to eq(original_assignee.id) + expect(issue.reload.assignees).to eq(original_assignees) end end @@ -205,7 +205,7 @@ describe Issues::UpdateService, services: true do context 'when is reassigned' do before do - update_issue(assignee: user2) + update_issue(assignees: [user2]) end it 'marks previous assignee todos as done' do @@ -408,6 +408,41 @@ describe Issues::UpdateService, services: true do end end + context 'updating asssignee_id' do + it 'does not update assignee when assignee_id is invalid' do + update_issue(assignee_ids: [-1]) + + expect(issue.reload.assignees).to eq([user3]) + end + + it 'unassigns assignee when user id is 0' do + update_issue(assignee_ids: [0]) + + expect(issue.reload.assignees).to be_empty + end + + it 'does not update assignee_id when user cannot read issue' do + update_issue(assignee_ids: [create(:user).id]) + + expect(issue.reload.assignees).to eq([user3]) + end + + context "when issuable feature is private" do + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + assignee = create(:user) + project.update(visibility_level: level) + feature_visibility_attr = :"#{issue.model_name.plural}_access_level" + project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) + + expect{ update_issue(assignee_ids: [assignee.id]) }.not_to change{ issue.assignees } + end + end + end + end + context 'updating mentions' do let(:mentionable) { issue } include_examples 'updating mentions', Issues::UpdateService diff --git a/spec/services/members/authorized_destroy_service_spec.rb b/spec/services/members/authorized_destroy_service_spec.rb index 3b35a3b8e3a..ab440d18e9f 100644 --- a/spec/services/members/authorized_destroy_service_spec.rb +++ b/spec/services/members/authorized_destroy_service_spec.rb @@ -14,8 +14,8 @@ describe Members::AuthorizedDestroyService, services: true do it "unassigns issues and merge requests" do group.add_developer(member_user) - issue = create :issue, project: group_project, assignee: member_user - create :issue, assignee: member_user + issue = create :issue, project: group_project, assignees: [member_user] + create :issue, assignees: [member_user] merge_request = create :merge_request, target_project: group_project, source_project: group_project, assignee: member_user create :merge_request, target_project: project, source_project: project, assignee: member_user @@ -33,7 +33,7 @@ describe Members::AuthorizedDestroyService, services: true do it "unassigns issues and merge requests" do project.team << [member_user, :developer] - create :issue, project: project, assignee: member_user + create :issue, project: project, assignees: [member_user] create :merge_request, target_project: project, source_project: project, assignee: member_user member = project.members.find_by(user_id: member_user.id) diff --git a/spec/services/merge_requests/assign_issues_service_spec.rb b/spec/services/merge_requests/assign_issues_service_spec.rb index fe75757dd29..d3556020d4d 100644 --- a/spec/services/merge_requests/assign_issues_service_spec.rb +++ b/spec/services/merge_requests/assign_issues_service_spec.rb @@ -15,14 +15,14 @@ describe MergeRequests::AssignIssuesService, services: true do expect(service.assignable_issues.map(&:id)).to include(issue.id) end - it 'ignores issues already assigned to any user' do - issue.update!(assignee: create(:user)) + it 'ignores issues the user cannot update assignee on' do + project.team.truncate expect(service.assignable_issues).to be_empty end - it 'ignores issues the user cannot update assignee on' do - project.team.truncate + it 'ignores issues already assigned to any user' do + issue.assignees = [create(:user)] expect(service.assignable_issues).to be_empty end @@ -44,7 +44,7 @@ describe MergeRequests::AssignIssuesService, services: true do end it 'assigns these to the merge request owner' do - expect { service.execute }.to change { issue.reload.assignee }.to(user) + expect { service.execute }.to change { issue.assignees.first }.to(user) end it 'ignores external issues' do diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 0e16c7cc94b..ace82380cc9 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -84,7 +84,87 @@ describe MergeRequests::CreateService, services: true do end end - it_behaves_like 'issuable create service' + context 'Slash commands' do + context 'with assignee and milestone in params and command' do + let(:merge_request) { described_class.new(project, user, opts).execute } + let(:milestone) { create(:milestone, project: project) } + + let(:opts) do + { + assignee_id: create(:user).id, + milestone_id: 1, + title: 'Title', + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}"), + source_branch: 'feature', + target_branch: 'master' + } + end + + before do + project.team << [user, :master] + project.team << [assignee, :master] + end + + it 'assigns and sets milestone to issuable from command' do + expect(merge_request).to be_persisted + expect(merge_request.assignee).to eq(assignee) + expect(merge_request.milestone).to eq(milestone) + end + end + end + + context 'merge request create service' do + context 'asssignee_id' do + let(:assignee) { create(:user) } + + before { project.team << [user, :master] } + + it 'removes assignee_id when user id is invalid' do + opts = { title: 'Title', description: 'Description', assignee_id: -1 } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + + it 'removes assignee_id when user id is 0' do + opts = { title: 'Title', description: 'Description', assignee_id: 0 } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + + it 'saves assignee when user id is valid' do + project.team << [assignee, :master] + opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee).to eq(assignee) + end + + context "when issuable feature is private" do + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, + merge_requests_access_level: ProjectFeature::PRIVATE) + end + + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + project.update(visibility_level: level) + opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + end + end + end + end context 'while saving references to issues that the created merge request closes' do let(:first_issue) { create(:issue, project: project) } diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index f2ca1e6fcbd..694ec3a579f 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -423,6 +423,54 @@ describe MergeRequests::UpdateService, services: true do end end + context 'updating asssignee_id' do + it 'does not update assignee when assignee_id is invalid' do + merge_request.update(assignee_id: user.id) + + update_merge_request(assignee_id: -1) + + expect(merge_request.reload.assignee).to eq(user) + end + + it 'unassigns assignee when user id is 0' do + merge_request.update(assignee_id: user.id) + + update_merge_request(assignee_id: 0) + + expect(merge_request.assignee_id).to be_nil + end + + it 'saves assignee when user id is valid' do + update_merge_request(assignee_id: user.id) + + expect(merge_request.assignee_id).to eq(user.id) + end + + it 'does not update assignee_id when user cannot read issue' do + non_member = create(:user) + original_assignee = merge_request.assignee + + update_merge_request(assignee_id: non_member.id) + + expect(merge_request.assignee_id).to eq(original_assignee.id) + end + + context "when issuable feature is private" do + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + assignee = create(:user) + project.update(visibility_level: level) + feature_visibility_attr = :"#{merge_request.model_name.plural}_access_level" + project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) + + expect{ update_merge_request(assignee_id: assignee) }.not_to change{ merge_request.assignee } + end + end + end + end + include_examples 'issuable update service' do let(:open_issuable) { merge_request } let(:closed_issuable) { create(:closed_merge_request, source_project: project) } diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb index 1a64c8bbf00..c9954dc3603 100644 --- a/spec/services/notes/slash_commands_service_spec.rb +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -66,7 +66,7 @@ describe Notes::SlashCommandsService, services: true do expect(content).to eq '' expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) - expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.assignees).to eq([assignee]) expect(note.noteable.milestone).to eq(milestone) end end @@ -113,7 +113,7 @@ describe Notes::SlashCommandsService, services: true do expect(content).to eq "HELLO\nWORLD" expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) - expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.assignees).to eq([assignee]) expect(note.noteable.milestone).to eq(milestone) end end @@ -220,4 +220,31 @@ describe Notes::SlashCommandsService, services: true do let(:note) { build(:note_on_commit, project: project) } end end + + context 'CE restriction for issue assignees' do + describe '/assign' do + let(:project) { create(:empty_project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + let(:master) { create(:user) } + let(:service) { described_class.new(project, master) } + let(:note) { create(:note_on_issue, note: note_text, project: project) } + + let(:note_text) do + %(/assign @#{assignee.username} @#{master.username}\n") + end + + before do + project.team << [master, :master] + project.team << [assignee, :master] + end + + it 'adds only one assignee from the list' do + _, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(note.noteable.assignees.count).to eq(1) + end + end + end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 989fd90cda9..74f96b97909 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -4,6 +4,7 @@ describe NotificationService, services: true do include EmailHelpers let(:notification) { NotificationService.new } + let(:assignee) { create(:user) } around(:each) do |example| perform_enqueued_jobs do @@ -52,7 +53,11 @@ describe NotificationService, services: true do shared_examples 'participating by assignee notification' do it 'emails the participant' do - issuable.update_attribute(:assignee, participant) + if issuable.is_a?(Issue) + issuable.assignees << participant + else + issuable.update_attribute(:assignee, participant) + end notification_trigger @@ -103,14 +108,14 @@ describe NotificationService, services: true do describe 'Notes' do context 'issue note' do let(:project) { create(:empty_project, :private) } - let(:issue) { create(:issue, project: project, assignee: create(:user)) } - let(:mentioned_issue) { create(:issue, assignee: issue.assignee) } + let(:issue) { create(:issue, project: project, assignees: [assignee]) } + let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @outsider also') } before do build_team(note.project) project.add_master(issue.author) - project.add_master(issue.assignee) + project.add_master(assignee) project.add_master(note.author) create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@subscribed_participant cc this guy') update_custom_notification(:new_note, @u_guest_custom, resource: project) @@ -130,7 +135,7 @@ describe NotificationService, services: true do should_email(@u_watcher) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_email(@u_custom_global) should_email(@u_mentioned) should_email(@subscriber) @@ -196,7 +201,7 @@ describe NotificationService, services: true do notification.new_note(note) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_email(@u_mentioned) should_email(@u_custom_global) should_not_email(@u_guest_custom) @@ -218,7 +223,7 @@ describe NotificationService, services: true do let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note) { create(:note_on_issue, noteable: confidential_issue, project: project, note: "#{author.to_reference} #{assignee.to_reference} #{non_member.to_reference} #{member.to_reference} #{admin.to_reference}") } let(:guest_watcher) { create_user_with_notification(:watch, "guest-watcher-confidential") } @@ -244,8 +249,8 @@ describe NotificationService, services: true do context 'issue note mention' do let(:project) { create(:empty_project, :public) } - let(:issue) { create(:issue, project: project, assignee: create(:user)) } - let(:mentioned_issue) { create(:issue, assignee: issue.assignee) } + let(:issue) { create(:issue, project: project, assignees: [assignee]) } + let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@all mentioned') } before do @@ -269,7 +274,7 @@ describe NotificationService, services: true do should_email(@u_guest_watcher) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_not_email(note.author) should_email(@u_mentioned) should_not_email(@u_disabled) @@ -449,7 +454,7 @@ describe NotificationService, services: true do let(:group) { create(:group) } let(:project) { create(:empty_project, :public, namespace: group) } let(:another_project) { create(:empty_project, :public, namespace: group) } - let(:issue) { create :issue, project: project, assignee: create(:user), description: 'cc @participant' } + let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant' } before do build_team(issue.project) @@ -465,7 +470,7 @@ describe NotificationService, services: true do it do notification.new_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(assignee) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -480,10 +485,10 @@ describe NotificationService, services: true do end it do - create_global_setting_for(issue.assignee, :mention) + create_global_setting_for(issue.assignees.first, :mention) notification.new_issue(issue, @u_disabled) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) end it "emails the author if they've opted into notifications about their activity" do @@ -528,7 +533,7 @@ describe NotificationService, services: true do let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) } it "emails subscribers of the issue's labels that can read the issue" do project.add_developer(member) @@ -572,9 +577,9 @@ describe NotificationService, services: true do end it 'emails new assignee' do - notification.reassigned_issue(issue, @u_disabled) + notification.reassigned_issue(issue, @u_disabled, [assignee]) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -588,9 +593,8 @@ describe NotificationService, services: true do end it 'emails previous assignee even if he has the "on mention" notif level' do - issue.update_attribute(:assignee, @u_mentioned) - issue.update_attributes(assignee: @u_watcher) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_watcher]) should_email(@u_mentioned) should_email(@u_watcher) @@ -606,11 +610,11 @@ describe NotificationService, services: true do end it 'emails new assignee even if he has the "on mention" notif level' do - issue.update_attributes(assignee: @u_mentioned) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned - should_email(issue.assignee) + expect(issue.assignees.first).to be @u_mentioned + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -624,11 +628,11 @@ describe NotificationService, services: true do end it 'emails new assignee' do - issue.update_attribute(:assignee, @u_mentioned) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned - should_email(issue.assignee) + expect(issue.assignees.first).to be @u_mentioned + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -642,17 +646,17 @@ describe NotificationService, services: true do end it 'does not email new assignee if they are the current user' do - issue.update_attribute(:assignee, @u_mentioned) - notification.reassigned_issue(issue, @u_mentioned) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_mentioned, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned + expect(issue.assignees.first).to be @u_mentioned should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) should_email(@u_participant_mentioned) should_email(@subscriber) should_email(@u_custom_global) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) @@ -662,7 +666,7 @@ describe NotificationService, services: true do it_behaves_like 'participating notifications' do let(:participant) { create(:user, username: 'user-participant') } let(:issuable) { issue } - let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled) } + let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) } end end @@ -705,7 +709,7 @@ describe NotificationService, services: true do it "doesn't send email to anyone but subscribers of the given labels" do notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) should_not_email(issue.author) should_not_email(@u_watcher) should_not_email(@u_guest_watcher) @@ -729,7 +733,7 @@ describe NotificationService, services: true do let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) } let!(:label_1) { create(:label, project: project, issues: [confidential_issue]) } let!(:label_2) { create(:label, project: project) } @@ -767,7 +771,7 @@ describe NotificationService, services: true do it 'sends email to issue assignee and issue author' do notification.close_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) @@ -798,7 +802,7 @@ describe NotificationService, services: true do it 'sends email to issue notification recipients' do notification.reopen_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) @@ -826,7 +830,7 @@ describe NotificationService, services: true do it 'sends email to issue notification recipients' do notification.issue_moved(issue, new_issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb index 7916c2d957c..c198c3eedfc 100644 --- a/spec/services/projects/autocomplete_service_spec.rb +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -11,7 +11,7 @@ describe Projects::AutocompleteService, services: true do let(:project) { create(:empty_project, :public) } let!(:issue) { create(:issue, project: project, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } it 'does not list project confidential issues for guests' do autocomplete = described_class.new(project, nil) diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 46cfac4a128..e5e400ee281 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe SlashCommands::InterpretService, services: true do let(:project) { create(:empty_project, :public) } let(:developer) { create(:user) } + let(:developer2) { create(:user) } let(:issue) { create(:issue, project: project) } let(:milestone) { create(:milestone, project: project, title: '9.10') } let(:inprogress) { create(:label, project: project, title: 'In Progress') } @@ -42,23 +43,6 @@ describe SlashCommands::InterpretService, services: true do end end - shared_examples 'assign command' do - it 'fetches assignee and populates assignee_id if content contains /assign' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: developer.id) - end - end - - shared_examples 'unassign command' do - it 'populates assignee_id: nil if content contains /unassign' do - issuable.update!(assignee_id: developer.id) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: nil) - end - end - shared_examples 'milestone command' do it 'fetches milestone and populates milestone_id if content contains /milestone' do milestone # populate the milestone @@ -371,14 +355,46 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { issue } end - it_behaves_like 'assign command' do + context 'assign command' do let(:content) { "/assign @#{developer.username}" } - let(:issuable) { issue } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: [developer.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end end - it_behaves_like 'assign command' do - let(:content) { "/assign @#{developer.username}" } - let(:issuable) { merge_request } + context 'assign command with multiple assignees' do + let(:content) { "/assign @#{developer.username} @#{developer2.username}" } + + before{ project.team << [developer2, :developer] } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates[:assignee_ids]).to match_array([developer.id, developer2.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end end it_behaves_like 'empty command' do @@ -391,14 +407,26 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { issue } end - it_behaves_like 'unassign command' do + context 'unassign command' do let(:content) { '/unassign' } - let(:issuable) { issue } - end - it_behaves_like 'unassign command' do - let(:content) { '/unassign' } - let(:issuable) { merge_request } + context 'Issue' do + it 'populates assignee_ids: [] if content contains /unassign' do + issue.update(assignee_ids: [developer.id]) + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: []) + end + end + + context 'Merge Request' do + it 'populates assignee_id: nil if content contains /unassign' do + merge_request.update(assignee_id: developer.id) + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: nil) + end + end end it_behaves_like 'milestone command' do @@ -846,7 +874,7 @@ describe SlashCommands::InterpretService, services: true do describe 'unassign command' do let(:content) { '/unassign' } - let(:issue) { create(:issue, project: project, assignee: developer) } + let(:issue) { create(:issue, project: project, assignees: [developer]) } it 'includes current assignee reference' do _, explanations = service.explain(content, issue) diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 75d7caf2508..68816bf36b8 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -6,6 +6,7 @@ describe SystemNoteService, services: true do let(:project) { create(:empty_project) } let(:author) { create(:user) } let(:noteable) { create(:issue, project: project) } + let(:issue) { noteable } shared_examples_for 'a system note' do let(:expected_noteable) { noteable } @@ -155,6 +156,52 @@ describe SystemNoteService, services: true do end end + describe '.change_issue_assignees' do + subject { described_class.change_issue_assignees(noteable, project, author, [assignee]) } + + let(:assignee) { create(:user) } + let(:assignee1) { create(:user) } + let(:assignee2) { create(:user) } + let(:assignee3) { create(:user) } + + it_behaves_like 'a system note' do + let(:action) { 'assignee' } + end + + def build_note(old_assignees, new_assignees) + issue.assignees = new_assignees + described_class.change_issue_assignees(issue, project, author, old_assignees).note + end + + it 'builds a correct phrase when an assignee is added to a non-assigned issue' do + expect(build_note([], [assignee1])).to eq "assigned to @#{assignee1.username}" + end + + it 'builds a correct phrase when assignee removed' do + expect(build_note([assignee1], [])).to eq 'removed all assignees' + end + + it 'builds a correct phrase when assignees changed' do + expect(build_note([assignee1], [assignee2])).to eq \ + "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}" + end + + it 'builds a correct phrase when three assignees removed and one added' do + expect(build_note([assignee, assignee1, assignee2], [assignee3])).to eq \ + "assigned to @#{assignee3.username} and unassigned @#{assignee.username}, @#{assignee1.username}, and @#{assignee2.username}" + end + + it 'builds a correct phrase when one assignee changed from a set' do + expect(build_note([assignee, assignee1], [assignee, assignee2])).to eq \ + "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}" + end + + it 'builds a correct phrase when one assignee removed from a set' do + expect(build_note([assignee, assignee1, assignee2], [assignee, assignee1])).to eq \ + "unassigned @#{assignee2.username}" + end + end + describe '.change_label' do subject { described_class.change_label(noteable, project, author, added, removed) } diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 89b3b6aad10..175a42a32d9 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -25,11 +25,11 @@ describe TodoService, services: true do end describe 'Issues' do - let(:issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") } - let(:addressed_issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") } - let(:unassigned_issue) { create(:issue, project: project, assignee: nil) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: mentions) } - let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: directly_addressed) } + let(:issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") } + let(:addressed_issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") } + let(:unassigned_issue) { create(:issue, project: project, assignees: []) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: mentions) } + let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: directly_addressed) } describe '#new_issue' do it 'creates a todo if assigned' do @@ -43,7 +43,7 @@ describe TodoService, services: true do end it 'creates a todo if assignee is the current user' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees = [john_doe] service.new_issue(unassigned_issue, john_doe) should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED) @@ -258,20 +258,20 @@ describe TodoService, services: true do describe '#reassigned_issue' do it 'creates a pending todo for new assignee' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees << john_doe service.reassigned_issue(unassigned_issue, author) should_create_todo(user: john_doe, target: unassigned_issue, action: Todo::ASSIGNED) end it 'does not create a todo if unassigned' do - issue.update_attribute(:assignee, nil) + issue.assignees.destroy_all should_not_create_any_todo { service.reassigned_issue(issue, author) } end it 'creates a todo if new assignee is the current user' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees << john_doe service.reassigned_issue(unassigned_issue, john_doe) should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED) @@ -361,7 +361,7 @@ describe TodoService, services: true do describe '#new_note' do let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note) { create(:note, project: project, noteable: issue, author: john_doe, note: mentions) } let(:addressed_note) { create(:note, project: project, noteable: issue, author: john_doe, note: directly_addressed) } let(:note_on_commit) { create(:note_on_commit, project: project, author: john_doe, note: mentions) } @@ -854,7 +854,7 @@ describe TodoService, services: true do end it 'updates cached counts when a todo is created' do - issue = create(:issue, project: project, assignee: john_doe, author: author, description: mentions) + issue = create(:issue, project: project, assignees: [john_doe], author: author, description: mentions) expect(john_doe.todos_pending_count).to eq(0) expect(john_doe).to receive(:update_todos_count_cache).and_call_original @@ -866,8 +866,8 @@ describe TodoService, services: true do end describe '#mark_todos_as_done' do - let(:issue) { create(:issue, project: project, author: author, assignee: john_doe) } - let(:another_issue) { create(:issue, project: project, author: author, assignee: john_doe) } + let(:issue) { create(:issue, project: project, author: author, assignees: [john_doe]) } + let(:another_issue) { create(:issue, project: project, author: author, assignees: [john_doe]) } it 'marks a relation of todos as done' do create(:todo, :mentioned, user: john_doe, target: issue, project: project) diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb index 4bc30018ebd..de37a61e388 100644 --- a/spec/services/users/destroy_service_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -47,7 +47,7 @@ describe Users::DestroyService, services: true do end context "for an issue the user was assigned to" do - let!(:issue) { create(:issue, project: project, assignee: user) } + let!(:issue) { create(:issue, project: project, assignees: [user]) } before do service.execute(user) @@ -60,7 +60,7 @@ describe Users::DestroyService, services: true do it 'migrates the issue so that it is "Unassigned"' do migrated_issue = Issue.find_by_id(issue.id) - expect(migrated_issue.assignee).to be_nil + expect(migrated_issue.assignees).to be_empty end end end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 595bb92f5c0..ad46b163cd6 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -63,7 +63,7 @@ shared_examples 'issuable record that supports slash commands in its description note = issuable.notes.user.first expect(note.note).to eq "Awesome!" - expect(issuable.assignee).to eq assignee + expect(issuable.assignees).to eq [assignee] expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone end @@ -81,7 +81,7 @@ shared_examples 'issuable record that supports slash commands in its description issuable.reload expect(issuable.notes.user).to be_empty - expect(issuable.assignee).to eq assignee + expect(issuable.assignees).to eq [assignee] expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone end diff --git a/spec/support/import_export/export_file_helper.rb b/spec/support/import_export/export_file_helper.rb index 944ea30656f..57b6abe12b7 100644 --- a/spec/support/import_export/export_file_helper.rb +++ b/spec/support/import_export/export_file_helper.rb @@ -10,7 +10,7 @@ module ExportFileHelper create(:release, project: project) - issue = create(:issue, assignee: user, project: project) + issue = create(:issue, assignees: [user], project: project) snippet = create(:project_snippet, project: project) label = create(:label, project: project) milestone = create(:milestone, project: project) diff --git a/spec/support/services/issuable_create_service_shared_examples.rb b/spec/support/services/issuable_create_service_shared_examples.rb deleted file mode 100644 index 4f0c745b7ee..00000000000 --- a/spec/support/services/issuable_create_service_shared_examples.rb +++ /dev/null @@ -1,52 +0,0 @@ -shared_examples 'issuable create service' do - context 'asssignee_id' do - let(:assignee) { create(:user) } - - before { project.team << [user, :master] } - - it 'removes assignee_id when user id is invalid' do - opts = { title: 'Title', description: 'Description', assignee_id: -1 } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - - it 'removes assignee_id when user id is 0' do - opts = { title: 'Title', description: 'Description', assignee_id: 0 } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - - it 'saves assignee when user id is valid' do - project.team << [assignee, :master] - opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to eq(assignee.id) - end - - context "when issuable feature is private" do - before do - project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, - merge_requests_access_level: ProjectFeature::PRIVATE) - end - - levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] - - levels.each do |level| - it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do - project.update(visibility_level: level) - opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - end - end - end -end diff --git a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb index 9e9cdf3e48b..1dd3663b944 100644 --- a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb +++ b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb @@ -49,23 +49,7 @@ shared_examples 'new issuable record that supports slash commands' do it 'assigns and sets milestone to issuable' do expect(issuable).to be_persisted - expect(issuable.assignee).to eq(assignee) - expect(issuable.milestone).to eq(milestone) - end - end - - context 'with assignee and milestone in params and command' do - let(:example_params) do - { - assignee: create(:user), - milestone_id: 1, - description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") - } - end - - it 'assigns and sets milestone to issuable from command' do - expect(issuable).to be_persisted - expect(issuable.assignee).to eq(assignee) + expect(issuable.assignees).to eq([assignee]) expect(issuable.milestone).to eq(milestone) end end diff --git a/spec/support/services/issuable_update_service_shared_examples.rb b/spec/support/services/issuable_update_service_shared_examples.rb index 49cea1e608c..8947f20562f 100644 --- a/spec/support/services/issuable_update_service_shared_examples.rb +++ b/spec/support/services/issuable_update_service_shared_examples.rb @@ -18,52 +18,4 @@ shared_examples 'issuable update service' do end end end - - context 'asssignee_id' do - it 'does not update assignee when assignee_id is invalid' do - open_issuable.update(assignee_id: user.id) - - update_issuable(assignee_id: -1) - - expect(open_issuable.reload.assignee).to eq(user) - end - - it 'unassigns assignee when user id is 0' do - open_issuable.update(assignee_id: user.id) - - update_issuable(assignee_id: 0) - - expect(open_issuable.assignee_id).to be_nil - end - - it 'saves assignee when user id is valid' do - update_issuable(assignee_id: user.id) - - expect(open_issuable.assignee_id).to eq(user.id) - end - - it 'does not update assignee_id when user cannot read issue' do - non_member = create(:user) - original_assignee = open_issuable.assignee - - update_issuable(assignee_id: non_member.id) - - expect(open_issuable.assignee_id).to eq(original_assignee.id) - end - - context "when issuable feature is private" do - levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] - - levels.each do |level| - it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do - assignee = create(:user) - project.update(visibility_level: level) - feature_visibility_attr = :"#{open_issuable.model_name.plural}_access_level" - project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) - - expect{ update_issuable(assignee_id: assignee) }.not_to change{ open_issuable.assignee } - end - end - end - end end diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index e94e17da7e5..84ef46ffa27 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -37,8 +37,7 @@ shared_examples 'issuable time tracker' do submit_time('/estimate 3w 1d 1h') submit_time('/remove_estimate') - wait_for_ajax - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do expect(page).to have_content 'No estimate or time spent' end end @@ -47,14 +46,13 @@ shared_examples 'issuable time tracker' do submit_time('/spend 3w 1d 1h') submit_time('/remove_time_spent') - wait_for_ajax - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do expect(page).to have_content 'No estimate or time spent' end end it 'shows the help state when icon is clicked' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click expect(page).to have_content 'Track time with slash commands' expect(page).to have_content 'Learn more' @@ -62,7 +60,7 @@ shared_examples 'issuable time tracker' do end it 'hides the help state when close icon is clicked' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click find('.close-help-button').click @@ -72,7 +70,7 @@ shared_examples 'issuable time tracker' do end it 'displays the correct help url' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click expect(find_link('Learn more')[:href]).to have_content('/help/workflow/time_tracking.md') |