Merge branch 'mia_backort' into 'master'
Backport of Multiple Assignees feature See merge request !11089
This commit is contained in:
commit
aa874a1cd6
|
@ -99,7 +99,7 @@ export default class FileTemplateMediator {
|
|||
});
|
||||
}
|
||||
|
||||
selectTemplateType(item, el, e) {
|
||||
selectTemplateType(item, e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
@ -117,6 +117,10 @@ export default class FileTemplateMediator {
|
|||
this.cacheToggleText();
|
||||
}
|
||||
|
||||
selectTemplateTypeOptions(options) {
|
||||
this.selectTemplateType(options.selectedObj, options.e);
|
||||
}
|
||||
|
||||
selectTemplateFile(selector, query, data) {
|
||||
selector.renderLoading();
|
||||
// in case undo menu is already already there
|
||||
|
|
|
@ -52,9 +52,17 @@ export default class FileTemplateSelector {
|
|||
.removeClass('fa-spinner fa-spin');
|
||||
}
|
||||
|
||||
reportSelection(query, el, e, data) {
|
||||
reportSelection(options) {
|
||||
const { query, e, data } = options;
|
||||
e.preventDefault();
|
||||
return this.mediator.selectTemplateFile(this, query, data);
|
||||
}
|
||||
|
||||
reportSelectionName(options) {
|
||||
const opts = options;
|
||||
opts.query = options.selectedObj.name;
|
||||
|
||||
this.reportSelection(opts);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,8 +37,8 @@ class TargetBranchDropDown {
|
|||
}
|
||||
return SELECT_ITEM_MSG;
|
||||
},
|
||||
clicked(item, el, e) {
|
||||
e.preventDefault();
|
||||
clicked(options) {
|
||||
options.e.preventDefault();
|
||||
self.onClick.call(self);
|
||||
},
|
||||
fieldName: self.fieldName,
|
||||
|
|
|
@ -24,7 +24,7 @@ export default class TemplateSelector {
|
|||
search: {
|
||||
fields: ['name'],
|
||||
},
|
||||
clicked: (item, el, e) => this.fetchFileTemplate(item, el, e),
|
||||
clicked: options => this.fetchFileTemplate(options),
|
||||
text: item => item.name,
|
||||
});
|
||||
}
|
||||
|
@ -51,7 +51,10 @@ export default class TemplateSelector {
|
|||
return this.$dropdownContainer.removeClass('hidden');
|
||||
}
|
||||
|
||||
fetchFileTemplate(item, el, e) {
|
||||
fetchFileTemplate(options) {
|
||||
const { e } = options;
|
||||
const item = options.selectedObj;
|
||||
|
||||
e.preventDefault();
|
||||
return this.requestFile(item);
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector {
|
|||
search: {
|
||||
fields: ['name'],
|
||||
},
|
||||
clicked: (query, el, e) => this.reportSelection(query.name, el, e),
|
||||
clicked: options => this.reportSelectionName(options),
|
||||
text: item => item.name,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector {
|
|||
search: {
|
||||
fields: ['name'],
|
||||
},
|
||||
clicked: (query, el, e) => this.reportSelection(query.name, el, e),
|
||||
clicked: options => this.reportSelectionName(options),
|
||||
text: item => item.name,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector {
|
|||
search: {
|
||||
fields: ['name'],
|
||||
},
|
||||
clicked: (query, el, e) => this.reportSelection(query.name, el, e),
|
||||
clicked: options => this.reportSelectionName(options),
|
||||
text: item => item.name,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector {
|
|||
search: {
|
||||
fields: ['name'],
|
||||
},
|
||||
clicked: (query, el, e) => {
|
||||
clicked: (options) => {
|
||||
const { e } = options;
|
||||
const el = options.$el;
|
||||
const query = options.selectedObj;
|
||||
|
||||
const data = {
|
||||
project: this.$dropdown.data('project'),
|
||||
fullname: this.$dropdown.data('fullname'),
|
||||
};
|
||||
|
||||
this.reportSelection(query.id, el, e, data);
|
||||
this.reportSelection({
|
||||
query: query.id,
|
||||
el,
|
||||
e,
|
||||
data,
|
||||
});
|
||||
},
|
||||
text: item => item.name,
|
||||
});
|
||||
|
|
|
@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector {
|
|||
filterable: false,
|
||||
selectable: true,
|
||||
toggleLabel: item => item.name,
|
||||
clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e),
|
||||
clicked: options => this.mediator.selectTemplateTypeOptions(options),
|
||||
text: item => item.name,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ require('./models/issue');
|
|||
require('./models/label');
|
||||
require('./models/list');
|
||||
require('./models/milestone');
|
||||
require('./models/user');
|
||||
require('./models/assignee');
|
||||
require('./stores/boards_store');
|
||||
require('./stores/modal_store');
|
||||
require('./services/board_service');
|
||||
|
|
|
@ -26,6 +26,7 @@ export default {
|
|||
title: this.title,
|
||||
labels,
|
||||
subscribed: true,
|
||||
assignees: [],
|
||||
});
|
||||
|
||||
this.list.newIssue(issue)
|
||||
|
|
|
@ -3,8 +3,13 @@
|
|||
/* global MilestoneSelect */
|
||||
/* global LabelsSelect */
|
||||
/* global Sidebar */
|
||||
/* global Flash */
|
||||
|
||||
import Vue from 'vue';
|
||||
import eventHub from '../../sidebar/event_hub';
|
||||
|
||||
import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
|
||||
import Assignees from '../../sidebar/components/assignees/assignees';
|
||||
|
||||
require('./sidebar/remove_issue');
|
||||
|
||||
|
@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
|
|||
detail: Store.detail,
|
||||
issue: {},
|
||||
list: {},
|
||||
loadingAssignees: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({
|
|||
|
||||
this.issue = this.detail.issue;
|
||||
this.list = this.detail.list;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
|
||||
});
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
|
@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({
|
|||
$('.right-sidebar').getNiceScroll().resize();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.issue = this.detail.issue;
|
||||
this.list = this.detail.list;
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
methods: {
|
||||
closeSidebar () {
|
||||
this.detail.issue = {};
|
||||
}
|
||||
},
|
||||
assignSelf () {
|
||||
// Notify gl dropdown that we are now assigning to current user
|
||||
this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
|
||||
|
||||
this.addAssignee(this.currentUser);
|
||||
this.saveAssignees();
|
||||
},
|
||||
removeAssignee (a) {
|
||||
gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a);
|
||||
},
|
||||
addAssignee (a) {
|
||||
gl.issueBoards.BoardsStore.detail.issue.addAssignee(a);
|
||||
},
|
||||
removeAllAssignees () {
|
||||
gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees();
|
||||
},
|
||||
saveAssignees () {
|
||||
this.loadingAssignees = true;
|
||||
|
||||
gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
|
||||
.then(() => {
|
||||
this.loadingAssignees = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.loadingAssignees = false;
|
||||
return new Flash('An error occurred while saving assignees');
|
||||
});
|
||||
},
|
||||
},
|
||||
created () {
|
||||
// Get events from glDropdown
|
||||
eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
|
||||
eventHub.$on('sidebar.addAssignee', this.addAssignee);
|
||||
eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
|
||||
eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
|
||||
eventHub.$off('sidebar.addAssignee', this.addAssignee);
|
||||
eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
|
||||
eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
|
||||
},
|
||||
mounted () {
|
||||
new IssuableContext(this.currentUser);
|
||||
|
@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
|
|||
},
|
||||
components: {
|
||||
removeBtn: gl.issueBoards.RemoveIssueBtn,
|
||||
'assignee-title': AssigneeTitle,
|
||||
assignees: Assignees,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -31,19 +31,37 @@ gl.issueBoards.IssueCardInner = Vue.extend({
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
limitBeforeCounter: 3,
|
||||
maxRender: 4,
|
||||
maxCounter: 99,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
numberOverLimit() {
|
||||
return this.issue.assignees.length - this.limitBeforeCounter;
|
||||
},
|
||||
assigneeCounterTooltip() {
|
||||
return `${this.assigneeCounterLabel} more`;
|
||||
},
|
||||
assigneeCounterLabel() {
|
||||
if (this.numberOverLimit > this.maxCounter) {
|
||||
return `${this.maxCounter}+`;
|
||||
}
|
||||
|
||||
return `+${this.numberOverLimit}`;
|
||||
},
|
||||
shouldRenderCounter() {
|
||||
if (this.issue.assignees.length <= this.maxRender) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.issue.assignees.length > this.numberOverLimit;
|
||||
},
|
||||
cardUrl() {
|
||||
return `${this.issueLinkBase}/${this.issue.id}`;
|
||||
},
|
||||
assigneeUrl() {
|
||||
return `${this.rootPath}${this.issue.assignee.username}`;
|
||||
},
|
||||
assigneeUrlTitle() {
|
||||
return `Assigned to ${this.issue.assignee.name}`;
|
||||
},
|
||||
avatarUrlTitle() {
|
||||
return `Avatar for ${this.issue.assignee.name}`;
|
||||
},
|
||||
issueId() {
|
||||
return `#${this.issue.id}`;
|
||||
},
|
||||
|
@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
isIndexLessThanlimit(index) {
|
||||
return index < this.limitBeforeCounter;
|
||||
},
|
||||
shouldRenderAssignee(index) {
|
||||
// Eg. maxRender is 4,
|
||||
// Render up to all 4 assignees if there are only 4 assigness
|
||||
// Otherwise render up to the limitBeforeCounter
|
||||
if (this.issue.assignees.length <= this.maxRender) {
|
||||
return index < this.maxRender;
|
||||
}
|
||||
|
||||
return index < this.limitBeforeCounter;
|
||||
},
|
||||
assigneeUrl(assignee) {
|
||||
return `${this.rootPath}${assignee.username}`;
|
||||
},
|
||||
assigneeUrlTitle(assignee) {
|
||||
return `Assigned to ${assignee.name}`;
|
||||
},
|
||||
avatarUrlTitle(assignee) {
|
||||
return `Avatar for ${assignee.name}`;
|
||||
},
|
||||
showLabel(label) {
|
||||
if (!this.list) return true;
|
||||
|
||||
|
@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({
|
|||
{{ issueId }}
|
||||
</span>
|
||||
</h4>
|
||||
<a
|
||||
class="card-assignee has-tooltip js-no-trigger"
|
||||
:href="assigneeUrl"
|
||||
:title="assigneeUrlTitle"
|
||||
v-if="issue.assignee"
|
||||
data-container="body"
|
||||
>
|
||||
<img
|
||||
class="avatar avatar-inline s20 js-no-trigger"
|
||||
:src="issue.assignee.avatar"
|
||||
width="20"
|
||||
height="20"
|
||||
:alt="avatarUrlTitle"
|
||||
/>
|
||||
</a>
|
||||
<div class="card-assignee">
|
||||
<a
|
||||
class="has-tooltip js-no-trigger"
|
||||
:href="assigneeUrl(assignee)"
|
||||
:title="assigneeUrlTitle(assignee)"
|
||||
v-for="(assignee, index) in issue.assignees"
|
||||
v-if="shouldRenderAssignee(index)"
|
||||
data-container="body"
|
||||
data-placement="bottom"
|
||||
>
|
||||
<img
|
||||
class="avatar avatar-inline s20"
|
||||
:src="assignee.avatar"
|
||||
width="20"
|
||||
height="20"
|
||||
:alt="avatarUrlTitle(assignee)"
|
||||
/>
|
||||
</a>
|
||||
<span
|
||||
class="avatar-counter has-tooltip"
|
||||
:title="assigneeCounterTooltip"
|
||||
v-if="shouldRenderCounter"
|
||||
>
|
||||
{{ assigneeCounterLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer" v-if="showLabelFooter">
|
||||
<div
|
||||
class="card-footer"
|
||||
v-if="showLabelFooter"
|
||||
>
|
||||
<button
|
||||
class="label color-label has-tooltip js-no-trigger"
|
||||
class="label color-label has-tooltip"
|
||||
v-for="label in issue.labels"
|
||||
type="button"
|
||||
v-if="showLabel(label)"
|
||||
|
|
|
@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => {
|
|||
filterable: true,
|
||||
selectable: true,
|
||||
multiSelect: true,
|
||||
clicked (label, $el, e) {
|
||||
clicked (options) {
|
||||
const { e } = options;
|
||||
const label = options.selectedObj;
|
||||
e.preventDefault();
|
||||
|
||||
if (!Store.findList('title', label.title)) {
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class ListUser {
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
class ListAssignee {
|
||||
constructor(user, defaultAvatar) {
|
||||
this.id = user.id;
|
||||
this.name = user.name;
|
||||
|
@ -7,4 +9,4 @@ class ListUser {
|
|||
}
|
||||
}
|
||||
|
||||
window.ListUser = ListUser;
|
||||
window.ListAssignee = ListAssignee;
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */
|
||||
/* global ListLabel */
|
||||
/* global ListMilestone */
|
||||
/* global ListUser */
|
||||
/* global ListAssignee */
|
||||
|
||||
import Vue from 'vue';
|
||||
|
||||
|
@ -14,14 +14,10 @@ class ListIssue {
|
|||
this.dueDate = obj.due_date;
|
||||
this.subscribed = obj.subscribed;
|
||||
this.labels = [];
|
||||
this.assignees = [];
|
||||
this.selected = false;
|
||||
this.assignee = false;
|
||||
this.position = obj.relative_position || Infinity;
|
||||
|
||||
if (obj.assignee) {
|
||||
this.assignee = new ListUser(obj.assignee, defaultAvatar);
|
||||
}
|
||||
|
||||
if (obj.milestone) {
|
||||
this.milestone = new ListMilestone(obj.milestone);
|
||||
}
|
||||
|
@ -29,6 +25,8 @@ class ListIssue {
|
|||
obj.labels.forEach((label) => {
|
||||
this.labels.push(new ListLabel(label));
|
||||
});
|
||||
|
||||
this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar));
|
||||
}
|
||||
|
||||
addLabel (label) {
|
||||
|
@ -51,6 +49,26 @@ class ListIssue {
|
|||
labels.forEach(this.removeLabel.bind(this));
|
||||
}
|
||||
|
||||
addAssignee (assignee) {
|
||||
if (!this.findAssignee(assignee)) {
|
||||
this.assignees.push(new ListAssignee(assignee));
|
||||
}
|
||||
}
|
||||
|
||||
findAssignee (findAssignee) {
|
||||
return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
|
||||
}
|
||||
|
||||
removeAssignee (removeAssignee) {
|
||||
if (removeAssignee) {
|
||||
this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
|
||||
}
|
||||
}
|
||||
|
||||
removeAllAssignees () {
|
||||
this.assignees = [];
|
||||
}
|
||||
|
||||
getLists () {
|
||||
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
|
||||
}
|
||||
|
@ -60,7 +78,7 @@ class ListIssue {
|
|||
issue: {
|
||||
milestone_id: this.milestone ? this.milestone.id : null,
|
||||
due_date: this.dueDate,
|
||||
assignee_id: this.assignee ? this.assignee.id : null,
|
||||
assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0],
|
||||
label_ids: this.labels.map((label) => label.id)
|
||||
}
|
||||
};
|
||||
|
|
|
@ -255,7 +255,8 @@ GitLabDropdown = (function() {
|
|||
}
|
||||
};
|
||||
// Remote data
|
||||
})(this)
|
||||
})(this),
|
||||
instance: this,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -269,6 +270,7 @@ GitLabDropdown = (function() {
|
|||
remote: this.options.filterRemote,
|
||||
query: this.options.data,
|
||||
keys: searchFields,
|
||||
instance: this,
|
||||
elements: (function(_this) {
|
||||
return function() {
|
||||
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
|
||||
|
@ -343,21 +345,26 @@ GitLabDropdown = (function() {
|
|||
}
|
||||
this.dropdown.on("click", selector, function(e) {
|
||||
var $el, selected, selectedObj, isMarking;
|
||||
$el = $(this);
|
||||
$el = $(e.currentTarget);
|
||||
selected = self.rowClicked($el);
|
||||
selectedObj = selected ? selected[0] : null;
|
||||
isMarking = selected ? selected[1] : null;
|
||||
if (self.options.clicked) {
|
||||
self.options.clicked(selectedObj, $el, e, isMarking);
|
||||
if (this.options.clicked) {
|
||||
this.options.clicked.call(this, {
|
||||
selectedObj,
|
||||
$el,
|
||||
e,
|
||||
isMarking,
|
||||
});
|
||||
}
|
||||
|
||||
// Update label right after all modifications in dropdown has been done
|
||||
if (self.options.toggleLabel) {
|
||||
self.updateLabel(selectedObj, $el, self);
|
||||
if (this.options.toggleLabel) {
|
||||
this.updateLabel(selectedObj, $el, this);
|
||||
}
|
||||
|
||||
$el.trigger('blur');
|
||||
});
|
||||
}.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -439,15 +446,34 @@ GitLabDropdown = (function() {
|
|||
}
|
||||
};
|
||||
|
||||
GitLabDropdown.prototype.filteredFullData = function() {
|
||||
return this.fullData.filter(r => typeof r === 'object'
|
||||
&& !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
|
||||
&& !Object.prototype.hasOwnProperty.call(r, 'header')
|
||||
);
|
||||
};
|
||||
|
||||
GitLabDropdown.prototype.opened = function(e) {
|
||||
var contentHtml;
|
||||
this.resetRows();
|
||||
this.addArrowKeyEvent();
|
||||
|
||||
const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
|
||||
const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
|
||||
const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
|
||||
|
||||
// Makes indeterminate items effective
|
||||
if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) {
|
||||
if (this.fullData && hasFilterBulkUpdate) {
|
||||
this.parseData(this.fullData);
|
||||
}
|
||||
|
||||
// Process the data to make sure rendered data
|
||||
// matches the correct layout
|
||||
if (this.fullData && hasMultiSelect && this.options.processData) {
|
||||
const inputValue = this.filterInput.val();
|
||||
this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
|
||||
}
|
||||
|
||||
contentHtml = $('.dropdown-content', this.dropdown).html();
|
||||
if (this.remote && contentHtml === "") {
|
||||
this.remote.execute();
|
||||
|
@ -709,6 +735,11 @@ GitLabDropdown = (function() {
|
|||
if (this.options.inputId != null) {
|
||||
$input.attr('id', this.options.inputId);
|
||||
}
|
||||
|
||||
if (this.options.inputMeta) {
|
||||
$input.attr('data-meta', selectedObject[this.options.inputMeta]);
|
||||
}
|
||||
|
||||
return this.dropdown.before($input);
|
||||
};
|
||||
|
||||
|
@ -829,7 +860,14 @@ GitLabDropdown = (function() {
|
|||
if (instance == null) {
|
||||
instance = null;
|
||||
}
|
||||
return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance));
|
||||
|
||||
let toggleText = this.options.toggleLabel(selected, el, instance);
|
||||
if (this.options.updateLabel) {
|
||||
// Option to override the dropdown label text
|
||||
toggleText = this.options.updateLabel;
|
||||
}
|
||||
|
||||
return $(this.el).find(".dropdown-toggle-text").text(toggleText);
|
||||
};
|
||||
|
||||
GitLabDropdown.prototype.clearField = function(field, isInput) {
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
require('./time_tracking/time_tracking_bundle');
|
|
@ -1,42 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import stopwatchSvg from 'icons/_icon_stopwatch.svg';
|
||||
|
||||
require('../../../lib/utils/pretty_time');
|
||||
|
||||
(() => {
|
||||
Vue.component('time-tracking-collapsed-state', {
|
||||
name: 'time-tracking-collapsed-state',
|
||||
props: [
|
||||
'showComparisonState',
|
||||
'showSpentOnlyState',
|
||||
'showEstimateOnlyState',
|
||||
'showNoTimeTrackingState',
|
||||
'timeSpentHumanReadable',
|
||||
'timeEstimateHumanReadable',
|
||||
],
|
||||
methods: {
|
||||
abbreviateTime(timeStr) {
|
||||
return gl.utils.prettyTime.abbreviateTime(timeStr);
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class='sidebar-collapsed-icon'>
|
||||
${stopwatchSvg}
|
||||
<div class='time-tracking-collapsed-summary'>
|
||||
<div class='compare' v-if='showComparisonState'>
|
||||
<span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
|
||||
</div>
|
||||
<div class='estimate-only' v-if='showEstimateOnlyState'>
|
||||
<span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
|
||||
</div>
|
||||
<div class='spend-only' v-if='showSpentOnlyState'>
|
||||
<span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span>
|
||||
</div>
|
||||
<div class='no-tracking' v-if='showNoTimeTrackingState'>
|
||||
<span class='no-value'>None</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -1,70 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
require('../../../lib/utils/pretty_time');
|
||||
|
||||
(() => {
|
||||
const prettyTime = gl.utils.prettyTime;
|
||||
|
||||
Vue.component('time-tracking-comparison-pane', {
|
||||
name: 'time-tracking-comparison-pane',
|
||||
props: [
|
||||
'timeSpent',
|
||||
'timeEstimate',
|
||||
'timeSpentHumanReadable',
|
||||
'timeEstimateHumanReadable',
|
||||
],
|
||||
computed: {
|
||||
parsedRemaining() {
|
||||
const diffSeconds = this.timeEstimate - this.timeSpent;
|
||||
return prettyTime.parseSeconds(diffSeconds);
|
||||
},
|
||||
timeRemainingHumanReadable() {
|
||||
return prettyTime.stringifyTime(this.parsedRemaining);
|
||||
},
|
||||
timeRemainingTooltip() {
|
||||
const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
|
||||
return `${prefix} ${this.timeRemainingHumanReadable}`;
|
||||
},
|
||||
/* Diff values for comparison meter */
|
||||
timeRemainingMinutes() {
|
||||
return this.timeEstimate - this.timeSpent;
|
||||
},
|
||||
timeRemainingPercent() {
|
||||
return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
|
||||
},
|
||||
timeRemainingStatusClass() {
|
||||
return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
|
||||
},
|
||||
/* Parsed time values */
|
||||
parsedEstimate() {
|
||||
return prettyTime.parseSeconds(this.timeEstimate);
|
||||
},
|
||||
parsedSpent() {
|
||||
return prettyTime.parseSeconds(this.timeSpent);
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class='time-tracking-comparison-pane'>
|
||||
<div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay'
|
||||
:aria-valuenow='timeRemainingTooltip'
|
||||
:title='timeRemainingTooltip'
|
||||
:data-original-title='timeRemainingTooltip'
|
||||
:class='timeRemainingStatusClass'>
|
||||
<div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'>
|
||||
<div :style='{ width: timeRemainingPercent }' class='meter-fill'></div>
|
||||
</div>
|
||||
<div class='compare-display-container'>
|
||||
<div class='compare-display pull-left'>
|
||||
<span class='compare-label'>Spent</span>
|
||||
<span class='compare-value spent'>{{ timeSpentHumanReadable }}</span>
|
||||
</div>
|
||||
<div class='compare-display estimated pull-right'>
|
||||
<span class='compare-label'>Est</span>
|
||||
<span class='compare-value'>{{ timeEstimateHumanReadable }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -1,14 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
(() => {
|
||||
Vue.component('time-tracking-estimate-only-pane', {
|
||||
name: 'time-tracking-estimate-only-pane',
|
||||
props: ['timeEstimateHumanReadable'],
|
||||
template: `
|
||||
<div class='time-tracking-estimate-only-pane'>
|
||||
<span class='bold'>Estimated:</span>
|
||||
{{ timeEstimateHumanReadable }}
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -1,25 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
(() => {
|
||||
Vue.component('time-tracking-help-state', {
|
||||
name: 'time-tracking-help-state',
|
||||
props: ['docsUrl'],
|
||||
template: `
|
||||
<div class='time-tracking-help-state'>
|
||||
<div class='time-tracking-info'>
|
||||
<h4>Track time with slash commands</h4>
|
||||
<p>Slash commands can be used in the issues description and comment boxes.</p>
|
||||
<p>
|
||||
<code>/estimate</code>
|
||||
will update the estimated time with the latest command.
|
||||
</p>
|
||||
<p>
|
||||
<code>/spend</code>
|
||||
will update the sum of the time spent.
|
||||
</p>
|
||||
<a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -1,12 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
(() => {
|
||||
Vue.component('time-tracking-no-tracking-pane', {
|
||||
name: 'time-tracking-no-tracking-pane',
|
||||
template: `
|
||||
<div class='time-tracking-no-tracking-pane'>
|
||||
<span class='no-value'>No estimate or time spent</span>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -1,14 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
(() => {
|
||||
Vue.component('time-tracking-spent-only-pane', {
|
||||
name: 'time-tracking-spent-only-pane',
|
||||
props: ['timeSpentHumanReadable'],
|
||||
template: `
|
||||
<div class='time-tracking-spend-only-pane'>
|
||||
<span class='bold'>Spent:</span>
|
||||
{{ timeSpentHumanReadable }}
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -1,117 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
require('./help_state');
|
||||
require('./collapsed_state');
|
||||
require('./spent_only_pane');
|
||||
require('./no_tracking_pane');
|
||||
require('./estimate_only_pane');
|
||||
require('./comparison_pane');
|
||||
|
||||
(() => {
|
||||
Vue.component('issuable-time-tracker', {
|
||||
name: 'issuable-time-tracker',
|
||||
props: [
|
||||
'time_estimate',
|
||||
'time_spent',
|
||||
'human_time_estimate',
|
||||
'human_time_spent',
|
||||
'docsUrl',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
showHelp: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
timeSpent() {
|
||||
return this.time_spent;
|
||||
},
|
||||
timeEstimate() {
|
||||
return this.time_estimate;
|
||||
},
|
||||
timeEstimateHumanReadable() {
|
||||
return this.human_time_estimate;
|
||||
},
|
||||
timeSpentHumanReadable() {
|
||||
return this.human_time_spent;
|
||||
},
|
||||
hasTimeSpent() {
|
||||
return !!this.timeSpent;
|
||||
},
|
||||
hasTimeEstimate() {
|
||||
return !!this.timeEstimate;
|
||||
},
|
||||
showComparisonState() {
|
||||
return this.hasTimeEstimate && this.hasTimeSpent;
|
||||
},
|
||||
showEstimateOnlyState() {
|
||||
return this.hasTimeEstimate && !this.hasTimeSpent;
|
||||
},
|
||||
showSpentOnlyState() {
|
||||
return this.hasTimeSpent && !this.hasTimeEstimate;
|
||||
},
|
||||
showNoTimeTrackingState() {
|
||||
return !this.hasTimeEstimate && !this.hasTimeSpent;
|
||||
},
|
||||
showHelpState() {
|
||||
return !!this.showHelp;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleHelpState(show) {
|
||||
this.showHelp = show;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class='time_tracker time-tracking-component-wrap' v-cloak>
|
||||
<time-tracking-collapsed-state
|
||||
:show-comparison-state='showComparisonState'
|
||||
:show-help-state='showHelpState'
|
||||
:show-spent-only-state='showSpentOnlyState'
|
||||
:show-estimate-only-state='showEstimateOnlyState'
|
||||
:time-spent-human-readable='timeSpentHumanReadable'
|
||||
:time-estimate-human-readable='timeEstimateHumanReadable'>
|
||||
</time-tracking-collapsed-state>
|
||||
<div class='title hide-collapsed'>
|
||||
Time tracking
|
||||
<div class='help-button pull-right'
|
||||
v-if='!showHelpState'
|
||||
@click='toggleHelpState(true)'>
|
||||
<i class='fa fa-question-circle' aria-hidden='true'></i>
|
||||
</div>
|
||||
<div class='close-help-button pull-right'
|
||||
v-if='showHelpState'
|
||||
@click='toggleHelpState(false)'>
|
||||
<i class='fa fa-close' aria-hidden='true'></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class='time-tracking-content hide-collapsed'>
|
||||
<time-tracking-estimate-only-pane
|
||||
v-if='showEstimateOnlyState'
|
||||
:time-estimate-human-readable='timeEstimateHumanReadable'>
|
||||
</time-tracking-estimate-only-pane>
|
||||
<time-tracking-spent-only-pane
|
||||
v-if='showSpentOnlyState'
|
||||
:time-spent-human-readable='timeSpentHumanReadable'>
|
||||
</time-tracking-spent-only-pane>
|
||||
<time-tracking-no-tracking-pane
|
||||
v-if='showNoTimeTrackingState'>
|
||||
</time-tracking-no-tracking-pane>
|
||||
<time-tracking-comparison-pane
|
||||
v-if='showComparisonState'
|
||||
:time-estimate='timeEstimate'
|
||||
:time-spent='timeSpent'
|
||||
:time-spent-human-readable='timeSpentHumanReadable'
|
||||
:time-estimate-human-readable='timeEstimateHumanReadable'>
|
||||
</time-tracking-comparison-pane>
|
||||
<transition name='help-state-toggle'>
|
||||
<time-tracking-help-state
|
||||
v-if='showHelpState'
|
||||
:docs-url='docsUrl'>
|
||||
</time-tracking-help-state>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -1,66 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import VueResource from 'vue-resource';
|
||||
|
||||
require('./components/time_tracker');
|
||||
require('../../smart_interval');
|
||||
require('../../subbable_resource');
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
(() => {
|
||||
/* This Vue instance represents what will become the parent instance for the
|
||||
* sidebar. It will be responsible for managing `issuable` state and propagating
|
||||
* changes to sidebar components. We will want to create a separate service to
|
||||
* interface with the server at that point.
|
||||
*/
|
||||
|
||||
class IssuableTimeTracking {
|
||||
constructor(issuableJSON) {
|
||||
const parsedIssuable = JSON.parse(issuableJSON);
|
||||
return this.initComponent(parsedIssuable);
|
||||
}
|
||||
|
||||
initComponent(parsedIssuable) {
|
||||
this.parentInstance = new Vue({
|
||||
el: '#issuable-time-tracker',
|
||||
data: {
|
||||
issuable: parsedIssuable,
|
||||
},
|
||||
methods: {
|
||||
fetchIssuable() {
|
||||
return gl.IssuableResource.get.call(gl.IssuableResource, {
|
||||
type: 'GET',
|
||||
url: gl.IssuableResource.endpoint,
|
||||
});
|
||||
},
|
||||
updateState(data) {
|
||||
this.issuable = data;
|
||||
},
|
||||
subscribeToUpdates() {
|
||||
gl.IssuableResource.subscribe(data => this.updateState(data));
|
||||
},
|
||||
listenForSlashCommands() {
|
||||
$(document).on('ajax:success', '.gfm-form', (e, data) => {
|
||||
const subscribedCommands = ['spend_time', 'time_estimate'];
|
||||
const changedCommands = data.commands_changes
|
||||
? Object.keys(data.commands_changes)
|
||||
: [];
|
||||
if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
|
||||
this.fetchIssuable();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchIssuable();
|
||||
},
|
||||
mounted() {
|
||||
this.subscribeToUpdates();
|
||||
this.listenForSlashCommands();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
gl.IssuableTimeTracking = IssuableTimeTracking;
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -19,8 +19,8 @@
|
|||
return label;
|
||||
};
|
||||
})(this),
|
||||
clicked: function(item, $el, e) {
|
||||
return e.preventDefault();
|
||||
clicked: function(options) {
|
||||
return options.e.preventDefault();
|
||||
},
|
||||
id: function(obj, el) {
|
||||
return $(el).data("id");
|
||||
|
|
|
@ -88,7 +88,10 @@
|
|||
const formData = {
|
||||
update: {
|
||||
state_event: this.form.find('input[name="update[state_event]"]').val(),
|
||||
// For Merge Requests
|
||||
assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
|
||||
// For Issues
|
||||
assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
|
||||
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
|
||||
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
|
||||
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
|
||||
|
|
|
@ -330,7 +330,10 @@
|
|||
},
|
||||
multiSelect: $dropdown.hasClass('js-multiselect'),
|
||||
vue: $dropdown.hasClass('js-issue-board-sidebar'),
|
||||
clicked: function(label, $el, e, isMarking) {
|
||||
clicked: function(options) {
|
||||
const { $el, e, isMarking } = options;
|
||||
const label = options.selectedObj;
|
||||
|
||||
var isIssueIndex, isMRIndex, page, boardsModel;
|
||||
var fadeOutLoader = () => {
|
||||
$loading.fadeOut();
|
||||
|
@ -352,7 +355,7 @@
|
|||
|
||||
if ($dropdown.hasClass('js-filter-bulk-update')) {
|
||||
_this.enableBulkLabelDropdown();
|
||||
_this.setDropdownData($dropdown, isMarking, this.id(label));
|
||||
_this.setDropdownData($dropdown, isMarking, label.id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -158,7 +158,6 @@ import './single_file_diff';
|
|||
import './smart_interval';
|
||||
import './snippets_list';
|
||||
import './star';
|
||||
import './subbable_resource';
|
||||
import './subscription';
|
||||
import './subscription_select';
|
||||
import './syntax_highlight';
|
||||
|
|
|
@ -31,8 +31,8 @@
|
|||
toggleLabel(selected, $el) {
|
||||
return $el.text();
|
||||
},
|
||||
clicked: (selected, $link) => {
|
||||
this.formSubmit(null, $link);
|
||||
clicked: (options) => {
|
||||
this.formSubmit(null, options.$el);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -121,7 +121,10 @@
|
|||
return $value.css('display', '');
|
||||
},
|
||||
vue: $dropdown.hasClass('js-issue-board-sidebar'),
|
||||
clicked: function(selected, $el, e) {
|
||||
clicked: function(options) {
|
||||
const { $el, e } = options;
|
||||
let selected = options.selectedObj;
|
||||
|
||||
var data, isIssueIndex, isMRIndex, page, boardsStore;
|
||||
page = $('body').data('page');
|
||||
isIssueIndex = page === 'projects:issues:index';
|
||||
|
|
|
@ -58,7 +58,8 @@
|
|||
});
|
||||
}
|
||||
|
||||
NamespaceSelect.prototype.onSelectItem = function(item, el, e) {
|
||||
NamespaceSelect.prototype.onSelectItem = function(options) {
|
||||
const { e } = options;
|
||||
return e.preventDefault();
|
||||
};
|
||||
|
||||
|
|
|
@ -112,7 +112,8 @@ import Cookies from 'js-cookie';
|
|||
toggleLabel: function(obj, $el) {
|
||||
return $el.text().trim();
|
||||
},
|
||||
clicked: function(selected, $el, e) {
|
||||
clicked: function(options) {
|
||||
const { e } = options;
|
||||
e.preventDefault();
|
||||
if ($('input[name="ref"]').length) {
|
||||
var $form = $dropdown.closest('form');
|
||||
|
|
|
@ -19,7 +19,9 @@
|
|||
return 'Select';
|
||||
}
|
||||
},
|
||||
clicked(item, $el, e) {
|
||||
clicked(opts) {
|
||||
const { e } = opts;
|
||||
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
|
|
|
@ -35,7 +35,8 @@ class ProtectedBranchDropdown {
|
|||
return _.escape(protectedBranch.id);
|
||||
},
|
||||
onFilter: this.toggleCreateNewButton.bind(this),
|
||||
clicked: (item, $el, e) => {
|
||||
clicked: (options) => {
|
||||
const { $el, e } = options;
|
||||
e.preventDefault();
|
||||
this.onSelect();
|
||||
}
|
||||
|
|
|
@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown {
|
|||
}
|
||||
return 'Select';
|
||||
},
|
||||
clicked(item, $el, e) {
|
||||
e.preventDefault();
|
||||
clicked(options) {
|
||||
options.e.preventDefault();
|
||||
onSelect();
|
||||
},
|
||||
});
|
||||
|
|
|
@ -39,8 +39,8 @@ export default class ProtectedTagDropdown {
|
|||
return _.escape(protectedTag.id);
|
||||
},
|
||||
onFilter: this.toggleCreateNewButton.bind(this),
|
||||
clicked: (item, $el, e) => {
|
||||
e.preventDefault();
|
||||
clicked: (options) => {
|
||||
options.e.preventDefault();
|
||||
this.onSelect();
|
||||
},
|
||||
});
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
export default {
|
||||
name: 'AssigneeTitle',
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
numberOfAssignees: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
assigneeTitle() {
|
||||
const assignees = this.numberOfAssignees;
|
||||
return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="title hide-collapsed">
|
||||
{{assigneeTitle}}
|
||||
<i
|
||||
v-if="loading"
|
||||
aria-hidden="true"
|
||||
class="fa fa-spinner fa-spin block-loading"
|
||||
/>
|
||||
<a
|
||||
v-if="editable"
|
||||
class="edit-link pull-right"
|
||||
href="#"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,224 @@
|
|||
export default {
|
||||
name: 'Assignees',
|
||||
data() {
|
||||
return {
|
||||
defaultRenderCount: 5,
|
||||
defaultMaxCounter: 99,
|
||||
showLess: true,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
rootPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
firstUser() {
|
||||
return this.users[0];
|
||||
},
|
||||
hasMoreThanTwoAssignees() {
|
||||
return this.users.length > 2;
|
||||
},
|
||||
hasMoreThanOneAssignee() {
|
||||
return this.users.length > 1;
|
||||
},
|
||||
hasAssignees() {
|
||||
return this.users.length > 0;
|
||||
},
|
||||
hasNoUsers() {
|
||||
return !this.users.length;
|
||||
},
|
||||
hasOneUser() {
|
||||
return this.users.length === 1;
|
||||
},
|
||||
renderShowMoreSection() {
|
||||
return this.users.length > this.defaultRenderCount;
|
||||
},
|
||||
numberOfHiddenAssignees() {
|
||||
return this.users.length - this.defaultRenderCount;
|
||||
},
|
||||
isHiddenAssignees() {
|
||||
return this.numberOfHiddenAssignees > 0;
|
||||
},
|
||||
hiddenAssigneesLabel() {
|
||||
return `+ ${this.numberOfHiddenAssignees} more`;
|
||||
},
|
||||
collapsedTooltipTitle() {
|
||||
const maxRender = Math.min(this.defaultRenderCount, this.users.length);
|
||||
const renderUsers = this.users.slice(0, maxRender);
|
||||
const names = renderUsers.map(u => u.name);
|
||||
|
||||
if (this.users.length > maxRender) {
|
||||
names.push(`+ ${this.users.length - maxRender} more`);
|
||||
}
|
||||
|
||||
return names.join(', ');
|
||||
},
|
||||
sidebarAvatarCounter() {
|
||||
let counter = `+${this.users.length - 1}`;
|
||||
|
||||
if (this.users.length > this.defaultMaxCounter) {
|
||||
counter = `${this.defaultMaxCounter}+`;
|
||||
}
|
||||
|
||||
return counter;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
assignSelf() {
|
||||
this.$emit('assign-self');
|
||||
},
|
||||
toggleShowLess() {
|
||||
this.showLess = !this.showLess;
|
||||
},
|
||||
renderAssignee(index) {
|
||||
return !this.showLess || (index < this.defaultRenderCount && this.showLess);
|
||||
},
|
||||
avatarUrl(user) {
|
||||
return user.avatar || user.avatar_url;
|
||||
},
|
||||
assigneeUrl(user) {
|
||||
return `${this.rootPath}${user.username}`;
|
||||
},
|
||||
assigneeAlt(user) {
|
||||
return `${user.name}'s avatar`;
|
||||
},
|
||||
assigneeUsername(user) {
|
||||
return `@${user.username}`;
|
||||
},
|
||||
shouldRenderCollapsedAssignee(index) {
|
||||
const firstTwo = this.users.length <= 2 && index <= 2;
|
||||
|
||||
return index === 0 || firstTwo;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div
|
||||
class="sidebar-collapsed-icon sidebar-collapsed-user"
|
||||
:class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
|
||||
data-container="body"
|
||||
data-placement="left"
|
||||
:title="collapsedTooltipTitle"
|
||||
>
|
||||
<i
|
||||
v-if="hasNoUsers"
|
||||
aria-label="No Assignee"
|
||||
class="fa fa-user"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
v-for="(user, index) in users"
|
||||
v-if="shouldRenderCollapsedAssignee(index)"
|
||||
>
|
||||
<img
|
||||
width="24"
|
||||
class="avatar avatar-inline s24"
|
||||
:alt="assigneeAlt(user)"
|
||||
:src="avatarUrl(user)"
|
||||
/>
|
||||
<span class="author">
|
||||
{{ user.name }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="hasMoreThanTwoAssignees"
|
||||
class="btn-link"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="avatar-counter sidebar-avatar-counter"
|
||||
>
|
||||
{{ sidebarAvatarCounter }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="value hide-collapsed">
|
||||
<template v-if="hasNoUsers">
|
||||
<span class="assign-yourself no-value">
|
||||
No assignee
|
||||
<template v-if="editable">
|
||||
-
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="assignSelf"
|
||||
>
|
||||
assign yourself
|
||||
</button>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="hasOneUser">
|
||||
<a
|
||||
class="author_link bold"
|
||||
:href="assigneeUrl(firstUser)"
|
||||
>
|
||||
<img
|
||||
width="32"
|
||||
class="avatar avatar-inline s32"
|
||||
:alt="assigneeAlt(firstUser)"
|
||||
:src="avatarUrl(firstUser)"
|
||||
/>
|
||||
<span class="author">
|
||||
{{ firstUser.name }}
|
||||
</span>
|
||||
<span class="username">
|
||||
{{ assigneeUsername(firstUser) }}
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="user-list">
|
||||
<div
|
||||
class="user-item"
|
||||
v-for="(user, index) in users"
|
||||
v-if="renderAssignee(index)"
|
||||
>
|
||||
<a
|
||||
class="user-link has-tooltip"
|
||||
data-placement="bottom"
|
||||
:href="assigneeUrl(user)"
|
||||
:data-title="user.name"
|
||||
>
|
||||
<img
|
||||
width="32"
|
||||
class="avatar avatar-inline s32"
|
||||
:alt="assigneeAlt(user)"
|
||||
:src="avatarUrl(user)"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="renderShowMoreSection"
|
||||
class="user-list-more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
@click="toggleShowLess"
|
||||
>
|
||||
<template v-if="showLess">
|
||||
{{ hiddenAssigneesLabel }}
|
||||
</template>
|
||||
<template v-else>
|
||||
- show less
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,84 @@
|
|||
/* global Flash */
|
||||
|
||||
import AssigneeTitle from './assignee_title';
|
||||
import Assignees from './assignees';
|
||||
|
||||
import Store from '../../stores/sidebar_store';
|
||||
import Mediator from '../../sidebar_mediator';
|
||||
|
||||
import eventHub from '../../event_hub';
|
||||
|
||||
export default {
|
||||
name: 'SidebarAssignees',
|
||||
data() {
|
||||
return {
|
||||
mediator: new Mediator(),
|
||||
store: new Store(),
|
||||
loading: false,
|
||||
field: '',
|
||||
};
|
||||
},
|
||||
components: {
|
||||
'assignee-title': AssigneeTitle,
|
||||
assignees: Assignees,
|
||||
},
|
||||
methods: {
|
||||
assignSelf() {
|
||||
// Notify gl dropdown that we are now assigning to current user
|
||||
this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
|
||||
|
||||
this.mediator.assignYourself();
|
||||
this.saveAssignees();
|
||||
},
|
||||
saveAssignees() {
|
||||
this.loading = true;
|
||||
|
||||
function setLoadingFalse() {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
this.mediator.saveAssignees(this.field)
|
||||
.then(setLoadingFalse.bind(this))
|
||||
.catch(() => {
|
||||
setLoadingFalse();
|
||||
return new Flash('Error occurred when saving assignees');
|
||||
});
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.removeAssignee = this.store.removeAssignee.bind(this.store);
|
||||
this.addAssignee = this.store.addAssignee.bind(this.store);
|
||||
this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store);
|
||||
|
||||
// Get events from glDropdown
|
||||
eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
|
||||
eventHub.$on('sidebar.addAssignee', this.addAssignee);
|
||||
eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
|
||||
eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
|
||||
eventHub.$off('sidebar.addAssignee', this.addAssignee);
|
||||
eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
|
||||
eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
|
||||
},
|
||||
beforeMount() {
|
||||
this.field = this.$el.dataset.field;
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<assignee-title
|
||||
:number-of-assignees="store.assignees.length"
|
||||
:loading="loading"
|
||||
:editable="store.editable"
|
||||
/>
|
||||
<assignees
|
||||
class="value"
|
||||
:root-path="store.rootPath"
|
||||
:users="store.assignees"
|
||||
:editable="store.editable"
|
||||
@assign-self="assignSelf"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,97 @@
|
|||
import stopwatchSvg from 'icons/_icon_stopwatch.svg';
|
||||
|
||||
import '../../../lib/utils/pretty_time';
|
||||
|
||||
export default {
|
||||
name: 'time-tracking-collapsed-state',
|
||||
props: {
|
||||
showComparisonState: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
showSpentOnlyState: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
showEstimateOnlyState: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
showNoTimeTrackingState: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
timeSpentHumanReadable: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
timeEstimateHumanReadable: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
timeSpent() {
|
||||
return this.abbreviateTime(this.timeSpentHumanReadable);
|
||||
},
|
||||
timeEstimate() {
|
||||
return this.abbreviateTime(this.timeEstimateHumanReadable);
|
||||
},
|
||||
divClass() {
|
||||
if (this.showComparisonState) {
|
||||
return 'compare';
|
||||
} else if (this.showEstimateOnlyState) {
|
||||
return 'estimate-only';
|
||||
} else if (this.showSpentOnlyState) {
|
||||
return 'spend-only';
|
||||
} else if (this.showNoTimeTrackingState) {
|
||||
return 'no-tracking';
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
spanClass() {
|
||||
if (this.showComparisonState) {
|
||||
return '';
|
||||
} else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
|
||||
return 'bold';
|
||||
} else if (this.showNoTimeTrackingState) {
|
||||
return 'no-value';
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
text() {
|
||||
if (this.showComparisonState) {
|
||||
return `${this.timeSpent} / ${this.timeEstimate}`;
|
||||
} else if (this.showEstimateOnlyState) {
|
||||
return `-- / ${this.timeEstimate}`;
|
||||
} else if (this.showSpentOnlyState) {
|
||||
return `${this.timeSpent} / --`;
|
||||
} else if (this.showNoTimeTrackingState) {
|
||||
return 'None';
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
abbreviateTime(timeStr) {
|
||||
return gl.utils.prettyTime.abbreviateTime(timeStr);
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="sidebar-collapsed-icon">
|
||||
${stopwatchSvg}
|
||||
<div class="time-tracking-collapsed-summary">
|
||||
<div :class="divClass">
|
||||
<span :class="spanClass">
|
||||
{{ text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
import '../../../lib/utils/pretty_time';
|
||||
|
||||
const prettyTime = gl.utils.prettyTime;
|
||||
|
||||
export default {
|
||||
name: 'time-tracking-comparison-pane',
|
||||
props: {
|
||||
timeSpent: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
timeEstimate: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
timeSpentHumanReadable: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
timeEstimateHumanReadable: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
parsedRemaining() {
|
||||
const diffSeconds = this.timeEstimate - this.timeSpent;
|
||||
return prettyTime.parseSeconds(diffSeconds);
|
||||
},
|
||||
timeRemainingHumanReadable() {
|
||||
return prettyTime.stringifyTime(this.parsedRemaining);
|
||||
},
|
||||
timeRemainingTooltip() {
|
||||
const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
|
||||
return `${prefix} ${this.timeRemainingHumanReadable}`;
|
||||
},
|
||||
/* Diff values for comparison meter */
|
||||
timeRemainingMinutes() {
|
||||
return this.timeEstimate - this.timeSpent;
|
||||
},
|
||||
timeRemainingPercent() {
|
||||
return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
|
||||
},
|
||||
timeRemainingStatusClass() {
|
||||
return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
|
||||
},
|
||||
/* Parsed time values */
|
||||
parsedEstimate() {
|
||||
return prettyTime.parseSeconds(this.timeEstimate);
|
||||
},
|
||||
parsedSpent() {
|
||||
return prettyTime.parseSeconds(this.timeSpent);
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="time-tracking-comparison-pane">
|
||||
<div
|
||||
class="compare-meter"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
role="timeRemainingDisplay"
|
||||
:aria-valuenow="timeRemainingTooltip"
|
||||
:title="timeRemainingTooltip"
|
||||
:data-original-title="timeRemainingTooltip"
|
||||
:class="timeRemainingStatusClass"
|
||||
>
|
||||
<div
|
||||
class="meter-container"
|
||||
role="timeSpentPercent"
|
||||
:aria-valuenow="timeRemainingPercent"
|
||||
>
|
||||
<div
|
||||
:style="{ width: timeRemainingPercent }"
|
||||
class="meter-fill"
|
||||
/>
|
||||
</div>
|
||||
<div class="compare-display-container">
|
||||
<div class="compare-display pull-left">
|
||||
<span class="compare-label">
|
||||
Spent
|
||||
</span>
|
||||
<span class="compare-value spent">
|
||||
{{ timeSpentHumanReadable }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="compare-display estimated pull-right">
|
||||
<span class="compare-label">
|
||||
Est
|
||||
</span>
|
||||
<span class="compare-value">
|
||||
{{ timeEstimateHumanReadable }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
export default {
|
||||
name: 'time-tracking-estimate-only-pane',
|
||||
props: {
|
||||
timeEstimateHumanReadable: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="time-tracking-estimate-only-pane">
|
||||
<span class="bold">
|
||||
Estimated:
|
||||
</span>
|
||||
{{ timeEstimateHumanReadable }}
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
export default {
|
||||
name: 'time-tracking-help-state',
|
||||
props: {
|
||||
rootPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
href() {
|
||||
return `${this.rootPath}help/workflow/time_tracking.md`;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="time-tracking-help-state">
|
||||
<div class="time-tracking-info">
|
||||
<h4>
|
||||
Track time with slash commands
|
||||
</h4>
|
||||
<p>
|
||||
Slash commands can be used in the issues description and comment boxes.
|
||||
</p>
|
||||
<p>
|
||||
<code>
|
||||
/estimate
|
||||
</code>
|
||||
will update the estimated time with the latest command.
|
||||
</p>
|
||||
<p>
|
||||
<code>
|
||||
/spend
|
||||
</code>
|
||||
will update the sum of the time spent.
|
||||
</p>
|
||||
<a
|
||||
class="btn btn-default learn-more-button"
|
||||
:href="href"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
export default {
|
||||
name: 'time-tracking-no-tracking-pane',
|
||||
template: `
|
||||
<div class="time-tracking-no-tracking-pane">
|
||||
<span class="no-value">
|
||||
No estimate or time spent
|
||||
</span>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,45 @@
|
|||
import '~/smart_interval';
|
||||
|
||||
import timeTracker from './time_tracker';
|
||||
|
||||
import Store from '../../stores/sidebar_store';
|
||||
import Mediator from '../../sidebar_mediator';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
mediator: new Mediator(),
|
||||
store: new Store(),
|
||||
};
|
||||
},
|
||||
components: {
|
||||
'issuable-time-tracker': timeTracker,
|
||||
},
|
||||
methods: {
|
||||
listenForSlashCommands() {
|
||||
$(document).on('ajax:success', '.gfm-form', (e, data) => {
|
||||
const subscribedCommands = ['spend_time', 'time_estimate'];
|
||||
const changedCommands = data.commands_changes
|
||||
? Object.keys(data.commands_changes)
|
||||
: [];
|
||||
if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
|
||||
this.mediator.fetch();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.listenForSlashCommands();
|
||||
},
|
||||
template: `
|
||||
<div class="block">
|
||||
<issuable-time-tracker
|
||||
:time_estimate="store.timeEstimate"
|
||||
:time_spent="store.totalTimeSpent"
|
||||
:human_time_estimate="store.humanTimeEstimate"
|
||||
:human_time_spent="store.humanTotalTimeSpent"
|
||||
:rootPath="store.rootPath"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
export default {
|
||||
name: 'time-tracking-spent-only-pane',
|
||||
props: {
|
||||
timeSpentHumanReadable: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="time-tracking-spend-only-pane">
|
||||
<span class="bold">Spent:</span>
|
||||
{{ timeSpentHumanReadable }}
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,163 @@
|
|||
import timeTrackingHelpState from './help_state';
|
||||
import timeTrackingCollapsedState from './collapsed_state';
|
||||
import timeTrackingSpentOnlyPane from './spent_only_pane';
|
||||
import timeTrackingNoTrackingPane from './no_tracking_pane';
|
||||
import timeTrackingEstimateOnlyPane from './estimate_only_pane';
|
||||
import timeTrackingComparisonPane from './comparison_pane';
|
||||
|
||||
import eventHub from '../../event_hub';
|
||||
|
||||
export default {
|
||||
name: 'issuable-time-tracker',
|
||||
props: {
|
||||
time_estimate: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
time_spent: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
human_time_estimate: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
human_time_spent: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
rootPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showHelp: false,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
'time-tracking-collapsed-state': timeTrackingCollapsedState,
|
||||
'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
|
||||
'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
|
||||
'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
|
||||
'time-tracking-comparison-pane': timeTrackingComparisonPane,
|
||||
'time-tracking-help-state': timeTrackingHelpState,
|
||||
},
|
||||
computed: {
|
||||
timeSpent() {
|
||||
return this.time_spent;
|
||||
},
|
||||
timeEstimate() {
|
||||
return this.time_estimate;
|
||||
},
|
||||
timeEstimateHumanReadable() {
|
||||
return this.human_time_estimate;
|
||||
},
|
||||
timeSpentHumanReadable() {
|
||||
return this.human_time_spent;
|
||||
},
|
||||
hasTimeSpent() {
|
||||
return !!this.timeSpent;
|
||||
},
|
||||
hasTimeEstimate() {
|
||||
return !!this.timeEstimate;
|
||||
},
|
||||
showComparisonState() {
|
||||
return this.hasTimeEstimate && this.hasTimeSpent;
|
||||
},
|
||||
showEstimateOnlyState() {
|
||||
return this.hasTimeEstimate && !this.hasTimeSpent;
|
||||
},
|
||||
showSpentOnlyState() {
|
||||
return this.hasTimeSpent && !this.hasTimeEstimate;
|
||||
},
|
||||
showNoTimeTrackingState() {
|
||||
return !this.hasTimeEstimate && !this.hasTimeSpent;
|
||||
},
|
||||
showHelpState() {
|
||||
return !!this.showHelp;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleHelpState(show) {
|
||||
this.showHelp = show;
|
||||
},
|
||||
update(data) {
|
||||
this.time_estimate = data.time_estimate;
|
||||
this.time_spent = data.time_spent;
|
||||
this.human_time_estimate = data.human_time_estimate;
|
||||
this.human_time_spent = data.human_time_spent;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
eventHub.$on('timeTracker:updateData', this.update);
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="time_tracker time-tracking-component-wrap"
|
||||
v-cloak
|
||||
>
|
||||
<time-tracking-collapsed-state
|
||||
:show-comparison-state="showComparisonState"
|
||||
:show-no-time-tracking-state="showNoTimeTrackingState"
|
||||
:show-help-state="showHelpState"
|
||||
:show-spent-only-state="showSpentOnlyState"
|
||||
:show-estimate-only-state="showEstimateOnlyState"
|
||||
:time-spent-human-readable="timeSpentHumanReadable"
|
||||
:time-estimate-human-readable="timeEstimateHumanReadable"
|
||||
/>
|
||||
<div class="title hide-collapsed">
|
||||
Time tracking
|
||||
<div
|
||||
class="help-button pull-right"
|
||||
v-if="!showHelpState"
|
||||
@click="toggleHelpState(true)"
|
||||
>
|
||||
<i
|
||||
class="fa fa-question-circle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="close-help-button pull-right"
|
||||
v-if="showHelpState"
|
||||
@click="toggleHelpState(false)"
|
||||
>
|
||||
<i
|
||||
class="fa fa-close"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="time-tracking-content hide-collapsed">
|
||||
<time-tracking-estimate-only-pane
|
||||
v-if="showEstimateOnlyState"
|
||||
:time-estimate-human-readable="timeEstimateHumanReadable"
|
||||
/>
|
||||
<time-tracking-spent-only-pane
|
||||
v-if="showSpentOnlyState"
|
||||
:time-spent-human-readable="timeSpentHumanReadable"
|
||||
/>
|
||||
<time-tracking-no-tracking-pane
|
||||
v-if="showNoTimeTrackingState"
|
||||
/>
|
||||
<time-tracking-comparison-pane
|
||||
v-if="showComparisonState"
|
||||
:time-estimate="timeEstimate"
|
||||
:time-spent="timeSpent"
|
||||
:time-spent-human-readable="timeSpentHumanReadable"
|
||||
:time-estimate-human-readable="timeEstimateHumanReadable"
|
||||
/>
|
||||
<transition name="help-state-toggle">
|
||||
<time-tracking-help-state
|
||||
v-if="showHelpState"
|
||||
:rootPath="rootPath"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
|
@ -0,0 +1,28 @@
|
|||
import Vue from 'vue';
|
||||
import VueResource from 'vue-resource';
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
export default class SidebarService {
|
||||
constructor(endpoint) {
|
||||
if (!SidebarService.singleton) {
|
||||
this.endpoint = endpoint;
|
||||
|
||||
SidebarService.singleton = this;
|
||||
}
|
||||
|
||||
return SidebarService.singleton;
|
||||
}
|
||||
|
||||
get() {
|
||||
return Vue.http.get(this.endpoint);
|
||||
}
|
||||
|
||||
update(key, data) {
|
||||
return Vue.http.put(this.endpoint, {
|
||||
[key]: data,
|
||||
}, {
|
||||
emulateJSON: true,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import Vue from 'vue';
|
||||
import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
|
||||
import sidebarAssignees from './components/assignees/sidebar_assignees';
|
||||
|
||||
import Mediator from './sidebar_mediator';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const mediator = new Mediator(gl.sidebarOptions);
|
||||
mediator.fetch();
|
||||
|
||||
const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
|
||||
|
||||
// Only create the sidebarAssignees vue app if it is found in the DOM
|
||||
// We currently do not use sidebarAssignees for the MR page
|
||||
if (sidebarAssigneesEl) {
|
||||
new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
|
||||
}
|
||||
|
||||
new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/* global Flash */
|
||||
|
||||
import Service from './services/sidebar_service';
|
||||
import Store from './stores/sidebar_store';
|
||||
|
||||
export default class SidebarMediator {
|
||||
constructor(options) {
|
||||
if (!SidebarMediator.singleton) {
|
||||
this.store = new Store(options);
|
||||
this.service = new Service(options.endpoint);
|
||||
SidebarMediator.singleton = this;
|
||||
}
|
||||
|
||||
return SidebarMediator.singleton;
|
||||
}
|
||||
|
||||
assignYourself() {
|
||||
this.store.addAssignee(this.store.currentUser);
|
||||
}
|
||||
|
||||
saveAssignees(field) {
|
||||
const selected = this.store.assignees.map(u => u.id);
|
||||
|
||||
// If there are no ids, that means we have to unassign (which is id = 0)
|
||||
// And it only accepts an array, hence [0]
|
||||
return this.service.update(field, selected.length === 0 ? [0] : selected);
|
||||
}
|
||||
|
||||
fetch() {
|
||||
this.service.get()
|
||||
.then((response) => {
|
||||
const data = response.json();
|
||||
this.store.processAssigneeData(data);
|
||||
this.store.processTimeTrackingData(data);
|
||||
})
|
||||
.catch(() => new Flash('Error occured when fetching sidebar data'));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
export default class SidebarStore {
|
||||
constructor(store) {
|
||||
if (!SidebarStore.singleton) {
|
||||
const { currentUser, rootPath, editable } = store;
|
||||
this.currentUser = currentUser;
|
||||
this.rootPath = rootPath;
|
||||
this.editable = editable;
|
||||
this.timeEstimate = 0;
|
||||
this.totalTimeSpent = 0;
|
||||
this.humanTimeEstimate = '';
|
||||
this.humanTimeSpent = '';
|
||||
this.assignees = [];
|
||||
|
||||
SidebarStore.singleton = this;
|
||||
}
|
||||
|
||||
return SidebarStore.singleton;
|
||||
}
|
||||
|
||||
processAssigneeData(data) {
|
||||
if (data.assignees) {
|
||||
this.assignees = data.assignees;
|
||||
}
|
||||
}
|
||||
|
||||
processTimeTrackingData(data) {
|
||||
this.timeEstimate = data.time_estimate;
|
||||
this.totalTimeSpent = data.total_time_spent;
|
||||
this.humanTimeEstimate = data.human_time_estimate;
|
||||
this.humanTotalTimeSpent = data.human_total_time_spent;
|
||||
}
|
||||
|
||||
addAssignee(assignee) {
|
||||
if (!this.findAssignee(assignee)) {
|
||||
this.assignees.push(assignee);
|
||||
}
|
||||
}
|
||||
|
||||
findAssignee(findAssignee) {
|
||||
return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
|
||||
}
|
||||
|
||||
removeAssignee(removeAssignee) {
|
||||
if (removeAssignee) {
|
||||
this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
|
||||
}
|
||||
}
|
||||
|
||||
removeAllAssignees() {
|
||||
this.assignees = [];
|
||||
}
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
(() => {
|
||||
/*
|
||||
* SubbableResource can be extended to provide a pubsub-style service for one-off REST
|
||||
* calls. Subscribe by passing a callback or render method you will use to handle responses.
|
||||
*
|
||||
* */
|
||||
|
||||
class SubbableResource {
|
||||
constructor(resourcePath) {
|
||||
this.endpoint = resourcePath;
|
||||
|
||||
// TODO: Switch to axios.create
|
||||
this.resource = $.ajax;
|
||||
this.subscribers = [];
|
||||
}
|
||||
|
||||
subscribe(callback) {
|
||||
this.subscribers.push(callback);
|
||||
}
|
||||
|
||||
publish(newResponse) {
|
||||
const responseCopy = _.extend({}, newResponse);
|
||||
this.subscribers.forEach((fn) => {
|
||||
fn(responseCopy);
|
||||
});
|
||||
return newResponse;
|
||||
}
|
||||
|
||||
get(payload) {
|
||||
return this.resource(payload)
|
||||
.then(data => this.publish(data));
|
||||
}
|
||||
|
||||
post(payload) {
|
||||
return this.resource(payload)
|
||||
.then(data => this.publish(data));
|
||||
}
|
||||
|
||||
put(payload) {
|
||||
return this.resource(payload)
|
||||
.then(data => this.publish(data));
|
||||
}
|
||||
|
||||
delete(payload) {
|
||||
return this.resource(payload)
|
||||
.then(data => this.publish(data));
|
||||
}
|
||||
}
|
||||
|
||||
gl.SubbableResource = SubbableResource;
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -19,8 +19,8 @@
|
|||
return label;
|
||||
};
|
||||
})(this),
|
||||
clicked: function(item, $el, e) {
|
||||
return e.preventDefault();
|
||||
clicked: function(options) {
|
||||
return options.e.preventDefault();
|
||||
},
|
||||
id: function(obj, el) {
|
||||
return $(el).data("id");
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */
|
||||
/* global Issuable */
|
||||
/* global ListUser */
|
||||
|
||||
import eventHub from './sidebar/event_hub';
|
||||
|
||||
(function() {
|
||||
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
|
||||
|
@ -54,42 +55,115 @@
|
|||
selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null;
|
||||
selectedId = $dropdown.data('selected') || selectedIdDefault;
|
||||
|
||||
var updateIssueBoardsIssue = function () {
|
||||
$loading.removeClass('hidden').fadeIn();
|
||||
gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
|
||||
.then(function () {
|
||||
$loading.fadeOut();
|
||||
})
|
||||
.catch(function () {
|
||||
$loading.fadeOut();
|
||||
});
|
||||
const assignYourself = function () {
|
||||
const unassignedSelected = $dropdown.closest('.selectbox')
|
||||
.find(`input[name='${$dropdown.data('field-name')}'][value=0]`);
|
||||
|
||||
if (unassignedSelected) {
|
||||
unassignedSelected.remove();
|
||||
}
|
||||
|
||||
// Save current selected user to the DOM
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = $dropdown.data('field-name');
|
||||
|
||||
const currentUserInfo = $dropdown.data('currentUserInfo');
|
||||
|
||||
if (currentUserInfo) {
|
||||
input.value = currentUserInfo.id;
|
||||
input.dataset.meta = currentUserInfo.name;
|
||||
} else if (_this.currentUser) {
|
||||
input.value = _this.currentUser.id;
|
||||
}
|
||||
|
||||
if ($selectbox) {
|
||||
$dropdown.parent().before(input);
|
||||
} else {
|
||||
$dropdown.after(input);
|
||||
}
|
||||
};
|
||||
|
||||
if ($block[0]) {
|
||||
$block[0].addEventListener('assignYourself', assignYourself);
|
||||
}
|
||||
|
||||
const getSelectedUserInputs = function() {
|
||||
return $selectbox
|
||||
.find(`input[name="${$dropdown.data('field-name')}"]`);
|
||||
};
|
||||
|
||||
const getSelected = function() {
|
||||
return getSelectedUserInputs()
|
||||
.map((index, input) => parseInt(input.value, 10))
|
||||
.get();
|
||||
};
|
||||
|
||||
const checkMaxSelect = function() {
|
||||
const maxSelect = $dropdown.data('max-select');
|
||||
if (maxSelect) {
|
||||
const selected = getSelected();
|
||||
|
||||
if (selected.length > maxSelect) {
|
||||
const firstSelectedId = selected[0];
|
||||
const firstSelected = $dropdown.closest('.selectbox')
|
||||
.find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`);
|
||||
|
||||
firstSelected.remove();
|
||||
eventHub.$emit('sidebar.removeAssignee', {
|
||||
id: firstSelectedId,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getMultiSelectDropdownTitle = function(selectedUser, isSelected) {
|
||||
const selectedUsers = getSelected()
|
||||
.filter(u => u !== 0);
|
||||
|
||||
const firstUser = getSelectedUserInputs()
|
||||
.map((index, input) => ({
|
||||
name: input.dataset.meta,
|
||||
value: parseInt(input.value, 10),
|
||||
}))
|
||||
.filter(u => u.id !== 0)
|
||||
.get(0);
|
||||
|
||||
if (selectedUsers.length === 0) {
|
||||
return 'Unassigned';
|
||||
} else if (selectedUsers.length === 1) {
|
||||
return firstUser.name;
|
||||
} else if (isSelected) {
|
||||
const otherSelected = selectedUsers.filter(s => s !== selectedUser.id);
|
||||
return `${selectedUser.name} + ${otherSelected.length} more`;
|
||||
} else {
|
||||
return `${firstUser.name} + ${selectedUsers.length - 1} more`;
|
||||
}
|
||||
};
|
||||
|
||||
$('.assign-to-me-link').on('click', (e) => {
|
||||
e.preventDefault();
|
||||
$(e.currentTarget).hide();
|
||||
const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
|
||||
$input.val(gon.current_user_id);
|
||||
selectedId = $input.val();
|
||||
$dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
|
||||
});
|
||||
|
||||
$block.on('click', '.js-assign-yourself', function(e) {
|
||||
e.preventDefault();
|
||||
if ($dropdown.data('multiSelect')) {
|
||||
assignYourself();
|
||||
checkMaxSelect();
|
||||
|
||||
if ($dropdown.hasClass('js-issue-board-sidebar')) {
|
||||
gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
|
||||
id: _this.currentUser.id,
|
||||
username: _this.currentUser.username,
|
||||
name: _this.currentUser.name,
|
||||
avatar_url: _this.currentUser.avatar_url
|
||||
}));
|
||||
|
||||
updateIssueBoardsIssue();
|
||||
const currentUserInfo = $dropdown.data('currentUserInfo');
|
||||
$dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default');
|
||||
} else {
|
||||
return assignTo(_this.currentUser.id);
|
||||
const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
|
||||
$input.val(gon.current_user_id);
|
||||
selectedId = $input.val();
|
||||
$dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
|
||||
}
|
||||
});
|
||||
|
||||
$block.on('click', '.js-assign-yourself', (e) => {
|
||||
e.preventDefault();
|
||||
return assignTo(_this.currentUser.id);
|
||||
});
|
||||
|
||||
assignTo = function(selected) {
|
||||
var data;
|
||||
data = {};
|
||||
|
@ -97,6 +171,7 @@
|
|||
data[abilityName].assignee_id = selected != null ? selected : null;
|
||||
$loading.removeClass('hidden').fadeIn();
|
||||
$dropdown.trigger('loading.gl.dropdown');
|
||||
|
||||
return $.ajax({
|
||||
type: 'PUT',
|
||||
dataType: 'json',
|
||||
|
@ -106,7 +181,6 @@
|
|||
var user;
|
||||
$dropdown.trigger('loaded.gl.dropdown');
|
||||
$loading.fadeOut();
|
||||
$selectbox.hide();
|
||||
if (data.assignee) {
|
||||
user = {
|
||||
name: data.assignee.name,
|
||||
|
@ -133,51 +207,90 @@
|
|||
var isAuthorFilter;
|
||||
isAuthorFilter = $('.js-author-search');
|
||||
return _this.users(term, options, function(users) {
|
||||
var anyUser, index, j, len, name, obj, showDivider;
|
||||
if (term.length === 0) {
|
||||
showDivider = 0;
|
||||
if (firstUser) {
|
||||
// Move current user to the front of the list
|
||||
for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
|
||||
obj = users[index];
|
||||
if (obj.username === firstUser) {
|
||||
users.splice(index, 1);
|
||||
users.unshift(obj);
|
||||
break;
|
||||
}
|
||||
// GitLabDropdownFilter returns this.instance
|
||||
// GitLabDropdownRemote returns this.options.instance
|
||||
const glDropdown = this.instance || this.options.instance;
|
||||
glDropdown.options.processData(term, users, callback);
|
||||
}.bind(this));
|
||||
},
|
||||
processData: function(term, users, callback) {
|
||||
let anyUser;
|
||||
let index;
|
||||
let j;
|
||||
let len;
|
||||
let name;
|
||||
let obj;
|
||||
let showDivider;
|
||||
if (term.length === 0) {
|
||||
showDivider = 0;
|
||||
if (firstUser) {
|
||||
// Move current user to the front of the list
|
||||
for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
|
||||
obj = users[index];
|
||||
if (obj.username === firstUser) {
|
||||
users.splice(index, 1);
|
||||
users.unshift(obj);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (showNullUser) {
|
||||
showDivider += 1;
|
||||
users.unshift({
|
||||
beforeDivider: true,
|
||||
name: 'Unassigned',
|
||||
id: 0
|
||||
});
|
||||
}
|
||||
if (showAnyUser) {
|
||||
showDivider += 1;
|
||||
name = showAnyUser;
|
||||
if (name === true) {
|
||||
name = 'Any User';
|
||||
}
|
||||
anyUser = {
|
||||
beforeDivider: true,
|
||||
name: name,
|
||||
id: null
|
||||
};
|
||||
users.unshift(anyUser);
|
||||
}
|
||||
}
|
||||
if (showDivider) {
|
||||
users.splice(showDivider, 0, "divider");
|
||||
if (showNullUser) {
|
||||
showDivider += 1;
|
||||
users.unshift({
|
||||
beforeDivider: true,
|
||||
name: 'Unassigned',
|
||||
id: 0
|
||||
});
|
||||
}
|
||||
if (showAnyUser) {
|
||||
showDivider += 1;
|
||||
name = showAnyUser;
|
||||
if (name === true) {
|
||||
name = 'Any User';
|
||||
}
|
||||
anyUser = {
|
||||
beforeDivider: true,
|
||||
name: name,
|
||||
id: null
|
||||
};
|
||||
users.unshift(anyUser);
|
||||
}
|
||||
|
||||
callback(users);
|
||||
if (showMenuAbove) {
|
||||
$dropdown.data('glDropdown').positionMenuAbove();
|
||||
if (showDivider) {
|
||||
users.splice(showDivider, 0, 'divider');
|
||||
}
|
||||
});
|
||||
|
||||
if ($dropdown.hasClass('js-multiselect')) {
|
||||
const selected = getSelected().filter(i => i !== 0);
|
||||
|
||||
if (selected.length > 0) {
|
||||
if ($dropdown.data('dropdown-header')) {
|
||||
showDivider += 1;
|
||||
users.splice(showDivider, 0, {
|
||||
header: $dropdown.data('dropdown-header'),
|
||||
});
|
||||
}
|
||||
|
||||
const selectedUsers = users
|
||||
.filter(u => selected.indexOf(u.id) !== -1)
|
||||
.sort((a, b) => a.name > b.name);
|
||||
|
||||
users = users.filter(u => selected.indexOf(u.id) === -1);
|
||||
|
||||
selectedUsers.forEach((selectedUser) => {
|
||||
showDivider += 1;
|
||||
users.splice(showDivider, 0, selectedUser);
|
||||
});
|
||||
|
||||
users.splice(showDivider + 1, 0, 'divider');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback(users);
|
||||
if (showMenuAbove) {
|
||||
$dropdown.data('glDropdown').positionMenuAbove();
|
||||
}
|
||||
},
|
||||
filterable: true,
|
||||
filterRemote: true,
|
||||
|
@ -186,7 +299,22 @@
|
|||
},
|
||||
selectable: true,
|
||||
fieldName: $dropdown.data('field-name'),
|
||||
toggleLabel: function(selected, el) {
|
||||
toggleLabel: function(selected, el, glDropdown) {
|
||||
const inputValue = glDropdown.filterInput.val();
|
||||
|
||||
if (this.multiSelect && inputValue === '') {
|
||||
// Remove non-users from the fullData array
|
||||
const users = glDropdown.filteredFullData();
|
||||
const callback = glDropdown.parseData.bind(glDropdown);
|
||||
|
||||
// Update the data model
|
||||
this.processData(inputValue, users, callback);
|
||||
}
|
||||
|
||||
if (this.multiSelect) {
|
||||
return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active'));
|
||||
}
|
||||
|
||||
if (selected && 'id' in selected && $(el).hasClass('is-active')) {
|
||||
$dropdown.find('.dropdown-toggle-text').removeClass('is-default');
|
||||
if (selected.text) {
|
||||
|
@ -200,22 +328,81 @@
|
|||
}
|
||||
},
|
||||
defaultLabel: defaultLabel,
|
||||
inputId: 'issue_assignee_id',
|
||||
hidden: function(e) {
|
||||
$selectbox.hide();
|
||||
// display:block overrides the hide-collapse rule
|
||||
return $value.css('display', '');
|
||||
if ($dropdown.hasClass('js-multiselect')) {
|
||||
eventHub.$emit('sidebar.saveAssignees');
|
||||
}
|
||||
|
||||
if (!$dropdown.data('always-show-selectbox')) {
|
||||
$selectbox.hide();
|
||||
|
||||
// Recalculate where .value is because vue might have changed it
|
||||
$block = $selectbox.closest('.block');
|
||||
$value = $block.find('.value');
|
||||
// display:block overrides the hide-collapse rule
|
||||
$value.css('display', '');
|
||||
}
|
||||
},
|
||||
vue: $dropdown.hasClass('js-issue-board-sidebar'),
|
||||
clicked: function(user, $el, e) {
|
||||
var isIssueIndex, isMRIndex, page, selected, isSelecting;
|
||||
multiSelect: $dropdown.hasClass('js-multiselect'),
|
||||
inputMeta: $dropdown.data('input-meta'),
|
||||
clicked: function(options) {
|
||||
const { $el, e, isMarking } = options;
|
||||
const user = options.selectedObj;
|
||||
|
||||
if ($dropdown.hasClass('js-multiselect')) {
|
||||
const isActive = $el.hasClass('is-active');
|
||||
const previouslySelected = $dropdown.closest('.selectbox')
|
||||
.find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]");
|
||||
|
||||
// Enables support for limiting the number of users selected
|
||||
// Automatically removes the first on the list if more users are selected
|
||||
checkMaxSelect();
|
||||
|
||||
if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') {
|
||||
// Unassigned selected
|
||||
previouslySelected.each((index, element) => {
|
||||
const id = parseInt(element.value, 10);
|
||||
element.remove();
|
||||
});
|
||||
eventHub.$emit('sidebar.removeAllAssignees');
|
||||
} else if (isActive) {
|
||||
// user selected
|
||||
eventHub.$emit('sidebar.addAssignee', user);
|
||||
|
||||
// Remove unassigned selection (if it was previously selected)
|
||||
const unassignedSelected = $dropdown.closest('.selectbox')
|
||||
.find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
|
||||
|
||||
if (unassignedSelected) {
|
||||
unassignedSelected.remove();
|
||||
}
|
||||
} else {
|
||||
if (previouslySelected.length === 0) {
|
||||
// Select unassigned because there is no more selected users
|
||||
this.addInput($dropdown.data('field-name'), 0, {});
|
||||
}
|
||||
|
||||
// User unselected
|
||||
eventHub.$emit('sidebar.removeAssignee', user);
|
||||
}
|
||||
|
||||
if (getSelected().find(u => u === gon.current_user_id)) {
|
||||
$('.assign-to-me-link').hide();
|
||||
} else {
|
||||
$('.assign-to-me-link').show();
|
||||
}
|
||||
}
|
||||
|
||||
var isIssueIndex, isMRIndex, page, selected;
|
||||
page = $('body').data('page');
|
||||
isIssueIndex = page === 'projects:issues:index';
|
||||
isMRIndex = (page === page && page === 'projects:merge_requests:index');
|
||||
isSelecting = (user.id !== selectedId);
|
||||
selectedId = isSelecting ? user.id : selectedIdDefault;
|
||||
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
|
||||
e.preventDefault();
|
||||
|
||||
const isSelecting = (user.id !== selectedId);
|
||||
selectedId = isSelecting ? user.id : selectedIdDefault;
|
||||
|
||||
if (selectedId === gon.current_user_id) {
|
||||
$('.assign-to-me-link').hide();
|
||||
} else {
|
||||
|
@ -229,20 +416,7 @@
|
|||
return Issuable.filterResults($dropdown.closest('form'));
|
||||
} else if ($dropdown.hasClass('js-filter-submit')) {
|
||||
return $dropdown.closest('form').submit();
|
||||
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
|
||||
if (user.id && isSelecting) {
|
||||
gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
avatar_url: user.avatar_url
|
||||
}));
|
||||
} else {
|
||||
gl.issueBoards.boardStoreIssueDelete('assignee');
|
||||
}
|
||||
|
||||
updateIssueBoardsIssue();
|
||||
} else {
|
||||
} else if (!$dropdown.hasClass('js-multiselect')) {
|
||||
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
|
||||
return assignTo(selected);
|
||||
}
|
||||
|
@ -256,29 +430,54 @@
|
|||
selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault;
|
||||
}
|
||||
$el.find('.is-active').removeClass('is-active');
|
||||
$el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active');
|
||||
|
||||
function highlightSelected(id) {
|
||||
$el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active');
|
||||
}
|
||||
|
||||
if ($selectbox[0]) {
|
||||
getSelected().forEach(selectedId => highlightSelected(selectedId));
|
||||
} else {
|
||||
highlightSelected(selectedId);
|
||||
}
|
||||
},
|
||||
updateLabel: $dropdown.data('dropdown-title'),
|
||||
renderRow: function(user) {
|
||||
var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username;
|
||||
var avatar, img, listClosingTags, listWithName, listWithUserName, username;
|
||||
username = user.username ? "@" + user.username : "";
|
||||
avatar = user.avatar_url ? user.avatar_url : false;
|
||||
selected = user.id === parseInt(selectedId, 10) ? "is-active" : "";
|
||||
img = "";
|
||||
if (user.beforeDivider != null) {
|
||||
"<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>";
|
||||
} else {
|
||||
if (avatar) {
|
||||
img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />";
|
||||
|
||||
let selected = user.id === parseInt(selectedId, 10);
|
||||
|
||||
if (this.multiSelect) {
|
||||
const fieldName = this.fieldName;
|
||||
const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']");
|
||||
|
||||
if (field.length) {
|
||||
selected = true;
|
||||
}
|
||||
}
|
||||
// split into three parts so we can remove the username section if nessesary
|
||||
listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>";
|
||||
listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>";
|
||||
listClosingTags = "</a> </li>";
|
||||
if (username === '') {
|
||||
listWithUserName = '';
|
||||
|
||||
img = "";
|
||||
if (user.beforeDivider != null) {
|
||||
`<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
|
||||
} else {
|
||||
if (avatar) {
|
||||
img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
|
||||
}
|
||||
}
|
||||
return listWithName + listWithUserName + listClosingTags;
|
||||
|
||||
return `
|
||||
<li data-user-id=${user.id}>
|
||||
<a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'>
|
||||
${img}
|
||||
<strong class='dropdown-menu-user-full-name'>
|
||||
${user.name}
|
||||
</strong>
|
||||
${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''}
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -93,3 +93,14 @@
|
|||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-counter {
|
||||
background-color: $gray-darkest;
|
||||
color: $white-light;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 1em;
|
||||
font-family: $regular_font;
|
||||
font-size: 9px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
@ -251,14 +251,16 @@
|
|||
}
|
||||
|
||||
.dropdown-header {
|
||||
color: $gl-text-color;
|
||||
color: $gl-text-color-secondary;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
text-transform: capitalize;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
&.capitalize-header .dropdown-header {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.separator + .dropdown-header {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
@ -337,8 +339,8 @@
|
|||
.dropdown-menu-user {
|
||||
.avatar {
|
||||
float: left;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 2 * $gl-padding;
|
||||
height: 2 * $gl-padding;
|
||||
margin: 0 10px 0 0;
|
||||
}
|
||||
}
|
||||
|
@ -381,6 +383,7 @@
|
|||
.dropdown-menu-selectable {
|
||||
a {
|
||||
padding-left: 26px;
|
||||
position: relative;
|
||||
|
||||
&.is-indeterminate,
|
||||
&.is-active {
|
||||
|
@ -406,6 +409,9 @@
|
|||
|
||||
&.is-active::before {
|
||||
content: "\f00c";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -255,6 +255,7 @@ ul.controls {
|
|||
.avatar-inline {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -207,8 +207,13 @@
|
|||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
&.is-active,
|
||||
&.is-active .card-assignee:hover a {
|
||||
background-color: $row-hover;
|
||||
|
||||
&:first-child:not(:only-child) {
|
||||
box-shadow: -10px 0 10px 1px $row-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
|
@ -224,7 +229,7 @@
|
|||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
margin: 0 30px 0 0;
|
||||
font-size: 1em;
|
||||
line-height: inherit;
|
||||
|
||||
|
@ -240,10 +245,69 @@
|
|||
min-height: 20px;
|
||||
|
||||
.card-assignee {
|
||||
margin-left: auto;
|
||||
margin-right: 5px;
|
||||
padding-left: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
|
||||
.avatar-counter {
|
||||
display: none;
|
||||
vertical-align: middle;
|
||||
min-width: 20px;
|
||||
line-height: 19px;
|
||||
height: 20px;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
border-radius: 2em;
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
a {
|
||||
position: relative;
|
||||
margin-left: -15px;
|
||||
}
|
||||
|
||||
a:nth-child(1) {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
a:nth-child(2) {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
a:nth-child(3) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
a:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.avatar-counter {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
a {
|
||||
position: static;
|
||||
background-color: $white-light;
|
||||
transition: background-color 0s;
|
||||
margin-left: auto;
|
||||
|
||||
&:nth-child(4) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:first-child:not(:only-child) {
|
||||
box-shadow: -10px 0 10px 1px $white-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
|
|
|
@ -570,14 +570,7 @@
|
|||
|
||||
.diff-comments-more-count,
|
||||
.diff-notes-collapse {
|
||||
background-color: $gray-darkest;
|
||||
color: $white-light;
|
||||
border: 1px solid $white-light;
|
||||
border-radius: 1em;
|
||||
font-family: $regular_font;
|
||||
font-size: 9px;
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
@extend .avatar-counter;
|
||||
}
|
||||
|
||||
.diff-notes-collapse {
|
||||
|
|
|
@ -95,10 +95,15 @@
|
|||
}
|
||||
|
||||
.right-sidebar {
|
||||
a {
|
||||
a,
|
||||
.btn-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.issuable-header-text {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
@ -215,6 +220,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.assign-yourself .btn-link {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.light {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
@ -239,6 +248,10 @@
|
|||
margin-left: 0;
|
||||
}
|
||||
|
||||
.assignee .user-list .avatar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
|
@ -301,6 +314,10 @@
|
|||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sidebar-avatar-counter {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.todo-undone {
|
||||
color: $gl-link-color;
|
||||
}
|
||||
|
@ -309,10 +326,15 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.avatar:hover {
|
||||
.avatar:hover,
|
||||
.avatar-counter:hover {
|
||||
border-color: $issuable-sidebar-color;
|
||||
}
|
||||
|
||||
.avatar-counter:hover {
|
||||
color: $issuable-sidebar-color;
|
||||
}
|
||||
|
||||
.btn-clipboard {
|
||||
border: none;
|
||||
color: $issuable-sidebar-color;
|
||||
|
@ -322,6 +344,17 @@
|
|||
color: $gl-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.multiple-users {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-avatar-counter {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.sidebar-collapsed-user {
|
||||
|
@ -332,6 +365,37 @@
|
|||
.issuable-header-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiple-users {
|
||||
height: 24px;
|
||||
margin-bottom: 17px;
|
||||
margin-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
.btn-link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
.avatar {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link:first-child {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.btn-link:last-child {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
|
@ -383,6 +447,12 @@
|
|||
margin: -5px;
|
||||
}
|
||||
|
||||
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.participants-author {
|
||||
display: inline-block;
|
||||
padding: 5px;
|
||||
|
@ -400,13 +470,39 @@
|
|||
}
|
||||
}
|
||||
|
||||
.participants-more {
|
||||
.user-item {
|
||||
display: inline-block;
|
||||
padding: 5px;
|
||||
flex-basis: 20%;
|
||||
|
||||
.user-link {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.participants-more,
|
||||
.user-list-more {
|
||||
margin-top: 5px;
|
||||
margin-left: 5px;
|
||||
|
||||
a {
|
||||
a,
|
||||
.btn-link {
|
||||
color: $gl-text-color-secondary;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
@extend a:hover;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-link:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.issuable-form-padding-top {
|
||||
|
@ -499,6 +595,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
.issuable-list li,
|
||||
.issue-info-container .controls {
|
||||
.avatar-counter {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
min-width: 16px;
|
||||
line-height: 14px;
|
||||
height: 16px;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.time_tracker {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
|
|
|
@ -66,6 +66,7 @@ module IssuableActions
|
|||
:milestone_id,
|
||||
:state_event,
|
||||
:subscription_event,
|
||||
assignee_ids: [],
|
||||
label_ids: [],
|
||||
add_label_ids: [],
|
||||
remove_label_ids: []
|
||||
|
|
|
@ -43,7 +43,7 @@ module IssuableCollections
|
|||
end
|
||||
|
||||
def issues_collection
|
||||
issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace)
|
||||
issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace)
|
||||
end
|
||||
|
||||
def merge_requests_collection
|
||||
|
|
|
@ -82,7 +82,7 @@ module Projects
|
|||
labels: true,
|
||||
only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
|
||||
include: {
|
||||
assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
|
||||
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
|
||||
milestone: { only: [:id, :title] }
|
||||
},
|
||||
user: current_user
|
||||
|
|
|
@ -67,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
|
||||
def new
|
||||
params[:issue] ||= ActionController::Parameters.new(
|
||||
assignee_id: ""
|
||||
assignee_ids: ""
|
||||
)
|
||||
build_params = issue_params.merge(
|
||||
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
|
||||
|
@ -150,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
if @issue.valid?
|
||||
render json: @issue.to_json(methods: [:task_status, :task_status_short],
|
||||
include: { milestone: {},
|
||||
assignee: { only: [:name, :username], methods: [:avatar_url] },
|
||||
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
|
||||
labels: { methods: :text_color } })
|
||||
else
|
||||
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
|
||||
|
@ -284,7 +284,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
def issue_params
|
||||
params.require(:issue).permit(
|
||||
:title, :assignee_id, :position, :description, :confidential,
|
||||
:milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: []
|
||||
:milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [],
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -231,7 +231,7 @@ class IssuableFinder
|
|||
when 'created-by-me', 'authored'
|
||||
items.where(author_id: current_user.id)
|
||||
when 'assigned-to-me'
|
||||
items.where(assignee_id: current_user.id)
|
||||
items.assigned_to(current_user)
|
||||
else
|
||||
items
|
||||
end
|
||||
|
|
|
@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder
|
|||
IssuesFinder.not_restricted_by_confidentiality(current_user)
|
||||
end
|
||||
|
||||
def by_assignee(items)
|
||||
if assignee
|
||||
items.assigned_to(assignee)
|
||||
elsif no_assignee?
|
||||
items.unassigned
|
||||
elsif assignee_id? || assignee_username? # assignee not found
|
||||
items.none
|
||||
else
|
||||
items
|
||||
end
|
||||
end
|
||||
|
||||
def self.not_restricted_by_confidentiality(user)
|
||||
return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank?
|
||||
return Issue.where('issues.confidential IS NOT TRUE') if user.blank?
|
||||
|
||||
return Issue.all if user.admin?
|
||||
|
||||
Issue.where('
|
||||
issues.confidential IS NULL
|
||||
OR issues.confidential IS FALSE
|
||||
issues.confidential IS NOT TRUE
|
||||
OR (issues.confidential = TRUE
|
||||
AND (issues.author_id = :user_id
|
||||
OR issues.assignee_id = :user_id
|
||||
OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
|
||||
OR issues.project_id IN(:project_ids)))',
|
||||
user_id: user.id,
|
||||
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
|
||||
|
|
|
@ -15,4 +15,36 @@ module FormHelper
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def issue_dropdown_options(issuable, has_multiple_assignees = true)
|
||||
options = {
|
||||
toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data',
|
||||
title: 'Select assignee',
|
||||
filter: true,
|
||||
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee',
|
||||
placeholder: 'Search users',
|
||||
data: {
|
||||
first_user: current_user&.username,
|
||||
null_user: true,
|
||||
current_user: true,
|
||||
project_id: issuable.project.try(:id),
|
||||
field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]",
|
||||
default_label: 'Assignee',
|
||||
'max-select': 1,
|
||||
'dropdown-header': 'Assignee',
|
||||
multi_select: true,
|
||||
'input-meta': 'name',
|
||||
'always-show-selectbox': true,
|
||||
current_user_info: current_user.to_json(only: [:id, :name])
|
||||
}
|
||||
}
|
||||
|
||||
if has_multiple_assignees
|
||||
options[:title] = 'Select assignee(s)'
|
||||
options[:data][:'dropdown-header'] = 'Assignee(s)'
|
||||
options[:data].delete(:'max-select')
|
||||
end
|
||||
|
||||
options
|
||||
end
|
||||
end
|
||||
|
|
|
@ -63,6 +63,16 @@ module IssuablesHelper
|
|||
end
|
||||
end
|
||||
|
||||
def users_dropdown_label(selected_users)
|
||||
if selected_users.length == 0
|
||||
"Unassigned"
|
||||
elsif selected_users.length == 1
|
||||
selected_users[0].name
|
||||
else
|
||||
"#{selected_users[0].name} + #{selected_users.length - 1} more"
|
||||
end
|
||||
end
|
||||
|
||||
def user_dropdown_label(user_id, default_label)
|
||||
return default_label if user_id.nil?
|
||||
return "Unassigned" if user_id == "0"
|
||||
|
|
|
@ -11,10 +11,12 @@ module Emails
|
|||
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
|
||||
end
|
||||
|
||||
def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id)
|
||||
def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id)
|
||||
setup_issue_mail(issue_id, recipient_id)
|
||||
|
||||
@previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
|
||||
@previous_assignees = []
|
||||
@previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
|
||||
|
||||
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
|
||||
end
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ module Issuable
|
|||
cache_markdown_field :description, issuable_state_filter_enabled: true
|
||||
|
||||
belongs_to :author, class_name: "User"
|
||||
belongs_to :assignee, class_name: "User"
|
||||
belongs_to :updated_by, class_name: "User"
|
||||
belongs_to :milestone
|
||||
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do
|
||||
|
@ -65,11 +64,8 @@ module Issuable
|
|||
validates :title, presence: true, length: { maximum: 255 }
|
||||
|
||||
scope :authored, ->(user) { where(author_id: user) }
|
||||
scope :assigned_to, ->(u) { where(assignee_id: u.id)}
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :order_position_asc, -> { reorder(position: :asc) }
|
||||
scope :assigned, -> { where("assignee_id IS NOT NULL") }
|
||||
scope :unassigned, -> { where("assignee_id IS NULL") }
|
||||
scope :of_projects, ->(ids) { where(project_id: ids) }
|
||||
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
|
||||
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
|
||||
|
@ -92,23 +88,14 @@ module Issuable
|
|||
attr_mentionable :description
|
||||
|
||||
participant :author
|
||||
participant :assignee
|
||||
participant :notes_with_associations
|
||||
|
||||
strip_attributes :title
|
||||
|
||||
acts_as_paranoid
|
||||
|
||||
after_save :update_assignee_cache_counts, if: :assignee_id_changed?
|
||||
after_save :record_metrics, unless: :imported?
|
||||
|
||||
def update_assignee_cache_counts
|
||||
# make sure we flush the cache for both the old *and* new assignees(if they exist)
|
||||
previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was
|
||||
previous_assignee&.update_cache_counts
|
||||
assignee&.update_cache_counts
|
||||
end
|
||||
|
||||
# We want to use optimistic lock for cases when only title or description are involved
|
||||
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
|
||||
def locking_enabled?
|
||||
|
@ -237,10 +224,6 @@ module Issuable
|
|||
today? && created_at == updated_at
|
||||
end
|
||||
|
||||
def is_being_reassigned?
|
||||
assignee_id_changed?
|
||||
end
|
||||
|
||||
def open?
|
||||
opened? || reopened?
|
||||
end
|
||||
|
@ -269,7 +252,11 @@ module Issuable
|
|||
# DEPRECATED
|
||||
repository: project.hook_attrs.slice(:name, :url, :description, :homepage)
|
||||
}
|
||||
hook_data[:assignee] = assignee.hook_attrs if assignee
|
||||
if self.is_a?(Issue)
|
||||
hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any?
|
||||
else
|
||||
hook_data[:assignee] = assignee.hook_attrs if assignee
|
||||
end
|
||||
|
||||
hook_data
|
||||
end
|
||||
|
@ -331,11 +318,6 @@ module Issuable
|
|||
false
|
||||
end
|
||||
|
||||
def assignee_or_author?(user)
|
||||
# We're comparing IDs here so we don't need to load any associations.
|
||||
author_id == user.id || assignee_id == user.id
|
||||
end
|
||||
|
||||
def record_metrics
|
||||
metrics = self.metrics || create_metrics
|
||||
metrics.record!
|
||||
|
|
|
@ -40,7 +40,7 @@ module Milestoneish
|
|||
def issues_visible_to_user(user)
|
||||
memoize_per_user(user, :issues_visible_to_user) do
|
||||
IssuesFinder.new(user, issues_finder_params)
|
||||
.execute.where(milestone_id: milestoneish_ids)
|
||||
.execute.includes(:assignees).where(milestone_id: milestoneish_ids)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ class GlobalMilestone
|
|||
closed = count_by_state(milestones_by_state_and_title, 'closed')
|
||||
all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
|
||||
|
||||
{
|
||||
{
|
||||
opened: opened,
|
||||
closed: closed,
|
||||
all: all
|
||||
|
@ -86,7 +86,7 @@ class GlobalMilestone
|
|||
end
|
||||
|
||||
def issues
|
||||
@issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels)
|
||||
@issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels)
|
||||
end
|
||||
|
||||
def merge_requests
|
||||
|
@ -94,7 +94,7 @@ class GlobalMilestone
|
|||
end
|
||||
|
||||
def participants
|
||||
@participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq
|
||||
@participants ||= milestones.map(&:participants).flatten.uniq
|
||||
end
|
||||
|
||||
def labels
|
||||
|
|
|
@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base
|
|||
|
||||
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
|
||||
|
||||
has_many :issue_assignees
|
||||
has_many :assignees, class_name: "User", through: :issue_assignees
|
||||
|
||||
validates :project, presence: true
|
||||
|
||||
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
|
||||
|
||||
scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
|
||||
scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
|
||||
scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)}
|
||||
|
||||
scope :without_due_date, -> { where(due_date: nil) }
|
||||
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
|
||||
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
|
||||
|
@ -37,13 +44,15 @@ class Issue < ActiveRecord::Base
|
|||
|
||||
scope :created_after, -> (datetime) { where("created_at >= ?", datetime) }
|
||||
|
||||
scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) }
|
||||
scope :include_associations, -> { includes(:labels, project: :namespace) }
|
||||
|
||||
after_save :expire_etag_cache
|
||||
|
||||
attr_spammable :title, spam_title: true
|
||||
attr_spammable :description, spam_description: true
|
||||
|
||||
participant :assignees
|
||||
|
||||
state_machine :state, initial: :opened do
|
||||
event :close do
|
||||
transition [:reopened, :opened] => :closed
|
||||
|
@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def hook_attrs
|
||||
assignee_ids = self.assignee_ids
|
||||
|
||||
attrs = {
|
||||
total_time_spent: total_time_spent,
|
||||
human_total_time_spent: human_total_time_spent,
|
||||
human_time_estimate: human_time_estimate
|
||||
human_time_estimate: human_time_estimate,
|
||||
assignee_ids: assignee_ids,
|
||||
assignee_id: assignee_ids.first # This key is deprecated
|
||||
}
|
||||
|
||||
attributes.merge!(attrs)
|
||||
|
@ -114,6 +127,22 @@ class Issue < ActiveRecord::Base
|
|||
"id DESC")
|
||||
end
|
||||
|
||||
# Returns a Hash of attributes to be used for Twitter card metadata
|
||||
def card_attributes
|
||||
{
|
||||
'Author' => author.try(:name),
|
||||
'Assignee' => assignee_list
|
||||
}
|
||||
end
|
||||
|
||||
def assignee_or_author?(user)
|
||||
author_id == user.id || assignees.exists?(user.id)
|
||||
end
|
||||
|
||||
def assignee_list
|
||||
assignees.map(&:name).to_sentence
|
||||
end
|
||||
|
||||
# `from` argument can be a Namespace or Project.
|
||||
def to_reference(from = nil, full: false)
|
||||
reference = "#{self.class.reference_prefix}#{iid}"
|
||||
|
@ -248,7 +277,7 @@ class Issue < ActiveRecord::Base
|
|||
true
|
||||
elsif confidential?
|
||||
author == user ||
|
||||
assignee == user ||
|
||||
assignees.include?(user) ||
|
||||
project.team.member?(user, Gitlab::Access::REPORTER)
|
||||
else
|
||||
project.public? ||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
class IssueAssignee < ActiveRecord::Base
|
||||
extend Gitlab::CurrentSettings
|
||||
|
||||
belongs_to :issue
|
||||
belongs_to :assignee, class_name: "User", foreign_key: :user_id
|
||||
|
||||
after_create :update_assignee_cache_counts
|
||||
after_destroy :update_assignee_cache_counts
|
||||
|
||||
def update_assignee_cache_counts
|
||||
assignee&.update_cache_counts
|
||||
end
|
||||
end
|
|
@ -17,6 +17,8 @@ class MergeRequest < ActiveRecord::Base
|
|||
|
||||
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
|
||||
|
||||
belongs_to :assignee, class_name: "User"
|
||||
|
||||
serialize :merge_params, Hash
|
||||
|
||||
after_create :ensure_merge_request_diff, unless: :importing?
|
||||
|
@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base
|
|||
|
||||
scope :join_project, -> { joins(:target_project) }
|
||||
scope :references_project, -> { references(:target_project) }
|
||||
scope :assigned, -> { where("assignee_id IS NOT NULL") }
|
||||
scope :unassigned, -> { where("assignee_id IS NULL") }
|
||||
scope :assigned_to, ->(u) { where(assignee_id: u.id)}
|
||||
|
||||
participant :assignee
|
||||
|
||||
after_save :keep_around_commit
|
||||
after_save :update_assignee_cache_counts, if: :assignee_id_changed?
|
||||
|
||||
def self.reference_prefix
|
||||
'!'
|
||||
|
@ -177,6 +185,30 @@ class MergeRequest < ActiveRecord::Base
|
|||
work_in_progress?(title) ? title : "WIP: #{title}"
|
||||
end
|
||||
|
||||
def update_assignee_cache_counts
|
||||
# make sure we flush the cache for both the old *and* new assignees(if they exist)
|
||||
previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was
|
||||
previous_assignee&.update_cache_counts
|
||||
assignee&.update_cache_counts
|
||||
end
|
||||
|
||||
# Returns a Hash of attributes to be used for Twitter card metadata
|
||||
def card_attributes
|
||||
{
|
||||
'Author' => author.try(:name),
|
||||
'Assignee' => assignee.try(:name)
|
||||
}
|
||||
end
|
||||
|
||||
# This method is needed for compatibility with issues to not mess view and other code
|
||||
def assignees
|
||||
Array(assignee)
|
||||
end
|
||||
|
||||
def assignee_or_author?(user)
|
||||
author_id == user.id || assignee_id == user.id
|
||||
end
|
||||
|
||||
# `from` argument can be a Namespace or Project.
|
||||
def to_reference(from = nil, full: false)
|
||||
reference = "#{self.class.reference_prefix}#{iid}"
|
||||
|
|
|
@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base
|
|||
has_many :issues
|
||||
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
|
||||
has_many :merge_requests
|
||||
has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee
|
||||
has_many :events, as: :target, dependent: :destroy
|
||||
|
||||
scope :active, -> { with_state(:active) }
|
||||
|
@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base
|
|||
end
|
||||
end
|
||||
|
||||
def participants
|
||||
User.joins(assigned_issues: :milestone).where("milestones.id = ?", id)
|
||||
end
|
||||
|
||||
def self.sort(method)
|
||||
case method.to_s
|
||||
when 'due_date_asc'
|
||||
|
|
|
@ -100,6 +100,10 @@ class User < ActiveRecord::Base
|
|||
has_many :award_emoji, dependent: :destroy
|
||||
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
|
||||
|
||||
has_many :issue_assignees
|
||||
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
|
||||
has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest"
|
||||
|
||||
# Issues that a user owns are expected to be moved to the "ghost" user before
|
||||
# the user is destroyed. If the user owns any issues during deletion, this
|
||||
# should be treated as an exceptional condition.
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
class IssuableEntity < Grape::Entity
|
||||
expose :id
|
||||
expose :iid
|
||||
expose :assignee_id
|
||||
expose :author_id
|
||||
expose :description
|
||||
expose :lock_version
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
class IssueEntity < IssuableEntity
|
||||
expose :branch_name
|
||||
expose :confidential
|
||||
expose :assignees, using: API::Entities::UserBasic
|
||||
expose :due_date
|
||||
expose :moved_to_id
|
||||
expose :project_id
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
class MergeRequestEntity < IssuableEntity
|
||||
expose :assignee_id
|
||||
expose :in_progress_merge_commit_sha
|
||||
expose :locked_at
|
||||
expose :merge_commit_sha
|
||||
|
|
|
@ -7,10 +7,14 @@ module Issuable
|
|||
ids = params.delete(:issuable_ids).split(",")
|
||||
items = model_class.where(id: ids)
|
||||
|
||||
%i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key|
|
||||
%i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key|
|
||||
params.delete(key) unless params[key].present?
|
||||
end
|
||||
|
||||
if params[:assignee_ids] == [IssuableFinder::NONE.to_s]
|
||||
params[:assignee_ids] = []
|
||||
end
|
||||
|
||||
items.each do |issuable|
|
||||
next unless can?(current_user, :"update_#{type}", issuable)
|
||||
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
class IssuableBaseService < BaseService
|
||||
private
|
||||
|
||||
def create_assignee_note(issuable)
|
||||
SystemNoteService.change_assignee(
|
||||
issuable, issuable.project, current_user, issuable.assignee)
|
||||
end
|
||||
|
||||
def create_milestone_note(issuable)
|
||||
SystemNoteService.change_milestone(
|
||||
issuable, issuable.project, current_user, issuable.milestone)
|
||||
|
@ -53,6 +48,7 @@ class IssuableBaseService < BaseService
|
|||
params.delete(:add_label_ids)
|
||||
params.delete(:remove_label_ids)
|
||||
params.delete(:label_ids)
|
||||
params.delete(:assignee_ids)
|
||||
params.delete(:assignee_id)
|
||||
params.delete(:due_date)
|
||||
end
|
||||
|
@ -77,7 +73,7 @@ class IssuableBaseService < BaseService
|
|||
def assignee_can_read?(issuable, assignee_id)
|
||||
new_assignee = User.find_by_id(assignee_id)
|
||||
|
||||
return false unless new_assignee.present?
|
||||
return false unless new_assignee
|
||||
|
||||
ability_name = :"read_#{issuable.to_ability_name}"
|
||||
resource = issuable.persisted? ? issuable : project
|
||||
|
@ -207,6 +203,7 @@ class IssuableBaseService < BaseService
|
|||
filter_params(issuable)
|
||||
old_labels = issuable.labels.to_a
|
||||
old_mentioned_users = issuable.mentioned_users.to_a
|
||||
old_assignees = issuable.assignees.to_a
|
||||
|
||||
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
|
||||
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
|
||||
|
@ -222,7 +219,13 @@ class IssuableBaseService < BaseService
|
|||
handle_common_system_notes(issuable, old_labels: old_labels)
|
||||
end
|
||||
|
||||
handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users)
|
||||
handle_changes(
|
||||
issuable,
|
||||
old_labels: old_labels,
|
||||
old_mentioned_users: old_mentioned_users,
|
||||
old_assignees: old_assignees
|
||||
)
|
||||
|
||||
after_update(issuable)
|
||||
issuable.create_new_cross_references!(current_user)
|
||||
execute_hooks(issuable, 'update')
|
||||
|
@ -272,7 +275,7 @@ class IssuableBaseService < BaseService
|
|||
end
|
||||
end
|
||||
|
||||
def has_changes?(issuable, old_labels: [])
|
||||
def has_changes?(issuable, old_labels: [], old_assignees: [])
|
||||
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
|
||||
|
||||
attrs_changed = valid_attrs.any? do |attr|
|
||||
|
@ -281,7 +284,9 @@ class IssuableBaseService < BaseService
|
|||
|
||||
labels_changed = issuable.labels != old_labels
|
||||
|
||||
attrs_changed || labels_changed
|
||||
assignees_changed = issuable.assignees != old_assignees
|
||||
|
||||
attrs_changed || labels_changed || assignees_changed
|
||||
end
|
||||
|
||||
def handle_common_system_notes(issuable, old_labels: [])
|
||||
|
|
|
@ -9,11 +9,33 @@ module Issues
|
|||
|
||||
private
|
||||
|
||||
def create_assignee_note(issue, old_assignees)
|
||||
SystemNoteService.change_issue_assignees(
|
||||
issue, issue.project, current_user, old_assignees)
|
||||
end
|
||||
|
||||
def execute_hooks(issue, action = 'open')
|
||||
issue_data = hook_data(issue, action)
|
||||
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
|
||||
issue.project.execute_hooks(issue_data, hooks_scope)
|
||||
issue.project.execute_services(issue_data, hooks_scope)
|
||||
end
|
||||
|
||||
def filter_assignee(issuable)
|
||||
return if params[:assignee_ids].blank?
|
||||
|
||||
# The number of assignees is limited by one for GitLab CE
|
||||
params[:assignee_ids] = params[:assignee_ids][0, 1]
|
||||
|
||||
assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
|
||||
|
||||
if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
|
||||
params[:assignee_ids] = []
|
||||
elsif assignee_ids.any?
|
||||
params[:assignee_ids] = assignee_ids
|
||||
else
|
||||
params.delete(:assignee_ids)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,8 +12,12 @@ module Issues
|
|||
spam_check(issue, current_user)
|
||||
end
|
||||
|
||||
def handle_changes(issue, old_labels: [], old_mentioned_users: [])
|
||||
if has_changes?(issue, old_labels: old_labels)
|
||||
def handle_changes(issue, options)
|
||||
old_labels = options[:old_labels] || []
|
||||
old_mentioned_users = options[:old_mentioned_users] || []
|
||||
old_assignees = options[:old_assignees] || []
|
||||
|
||||
if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
|
||||
todo_service.mark_pending_todos_as_done(issue, current_user)
|
||||
end
|
||||
|
||||
|
@ -26,9 +30,9 @@ module Issues
|
|||
create_milestone_note(issue)
|
||||
end
|
||||
|
||||
if issue.previous_changes.include?('assignee_id')
|
||||
create_assignee_note(issue)
|
||||
notification_service.reassigned_issue(issue, current_user)
|
||||
if issue.assignees != old_assignees
|
||||
create_assignee_note(issue, old_assignees)
|
||||
notification_service.reassigned_issue(issue, current_user, old_assignees)
|
||||
todo_service.reassigned_issue(issue, current_user)
|
||||
end
|
||||
|
||||
|
|
|
@ -26,15 +26,22 @@ module Members
|
|||
|
||||
def unassign_issues_and_merge_requests(member)
|
||||
if member.is_a?(GroupMember)
|
||||
IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
|
||||
execute.
|
||||
update_all(assignee_id: nil)
|
||||
issue_ids = IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
|
||||
execute.pluck(:id)
|
||||
|
||||
IssueAssignee.destroy_all(issue_id: issue_ids, user_id: member.user_id)
|
||||
|
||||
MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
|
||||
execute.
|
||||
update_all(assignee_id: nil)
|
||||
else
|
||||
project = member.source
|
||||
project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil)
|
||||
|
||||
IssueAssignee.destroy_all(
|
||||
user_id: member.user_id,
|
||||
issue_id: project.issues.opened.assigned_to(member.user).select(:id)
|
||||
)
|
||||
|
||||
project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil)
|
||||
member.user.update_cache_counts
|
||||
end
|
||||
|
|
|
@ -4,7 +4,7 @@ module MergeRequests
|
|||
@assignable_issues ||= begin
|
||||
if current_user == merge_request.author
|
||||
closes_issues.select do |issue|
|
||||
!issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue)
|
||||
!issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue)
|
||||
end
|
||||
else
|
||||
[]
|
||||
|
@ -14,7 +14,7 @@ module MergeRequests
|
|||
|
||||
def execute
|
||||
assignable_issues.each do |issue|
|
||||
Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue)
|
||||
Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue)
|
||||
end
|
||||
|
||||
{
|
||||
|
|
|
@ -38,6 +38,11 @@ module MergeRequests
|
|||
|
||||
private
|
||||
|
||||
def create_assignee_note(merge_request)
|
||||
SystemNoteService.change_assignee(
|
||||
merge_request, merge_request.project, current_user, merge_request.assignee)
|
||||
end
|
||||
|
||||
# Returns all origin and fork merge requests from `@project` satisfying passed arguments.
|
||||
def merge_requests_for(source_branch, mr_states: [:opened, :reopened])
|
||||
MergeRequest
|
||||
|
|
|
@ -21,7 +21,10 @@ module MergeRequests
|
|||
update(merge_request)
|
||||
end
|
||||
|
||||
def handle_changes(merge_request, old_labels: [], old_mentioned_users: [])
|
||||
def handle_changes(merge_request, options)
|
||||
old_labels = options[:old_labels] || []
|
||||
old_mentioned_users = options[:old_mentioned_users] || []
|
||||
|
||||
if has_changes?(merge_request, old_labels: old_labels)
|
||||
todo_service.mark_pending_todos_as_done(merge_request, current_user)
|
||||
end
|
||||
|
|
|
@ -19,9 +19,14 @@ class NotificationRecipientService
|
|||
# Re-assign is considered as a mention of the new assignee so we add the
|
||||
# new assignee to the list of recipients after we rejected users with
|
||||
# the "on mention" notification level
|
||||
if [:reassign_merge_request, :reassign_issue].include?(custom_action)
|
||||
case custom_action
|
||||
when :reassign_merge_request
|
||||
recipients << previous_assignee if previous_assignee
|
||||
recipients << target.assignee
|
||||
when :reassign_issue
|
||||
previous_assignees = Array(previous_assignee)
|
||||
recipients.concat(previous_assignees)
|
||||
recipients.concat(target.assignees)
|
||||
end
|
||||
|
||||
recipients = reject_muted_users(recipients)
|
||||
|
|
|
@ -66,8 +66,25 @@ class NotificationService
|
|||
# * issue new assignee if their notification level is not Disabled
|
||||
# * users with custom level checked with "reassign issue"
|
||||
#
|
||||
def reassigned_issue(issue, current_user)
|
||||
reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email)
|
||||
def reassigned_issue(issue, current_user, previous_assignees = [])
|
||||
recipients = NotificationRecipientService.new(issue.project).build_recipients(
|
||||
issue,
|
||||
current_user,
|
||||
action: "reassign",
|
||||
previous_assignee: previous_assignees
|
||||
)
|
||||
|
||||
previous_assignee_ids = previous_assignees.map(&:id)
|
||||
|
||||
recipients.each do |recipient|
|
||||
mailer.send(
|
||||
:reassigned_issue_email,
|
||||
recipient.id,
|
||||
issue.id,
|
||||
previous_assignee_ids,
|
||||
current_user.id
|
||||
).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
# When we add labels to an issue we should send an email to:
|
||||
|
@ -367,10 +384,10 @@ class NotificationService
|
|||
end
|
||||
|
||||
def previous_record(object, attribute)
|
||||
if object && attribute
|
||||
if object.previous_changes.include?(attribute)
|
||||
object.previous_changes[attribute].first
|
||||
end
|
||||
return unless object && attribute
|
||||
|
||||
if object.previous_changes.include?(attribute)
|
||||
object.previous_changes[attribute].first
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -91,32 +91,47 @@ module SlashCommands
|
|||
end
|
||||
|
||||
desc 'Assign'
|
||||
explanation do |user|
|
||||
"Assigns #{user.to_reference}." if user
|
||||
explanation do |users|
|
||||
"Assigns #{users.map(&:to_reference).to_sentence}." if users.any?
|
||||
end
|
||||
params '@user'
|
||||
condition do
|
||||
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
|
||||
end
|
||||
parse_params do |assignee_param|
|
||||
extract_references(assignee_param, :user).first ||
|
||||
User.find_by(username: assignee_param)
|
||||
users = extract_references(assignee_param, :user)
|
||||
|
||||
if users.empty?
|
||||
users = User.where(username: assignee_param.split(' ').map(&:strip))
|
||||
end
|
||||
|
||||
users
|
||||
end
|
||||
command :assign do |user|
|
||||
@updates[:assignee_id] = user.id if user
|
||||
command :assign do |users|
|
||||
next if users.empty?
|
||||
|
||||
if issuable.is_a?(Issue)
|
||||
@updates[:assignee_ids] = users.map(&:id)
|
||||
else
|
||||
@updates[:assignee_id] = users.last.id
|
||||
end
|
||||
end
|
||||
|
||||
desc 'Remove assignee'
|
||||
explanation do
|
||||
"Removes assignee #{issuable.assignee.to_reference}."
|
||||
"Removes assignee #{issuable.assignees.first.to_reference}."
|
||||
end
|
||||
condition do
|
||||
issuable.persisted? &&
|
||||
issuable.assignee_id? &&
|
||||
issuable.assignees.any? &&
|
||||
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
|
||||
end
|
||||
command :unassign do
|
||||
@updates[:assignee_id] = nil
|
||||
if issuable.is_a?(Issue)
|
||||
@updates[:assignee_ids] = []
|
||||
else
|
||||
@updates[:assignee_id] = nil
|
||||
end
|
||||
end
|
||||
|
||||
desc 'Set milestone'
|
||||
|
|
|
@ -49,6 +49,44 @@ module SystemNoteService
|
|||
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
|
||||
end
|
||||
|
||||
# Called when the assignees of an Issue is changed or removed
|
||||
#
|
||||
# issue - Issue object
|
||||
# project - Project owning noteable
|
||||
# author - User performing the change
|
||||
# assignees - Users being assigned, or nil
|
||||
#
|
||||
# Example Note text:
|
||||
#
|
||||
# "removed all assignees"
|
||||
#
|
||||
# "assigned to @user1 additionally to @user2"
|
||||
#
|
||||
# "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5"
|
||||
#
|
||||
# "assigned to @user1 and @user2"
|
||||
#
|
||||
# Returns the created Note object
|
||||
def change_issue_assignees(issue, project, author, old_assignees)
|
||||
body =
|
||||
if issue.assignees.any? && old_assignees.any?
|
||||
unassigned_users = old_assignees - issue.assignees
|
||||
added_users = issue.assignees.to_a - old_assignees
|
||||
|
||||
text_parts = []
|
||||
text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
|
||||
text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
|
||||
|
||||
text_parts.join(' and ')
|
||||
elsif old_assignees.any?
|
||||
"removed all assignees"
|
||||
elsif issue.assignees.any?
|
||||
"assigned to #{issue.assignees.map(&:to_reference).to_sentence}"
|
||||
end
|
||||
|
||||
create_note(NoteSummary.new(issue, project, author, body, action: 'assignee'))
|
||||
end
|
||||
|
||||
# Called when one or more labels on a Noteable are added and/or removed
|
||||
#
|
||||
# noteable - Noteable object
|
||||
|
|
|
@ -251,9 +251,9 @@ class TodoService
|
|||
end
|
||||
|
||||
def create_assignment_todo(issuable, author)
|
||||
if issuable.assignee
|
||||
if issuable.assignees.any?
|
||||
attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED)
|
||||
create_todos(issuable.assignee, attributes)
|
||||
create_todos(issuable.assignees, attributes)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -23,10 +23,19 @@ xml.entry do
|
|||
end
|
||||
end
|
||||
|
||||
if issue.assignee
|
||||
if issue.assignees.any?
|
||||
xml.assignees do
|
||||
issue.assignees.each do |assignee|
|
||||
xml.assignee do
|
||||
xml.name assignee.name
|
||||
xml.email assignee.public_email
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
xml.assignee do
|
||||
xml.name issue.assignee.name
|
||||
xml.email issue.assignee_public_email
|
||||
xml.name issue.assignees.first.name
|
||||
xml.email issue.assignees.first.public_email
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %>
|
||||
|
||||
<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %>
|
||||
|
||||
Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
|
||||
to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %>
|
|
@ -2,9 +2,9 @@
|
|||
%p.details
|
||||
#{link_to @issue.author_name, user_url(@issue.author)} created an issue:
|
||||
|
||||
- if @issue.assignee_id.present?
|
||||
- if @issue.assignees.any?
|
||||
%p
|
||||
Assignee: #{@issue.assignee_name}
|
||||
Assignee: #{@issue.assignee_list}
|
||||
|
||||
- if @issue.description
|
||||
%div
|
||||
|
|
|
@ -2,6 +2,6 @@ New Issue was created.
|
|||
|
||||
Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
|
||||
Author: <%= @issue.author_name %>
|
||||
Assignee: <%= @issue.assignee_name %>
|
||||
Assignee: <%= @issue.assignee_list %>
|
||||
|
||||
<%= @issue.description %>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue