Commit aa874a1c authored by Jacob Schatz's avatar Jacob Schatz

Merge branch 'mia_backort' into 'master'

Backport of Multiple Assignees feature

See merge request !11089
parents 0c5b7f84 933447e0
...@@ -99,7 +99,7 @@ export default class FileTemplateMediator { ...@@ -99,7 +99,7 @@ export default class FileTemplateMediator {
}); });
} }
selectTemplateType(item, el, e) { selectTemplateType(item, e) {
if (e) { if (e) {
e.preventDefault(); e.preventDefault();
} }
...@@ -117,6 +117,10 @@ export default class FileTemplateMediator { ...@@ -117,6 +117,10 @@ export default class FileTemplateMediator {
this.cacheToggleText(); this.cacheToggleText();
} }
selectTemplateTypeOptions(options) {
this.selectTemplateType(options.selectedObj, options.e);
}
selectTemplateFile(selector, query, data) { selectTemplateFile(selector, query, data) {
selector.renderLoading(); selector.renderLoading();
// in case undo menu is already already there // in case undo menu is already already there
......
...@@ -52,9 +52,17 @@ export default class FileTemplateSelector { ...@@ -52,9 +52,17 @@ export default class FileTemplateSelector {
.removeClass('fa-spinner fa-spin'); .removeClass('fa-spinner fa-spin');
} }
reportSelection(query, el, e, data) { reportSelection(options) {
const { query, e, data } = options;
e.preventDefault(); e.preventDefault();
return this.mediator.selectTemplateFile(this, query, data); return this.mediator.selectTemplateFile(this, query, data);
} }
reportSelectionName(options) {
const opts = options;
opts.query = options.selectedObj.name;
this.reportSelection(opts);
}
} }
...@@ -37,8 +37,8 @@ class TargetBranchDropDown { ...@@ -37,8 +37,8 @@ class TargetBranchDropDown {
} }
return SELECT_ITEM_MSG; return SELECT_ITEM_MSG;
}, },
clicked(item, el, e) { clicked(options) {
e.preventDefault(); options.e.preventDefault();
self.onClick.call(self); self.onClick.call(self);
}, },
fieldName: self.fieldName, fieldName: self.fieldName,
......
...@@ -24,7 +24,7 @@ export default class TemplateSelector { ...@@ -24,7 +24,7 @@ export default class TemplateSelector {
search: { search: {
fields: ['name'], fields: ['name'],
}, },
clicked: (item, el, e) => this.fetchFileTemplate(item, el, e), clicked: options => this.fetchFileTemplate(options),
text: item => item.name, text: item => item.name,
}); });
} }
...@@ -51,7 +51,10 @@ export default class TemplateSelector { ...@@ -51,7 +51,10 @@ export default class TemplateSelector {
return this.$dropdownContainer.removeClass('hidden'); return this.$dropdownContainer.removeClass('hidden');
} }
fetchFileTemplate(item, el, e) { fetchFileTemplate(options) {
const { e } = options;
const item = options.selectedObj;
e.preventDefault(); e.preventDefault();
return this.requestFile(item); return this.requestFile(item);
} }
......
...@@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector { ...@@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector {
search: { search: {
fields: ['name'], fields: ['name'],
}, },
clicked: (query, el, e) => this.reportSelection(query.name, el, e), clicked: options => this.reportSelectionName(options),
text: item => item.name, text: item => item.name,
}); });
} }
......
...@@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector { ...@@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector {
search: { search: {
fields: ['name'], fields: ['name'],
}, },
clicked: (query, el, e) => this.reportSelection(query.name, el, e), clicked: options => this.reportSelectionName(options),
text: item => item.name, text: item => item.name,
}); });
} }
......
...@@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector { ...@@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector {
search: { search: {
fields: ['name'], fields: ['name'],
}, },
clicked: (query, el, e) => this.reportSelection(query.name, el, e), clicked: options => this.reportSelectionName(options),
text: item => item.name, text: item => item.name,
}); });
} }
......
...@@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector { ...@@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector {
search: { search: {
fields: ['name'], fields: ['name'],
}, },
clicked: (query, el, e) => { clicked: (options) => {
const { e } = options;
const el = options.$el;
const query = options.selectedObj;
const data = { const data = {
project: this.$dropdown.data('project'), project: this.$dropdown.data('project'),
fullname: this.$dropdown.data('fullname'), fullname: this.$dropdown.data('fullname'),
}; };
this.reportSelection(query.id, el, e, data); this.reportSelection({
query: query.id,
el,
e,
data,
});
}, },
text: item => item.name, text: item => item.name,
}); });
......
...@@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector { ...@@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector {
filterable: false, filterable: false,
selectable: true, selectable: true,
toggleLabel: item => item.name, toggleLabel: item => item.name,
clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e), clicked: options => this.mediator.selectTemplateTypeOptions(options),
text: item => item.name, text: item => item.name,
}); });
} }
......
...@@ -11,7 +11,7 @@ require('./models/issue'); ...@@ -11,7 +11,7 @@ require('./models/issue');
require('./models/label'); require('./models/label');
require('./models/list'); require('./models/list');
require('./models/milestone'); require('./models/milestone');
require('./models/user'); require('./models/assignee');
require('./stores/boards_store'); require('./stores/boards_store');
require('./stores/modal_store'); require('./stores/modal_store');
require('./services/board_service'); require('./services/board_service');
......
...@@ -26,6 +26,7 @@ export default { ...@@ -26,6 +26,7 @@ export default {
title: this.title, title: this.title,
labels, labels,
subscribed: true, subscribed: true,
assignees: [],
}); });
this.list.newIssue(issue) this.list.newIssue(issue)
......
...@@ -3,8 +3,13 @@ ...@@ -3,8 +3,13 @@
/* global MilestoneSelect */ /* global MilestoneSelect */
/* global LabelsSelect */ /* global LabelsSelect */
/* global Sidebar */ /* global Sidebar */
/* global Flash */
import Vue from 'vue'; 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'); require('./sidebar/remove_issue');
...@@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ ...@@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
detail: Store.detail, detail: Store.detail,
issue: {}, issue: {},
list: {}, list: {},
loadingAssignees: false,
}; };
}, },
computed: { computed: {
...@@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({ ...@@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({
this.issue = this.detail.issue; this.issue = this.detail.issue;
this.list = this.detail.list; this.list = this.detail.list;
this.$nextTick(() => {
this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
});
}, },
deep: true deep: true
}, },
...@@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({ ...@@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({
$('.right-sidebar').getNiceScroll().resize(); $('.right-sidebar').getNiceScroll().resize();
}); });
} }
}
this.issue = this.detail.issue;
this.list = this.detail.list;
},
deep: true
}, },
methods: { methods: {
closeSidebar () { closeSidebar () {
this.detail.issue = {}; 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 () { mounted () {
new IssuableContext(this.currentUser); new IssuableContext(this.currentUser);
...@@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ ...@@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
}, },
components: { components: {
removeBtn: gl.issueBoards.RemoveIssueBtn, removeBtn: gl.issueBoards.RemoveIssueBtn,
'assignee-title': AssigneeTitle,
assignees: Assignees,
}, },
}); });
...@@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({
default: false, default: false,
}, },
}, },
data() {
return {
limitBeforeCounter: 3,
maxRender: 4,
maxCounter: 99,
};
},
computed: { computed: {
cardUrl() { numberOverLimit() {
return `${this.issueLinkBase}/${this.issue.id}`; return this.issue.assignees.length - this.limitBeforeCounter;
},
assigneeCounterTooltip() {
return `${this.assigneeCounterLabel} more`;
}, },
assigneeUrl() { assigneeCounterLabel() {
return `${this.rootPath}${this.issue.assignee.username}`; if (this.numberOverLimit > this.maxCounter) {
return `${this.maxCounter}+`;
}
return `+${this.numberOverLimit}`;
}, },
assigneeUrlTitle() { shouldRenderCounter() {
return `Assigned to ${this.issue.assignee.name}`; if (this.issue.assignees.length <= this.maxRender) {
return false;
}
return this.issue.assignees.length > this.numberOverLimit;
}, },
avatarUrlTitle() { cardUrl() {
return `Avatar for ${this.issue.assignee.name}`; return `${this.issueLinkBase}/${this.issue.id}`;
}, },
issueId() { issueId() {
return `#${this.issue.id}`; return `#${this.issue.id}`;
...@@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({
}, },
}, },
methods: { 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) { showLabel(label) {
if (!this.list) return true; if (!this.list) return true;
...@@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({
{{ issueId }} {{ issueId }}
</span> </span>
</h4> </h4>
<div class="card-assignee">
<a <a
class="card-assignee has-tooltip js-no-trigger" class="has-tooltip js-no-trigger"
:href="assigneeUrl" :href="assigneeUrl(assignee)"
:title="assigneeUrlTitle" :title="assigneeUrlTitle(assignee)"
v-if="issue.assignee" v-for="(assignee, index) in issue.assignees"
v-if="shouldRenderAssignee(index)"
data-container="body" data-container="body"
data-placement="bottom"
> >
<img <img
class="avatar avatar-inline s20 js-no-trigger" class="avatar avatar-inline s20"
:src="issue.assignee.avatar" :src="assignee.avatar"
width="20" width="20"
height="20" height="20"
:alt="avatarUrlTitle" :alt="avatarUrlTitle(assignee)"
/> />
</a> </a>
<span
class="avatar-counter has-tooltip"
:title="assigneeCounterTooltip"
v-if="shouldRenderCounter"
>
{{ assigneeCounterLabel }}
</span>
</div> </div>
<div class="card-footer" v-if="showLabelFooter"> </div>
<div
class="card-footer"
v-if="showLabelFooter"
>
<button <button
class="label color-label has-tooltip js-no-trigger" class="label color-label has-tooltip"
v-for="label in issue.labels" v-for="label in issue.labels"
type="button" type="button"
v-if="showLabel(label)" v-if="showLabel(label)"
......
...@@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => { ...@@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => {
filterable: true, filterable: true,
selectable: true, selectable: true,
multiSelect: true, multiSelect: true,
clicked (label, $el, e) { clicked (options) {
const { e } = options;
const label = options.selectedObj;
e.preventDefault(); e.preventDefault();
if (!Store.findList('title', label.title)) { if (!Store.findList('title', label.title)) {
......
class ListUser { /* eslint-disable no-unused-vars */
class ListAssignee {
constructor(user, defaultAvatar) { constructor(user, defaultAvatar) {
this.id = user.id; this.id = user.id;
this.name = user.name; this.name = user.name;
...@@ -7,4 +9,4 @@ class ListUser { ...@@ -7,4 +9,4 @@ class ListUser {
} }
} }
window.ListUser = ListUser; window.ListAssignee = ListAssignee;
/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ /* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */
/* global ListLabel */ /* global ListLabel */
/* global ListMilestone */ /* global ListMilestone */
/* global ListUser */ /* global ListAssignee */
import Vue from 'vue'; import Vue from 'vue';
...@@ -14,14 +14,10 @@ class ListIssue { ...@@ -14,14 +14,10 @@ class ListIssue {
this.dueDate = obj.due_date; this.dueDate = obj.due_date;
this.subscribed = obj.subscribed; this.subscribed = obj.subscribed;
this.labels = []; this.labels = [];
this.assignees = [];
this.selected = false; this.selected = false;
this.assignee = false;
this.position = obj.relative_position || Infinity; this.position = obj.relative_position || Infinity;
if (obj.assignee) {
this.assignee = new ListUser(obj.assignee, defaultAvatar);
}
if (obj.milestone) { if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone); this.milestone = new ListMilestone(obj.milestone);
} }
...@@ -29,6 +25,8 @@ class ListIssue { ...@@ -29,6 +25,8 @@ class ListIssue {
obj.labels.forEach((label) => { obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label)); this.labels.push(new ListLabel(label));
}); });
this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar));
} }
addLabel (label) { addLabel (label) {
...@@ -51,6 +49,26 @@ class ListIssue { ...@@ -51,6 +49,26 @@ class ListIssue {
labels.forEach(this.removeLabel.bind(this)); 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 () { getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
} }
...@@ -60,7 +78,7 @@ class ListIssue { ...@@ -60,7 +78,7 @@ class ListIssue {
issue: { issue: {
milestone_id: this.milestone ? this.milestone.id : null, milestone_id: this.milestone ? this.milestone.id : null,
due_date: this.dueDate, 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) label_ids: this.labels.map((label) => label.id)
} }
}; };
......
...@@ -255,7 +255,8 @@ GitLabDropdown = (function() { ...@@ -255,7 +255,8 @@ GitLabDropdown = (function() {
} }
}; };
// Remote data // Remote data
})(this) })(this),
instance: this,
}); });
} }
} }
...@@ -269,6 +270,7 @@ GitLabDropdown = (function() { ...@@ -269,6 +270,7 @@ GitLabDropdown = (function() {
remote: this.options.filterRemote, remote: this.options.filterRemote,
query: this.options.data, query: this.options.data,
keys: searchFields, keys: searchFields,
instance: this,
elements: (function(_this) { elements: (function(_this) {
return function() { return function() {
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
...@@ -343,21 +345,26 @@ GitLabDropdown = (function() { ...@@ -343,21 +345,26 @@ GitLabDropdown = (function() {
} }
this.dropdown.on("click", selector, function(e) { this.dropdown.on("click", selector, function(e) {
var $el, selected, selectedObj, isMarking; var $el, selected, selectedObj, isMarking;
$el = $(this); $el = $(e.currentTarget);
selected = self.rowClicked($el); selected = self.rowClicked($el);
selectedObj = selected ? selected[0] : null; selectedObj = selected ? selected[0] : null;
isMarking = selected ? selected[1] : null; isMarking = selected ? selected[1] : null;
if (self.options.clicked) { if (this.options.clicked) {
self.options.clicked(selectedObj, $el, e, isMarking); this.options.clicked.call(this, {
selectedObj,
$el,
e,
isMarking,
});
} }
// Update label right after all modifications in dropdown has been done // Update label right after all modifications in dropdown has been done
if (self.options.toggleLabel) { if (this.options.toggleLabel) {
self.updateLabel(selectedObj, $el, self); this.updateLabel(selectedObj, $el, this);
} }
$el.trigger('blur'); $el.trigger('blur');
}); }.bind(this));
} }
} }
...@@ -439,15 +446,34 @@ GitLabDropdown = (function() { ...@@ -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) { GitLabDropdown.prototype.opened = function(e) {
var contentHtml; var contentHtml;
this.resetRows(); this.resetRows();
this.addArrowKeyEvent(); 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 // 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); 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(); contentHtml = $('.dropdown-content', this.dropdown).html();
if (this.remote && contentHtml === "") { if (this.remote && contentHtml === "") {
this.remote.execute(); this.remote.execute();
...@@ -709,6 +735,11 @@ GitLabDropdown = (function() { ...@@ -709,6 +735,11 @@ GitLabDropdown = (function() {
if (this.options.inputId != null) { if (this.options.inputId != null) {
$input.attr('id', this.options.inputId); $input.attr('id', this.options.inputId);
} }
if (this.options.inputMeta) {
$input.attr('data-meta', selectedObject[this.options.inputMeta]);
}
return this.dropdown.before($input); return this.dropdown.before($input);
}; };
...@@ -829,7 +860,14 @@ GitLabDropdown = (function() { ...@@ -829,7 +860,14 @@ GitLabDropdown = (function() {
if (instance == null) { if (instance == null) {
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) { GitLabDropdown.prototype.clearField = function(field, isInput) {
......
require('./time_tracking/time_tracking_bundle');
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>
`,
});
})();
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>
`,
});
})();
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>
`,
});
})();
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>
`,
});
})();
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>
`,
});
})();
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>
`,
});
})();
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>
`,
});
})();
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 = {}));
...@@ -19,8 +19,8 @@ ...@@ -19,8 +19,8 @@
return label; return label;
}; };
})(this), })(this),
clicked: function(item, $el, e) { clicked: function(options) {
return e.preventDefault(); return options.e.preventDefault();
}, },
id: function(obj, el) { id: function(obj, el) {
return $(el).data("id"); return $(el).data("id");
......
...@@ -88,7 +88,10 @@ ...@@ -88,7 +88,10 @@
const formData = { const formData = {
update: { update: {
state_event: this.form.find('input[name="update[state_event]"]').val(), 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(), 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(), milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
......
...@@ -330,7 +330,10 @@ ...@@ -330,7 +330,10 @@
}, },
multiSelect: $dropdown.hasClass('js-multiselect'), multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'), 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 isIssueIndex, isMRIndex, page, boardsModel;
var fadeOutLoader = () => { var fadeOutLoader = () => {
$loading.fadeOut(); $loading.fadeOut();
...@@ -352,7 +355,7 @@ ...@@ -352,7 +355,7 @@
if ($dropdown.hasClass('js-filter-bulk-update')) { if ($dropdown.hasClass('js-filter-bulk-update')) {
_this.enableBulkLabelDropdown(); _this.enableBulkLabelDropdown();
_this.setDropdownData($dropdown, isMarking, this.id(label)); _this.setDropdownData($dropdown, isMarking, label.id);
return; return;
} }
......
...@@ -158,7 +158,6 @@ import './single_file_diff'; ...@@ -158,7 +158,6 @@ import './single_file_diff';
import './smart_interval'; import './smart_interval';
import './snippets_list'; import './snippets_list';
import './star'; import './star';
import './subbable_resource';
import './subscription'; import './subscription';
import './subscription_select'; import './subscription_select';
import './syntax_highlight'; import './syntax_highlight';
......
...@@ -31,8 +31,8 @@ ...@@ -31,8 +31,8 @@
toggleLabel(selected, $el) { toggleLabel(selected, $el) {
return $el.text(); return $el.text();
}, },
clicked: (selected, $link) => { clicked: (options) => {
this.formSubmit(null, $link); this.formSubmit(null, options.$el);
}, },
}); });
}); });
......
...@@ -121,7 +121,10 @@ ...@@ -121,7 +121,10 @@
return $value.css('display', ''); return $value.css('display', '');
}, },
vue: $dropdown.hasClass('js-issue-board-sidebar'), 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; var data, isIssueIndex, isMRIndex, page, boardsStore;
page = $('body').data('page'); page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index'; isIssueIndex = page === 'projects:issues:index';
......
...@@ -58,7 +58,8 @@ ...@@ -58,7 +58,8 @@
}); });
} }
NamespaceSelect.prototype.onSelectItem = function(item, el, e) { NamespaceSelect.prototype.onSelectItem = function(options) {
const { e } = options;
return e.preventDefault(); return e.preventDefault();
}; };
......
...@@ -112,7 +112,8 @@ import Cookies from 'js-cookie'; ...@@ -112,7 +112,8 @@ import Cookies from 'js-cookie';
toggleLabel: function(obj, $el) { toggleLabel: function(obj, $el) {
return $el.text().trim(); return $el.text().trim();
}, },
clicked: function(selected, $el, e) { clicked: function(options) {
const { e } = options;
e.preventDefault(); e.preventDefault();
if ($('input[name="ref"]').length) { if ($('input[name="ref"]').length) {
var $form = $dropdown.closest('form'); var $form = $dropdown.closest('form');
......
...@@ -19,7 +19,9 @@ ...@@ -19,7 +19,9 @@
return 'Select'; return 'Select';
} }
}, },
clicked(item, $el, e) { clicked(opts) {
const { e } = opts;
e.preventDefault(); e.preventDefault();
onSelect(); onSelect();
} }
......
...@@ -35,7 +35,8 @@ class ProtectedBranchDropdown { ...@@ -35,7 +35,8 @@ class ProtectedBranchDropdown {
return _.escape(protectedBranch.id); return _.escape(protectedBranch.id);
}, },
onFilter: this.toggleCreateNewButton.bind(this), onFilter: this.toggleCreateNewButton.bind(this),
clicked: (item, $el, e) => { clicked: (options) => {
const { $el, e } = options;
e.preventDefault(); e.preventDefault();
this.onSelect(); this.onSelect();
} }
......
...@@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown { ...@@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown {
} }
return 'Select'; return 'Select';
}, },
clicked(item, $el, e) { clicked(options) {
e.preventDefault(); options.e.preventDefault();
onSelect(); onSelect();
}, },
}); });
......
...@@ -39,8 +39,8 @@ export default class ProtectedTagDropdown { ...@@ -39,8 +39,8 @@ export default class ProtectedTagDropdown {
return _.escape(protectedTag.id); return _.escape(protectedTag.id);
}, },
onFilter: this.toggleCreateNewButton.bind(this), onFilter: this.toggleCreateNewButton.bind(this),
clicked: (item, $el, e) => { clicked: (options) => {
e.preventDefault(); options.e.preventDefault();
this.onSelect(); this.onSelect();
}, },
}); });
......
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>
`,
};
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>
`,
};
/* 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>
`,
};
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>
`,
};
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>
`,
};
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>
`,
};
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>
`,
};
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>
`,
};
import '~/smart_interval';
import timeTracker from './time_tracker';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
export default {
data() {
return {
mediator: new Mediator(),
store: new Store(),
};
},
components: {
'issuable-time-tracker': timeTracker,
},
methods: {
listenForSlashCommands() {
$(document).on('ajax:success', '.gfm-form', (e, data) => {
const subscribedCommands = ['spend_time', 'time_estimate'];
const changedCommands = data.commands_changes
? Object.keys(data.commands_changes)
: [];
if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
this.mediator.fetch();
}
});
},
},
mounted() {
this.listenForSlashCommands();
},
template: `
<div class="block">
<issuable-time-tracker
:time_estimate="store.timeEstimate"
:time_spent="store.totalTimeSpent"
:human_time_estimate="store.humanTimeEstimate"
:human_time_spent="store.humanTotalTimeSpent"
:rootPath="store.rootPath"
/>
</div>
`,
};
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>
`,
};
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>
`,
};
import Vue from 'vue';
export default new Vue();
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,
});
}
}
import Vue from 'vue';
import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
import sidebarAssignees from './components/assignees/sidebar_assignees';
import Mediator from './sidebar_mediator';
document.addEventListener('DOMContentLoaded', () => {
const mediator = new Mediator(gl.sidebarOptions);
mediator.fetch();
const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
// Only create the sidebarAssignees vue app if it is found in the DOM
// We currently do not use sidebarAssignees for the MR page
if (sidebarAssigneesEl) {
new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
}
new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
});
/* global Flash */
import Service from './services/sidebar_service';
import Store from './stores/sidebar_store';
export default class SidebarMediator {
constructor(options) {
if (!SidebarMediator.singleton) {
this.store = new Store(options);
this.service = new Service(options.endpoint);
SidebarMediator.singleton = this;
}
return SidebarMediator.singleton;
}
assignYourself() {
this.store.addAssignee(this.store.currentUser);
}
saveAssignees(field) {
const selected = this.store.assignees.map(u => u.id);
// If there are no ids, that means we have to unassign (which is id = 0)
// And it only accepts an array, hence [0]
return this.service.update(field, selected.length === 0 ? [0] : selected);
}
fetch() {
this.service.get()
.then((response) => {
const data = response.json();
this.store.processAssigneeData(data);
this.store.processTimeTrackingData(data);
})
.catch(() => new Flash('Error occured when fetching sidebar data'));
}
}
export default class SidebarStore {
constructor(store) {
if (!SidebarStore.singleton) {
const { currentUser, rootPath, editable } = store;
this.currentUser = currentUser;
this.rootPath = rootPath;
this.editable = editable;
this.timeEstimate = 0;
this.totalTimeSpent = 0;
this.humanTimeEstimate = '';
this.humanTimeSpent = '';
this.assignees = [];
SidebarStore.singleton = this;
}
return SidebarStore.singleton;
}
processAssigneeData(data) {
if (data.assignees) {
this.assignees = data.assignees;
}
}
processTimeTrackingData(data) {
this.timeEstimate = data.time_estimate;
this.totalTimeSpent = data.total_time_spent;
this.humanTimeEstimate = data.human_time_estimate;
this.humanTotalTimeSpent = data.human_total_time_spent;
}
addAssignee(assignee) {
if (!this.findAssignee(assignee)) {
this.assignees.push(assignee);
}
}
findAssignee(findAssignee) {
return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
}
removeAssignee(removeAssignee) {
if (removeAssignee) {
this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
}
}
removeAllAssignees() {
this.assignees = [];
}
}
(() => {
/*
* 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 = {}));
...@@ -19,8 +19,8 @@ ...@@ -19,8 +19,8 @@
return label; return label;
}; };
})(this), })(this),
clicked: function(item, $el, e) { clicked: function(options) {
return e.preventDefault(); return options.e.preventDefault();
}, },
id: function(obj, el) { id: function(obj, el) {
return $(el).data("id"); return $(el).data("id");
......
This diff is collapsed.
...@@ -93,3 +93,14 @@ ...@@ -93,3 +93,14 @@
align-self: center; 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;
}
...@@ -251,14 +251,16 @@ ...@@ -251,14 +251,16 @@
} }
.dropdown-header { .dropdown-header {
color: $gl-text-color; color: $gl-text-color-secondary;
font-size: 13px; font-size: 13px;
font-weight: 600;
line-height: 22px; line-height: 22px;
text-transform: capitalize;
padding: 0 16px; padding: 0 16px;
} }
&.capitalize-header .dropdown-header {
text-transform: capitalize;
}
.separator + .dropdown-header { .separator + .dropdown-header {
padding-top: 2px; padding-top: 2px;
} }
...@@ -337,8 +339,8 @@ ...@@ -337,8 +339,8 @@
.dropdown-menu-user { .dropdown-menu-user {
.avatar { .avatar {
float: left; float: left;
width: 30px; width: 2 * $gl-padding;
height: 30px; height: 2 * $gl-padding;
margin: 0 10px 0 0; margin: 0 10px 0 0;
} }
} }
...@@ -381,6 +383,7 @@ ...@@ -381,6 +383,7 @@
.dropdown-menu-selectable { .dropdown-menu-selectable {
a { a {
padding-left: 26px; padding-left: 26px;
position: relative;
&.is-indeterminate, &.is-indeterminate,
&.is-active { &.is-active {
...@@ -406,6 +409,9 @@ ...@@ -406,6 +409,9 @@
&.is-active::before { &.is-active::before {
content: "\f00c"; content: "\f00c";
position: absolute;
top: 50%;
transform: translateY(-50%);
} }
} }
} }
......
...@@ -255,6 +255,7 @@ ul.controls { ...@@ -255,6 +255,7 @@ ul.controls {
.avatar-inline { .avatar-inline {
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
margin-bottom: 0;
} }
} }
} }
......
...@@ -207,8 +207,13 @@ ...@@ -207,8 +207,13 @@
margin-bottom: 5px; margin-bottom: 5px;
} }
&.is-active { &.is-active,
&.is-active .card-assignee:hover a {
background-color: $row-hover; background-color: $row-hover;
&:first-child:not(:only-child) {
box-shadow: -10px 0 10px 1px $row-hover;
}
} }
.label { .label {
...@@ -224,7 +229,7 @@ ...@@ -224,7 +229,7 @@
} }
.card-title { .card-title {
margin: 0; margin: 0 30px 0 0;
font-size: 1em; font-size: 1em;
line-height: inherit; line-height: inherit;
...@@ -240,10 +245,69 @@ ...@@ -240,10 +245,69 @@
min-height: 20px; min-height: 20px;
.card-assignee { .card-assignee {
margin-left: auto; display: flex;
margin-right: 5px; justify-content: flex-end;
padding-left: 10px; position: absolute;
right: 15px;
height: 20px;
width: 20px;
.avatar-counter {
display: none;
vertical-align: middle;
min-width: 20px;
line-height: 19px;
height: 20px; 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 { .avatar {
......
...@@ -570,14 +570,7 @@ ...@@ -570,14 +570,7 @@
.diff-comments-more-count, .diff-comments-more-count,
.diff-notes-collapse { .diff-notes-collapse {
background-color: $gray-darkest; @extend .avatar-counter;
color: $white-light;
border: 1px solid $white-light;
border-radius: 1em;
font-family: $regular_font;
font-size: 9px;
line-height: 17px;
text-align: center;
} }
.diff-notes-collapse { .diff-notes-collapse {
......
...@@ -95,10 +95,15 @@ ...@@ -95,10 +95,15 @@
} }
.right-sidebar { .right-sidebar {
a { a,
.btn-link {
color: inherit; color: inherit;
} }
.btn-link {
outline: none;
}
.issuable-header-text { .issuable-header-text {
margin-top: 7px; margin-top: 7px;
} }
...@@ -215,6 +220,10 @@ ...@@ -215,6 +220,10 @@
} }
} }
.assign-yourself .btn-link {
padding-left: 0;
}
.light { .light {
font-weight: normal; font-weight: normal;
} }
...@@ -239,6 +248,10 @@ ...@@ -239,6 +248,10 @@
margin-left: 0; margin-left: 0;
} }
.assignee .user-list .avatar {
margin: 0;
}
.username { .username {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
...@@ -301,6 +314,10 @@ ...@@ -301,6 +314,10 @@
margin-top: 0; margin-top: 0;
} }
.sidebar-avatar-counter {
padding-top: 2px;
}
.todo-undone { .todo-undone {
color: $gl-link-color; color: $gl-link-color;
} }
...@@ -309,10 +326,15 @@ ...@@ -309,10 +326,15 @@
display: none; display: none;
} }
.avatar:hover { .avatar:hover,
.avatar-counter:hover {
border-color: $issuable-sidebar-color; border-color: $issuable-sidebar-color;
} }
.avatar-counter:hover {
color: $issuable-sidebar-color;
}
.btn-clipboard { .btn-clipboard {
border: none; border: none;
color: $issuable-sidebar-color; color: $issuable-sidebar-color;
...@@ -322,6 +344,17 @@ ...@@ -322,6 +344,17 @@
color: $gl-text-color; color: $gl-text-color;
} }
} }
&.multiple-users {
display: flex;
justify-content: center;
}
}
.sidebar-avatar-counter {
width: 24px;
height: 24px;
border-radius: 12px;
} }
.sidebar-collapsed-user { .sidebar-collapsed-user {
...@@ -332,6 +365,37 @@ ...@@ -332,6 +365,37 @@
.issuable-header-btn { .issuable-header-btn {
display: none; 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 { a {
...@@ -383,6 +447,12 @@ ...@@ -383,6 +447,12 @@
margin: -5px; margin: -5px;
} }
.user-list {
display: flex;
flex-wrap: wrap;
}
.participants-author { .participants-author {
display: inline-block; display: inline-block;
padding: 5px; padding: 5px;
...@@ -400,13 +470,39 @@ ...@@ -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-top: 5px;
margin-left: 5px; margin-left: 5px;
a { a,
.btn-link {
color: $gl-text-color-secondary; 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 { .issuable-form-padding-top {
...@@ -499,6 +595,19 @@ ...@@ -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 { .time_tracker {
padding-bottom: 0; padding-bottom: 0;
border-bottom: 0; border-bottom: 0;
......
...@@ -66,6 +66,7 @@ module IssuableActions ...@@ -66,6 +66,7 @@ module IssuableActions
:milestone_id, :milestone_id,
:state_event, :state_event,
:subscription_event, :subscription_event,
assignee_ids: [],
label_ids: [], label_ids: [],
add_label_ids: [], add_label_ids: [],
remove_label_ids: [] remove_label_ids: []
......
...@@ -43,7 +43,7 @@ module IssuableCollections ...@@ -43,7 +43,7 @@ module IssuableCollections
end end
def issues_collection 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 end
def merge_requests_collection def merge_requests_collection
......
...@@ -82,7 +82,7 @@ module Projects ...@@ -82,7 +82,7 @@ module Projects
labels: true, labels: true,
only: [:id, :iid, :title, :confidential, :due_date, :relative_position], only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
include: { include: {
assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] } milestone: { only: [:id, :title] }
}, },
user: current_user user: current_user
......
...@@ -67,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -67,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController
def new def new
params[:issue] ||= ActionController::Parameters.new( params[:issue] ||= ActionController::Parameters.new(
assignee_id: "" assignee_ids: ""
) )
build_params = issue_params.merge( build_params = issue_params.merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
...@@ -150,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -150,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController
if @issue.valid? if @issue.valid?
render json: @issue.to_json(methods: [:task_status, :task_status_short], render json: @issue.to_json(methods: [:task_status, :task_status_short],
include: { milestone: {}, include: { milestone: {},
assignee: { only: [:name, :username], methods: [:avatar_url] }, assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
labels: { methods: :text_color } }) labels: { methods: :text_color } })
else else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
...@@ -284,7 +284,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -284,7 +284,7 @@ class Projects::IssuesController < Projects::ApplicationController
def issue_params def issue_params
params.require(:issue).permit( params.require(:issue).permit(
:title, :assignee_id, :position, :description, :confidential, :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 end
......
...@@ -231,7 +231,7 @@ class IssuableFinder ...@@ -231,7 +231,7 @@ class IssuableFinder
when 'created-by-me', 'authored' when 'created-by-me', 'authored'
items.where(author_id: current_user.id) items.where(author_id: current_user.id)
when 'assigned-to-me' when 'assigned-to-me'
items.where(assignee_id: current_user.id) items.assigned_to(current_user)
else else
items items
end end
......
...@@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder ...@@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder
IssuesFinder.not_restricted_by_confidentiality(current_user) IssuesFinder.not_restricted_by_confidentiality(current_user)
end 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) 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? return Issue.all if user.admin?
Issue.where(' Issue.where('
issues.confidential IS NULL issues.confidential IS NOT TRUE
OR issues.confidential IS FALSE
OR (issues.confidential = TRUE OR (issues.confidential = TRUE
AND (issues.author_id = :user_id 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)))', OR issues.project_id IN(:project_ids)))',
user_id: user.id, user_id: user.id,
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
......
...@@ -15,4 +15,36 @@ module FormHelper ...@@ -15,4 +15,36 @@ module FormHelper
end end
end 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 end
...@@ -63,6 +63,16 @@ module IssuablesHelper ...@@ -63,6 +63,16 @@ module IssuablesHelper
end end
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) def user_dropdown_label(user_id, default_label)
return default_label if user_id.nil? return default_label if user_id.nil?
return "Unassigned" if user_id == "0" return "Unassigned" if user_id == "0"
......
...@@ -11,10 +11,12 @@ module Emails ...@@ -11,10 +11,12 @@ module Emails
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end 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) 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)) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end end
......
...@@ -26,7 +26,6 @@ module Issuable ...@@ -26,7 +26,6 @@ module Issuable
cache_markdown_field :description, issuable_state_filter_enabled: true cache_markdown_field :description, issuable_state_filter_enabled: true
belongs_to :author, class_name: "User" belongs_to :author, class_name: "User"
belongs_to :assignee, class_name: "User"
belongs_to :updated_by, class_name: "User" belongs_to :updated_by, class_name: "User"
belongs_to :milestone belongs_to :milestone
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do
...@@ -65,11 +64,8 @@ module Issuable ...@@ -65,11 +64,8 @@ module Issuable
validates :title, presence: true, length: { maximum: 255 } validates :title, presence: true, length: { maximum: 255 }
scope :authored, ->(user) { where(author_id: user) } scope :authored, ->(user) { where(author_id: user) }
scope :assigned_to, ->(u) { where(assignee_id: u.id)}
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :order_position_asc, -> { reorder(position: :asc) } 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_projects, ->(ids) { where(project_id: ids) }
scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
...@@ -92,23 +88,14 @@ module Issuable ...@@ -92,23 +88,14 @@ module Issuable
attr_mentionable :description attr_mentionable :description
participant :author participant :author
participant :assignee
participant :notes_with_associations participant :notes_with_associations
strip_attributes :title strip_attributes :title
acts_as_paranoid acts_as_paranoid
after_save :update_assignee_cache_counts, if: :assignee_id_changed?
after_save :record_metrics, unless: :imported? 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 # We want to use optimistic lock for cases when only title or description are involved
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
def locking_enabled? def locking_enabled?
...@@ -237,10 +224,6 @@ module Issuable ...@@ -237,10 +224,6 @@ module Issuable
today? && created_at == updated_at today? && created_at == updated_at
end end
def is_being_reassigned?
assignee_id_changed?
end
def open? def open?
opened? || reopened? opened? || reopened?
end end
...@@ -269,7 +252,11 @@ module Issuable ...@@ -269,7 +252,11 @@ module Issuable
# DEPRECATED # DEPRECATED
repository: project.hook_attrs.slice(:name, :url, :description, :homepage) repository: project.hook_attrs.slice(:name, :url, :description, :homepage)
} }
if self.is_a?(Issue)
hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any?
else
hook_data[:assignee] = assignee.hook_attrs if assignee hook_data[:assignee] = assignee.hook_attrs if assignee
end
hook_data hook_data
end end
...@@ -331,11 +318,6 @@ module Issuable ...@@ -331,11 +318,6 @@ module Issuable
false false
end 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 def record_metrics
metrics = self.metrics || create_metrics metrics = self.metrics || create_metrics
metrics.record! metrics.record!
......
...@@ -40,7 +40,7 @@ module Milestoneish ...@@ -40,7 +40,7 @@ module Milestoneish
def issues_visible_to_user(user) def issues_visible_to_user(user)
memoize_per_user(user, :issues_visible_to_user) do memoize_per_user(user, :issues_visible_to_user) do
IssuesFinder.new(user, issues_finder_params) IssuesFinder.new(user, issues_finder_params)
.execute.where(milestone_id: milestoneish_ids) .execute.includes(:assignees).where(milestone_id: milestoneish_ids)
end end
end end
......
...@@ -86,7 +86,7 @@ class GlobalMilestone ...@@ -86,7 +86,7 @@ class GlobalMilestone
end end
def issues def issues
@issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels) @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels)
end end
def merge_requests def merge_requests
...@@ -94,7 +94,7 @@ class GlobalMilestone ...@@ -94,7 +94,7 @@ class GlobalMilestone
end end
def participants def participants
@participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq @participants ||= milestones.map(&:participants).flatten.uniq
end end
def labels def labels
......
...@@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base ...@@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all 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 validates :project, presence: true
scope :in_projects, ->(project_ids) { where(project_id: project_ids) } 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 :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) } 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) } 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 ...@@ -37,13 +44,15 @@ class Issue < ActiveRecord::Base
scope :created_after, -> (datetime) { where("created_at >= ?", datetime) } 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 after_save :expire_etag_cache
attr_spammable :title, spam_title: true attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true attr_spammable :description, spam_description: true
participant :assignees
state_machine :state, initial: :opened do state_machine :state, initial: :opened do
event :close do event :close do
transition [:reopened, :opened] => :closed transition [:reopened, :opened] => :closed
...@@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base ...@@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base
end end
def hook_attrs def hook_attrs
assignee_ids = self.assignee_ids
attrs = { attrs = {
total_time_spent: total_time_spent, total_time_spent: total_time_spent,
human_total_time_spent: human_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) attributes.merge!(attrs)
...@@ -114,6 +127,22 @@ class Issue < ActiveRecord::Base ...@@ -114,6 +127,22 @@ class Issue < ActiveRecord::Base
"id DESC") "id DESC")
end 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. # `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false) def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}" reference = "#{self.class.reference_prefix}#{iid}"
...@@ -248,7 +277,7 @@ class Issue < ActiveRecord::Base ...@@ -248,7 +277,7 @@ class Issue < ActiveRecord::Base
true true
elsif confidential? elsif confidential?
author == user || author == user ||
assignee == user || assignees.include?(user) ||
project.team.member?(user, Gitlab::Access::REPORTER) project.team.member?(user, Gitlab::Access::REPORTER)
else else
project.public? || project.public? ||
......
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
...@@ -17,6 +17,8 @@ class MergeRequest < ActiveRecord::Base ...@@ -17,6 +17,8 @@ class MergeRequest < ActiveRecord::Base
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
belongs_to :assignee, class_name: "User"
serialize :merge_params, Hash serialize :merge_params, Hash
after_create :ensure_merge_request_diff, unless: :importing? after_create :ensure_merge_request_diff, unless: :importing?
...@@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base ...@@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base
scope :join_project, -> { joins(:target_project) } scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(: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 :keep_around_commit
after_save :update_assignee_cache_counts, if: :assignee_id_changed?
def self.reference_prefix def self.reference_prefix
'!' '!'
...@@ -177,6 +185,30 @@ class MergeRequest < ActiveRecord::Base ...@@ -177,6 +185,30 @@ class MergeRequest < ActiveRecord::Base
work_in_progress?(title) ? title : "WIP: #{title}" work_in_progress?(title) ? title : "WIP: #{title}"
end 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. # `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false) def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}" reference = "#{self.class.reference_prefix}#{iid}"
......
...@@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base ...@@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base
has_many :issues has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests has_many :merge_requests
has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee
has_many :events, as: :target, dependent: :destroy has_many :events, as: :target, dependent: :destroy
scope :active, -> { with_state(:active) } scope :active, -> { with_state(:active) }
...@@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base ...@@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base
end end
end end
def participants
User.joins(assigned_issues: :milestone).where("milestones.id = ?", id)
end
def self.sort(method) def self.sort(method)
case method.to_s case method.to_s
when 'due_date_asc' when 'due_date_asc'
......
...@@ -100,6 +100,10 @@ class User < ActiveRecord::Base ...@@ -100,6 +100,10 @@ class User < ActiveRecord::Base
has_many :award_emoji, dependent: :destroy has_many :award_emoji, dependent: :destroy
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id 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 # 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 # the user is destroyed. If the user owns any issues during deletion, this
# should be treated as an exceptional condition. # should be treated as an exceptional condition.
......
class IssuableEntity < Grape::Entity class IssuableEntity < Grape::Entity
expose :id expose :id
expose :iid expose :iid
expose :assignee_id
expose :author_id expose :author_id
expose :description expose :description
expose :lock_version expose :lock_version
......
class IssueEntity < IssuableEntity class IssueEntity < IssuableEntity
expose :branch_name expose :branch_name
expose :confidential expose :confidential
expose :assignees, using: API::Entities::UserBasic
expose :due_date expose :due_date
expose :moved_to_id expose :moved_to_id
expose :project_id expose :project_id
......
class MergeRequestEntity < IssuableEntity class MergeRequestEntity < IssuableEntity
expose :assignee_id
expose :in_progress_merge_commit_sha expose :in_progress_merge_commit_sha
expose :locked_at expose :locked_at
expose :merge_commit_sha expose :merge_commit_sha
......
...@@ -7,10 +7,14 @@ module Issuable ...@@ -7,10 +7,14 @@ module Issuable
ids = params.delete(:issuable_ids).split(",") ids = params.delete(:issuable_ids).split(",")
items = model_class.where(id: ids) 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? params.delete(key) unless params[key].present?
end end
if params[:assignee_ids] == [IssuableFinder::NONE.to_s]
params[:assignee_ids] = []
end
items.each do |issuable| items.each do |issuable|
next unless can?(current_user, :"update_#{type}", issuable) next unless can?(current_user, :"update_#{type}", issuable)
......
class IssuableBaseService < BaseService class IssuableBaseService < BaseService
private private
def create_assignee_note(issuable)
SystemNoteService.change_assignee(
issuable, issuable.project, current_user, issuable.assignee)
end
def create_milestone_note(issuable) def create_milestone_note(issuable)
SystemNoteService.change_milestone( SystemNoteService.change_milestone(
issuable, issuable.project, current_user, issuable.milestone) issuable, issuable.project, current_user, issuable.milestone)
...@@ -53,6 +48,7 @@ class IssuableBaseService < BaseService ...@@ -53,6 +48,7 @@ class IssuableBaseService < BaseService
params.delete(:add_label_ids) params.delete(:add_label_ids)
params.delete(:remove_label_ids) params.delete(:remove_label_ids)
params.delete(:label_ids) params.delete(:label_ids)
params.delete(:assignee_ids)
params.delete(:assignee_id) params.delete(:assignee_id)
params.delete(:due_date) params.delete(:due_date)
end end
...@@ -77,7 +73,7 @@ class IssuableBaseService < BaseService ...@@ -77,7 +73,7 @@ class IssuableBaseService < BaseService
def assignee_can_read?(issuable, assignee_id) def assignee_can_read?(issuable, assignee_id)
new_assignee = User.find_by_id(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}" ability_name = :"read_#{issuable.to_ability_name}"
resource = issuable.persisted? ? issuable : project resource = issuable.persisted? ? issuable : project
...@@ -207,6 +203,7 @@ class IssuableBaseService < BaseService ...@@ -207,6 +203,7 @@ class IssuableBaseService < BaseService
filter_params(issuable) filter_params(issuable)
old_labels = issuable.labels.to_a old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.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) 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) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
...@@ -222,7 +219,13 @@ class IssuableBaseService < BaseService ...@@ -222,7 +219,13 @@ class IssuableBaseService < BaseService
handle_common_system_notes(issuable, old_labels: old_labels) handle_common_system_notes(issuable, old_labels: old_labels)
end 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) after_update(issuable)
issuable.create_new_cross_references!(current_user) issuable.create_new_cross_references!(current_user)
execute_hooks(issuable, 'update') execute_hooks(issuable, 'update')
...@@ -272,7 +275,7 @@ class IssuableBaseService < BaseService ...@@ -272,7 +275,7 @@ class IssuableBaseService < BaseService
end end
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] valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
attrs_changed = valid_attrs.any? do |attr| attrs_changed = valid_attrs.any? do |attr|
...@@ -281,7 +284,9 @@ class IssuableBaseService < BaseService ...@@ -281,7 +284,9 @@ class IssuableBaseService < BaseService
labels_changed = issuable.labels != old_labels labels_changed = issuable.labels != old_labels
attrs_changed || labels_changed assignees_changed = issuable.assignees != old_assignees
attrs_changed || labels_changed || assignees_changed
end end
def handle_common_system_notes(issuable, old_labels: []) def handle_common_system_notes(issuable, old_labels: [])
......
...@@ -9,11 +9,33 @@ module Issues ...@@ -9,11 +9,33 @@ module Issues
private 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') def execute_hooks(issue, action = 'open')
issue_data = hook_data(issue, action) issue_data = hook_data(issue, action)
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
issue.project.execute_hooks(issue_data, hooks_scope) issue.project.execute_hooks(issue_data, hooks_scope)
issue.project.execute_services(issue_data, hooks_scope) issue.project.execute_services(issue_data, hooks_scope)
end 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
end end
...@@ -12,8 +12,12 @@ module Issues ...@@ -12,8 +12,12 @@ module Issues
spam_check(issue, current_user) spam_check(issue, current_user)
end end
def handle_changes(issue, old_labels: [], old_mentioned_users: []) def handle_changes(issue, options)
if has_changes?(issue, old_labels: old_labels) 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) todo_service.mark_pending_todos_as_done(issue, current_user)
end end
...@@ -26,9 +30,9 @@ module Issues ...@@ -26,9 +30,9 @@ module Issues
create_milestone_note(issue) create_milestone_note(issue)
end end
if issue.previous_changes.include?('assignee_id') if issue.assignees != old_assignees
create_assignee_note(issue) create_assignee_note(issue, old_assignees)
notification_service.reassigned_issue(issue, current_user) notification_service.reassigned_issue(issue, current_user, old_assignees)
todo_service.reassigned_issue(issue, current_user) todo_service.reassigned_issue(issue, current_user)
end end
......
...@@ -26,15 +26,22 @@ module Members ...@@ -26,15 +26,22 @@ module Members
def unassign_issues_and_merge_requests(member) def unassign_issues_and_merge_requests(member)
if member.is_a?(GroupMember) if member.is_a?(GroupMember)
IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). issue_ids = IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
execute. execute.pluck(:id)
update_all(assignee_id: nil)
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). MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
execute. execute.
update_all(assignee_id: nil) update_all(assignee_id: nil)
else else
project = member.source 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) project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil)
member.user.update_cache_counts member.user.update_cache_counts
end end
......
...@@ -4,7 +4,7 @@ module MergeRequests ...@@ -4,7 +4,7 @@ module MergeRequests
@assignable_issues ||= begin @assignable_issues ||= begin
if current_user == merge_request.author if current_user == merge_request.author
closes_issues.select do |issue| 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 end
else else
[] []
...@@ -14,7 +14,7 @@ module MergeRequests ...@@ -14,7 +14,7 @@ module MergeRequests
def execute def execute
assignable_issues.each do |issue| 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 end
{ {
......
...@@ -38,6 +38,11 @@ module MergeRequests ...@@ -38,6 +38,11 @@ module MergeRequests
private 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. # Returns all origin and fork merge requests from `@project` satisfying passed arguments.
def merge_requests_for(source_branch, mr_states: [:opened, :reopened]) def merge_requests_for(source_branch, mr_states: [:opened, :reopened])
MergeRequest MergeRequest
......
...@@ -21,7 +21,10 @@ module MergeRequests ...@@ -21,7 +21,10 @@ module MergeRequests
update(merge_request) update(merge_request)
end 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) if has_changes?(merge_request, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(merge_request, current_user) todo_service.mark_pending_todos_as_done(merge_request, current_user)
end end
......
...@@ -19,9 +19,14 @@ class NotificationRecipientService ...@@ -19,9 +19,14 @@ class NotificationRecipientService
# Re-assign is considered as a mention of the new assignee so we add the # 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 # new assignee to the list of recipients after we rejected users with
# the "on mention" notification level # 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 << previous_assignee if previous_assignee
recipients << target.assignee recipients << target.assignee
when :reassign_issue
previous_assignees = Array(previous_assignee)
recipients.concat(previous_assignees)
recipients.concat(target.assignees)
end end
recipients = reject_muted_users(recipients) recipients = reject_muted_users(recipients)
......
...@@ -66,8 +66,25 @@ class NotificationService ...@@ -66,8 +66,25 @@ class NotificationService
# * issue new assignee if their notification level is not Disabled # * issue new assignee if their notification level is not Disabled
# * users with custom level checked with "reassign issue" # * users with custom level checked with "reassign issue"
# #
def reassigned_issue(issue, current_user) def reassigned_issue(issue, current_user, previous_assignees = [])
reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email) 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 end
# When we add labels to an issue we should send an email to: # When we add labels to an issue we should send an email to:
...@@ -367,10 +384,10 @@ class NotificationService ...@@ -367,10 +384,10 @@ class NotificationService
end end
def previous_record(object, attribute) def previous_record(object, attribute)
if object && attribute return unless object && attribute
if object.previous_changes.include?(attribute) if object.previous_changes.include?(attribute)
object.previous_changes[attribute].first object.previous_changes[attribute].first
end end
end end
end
end end
...@@ -91,33 +91,48 @@ module SlashCommands ...@@ -91,33 +91,48 @@ module SlashCommands
end end
desc 'Assign' desc 'Assign'
explanation do |user| explanation do |users|
"Assigns #{user.to_reference}." if user "Assigns #{users.map(&:to_reference).to_sentence}." if users.any?
end end
params '@user' params '@user'
condition do condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end end
parse_params do |assignee_param| parse_params do |assignee_param|
extract_references(assignee_param, :user).first || users = extract_references(assignee_param, :user)
User.find_by(username: assignee_param)
if users.empty?
users = User.where(username: assignee_param.split(' ').map(&:strip))
end
users
end
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
command :assign do |user|
@updates[:assignee_id] = user.id if user
end end
desc 'Remove assignee' desc 'Remove assignee'
explanation do explanation do
"Removes assignee #{issuable.assignee.to_reference}." "Removes assignee #{issuable.assignees.first.to_reference}."
end end
condition do condition do
issuable.persisted? && issuable.persisted? &&
issuable.assignee_id? && issuable.assignees.any? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project) current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end end
command :unassign do command :unassign do
if issuable.is_a?(Issue)
@updates[:assignee_ids] = []
else
@updates[:assignee_id] = nil @updates[:assignee_id] = nil
end end
end
desc 'Set milestone' desc 'Set milestone'
explanation do |milestone| explanation do |milestone|
......
...@@ -49,6 +49,44 @@ module SystemNoteService ...@@ -49,6 +49,44 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
end 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 # Called when one or more labels on a Noteable are added and/or removed
# #
# noteable - Noteable object # noteable - Noteable object
......
...@@ -251,9 +251,9 @@ class TodoService ...@@ -251,9 +251,9 @@ class TodoService
end end
def create_assignment_todo(issuable, author) def create_assignment_todo(issuable, author)
if issuable.assignee if issuable.assignees.any?
attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED) attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED)
create_todos(issuable.assignee, attributes) create_todos(issuable.assignees, attributes)
end end
end end
......
...@@ -23,10 +23,19 @@ xml.entry do ...@@ -23,10 +23,19 @@ xml.entry do
end end
end end
if issue.assignee if issue.assignees.any?
xml.assignees do
issue.assignees.each do |assignee|
xml.assignee do xml.assignee do
xml.name issue.assignee.name xml.name assignee.name
xml.email issue.assignee_public_email xml.email assignee.public_email
end
end
end
xml.assignee do
xml.name issue.assignees.first.name
xml.email issue.assignees.first.public_email
end end
end end
end end
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'}" %>
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
%p.details %p.details
#{link_to @issue.author_name, user_url(@issue.author)} created an issue: #{link_to @issue.author_name, user_url(@issue.author)} created an issue:
- if @issue.assignee_id.present? - if @issue.assignees.any?
%p %p
Assignee: #{@issue.assignee_name} Assignee: #{@issue.assignee_list}
- if @issue.description - if @issue.description
%div %div
......
...@@ -2,6 +2,6 @@ New Issue was created. ...@@ -2,6 +2,6 @@ New Issue was created.
Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
Author: <%= @issue.author_name %> Author: <%= @issue.author_name %>
Assignee: <%= @issue.assignee_name %> Assignee: <%= @issue.assignee_list %>
<%= @issue.description %> <%= @issue.description %>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment