diff options
Diffstat (limited to 'app')
147 files changed, 2733 insertions, 844 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/dispatcher.js b/app/assets/javascripts/dispatcher.js index b87c57c38fe..b16ff2a0221 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -252,6 +252,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); } break; case 'projects:pipelines:builds': + case 'projects:pipelines:failures': case 'projects:pipelines:show': const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`; diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 59ce0587e1e..f3003b86493 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,3 +1,5 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; +import '~/flash'; /* global Flash */ import FilteredSearchContainer from './container'; class FilteredSearchVisualTokens { @@ -48,6 +50,40 @@ class FilteredSearchVisualTokens { `; } + static updateLabelTokenColor(tokenValueContainer, tokenValue) { + const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); + const baseEndpoint = filteredSearchInput.dataset.baseEndpoint; + const labelsEndpoint = `${baseEndpoint}/labels.json`; + + return AjaxCache.retrieve(labelsEndpoint) + .then((labels) => { + const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue); + + if (!matchingLabel) { + return; + } + + const tokenValueStyle = tokenValueContainer.style; + tokenValueStyle.backgroundColor = matchingLabel.color; + tokenValueStyle.color = matchingLabel.text_color; + + if (matchingLabel.text_color === '#FFFFFF') { + const removeToken = tokenValueContainer.querySelector('.remove-token'); + removeToken.classList.add('inverted'); + } + }) + .catch(() => new Flash('An error occurred while fetching label colors.')); + } + + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { + const tokenValueContainer = parentElement.querySelector('.value-container'); + tokenValueContainer.querySelector('.value').innerText = tokenValue; + + if (tokenName.toLowerCase() === 'label') { + FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); + } + } + static addVisualTokenElement(name, value, isSearchTerm) { const li = document.createElement('li'); li.classList.add('js-visual-token'); @@ -55,7 +91,7 @@ class FilteredSearchVisualTokens { if (value) { li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); - li.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); } else { li.innerHTML = '<div class="name"></div>'; } @@ -74,7 +110,7 @@ class FilteredSearchVisualTokens { const name = FilteredSearchVisualTokens.getLastTokenPartial(); lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); lastVisualToken.querySelector('.name').innerText = name; - lastVisualToken.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value); } } 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/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js new file mode 100644 index 00000000000..d99eefb5089 --- /dev/null +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -0,0 +1,32 @@ +const AjaxCache = { + internalStorage: { }, + get(endpoint) { + return this.internalStorage[endpoint]; + }, + hasData(endpoint) { + return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint); + }, + purge(endpoint) { + delete this.internalStorage[endpoint]; + }, + retrieve(endpoint) { + if (AjaxCache.hasData(endpoint)) { + return Promise.resolve(AjaxCache.get(endpoint)); + } + + return new Promise((resolve, reject) => { + $.ajax(endpoint) // eslint-disable-line promise/catch-or-return + .then(data => resolve(data), + (jqXHR, textStatus, errorThrown) => { + const error = new Error(`${endpoint}: ${errorThrown}`); + error.textStatus = textStatus; + reject(error); + }, + ); + }) + .then((data) => { this.internalStorage[endpoint] = data; }) + .then(() => AjaxCache.get(endpoint)); + }, +}; + +export default AjaxCache; 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/notes.js b/app/assets/javascripts/notes.js index 72709f68070..55391ebc089 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1356,7 +1356,7 @@ const normalizeNewlines = function(str) { // Show updated comment content temporarily $noteBodyText.html(formContent); - $editingNote.removeClass('is-editing').addClass('being-posted fade-in-half'); + $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>'); /* eslint-disable promise/catch-or-return */ 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..244b67b3ad9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -0,0 +1,51 @@ +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', this.slashCommandListened); + }, + slashCommandListened(e, data) { + const subscribedCommands = ['spend_time', 'time_estimate']; + let changedCommands; + if (data !== undefined) { + changedCommands = data.commands_changes + ? Object.keys(data.commands_changes) + : []; + } else { + changedCommands = []; + } + 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..2b02af87d8a --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -0,0 +1,24 @@ +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'; + +function 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'); +} + +document.addEventListener('DOMContentLoaded', domContentLoaded); + +export default domContentLoaded; diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js new file mode 100644 index 00000000000..5ccfb4ee9c1 --- /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.setAssigneeData(data); + this.store.setTimeTrackingData(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..2d44c05bb8d --- /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; + } + + setAssigneeData(data) { + if (data.assignees) { + this.assignees = data.assignees; + } + } + + setTimeTrackingData(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/filters.scss b/app/assets/stylesheets/framework/filters.scss index 0692f65043b..e624d0d951e 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -114,11 +114,21 @@ padding-right: 8px; .fa-close { - color: $gl-text-color-disabled; + color: $gl-text-color-secondary; } &:hover .fa-close { - color: $gl-text-color-secondary; + color: $gl-text-color; + } + + &.inverted { + .fa-close { + color: $gl-text-color-secondary-inverted; + } + + &:hover .fa-close { + color: $gl-text-color-inverted; + } } } 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/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 49741c963df..08bcb582613 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -101,6 +101,8 @@ $gl-font-size: 14px; $gl-text-color: rgba(0, 0, 0, .85); $gl-text-color-secondary: rgba(0, 0, 0, .55); $gl-text-color-disabled: rgba(0, 0, 0, .35); +$gl-text-color-inverted: rgba(255, 255, 255, 1.0); +$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85); $gl-text-green: $green-600; $gl-text-red: $red-500; $gl-text-orange: $orange-600; 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/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 9115d26c779..530a6f3c6a1 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -273,6 +273,7 @@ .stage-container { display: inline-block; position: relative; + vertical-align: middle; height: 22px; margin: 3px 6px 3px 0; @@ -316,6 +317,32 @@ } } +.build-failures { + .build-state { + padding: 20px 2px; + + .build-name { + float: right; + font-weight: 500; + } + + .ci-status-icon-failed svg { + vertical-align: middle; + } + + .stage { + color: $gl-text-color-secondary; + font-weight: 500; + vertical-align: middle; + } + } + + .build-log { + border: none; + line-height: initial; + } +} + // Pipeline graph .pipeline-graph { width: 100%; diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index 37a1a23178e..4c3d336b3af 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -16,6 +16,8 @@ class Admin::ServicesController < Admin::ApplicationController def update if service.update_attributes(service_params[:service]) + PropagateServiceTemplateWorker.perform_async(service.id) if service.active? + redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' else 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/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb index d5031da867a..dd1d46a68c7 100644 --- a/app/controllers/dashboard/labels_controller.rb +++ b/app/controllers/dashboard/labels_controller.rb @@ -3,7 +3,7 @@ class Dashboard::LabelsController < Dashboard::ApplicationController labels = LabelsFinder.new(current_user).execute respond_to do |format| - format.json { render json: labels.as_json(only: [:id, :title, :color]) } + format.json { render json: LabelSerializer.new.represent_appearance(labels) } end end end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index facb25525b5..3fa0516fb0c 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -15,7 +15,7 @@ class Groups::LabelsController < Groups::ApplicationController format.json do available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute - render json: available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(available_labels) end end end 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/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 10adddb4636..9e4edcae101 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -59,7 +59,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController def render_ok set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.git_http_ok(repository, user, action_name) + render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name) end def render_http_not_allowed 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/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 2f55ba4e700..71bfb7163da 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -19,7 +19,7 @@ class Projects::LabelsController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: @available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(@available_labels) end end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 2908036607a..f9adedcb074 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -1,6 +1,6 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :pipeline, except: [:index, :new, :create, :charts] - before_action :commit, only: [:show, :builds] + before_action :commit, only: [:show, :builds, :failures] before_action :authorize_read_pipeline! before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] @@ -69,10 +69,14 @@ class Projects::PipelinesController < Projects::ApplicationController end def builds - respond_to do |format| - format.html do - render 'show' - end + render_show + end + + def failures + if @pipeline.statuses.latest.failed.present? + render_show + else + redirect_to pipeline_path(@pipeline) end end @@ -125,6 +129,14 @@ class Projects::PipelinesController < Projects::ApplicationController private + def render_show + respond_to do |format| + format.html do + render 'show' + end + end + end + def create_params params.require(:pipeline).permit(:ref) 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/application_helper.rb b/app/helpers/application_helper.rb index fff57472a4f..6d6bcbaf88a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -180,16 +180,16 @@ module ApplicationHelper element end - def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false) - return if object.updated_at == object.created_at + def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) + return if object.last_edited_at == object.created_at || object.last_edited_at.blank? - content_tag :small, class: "edited-text" do - output = content_tag(:span, "Edited ") - output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class) + content_tag :small, class: 'edited-text' do + output = content_tag(:span, 'Edited ') + output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class) - if include_author && object.updated_by && object.updated_by != object.author - output << content_tag(:span, " by ") - output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) + if !exclude_author && object.last_edited_by + output << content_tag(:span, ' by ') + output << link_to_member(object.project, object.last_edited_by, avatar: false, author_class: nil) end output diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 2fcb7a59fc3..2eb2c6c7389 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -1,4 +1,16 @@ module BuildsHelper + def build_summary(build, skip: false) + if build.has_trace? + if skip + link_to "View job trace", pipeline_build_url(build.pipeline, build) + else + build.trace.html(last_lines: 10).html_safe + end + else + "No job trace" + end + end + def sidebar_build_class(build, current_build) build_class = '' build_class += ' active' if build.id === current_build.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/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 1ea60e39386..d889d141101 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -1,6 +1,7 @@ module SystemNoteHelper ICON_NAMES_BY_ACTION = { 'commit' => 'icon_commit', + 'description' => 'icon_edit', 'merge' => 'icon_merge', 'merged' => 'icon_merged', 'opened' => 'icon_status_open', 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..075ec575f9d 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -26,8 +26,8 @@ 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 :last_edited_by, class_name: 'User' belongs_to :milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do def authors_loaded? @@ -65,11 +65,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 +89,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 +225,6 @@ module Issuable today? && created_at == updated_at end - def is_being_reassigned? - assignee_id_changed? - end - def open? opened? || reopened? end @@ -269,7 +253,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 +319,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/note.rb b/app/models/note.rb index b06985b4a6f..46d0a4f159f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -18,6 +18,11 @@ class Note < ActiveRecord::Base cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by + # Attribute containing rendered and redacted Markdown as generated by # Banzai::ObjectRenderer. attr_accessor :redacted_note_html @@ -38,6 +43,7 @@ class Note < ActiveRecord::Base belongs_to :noteable, polymorphic: true, touch: true belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" + belongs_to :last_edited_by, class_name: 'User' has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy diff --git a/app/models/snippet.rb b/app/models/snippet.rb index d8860718cb5..abfbefdf9a0 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -12,6 +12,11 @@ class Snippet < ActiveRecord::Base cache_markdown_field :title, pipeline: :single_line cache_markdown_field :content + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by + # If file_name changes, it invalidates content alias_method :default_content_html_invalidator, :content_html_invalidated? def content_html_invalidated? diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 1e6fc837a75..b44f4fe000c 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -1,6 +1,6 @@ class SystemNoteMetadata < ActiveRecord::Base ICON_TYPES = %w[ - commit merge confidential visible label assignee cross_reference + commit description merge confidential visible label assignee cross_reference title time_tracking branch milestone discussion task moved opened closed merged ].freeze diff --git a/app/models/user.rb b/app/models/user.rb index dd2c8f1b6ef..accaa91b805 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/label_entity.rb b/app/serializers/label_entity.rb index 304fd9de08f..ad565654342 100644 --- a/app/serializers/label_entity.rb +++ b/app/serializers/label_entity.rb @@ -6,6 +6,7 @@ class LabelEntity < Grape::Entity expose :group_id expose :project_id expose :template + expose :text_color expose :created_at expose :updated_at end diff --git a/app/serializers/label_serializer.rb b/app/serializers/label_serializer.rb new file mode 100644 index 00000000000..ad6ba8c46c9 --- /dev/null +++ b/app/serializers/label_serializer.rb @@ -0,0 +1,7 @@ +class LabelSerializer < BaseSerializer + entity LabelEntity + + def represent_appearance(resource) + represent(resource, { only: [:id, :title, :color, :text_color] }) + end +end 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..c1e532b504a 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) @@ -24,6 +19,10 @@ class IssuableBaseService < BaseService issuable, issuable.project, current_user, old_title) end + def create_description_change_note(issuable) + SystemNoteService.change_description(issuable, issuable.project, current_user) + end + def create_branch_change_note(issuable, branch_type, old_branch, new_branch) SystemNoteService.change_branch( issuable, issuable.project, current_user, branch_type, @@ -53,6 +52,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 +77,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 +207,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) @@ -214,6 +215,10 @@ class IssuableBaseService < BaseService if issuable.changed? || params.present? issuable.assign_attributes(params.merge(updated_by: current_user)) + if has_title_or_description_changed?(issuable) + issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user) + end + before_update(issuable) if issuable.with_transaction_returning_status { issuable.save } @@ -222,7 +227,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') @@ -236,6 +247,10 @@ class IssuableBaseService < BaseService old_label_ids.sort != new_label_ids.sort end + def has_title_or_description_changed?(issuable) + issuable.title_changed? || issuable.description_changed? + end + def change_state(issuable) case params.delete(:state_event) when 'reopen' @@ -272,7 +287,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 +296,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: []) @@ -289,6 +306,10 @@ class IssuableBaseService < BaseService create_title_change_note(issuable, issuable.previous_changes['title'].first) end + if issuable.previous_changes.include?('description') + create_description_change_note(issuable) + end + if issuable.previous_changes.include?('description') && issuable.tasks? create_task_status_note(issuable) end 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/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb new file mode 100644 index 00000000000..a8ef2108492 --- /dev/null +++ b/app/services/projects/propagate_service_template.rb @@ -0,0 +1,103 @@ +module Projects + class PropagateServiceTemplate + BATCH_SIZE = 100 + + def self.propagate(*args) + new(*args).propagate + end + + def initialize(template) + @template = template + end + + def propagate + return unless @template.active? + + Rails.logger.info("Propagating services for template #{@template.id}") + + propagate_projects_with_template + end + + private + + def propagate_projects_with_template + loop do + batch = project_ids_batch + + bulk_create_from_template(batch) unless batch.empty? + + break if batch.size < BATCH_SIZE + end + end + + def bulk_create_from_template(batch) + service_list = batch.map do |project_id| + service_hash.values << project_id + end + + Project.transaction do + bulk_insert_services(service_hash.keys << 'project_id', service_list) + run_callbacks(batch) + end + end + + def project_ids_batch + Project.connection.select_values( + <<-SQL + SELECT id + FROM projects + WHERE NOT EXISTS ( + SELECT true + FROM services + WHERE services.project_id = projects.id + AND services.type = '#{@template.type}' + ) + AND projects.pending_delete = false + AND projects.archived = false + LIMIT #{BATCH_SIZE} + SQL + ) + end + + def bulk_insert_services(columns, values_array) + ActiveRecord::Base.connection.execute( + <<-SQL.strip_heredoc + INSERT INTO services (#{columns.join(', ')}) + VALUES #{values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + SQL + ) + end + + def service_hash + @service_hash ||= + begin + template_hash = @template.as_json(methods: :type).except('id', 'template', 'project_id') + + template_hash.each_with_object({}) do |(key, value), service_hash| + value = value.is_a?(Hash) ? value.to_json : value + + service_hash[ActiveRecord::Base.connection.quote_column_name(key)] = + ActiveRecord::Base.sanitize(value) + end + end + end + + def run_callbacks(batch) + if active_external_issue_tracker? + Project.where(id: batch).update_all(has_external_issue_tracker: true) + end + + if active_external_wiki? + Project.where(id: batch).update_all(has_external_wiki: true) + end + end + + def active_external_issue_tracker? + @template.issue_tracker? && !@template.default + end + + def active_external_wiki? + @template.type == 'ExternalWikiService' + 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..174e7c6e95b 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 @@ -261,6 +299,23 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end + # Called when the description of a Noteable is changed + # + # noteable - Noteable object that responds to `description` + # project - Project owning noteable + # author - User performing the change + # + # Example Note text: + # + # "changed the description" + # + # Returns the created Note object + def change_description(noteable, project, author) + body = 'changed the description' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) + end + # Called when the confidentiality changes # # issue - Issue 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/_board.html.haml b/app/views/projects/boards/components/_board.html.haml index 5a4eaf92b16..bc5c727bf0d 100644 --- a/app/views/projects/boards/components/_board.html.haml +++ b/app/views/projects/boards/components/_board.html.haml @@ -13,8 +13,8 @@ %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button", "@click" => "showNewIssueForm", "v-if" => 'list.type !== "closed"', - "aria-label" => "Add an issue", - "title" => "Add an issue", + "aria-label" => "New issue", + "title" => "New issue", data: { placement: "top", container: "body" } } = icon("plus") - if can?(current_user, :admin_list, @project) 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/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index d7cefb8613e..1aa48bf9813 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,3 +1,5 @@ +- failed_builds = @pipeline.statuses.latest.failed + .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom %li.js-pipeline-tab-link @@ -7,8 +9,11 @@ = link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do Jobs %span.badge.js-builds-counter= pipeline.statuses.count - - + - if failed_builds.present? + %li.js-failures-tab-link + = link_to failures_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do + Failed Jobs + %span.badge.js-failures-counter= failed_builds.count .tab-content #js-tab-pipeline.tab-pane @@ -39,3 +44,13 @@ %th Coverage %th = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage + - if failed_builds.present? + #js-tab-failures.build-failures.tab-pane + - failed_builds.each_with_index do |build, index| + .build-state + %span.ci-status-icon-failed= custom_icon('icon_status_failed') + %span.stage + = build.stage.titleize + %span.build-name + = link_to build.name, pipeline_build_url(pipeline, build) + %pre.build-log= build_summary(build, skip: index >= 10) 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/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 071c48fa2e4..5c1156b06fb 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -40,7 +40,7 @@ .note-body{ class: note_editable ? 'js-task-list-container' : '' } .note-text.md = note.redacted_note_html - = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) + = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') - if note_editable = render 'shared/notes/edit', note: note .note-awards diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index d084f5e9684..501c09d71d5 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -21,4 +21,4 @@ = markdown_field(@snippet, :title) - if @snippet.updated_at != @snippet.created_at - = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago') + = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 015a41b6e82..127d8dfbb61 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -2,27 +2,24 @@ class PostReceive include Sidekiq::Worker include DedicatedSidekiqQueue - def perform(repo_path, identifier, changes) - repo_relative_path = Gitlab::RepoPath.strip_storage_path(repo_path) + def perform(project_identifier, identifier, changes) + project, is_wiki = parse_project_identifier(project_identifier) + + if project.nil? + log("Triggered hook for non-existing project with identifier \"#{project_identifier}\"") + return false + end changes = Base64.decode64(changes) unless changes.include?(' ') # Use Sidekiq.logger so arguments can be correlated with execution # time and thread ID's. Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] - post_received = Gitlab::GitPostReceive.new(repo_relative_path, identifier, changes) - - if post_received.project.nil? - log("Triggered hook for non-existing project with full path \"#{repo_relative_path}\"") - return false - end + post_received = Gitlab::GitPostReceive.new(project, identifier, changes) - if post_received.wiki? + if is_wiki # Nothing defined here yet. - elsif post_received.regular_project? - process_project_changes(post_received) else - log("Triggered hook for unidentifiable repository type with full path \"#{repo_relative_path}\"") - false + process_project_changes(post_received) end end @@ -47,6 +44,21 @@ class PostReceive private + # To maintain backwards compatibility, we accept both gl_repository or + # repository paths as project identifiers. Our plan is to migrate to + # gl_repository only with the following plan: + # 9.2: Handle both possible values. Keep Gitlab-Shell sending only repo paths + # 9.3 (or patch release): Make GitLab Shell pass gl_repository if present + # 9.4 (or patch release): Make GitLab Shell always pass gl_repository + # 9.5 (or patch release): Handle only gl_repository as project identifier on this method + def parse_project_identifier(project_identifier) + if project_identifier.start_with?('/') + Gitlab::RepoPath.parse(project_identifier) + else + Gitlab::GlRepository.parse(project_identifier) + end + end + def log(message) Gitlab::GitLogger.error("POST-RECEIVE: #{message}") end diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb new file mode 100644 index 00000000000..5ce0e0405d0 --- /dev/null +++ b/app/workers/propagate_service_template_worker.rb @@ -0,0 +1,21 @@ +# Worker for updating any project specific caches. +class PropagateServiceTemplateWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + LEASE_TIMEOUT = 4.hours.to_i + + def perform(template_id) + return unless try_obtain_lease_for(template_id) + + Projects::PropagateServiceTemplate.propagate(Service.find_by(id: template_id)) + end + + private + + def try_obtain_lease_for(template_id) + Gitlab::ExclusiveLease. + new("propagate_service_template_worker:#{template_id}", timeout: LEASE_TIMEOUT). + try_obtain + end +end |