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) {
|
if (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
@ -117,6 +117,10 @@ export default class FileTemplateMediator {
|
||||||
this.cacheToggleText();
|
this.cacheToggleText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectTemplateTypeOptions(options) {
|
||||||
|
this.selectTemplateType(options.selectedObj, options.e);
|
||||||
|
}
|
||||||
|
|
||||||
selectTemplateFile(selector, query, data) {
|
selectTemplateFile(selector, query, data) {
|
||||||
selector.renderLoading();
|
selector.renderLoading();
|
||||||
// in case undo menu is already already there
|
// in case undo menu is already already there
|
||||||
|
|
|
@ -52,9 +52,17 @@ export default class FileTemplateSelector {
|
||||||
.removeClass('fa-spinner fa-spin');
|
.removeClass('fa-spinner fa-spin');
|
||||||
}
|
}
|
||||||
|
|
||||||
reportSelection(query, el, e, data) {
|
reportSelection(options) {
|
||||||
|
const { query, e, data } = options;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return this.mediator.selectTemplateFile(this, query, data);
|
return this.mediator.selectTemplateFile(this, query, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportSelectionName(options) {
|
||||||
|
const opts = options;
|
||||||
|
opts.query = options.selectedObj.name;
|
||||||
|
|
||||||
|
this.reportSelection(opts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,8 +37,8 @@ class TargetBranchDropDown {
|
||||||
}
|
}
|
||||||
return SELECT_ITEM_MSG;
|
return SELECT_ITEM_MSG;
|
||||||
},
|
},
|
||||||
clicked(item, el, e) {
|
clicked(options) {
|
||||||
e.preventDefault();
|
options.e.preventDefault();
|
||||||
self.onClick.call(self);
|
self.onClick.call(self);
|
||||||
},
|
},
|
||||||
fieldName: self.fieldName,
|
fieldName: self.fieldName,
|
||||||
|
|
|
@ -24,7 +24,7 @@ export default class TemplateSelector {
|
||||||
search: {
|
search: {
|
||||||
fields: ['name'],
|
fields: ['name'],
|
||||||
},
|
},
|
||||||
clicked: (item, el, e) => this.fetchFileTemplate(item, el, e),
|
clicked: options => this.fetchFileTemplate(options),
|
||||||
text: item => item.name,
|
text: item => item.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,10 @@ export default class TemplateSelector {
|
||||||
return this.$dropdownContainer.removeClass('hidden');
|
return this.$dropdownContainer.removeClass('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchFileTemplate(item, el, e) {
|
fetchFileTemplate(options) {
|
||||||
|
const { e } = options;
|
||||||
|
const item = options.selectedObj;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return this.requestFile(item);
|
return this.requestFile(item);
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector {
|
||||||
search: {
|
search: {
|
||||||
fields: ['name'],
|
fields: ['name'],
|
||||||
},
|
},
|
||||||
clicked: (query, el, e) => this.reportSelection(query.name, el, e),
|
clicked: options => this.reportSelectionName(options),
|
||||||
text: item => item.name,
|
text: item => item.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector {
|
||||||
search: {
|
search: {
|
||||||
fields: ['name'],
|
fields: ['name'],
|
||||||
},
|
},
|
||||||
clicked: (query, el, e) => this.reportSelection(query.name, el, e),
|
clicked: options => this.reportSelectionName(options),
|
||||||
text: item => item.name,
|
text: item => item.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector {
|
||||||
search: {
|
search: {
|
||||||
fields: ['name'],
|
fields: ['name'],
|
||||||
},
|
},
|
||||||
clicked: (query, el, e) => this.reportSelection(query.name, el, e),
|
clicked: options => this.reportSelectionName(options),
|
||||||
text: item => item.name,
|
text: item => item.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector {
|
||||||
search: {
|
search: {
|
||||||
fields: ['name'],
|
fields: ['name'],
|
||||||
},
|
},
|
||||||
clicked: (query, el, e) => {
|
clicked: (options) => {
|
||||||
|
const { e } = options;
|
||||||
|
const el = options.$el;
|
||||||
|
const query = options.selectedObj;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
project: this.$dropdown.data('project'),
|
project: this.$dropdown.data('project'),
|
||||||
fullname: this.$dropdown.data('fullname'),
|
fullname: this.$dropdown.data('fullname'),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.reportSelection(query.id, el, e, data);
|
this.reportSelection({
|
||||||
|
query: query.id,
|
||||||
|
el,
|
||||||
|
e,
|
||||||
|
data,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
text: item => item.name,
|
text: item => item.name,
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector {
|
||||||
filterable: false,
|
filterable: false,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
toggleLabel: item => item.name,
|
toggleLabel: item => item.name,
|
||||||
clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e),
|
clicked: options => this.mediator.selectTemplateTypeOptions(options),
|
||||||
text: item => item.name,
|
text: item => item.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ require('./models/issue');
|
||||||
require('./models/label');
|
require('./models/label');
|
||||||
require('./models/list');
|
require('./models/list');
|
||||||
require('./models/milestone');
|
require('./models/milestone');
|
||||||
require('./models/user');
|
require('./models/assignee');
|
||||||
require('./stores/boards_store');
|
require('./stores/boards_store');
|
||||||
require('./stores/modal_store');
|
require('./stores/modal_store');
|
||||||
require('./services/board_service');
|
require('./services/board_service');
|
||||||
|
|
|
@ -26,6 +26,7 @@ export default {
|
||||||
title: this.title,
|
title: this.title,
|
||||||
labels,
|
labels,
|
||||||
subscribed: true,
|
subscribed: true,
|
||||||
|
assignees: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.list.newIssue(issue)
|
this.list.newIssue(issue)
|
||||||
|
|
|
@ -3,8 +3,13 @@
|
||||||
/* global MilestoneSelect */
|
/* global MilestoneSelect */
|
||||||
/* global LabelsSelect */
|
/* global LabelsSelect */
|
||||||
/* global Sidebar */
|
/* global Sidebar */
|
||||||
|
/* global Flash */
|
||||||
|
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
|
import eventHub from '../../sidebar/event_hub';
|
||||||
|
|
||||||
|
import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
|
||||||
|
import Assignees from '../../sidebar/components/assignees/assignees';
|
||||||
|
|
||||||
require('./sidebar/remove_issue');
|
require('./sidebar/remove_issue');
|
||||||
|
|
||||||
|
@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
|
||||||
detail: Store.detail,
|
detail: Store.detail,
|
||||||
issue: {},
|
issue: {},
|
||||||
list: {},
|
list: {},
|
||||||
|
loadingAssignees: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({
|
||||||
|
|
||||||
this.issue = this.detail.issue;
|
this.issue = this.detail.issue;
|
||||||
this.list = this.detail.list;
|
this.list = this.detail.list;
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true
|
||||||
},
|
},
|
||||||
|
@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({
|
||||||
$('.right-sidebar').getNiceScroll().resize();
|
$('.right-sidebar').getNiceScroll().resize();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
this.issue = this.detail.issue;
|
||||||
|
this.list = this.detail.list;
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
closeSidebar () {
|
closeSidebar () {
|
||||||
this.detail.issue = {};
|
this.detail.issue = {};
|
||||||
}
|
},
|
||||||
|
assignSelf () {
|
||||||
|
// Notify gl dropdown that we are now assigning to current user
|
||||||
|
this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
|
||||||
|
|
||||||
|
this.addAssignee(this.currentUser);
|
||||||
|
this.saveAssignees();
|
||||||
|
},
|
||||||
|
removeAssignee (a) {
|
||||||
|
gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a);
|
||||||
|
},
|
||||||
|
addAssignee (a) {
|
||||||
|
gl.issueBoards.BoardsStore.detail.issue.addAssignee(a);
|
||||||
|
},
|
||||||
|
removeAllAssignees () {
|
||||||
|
gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees();
|
||||||
|
},
|
||||||
|
saveAssignees () {
|
||||||
|
this.loadingAssignees = true;
|
||||||
|
|
||||||
|
gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
|
||||||
|
.then(() => {
|
||||||
|
this.loadingAssignees = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.loadingAssignees = false;
|
||||||
|
return new Flash('An error occurred while saving assignees');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
// Get events from glDropdown
|
||||||
|
eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
|
||||||
|
eventHub.$on('sidebar.addAssignee', this.addAssignee);
|
||||||
|
eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
|
||||||
|
eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
|
||||||
|
eventHub.$off('sidebar.addAssignee', this.addAssignee);
|
||||||
|
eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
|
||||||
|
eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
new IssuableContext(this.currentUser);
|
new IssuableContext(this.currentUser);
|
||||||
|
@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
removeBtn: gl.issueBoards.RemoveIssueBtn,
|
removeBtn: gl.issueBoards.RemoveIssueBtn,
|
||||||
|
'assignee-title': AssigneeTitle,
|
||||||
|
assignees: Assignees,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -31,19 +31,37 @@ gl.issueBoards.IssueCardInner = Vue.extend({
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
limitBeforeCounter: 3,
|
||||||
|
maxRender: 4,
|
||||||
|
maxCounter: 99,
|
||||||
|
};
|
||||||
|
},
|
||||||
computed: {
|
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() {
|
cardUrl() {
|
||||||
return `${this.issueLinkBase}/${this.issue.id}`;
|
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() {
|
issueId() {
|
||||||
return `#${this.issue.id}`;
|
return `#${this.issue.id}`;
|
||||||
},
|
},
|
||||||
|
@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
isIndexLessThanlimit(index) {
|
||||||
|
return index < this.limitBeforeCounter;
|
||||||
|
},
|
||||||
|
shouldRenderAssignee(index) {
|
||||||
|
// Eg. maxRender is 4,
|
||||||
|
// Render up to all 4 assignees if there are only 4 assigness
|
||||||
|
// Otherwise render up to the limitBeforeCounter
|
||||||
|
if (this.issue.assignees.length <= this.maxRender) {
|
||||||
|
return index < this.maxRender;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index < this.limitBeforeCounter;
|
||||||
|
},
|
||||||
|
assigneeUrl(assignee) {
|
||||||
|
return `${this.rootPath}${assignee.username}`;
|
||||||
|
},
|
||||||
|
assigneeUrlTitle(assignee) {
|
||||||
|
return `Assigned to ${assignee.name}`;
|
||||||
|
},
|
||||||
|
avatarUrlTitle(assignee) {
|
||||||
|
return `Avatar for ${assignee.name}`;
|
||||||
|
},
|
||||||
showLabel(label) {
|
showLabel(label) {
|
||||||
if (!this.list) return true;
|
if (!this.list) return true;
|
||||||
|
|
||||||
|
@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({
|
||||||
{{ issueId }}
|
{{ issueId }}
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
<a
|
<div class="card-assignee">
|
||||||
class="card-assignee has-tooltip js-no-trigger"
|
<a
|
||||||
:href="assigneeUrl"
|
class="has-tooltip js-no-trigger"
|
||||||
:title="assigneeUrlTitle"
|
:href="assigneeUrl(assignee)"
|
||||||
v-if="issue.assignee"
|
:title="assigneeUrlTitle(assignee)"
|
||||||
data-container="body"
|
v-for="(assignee, index) in issue.assignees"
|
||||||
>
|
v-if="shouldRenderAssignee(index)"
|
||||||
<img
|
data-container="body"
|
||||||
class="avatar avatar-inline s20 js-no-trigger"
|
data-placement="bottom"
|
||||||
:src="issue.assignee.avatar"
|
>
|
||||||
width="20"
|
<img
|
||||||
height="20"
|
class="avatar avatar-inline s20"
|
||||||
:alt="avatarUrlTitle"
|
:src="assignee.avatar"
|
||||||
/>
|
width="20"
|
||||||
</a>
|
height="20"
|
||||||
|
:alt="avatarUrlTitle(assignee)"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
class="avatar-counter has-tooltip"
|
||||||
|
:title="assigneeCounterTooltip"
|
||||||
|
v-if="shouldRenderCounter"
|
||||||
|
>
|
||||||
|
{{ assigneeCounterLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer" v-if="showLabelFooter">
|
<div
|
||||||
|
class="card-footer"
|
||||||
|
v-if="showLabelFooter"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="label color-label has-tooltip js-no-trigger"
|
class="label color-label has-tooltip"
|
||||||
v-for="label in issue.labels"
|
v-for="label in issue.labels"
|
||||||
type="button"
|
type="button"
|
||||||
v-if="showLabel(label)"
|
v-if="showLabel(label)"
|
||||||
|
|
|
@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => {
|
||||||
filterable: true,
|
filterable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
multiSelect: true,
|
multiSelect: true,
|
||||||
clicked (label, $el, e) {
|
clicked (options) {
|
||||||
|
const { e } = options;
|
||||||
|
const label = options.selectedObj;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!Store.findList('title', label.title)) {
|
if (!Store.findList('title', label.title)) {
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
class ListUser {
|
/* eslint-disable no-unused-vars */
|
||||||
|
|
||||||
|
class ListAssignee {
|
||||||
constructor(user, defaultAvatar) {
|
constructor(user, defaultAvatar) {
|
||||||
this.id = user.id;
|
this.id = user.id;
|
||||||
this.name = user.name;
|
this.name = user.name;
|
||||||
|
@ -7,4 +9,4 @@ class ListUser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 */
|
/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */
|
||||||
/* global ListLabel */
|
/* global ListLabel */
|
||||||
/* global ListMilestone */
|
/* global ListMilestone */
|
||||||
/* global ListUser */
|
/* global ListAssignee */
|
||||||
|
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
|
|
||||||
|
@ -14,14 +14,10 @@ class ListIssue {
|
||||||
this.dueDate = obj.due_date;
|
this.dueDate = obj.due_date;
|
||||||
this.subscribed = obj.subscribed;
|
this.subscribed = obj.subscribed;
|
||||||
this.labels = [];
|
this.labels = [];
|
||||||
|
this.assignees = [];
|
||||||
this.selected = false;
|
this.selected = false;
|
||||||
this.assignee = false;
|
|
||||||
this.position = obj.relative_position || Infinity;
|
this.position = obj.relative_position || Infinity;
|
||||||
|
|
||||||
if (obj.assignee) {
|
|
||||||
this.assignee = new ListUser(obj.assignee, defaultAvatar);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (obj.milestone) {
|
if (obj.milestone) {
|
||||||
this.milestone = new ListMilestone(obj.milestone);
|
this.milestone = new ListMilestone(obj.milestone);
|
||||||
}
|
}
|
||||||
|
@ -29,6 +25,8 @@ class ListIssue {
|
||||||
obj.labels.forEach((label) => {
|
obj.labels.forEach((label) => {
|
||||||
this.labels.push(new ListLabel(label));
|
this.labels.push(new ListLabel(label));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar));
|
||||||
}
|
}
|
||||||
|
|
||||||
addLabel (label) {
|
addLabel (label) {
|
||||||
|
@ -51,6 +49,26 @@ class ListIssue {
|
||||||
labels.forEach(this.removeLabel.bind(this));
|
labels.forEach(this.removeLabel.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addAssignee (assignee) {
|
||||||
|
if (!this.findAssignee(assignee)) {
|
||||||
|
this.assignees.push(new ListAssignee(assignee));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findAssignee (findAssignee) {
|
||||||
|
return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAssignee (removeAssignee) {
|
||||||
|
if (removeAssignee) {
|
||||||
|
this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllAssignees () {
|
||||||
|
this.assignees = [];
|
||||||
|
}
|
||||||
|
|
||||||
getLists () {
|
getLists () {
|
||||||
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
|
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
|
||||||
}
|
}
|
||||||
|
@ -60,7 +78,7 @@ class ListIssue {
|
||||||
issue: {
|
issue: {
|
||||||
milestone_id: this.milestone ? this.milestone.id : null,
|
milestone_id: this.milestone ? this.milestone.id : null,
|
||||||
due_date: this.dueDate,
|
due_date: this.dueDate,
|
||||||
assignee_id: this.assignee ? this.assignee.id : null,
|
assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0],
|
||||||
label_ids: this.labels.map((label) => label.id)
|
label_ids: this.labels.map((label) => label.id)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -255,7 +255,8 @@ GitLabDropdown = (function() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Remote data
|
// Remote data
|
||||||
})(this)
|
})(this),
|
||||||
|
instance: this,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -269,6 +270,7 @@ GitLabDropdown = (function() {
|
||||||
remote: this.options.filterRemote,
|
remote: this.options.filterRemote,
|
||||||
query: this.options.data,
|
query: this.options.data,
|
||||||
keys: searchFields,
|
keys: searchFields,
|
||||||
|
instance: this,
|
||||||
elements: (function(_this) {
|
elements: (function(_this) {
|
||||||
return function() {
|
return function() {
|
||||||
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
|
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
|
||||||
|
@ -343,21 +345,26 @@ GitLabDropdown = (function() {
|
||||||
}
|
}
|
||||||
this.dropdown.on("click", selector, function(e) {
|
this.dropdown.on("click", selector, function(e) {
|
||||||
var $el, selected, selectedObj, isMarking;
|
var $el, selected, selectedObj, isMarking;
|
||||||
$el = $(this);
|
$el = $(e.currentTarget);
|
||||||
selected = self.rowClicked($el);
|
selected = self.rowClicked($el);
|
||||||
selectedObj = selected ? selected[0] : null;
|
selectedObj = selected ? selected[0] : null;
|
||||||
isMarking = selected ? selected[1] : null;
|
isMarking = selected ? selected[1] : null;
|
||||||
if (self.options.clicked) {
|
if (this.options.clicked) {
|
||||||
self.options.clicked(selectedObj, $el, e, isMarking);
|
this.options.clicked.call(this, {
|
||||||
|
selectedObj,
|
||||||
|
$el,
|
||||||
|
e,
|
||||||
|
isMarking,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update label right after all modifications in dropdown has been done
|
// Update label right after all modifications in dropdown has been done
|
||||||
if (self.options.toggleLabel) {
|
if (this.options.toggleLabel) {
|
||||||
self.updateLabel(selectedObj, $el, self);
|
this.updateLabel(selectedObj, $el, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
$el.trigger('blur');
|
$el.trigger('blur');
|
||||||
});
|
}.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -439,15 +446,34 @@ GitLabDropdown = (function() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
GitLabDropdown.prototype.filteredFullData = function() {
|
||||||
|
return this.fullData.filter(r => typeof r === 'object'
|
||||||
|
&& !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
|
||||||
|
&& !Object.prototype.hasOwnProperty.call(r, 'header')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
GitLabDropdown.prototype.opened = function(e) {
|
GitLabDropdown.prototype.opened = function(e) {
|
||||||
var contentHtml;
|
var contentHtml;
|
||||||
this.resetRows();
|
this.resetRows();
|
||||||
this.addArrowKeyEvent();
|
this.addArrowKeyEvent();
|
||||||
|
|
||||||
|
const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
|
||||||
|
const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
|
||||||
|
const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
|
||||||
|
|
||||||
// Makes indeterminate items effective
|
// Makes indeterminate items effective
|
||||||
if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) {
|
if (this.fullData && hasFilterBulkUpdate) {
|
||||||
this.parseData(this.fullData);
|
this.parseData(this.fullData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process the data to make sure rendered data
|
||||||
|
// matches the correct layout
|
||||||
|
if (this.fullData && hasMultiSelect && this.options.processData) {
|
||||||
|
const inputValue = this.filterInput.val();
|
||||||
|
this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
contentHtml = $('.dropdown-content', this.dropdown).html();
|
contentHtml = $('.dropdown-content', this.dropdown).html();
|
||||||
if (this.remote && contentHtml === "") {
|
if (this.remote && contentHtml === "") {
|
||||||
this.remote.execute();
|
this.remote.execute();
|
||||||
|
@ -709,6 +735,11 @@ GitLabDropdown = (function() {
|
||||||
if (this.options.inputId != null) {
|
if (this.options.inputId != null) {
|
||||||
$input.attr('id', this.options.inputId);
|
$input.attr('id', this.options.inputId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.options.inputMeta) {
|
||||||
|
$input.attr('data-meta', selectedObject[this.options.inputMeta]);
|
||||||
|
}
|
||||||
|
|
||||||
return this.dropdown.before($input);
|
return this.dropdown.before($input);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -829,7 +860,14 @@ GitLabDropdown = (function() {
|
||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
instance = null;
|
instance = null;
|
||||||
}
|
}
|
||||||
return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance));
|
|
||||||
|
let toggleText = this.options.toggleLabel(selected, el, instance);
|
||||||
|
if (this.options.updateLabel) {
|
||||||
|
// Option to override the dropdown label text
|
||||||
|
toggleText = this.options.updateLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $(this.el).find(".dropdown-toggle-text").text(toggleText);
|
||||||
};
|
};
|
||||||
|
|
||||||
GitLabDropdown.prototype.clearField = function(field, isInput) {
|
GitLabDropdown.prototype.clearField = function(field, isInput) {
|
||||||
|
|
|
@ -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;
|
return label;
|
||||||
};
|
};
|
||||||
})(this),
|
})(this),
|
||||||
clicked: function(item, $el, e) {
|
clicked: function(options) {
|
||||||
return e.preventDefault();
|
return options.e.preventDefault();
|
||||||
},
|
},
|
||||||
id: function(obj, el) {
|
id: function(obj, el) {
|
||||||
return $(el).data("id");
|
return $(el).data("id");
|
||||||
|
|
|
@ -88,7 +88,10 @@
|
||||||
const formData = {
|
const formData = {
|
||||||
update: {
|
update: {
|
||||||
state_event: this.form.find('input[name="update[state_event]"]').val(),
|
state_event: this.form.find('input[name="update[state_event]"]').val(),
|
||||||
|
// For Merge Requests
|
||||||
assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
|
assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
|
||||||
|
// For Issues
|
||||||
|
assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
|
||||||
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
|
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
|
||||||
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
|
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
|
||||||
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
|
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
|
||||||
|
|
|
@ -330,7 +330,10 @@
|
||||||
},
|
},
|
||||||
multiSelect: $dropdown.hasClass('js-multiselect'),
|
multiSelect: $dropdown.hasClass('js-multiselect'),
|
||||||
vue: $dropdown.hasClass('js-issue-board-sidebar'),
|
vue: $dropdown.hasClass('js-issue-board-sidebar'),
|
||||||
clicked: function(label, $el, e, isMarking) {
|
clicked: function(options) {
|
||||||
|
const { $el, e, isMarking } = options;
|
||||||
|
const label = options.selectedObj;
|
||||||
|
|
||||||
var isIssueIndex, isMRIndex, page, boardsModel;
|
var isIssueIndex, isMRIndex, page, boardsModel;
|
||||||
var fadeOutLoader = () => {
|
var fadeOutLoader = () => {
|
||||||
$loading.fadeOut();
|
$loading.fadeOut();
|
||||||
|
@ -352,7 +355,7 @@
|
||||||
|
|
||||||
if ($dropdown.hasClass('js-filter-bulk-update')) {
|
if ($dropdown.hasClass('js-filter-bulk-update')) {
|
||||||
_this.enableBulkLabelDropdown();
|
_this.enableBulkLabelDropdown();
|
||||||
_this.setDropdownData($dropdown, isMarking, this.id(label));
|
_this.setDropdownData($dropdown, isMarking, label.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -158,7 +158,6 @@ import './single_file_diff';
|
||||||
import './smart_interval';
|
import './smart_interval';
|
||||||
import './snippets_list';
|
import './snippets_list';
|
||||||
import './star';
|
import './star';
|
||||||
import './subbable_resource';
|
|
||||||
import './subscription';
|
import './subscription';
|
||||||
import './subscription_select';
|
import './subscription_select';
|
||||||
import './syntax_highlight';
|
import './syntax_highlight';
|
||||||
|
|
|
@ -31,8 +31,8 @@
|
||||||
toggleLabel(selected, $el) {
|
toggleLabel(selected, $el) {
|
||||||
return $el.text();
|
return $el.text();
|
||||||
},
|
},
|
||||||
clicked: (selected, $link) => {
|
clicked: (options) => {
|
||||||
this.formSubmit(null, $link);
|
this.formSubmit(null, options.$el);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -121,7 +121,10 @@
|
||||||
return $value.css('display', '');
|
return $value.css('display', '');
|
||||||
},
|
},
|
||||||
vue: $dropdown.hasClass('js-issue-board-sidebar'),
|
vue: $dropdown.hasClass('js-issue-board-sidebar'),
|
||||||
clicked: function(selected, $el, e) {
|
clicked: function(options) {
|
||||||
|
const { $el, e } = options;
|
||||||
|
let selected = options.selectedObj;
|
||||||
|
|
||||||
var data, isIssueIndex, isMRIndex, page, boardsStore;
|
var data, isIssueIndex, isMRIndex, page, boardsStore;
|
||||||
page = $('body').data('page');
|
page = $('body').data('page');
|
||||||
isIssueIndex = page === 'projects:issues:index';
|
isIssueIndex = page === 'projects:issues:index';
|
||||||
|
|
|
@ -58,7 +58,8 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
NamespaceSelect.prototype.onSelectItem = function(item, el, e) {
|
NamespaceSelect.prototype.onSelectItem = function(options) {
|
||||||
|
const { e } = options;
|
||||||
return e.preventDefault();
|
return e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -112,7 +112,8 @@ import Cookies from 'js-cookie';
|
||||||
toggleLabel: function(obj, $el) {
|
toggleLabel: function(obj, $el) {
|
||||||
return $el.text().trim();
|
return $el.text().trim();
|
||||||
},
|
},
|
||||||
clicked: function(selected, $el, e) {
|
clicked: function(options) {
|
||||||
|
const { e } = options;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if ($('input[name="ref"]').length) {
|
if ($('input[name="ref"]').length) {
|
||||||
var $form = $dropdown.closest('form');
|
var $form = $dropdown.closest('form');
|
||||||
|
|
|
@ -19,7 +19,9 @@
|
||||||
return 'Select';
|
return 'Select';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clicked(item, $el, e) {
|
clicked(opts) {
|
||||||
|
const { e } = opts;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSelect();
|
onSelect();
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,8 @@ class ProtectedBranchDropdown {
|
||||||
return _.escape(protectedBranch.id);
|
return _.escape(protectedBranch.id);
|
||||||
},
|
},
|
||||||
onFilter: this.toggleCreateNewButton.bind(this),
|
onFilter: this.toggleCreateNewButton.bind(this),
|
||||||
clicked: (item, $el, e) => {
|
clicked: (options) => {
|
||||||
|
const { $el, e } = options;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.onSelect();
|
this.onSelect();
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown {
|
||||||
}
|
}
|
||||||
return 'Select';
|
return 'Select';
|
||||||
},
|
},
|
||||||
clicked(item, $el, e) {
|
clicked(options) {
|
||||||
e.preventDefault();
|
options.e.preventDefault();
|
||||||
onSelect();
|
onSelect();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -39,8 +39,8 @@ export default class ProtectedTagDropdown {
|
||||||
return _.escape(protectedTag.id);
|
return _.escape(protectedTag.id);
|
||||||
},
|
},
|
||||||
onFilter: this.toggleCreateNewButton.bind(this),
|
onFilter: this.toggleCreateNewButton.bind(this),
|
||||||
clicked: (item, $el, e) => {
|
clicked: (options) => {
|
||||||
e.preventDefault();
|
options.e.preventDefault();
|
||||||
this.onSelect();
|
this.onSelect();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
return label;
|
||||||
};
|
};
|
||||||
})(this),
|
})(this),
|
||||||
clicked: function(item, $el, e) {
|
clicked: function(options) {
|
||||||
return e.preventDefault();
|
return options.e.preventDefault();
|
||||||
},
|
},
|
||||||
id: function(obj, el) {
|
id: function(obj, el) {
|
||||||
return $(el).data("id");
|
return $(el).data("id");
|
||||||
|
|
|
@ -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 */
|
/* 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 Issuable */
|
||||||
/* global ListUser */
|
|
||||||
|
import eventHub from './sidebar/event_hub';
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
|
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
|
||||||
|
@ -54,42 +55,115 @@
|
||||||
selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null;
|
selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null;
|
||||||
selectedId = $dropdown.data('selected') || selectedIdDefault;
|
selectedId = $dropdown.data('selected') || selectedIdDefault;
|
||||||
|
|
||||||
var updateIssueBoardsIssue = function () {
|
const assignYourself = function () {
|
||||||
$loading.removeClass('hidden').fadeIn();
|
const unassignedSelected = $dropdown.closest('.selectbox')
|
||||||
gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
|
.find(`input[name='${$dropdown.data('field-name')}'][value=0]`);
|
||||||
.then(function () {
|
|
||||||
$loading.fadeOut();
|
if (unassignedSelected) {
|
||||||
})
|
unassignedSelected.remove();
|
||||||
.catch(function () {
|
}
|
||||||
$loading.fadeOut();
|
|
||||||
});
|
// 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) => {
|
$('.assign-to-me-link').on('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$(e.currentTarget).hide();
|
$(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) {
|
if ($dropdown.data('multiSelect')) {
|
||||||
e.preventDefault();
|
assignYourself();
|
||||||
|
checkMaxSelect();
|
||||||
|
|
||||||
if ($dropdown.hasClass('js-issue-board-sidebar')) {
|
const currentUserInfo = $dropdown.data('currentUserInfo');
|
||||||
gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
|
$dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default');
|
||||||
id: _this.currentUser.id,
|
|
||||||
username: _this.currentUser.username,
|
|
||||||
name: _this.currentUser.name,
|
|
||||||
avatar_url: _this.currentUser.avatar_url
|
|
||||||
}));
|
|
||||||
|
|
||||||
updateIssueBoardsIssue();
|
|
||||||
} else {
|
} 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) {
|
assignTo = function(selected) {
|
||||||
var data;
|
var data;
|
||||||
data = {};
|
data = {};
|
||||||
|
@ -97,6 +171,7 @@
|
||||||
data[abilityName].assignee_id = selected != null ? selected : null;
|
data[abilityName].assignee_id = selected != null ? selected : null;
|
||||||
$loading.removeClass('hidden').fadeIn();
|
$loading.removeClass('hidden').fadeIn();
|
||||||
$dropdown.trigger('loading.gl.dropdown');
|
$dropdown.trigger('loading.gl.dropdown');
|
||||||
|
|
||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
|
@ -106,7 +181,6 @@
|
||||||
var user;
|
var user;
|
||||||
$dropdown.trigger('loaded.gl.dropdown');
|
$dropdown.trigger('loaded.gl.dropdown');
|
||||||
$loading.fadeOut();
|
$loading.fadeOut();
|
||||||
$selectbox.hide();
|
|
||||||
if (data.assignee) {
|
if (data.assignee) {
|
||||||
user = {
|
user = {
|
||||||
name: data.assignee.name,
|
name: data.assignee.name,
|
||||||
|
@ -133,51 +207,90 @@
|
||||||
var isAuthorFilter;
|
var isAuthorFilter;
|
||||||
isAuthorFilter = $('.js-author-search');
|
isAuthorFilter = $('.js-author-search');
|
||||||
return _this.users(term, options, function(users) {
|
return _this.users(term, options, function(users) {
|
||||||
var anyUser, index, j, len, name, obj, showDivider;
|
// GitLabDropdownFilter returns this.instance
|
||||||
if (term.length === 0) {
|
// GitLabDropdownRemote returns this.options.instance
|
||||||
showDivider = 0;
|
const glDropdown = this.instance || this.options.instance;
|
||||||
if (firstUser) {
|
glDropdown.options.processData(term, users, callback);
|
||||||
// Move current user to the front of the list
|
}.bind(this));
|
||||||
for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
|
},
|
||||||
obj = users[index];
|
processData: function(term, users, callback) {
|
||||||
if (obj.username === firstUser) {
|
let anyUser;
|
||||||
users.splice(index, 1);
|
let index;
|
||||||
users.unshift(obj);
|
let j;
|
||||||
break;
|
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) {
|
if (showNullUser) {
|
||||||
users.splice(showDivider, 0, "divider");
|
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 (showDivider) {
|
||||||
if (showMenuAbove) {
|
users.splice(showDivider, 0, 'divider');
|
||||||
$dropdown.data('glDropdown').positionMenuAbove();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
if ($dropdown.hasClass('js-multiselect')) {
|
||||||
|
const selected = getSelected().filter(i => i !== 0);
|
||||||
|
|
||||||
|
if (selected.length > 0) {
|
||||||
|
if ($dropdown.data('dropdown-header')) {
|
||||||
|
showDivider += 1;
|
||||||
|
users.splice(showDivider, 0, {
|
||||||
|
header: $dropdown.data('dropdown-header'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedUsers = users
|
||||||
|
.filter(u => selected.indexOf(u.id) !== -1)
|
||||||
|
.sort((a, b) => a.name > b.name);
|
||||||
|
|
||||||
|
users = users.filter(u => selected.indexOf(u.id) === -1);
|
||||||
|
|
||||||
|
selectedUsers.forEach((selectedUser) => {
|
||||||
|
showDivider += 1;
|
||||||
|
users.splice(showDivider, 0, selectedUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
users.splice(showDivider + 1, 0, 'divider');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(users);
|
||||||
|
if (showMenuAbove) {
|
||||||
|
$dropdown.data('glDropdown').positionMenuAbove();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
filterable: true,
|
filterable: true,
|
||||||
filterRemote: true,
|
filterRemote: true,
|
||||||
|
@ -186,7 +299,22 @@
|
||||||
},
|
},
|
||||||
selectable: true,
|
selectable: true,
|
||||||
fieldName: $dropdown.data('field-name'),
|
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')) {
|
if (selected && 'id' in selected && $(el).hasClass('is-active')) {
|
||||||
$dropdown.find('.dropdown-toggle-text').removeClass('is-default');
|
$dropdown.find('.dropdown-toggle-text').removeClass('is-default');
|
||||||
if (selected.text) {
|
if (selected.text) {
|
||||||
|
@ -200,22 +328,81 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
defaultLabel: defaultLabel,
|
defaultLabel: defaultLabel,
|
||||||
inputId: 'issue_assignee_id',
|
|
||||||
hidden: function(e) {
|
hidden: function(e) {
|
||||||
$selectbox.hide();
|
if ($dropdown.hasClass('js-multiselect')) {
|
||||||
// display:block overrides the hide-collapse rule
|
eventHub.$emit('sidebar.saveAssignees');
|
||||||
return $value.css('display', '');
|
}
|
||||||
|
|
||||||
|
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'),
|
multiSelect: $dropdown.hasClass('js-multiselect'),
|
||||||
clicked: function(user, $el, e) {
|
inputMeta: $dropdown.data('input-meta'),
|
||||||
var isIssueIndex, isMRIndex, page, selected, isSelecting;
|
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');
|
page = $('body').data('page');
|
||||||
isIssueIndex = page === 'projects:issues:index';
|
isIssueIndex = page === 'projects:issues:index';
|
||||||
isMRIndex = (page === page && page === 'projects:merge_requests: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')) {
|
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const isSelecting = (user.id !== selectedId);
|
||||||
|
selectedId = isSelecting ? user.id : selectedIdDefault;
|
||||||
|
|
||||||
if (selectedId === gon.current_user_id) {
|
if (selectedId === gon.current_user_id) {
|
||||||
$('.assign-to-me-link').hide();
|
$('.assign-to-me-link').hide();
|
||||||
} else {
|
} else {
|
||||||
|
@ -229,20 +416,7 @@
|
||||||
return Issuable.filterResults($dropdown.closest('form'));
|
return Issuable.filterResults($dropdown.closest('form'));
|
||||||
} else if ($dropdown.hasClass('js-filter-submit')) {
|
} else if ($dropdown.hasClass('js-filter-submit')) {
|
||||||
return $dropdown.closest('form').submit();
|
return $dropdown.closest('form').submit();
|
||||||
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
|
} else if (!$dropdown.hasClass('js-multiselect')) {
|
||||||
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 {
|
|
||||||
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
|
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
|
||||||
return assignTo(selected);
|
return assignTo(selected);
|
||||||
}
|
}
|
||||||
|
@ -256,29 +430,54 @@
|
||||||
selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault;
|
selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault;
|
||||||
}
|
}
|
||||||
$el.find('.is-active').removeClass('is-active');
|
$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) {
|
renderRow: function(user) {
|
||||||
var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username;
|
var avatar, img, listClosingTags, listWithName, listWithUserName, username;
|
||||||
username = user.username ? "@" + user.username : "";
|
username = user.username ? "@" + user.username : "";
|
||||||
avatar = user.avatar_url ? user.avatar_url : false;
|
avatar = user.avatar_url ? user.avatar_url : false;
|
||||||
selected = user.id === parseInt(selectedId, 10) ? "is-active" : "";
|
|
||||||
img = "";
|
let selected = user.id === parseInt(selectedId, 10);
|
||||||
if (user.beforeDivider != null) {
|
|
||||||
"<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>";
|
if (this.multiSelect) {
|
||||||
} else {
|
const fieldName = this.fieldName;
|
||||||
if (avatar) {
|
const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']");
|
||||||
img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />";
|
|
||||||
|
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>";
|
img = "";
|
||||||
listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>";
|
if (user.beforeDivider != null) {
|
||||||
listClosingTags = "</a> </li>";
|
`<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
|
||||||
if (username === '') {
|
} else {
|
||||||
listWithUserName = '';
|
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;
|
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 {
|
.dropdown-header {
|
||||||
color: $gl-text-color;
|
color: $gl-text-color-secondary;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
text-transform: capitalize;
|
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.capitalize-header .dropdown-header {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
.separator + .dropdown-header {
|
.separator + .dropdown-header {
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
@ -337,8 +339,8 @@
|
||||||
.dropdown-menu-user {
|
.dropdown-menu-user {
|
||||||
.avatar {
|
.avatar {
|
||||||
float: left;
|
float: left;
|
||||||
width: 30px;
|
width: 2 * $gl-padding;
|
||||||
height: 30px;
|
height: 2 * $gl-padding;
|
||||||
margin: 0 10px 0 0;
|
margin: 0 10px 0 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -381,6 +383,7 @@
|
||||||
.dropdown-menu-selectable {
|
.dropdown-menu-selectable {
|
||||||
a {
|
a {
|
||||||
padding-left: 26px;
|
padding-left: 26px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&.is-indeterminate,
|
&.is-indeterminate,
|
||||||
&.is-active {
|
&.is-active {
|
||||||
|
@ -406,6 +409,9 @@
|
||||||
|
|
||||||
&.is-active::before {
|
&.is-active::before {
|
||||||
content: "\f00c";
|
content: "\f00c";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -255,6 +255,7 @@ ul.controls {
|
||||||
.avatar-inline {
|
.avatar-inline {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -207,8 +207,13 @@
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active,
|
||||||
|
&.is-active .card-assignee:hover a {
|
||||||
background-color: $row-hover;
|
background-color: $row-hover;
|
||||||
|
|
||||||
|
&:first-child:not(:only-child) {
|
||||||
|
box-shadow: -10px 0 10px 1px $row-hover;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
@ -224,7 +229,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
margin: 0;
|
margin: 0 30px 0 0;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
|
|
||||||
|
@ -240,10 +245,69 @@
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
|
|
||||||
.card-assignee {
|
.card-assignee {
|
||||||
margin-left: auto;
|
display: flex;
|
||||||
margin-right: 5px;
|
justify-content: flex-end;
|
||||||
padding-left: 10px;
|
position: absolute;
|
||||||
|
right: 15px;
|
||||||
height: 20px;
|
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 {
|
.avatar {
|
||||||
|
|
|
@ -570,14 +570,7 @@
|
||||||
|
|
||||||
.diff-comments-more-count,
|
.diff-comments-more-count,
|
||||||
.diff-notes-collapse {
|
.diff-notes-collapse {
|
||||||
background-color: $gray-darkest;
|
@extend .avatar-counter;
|
||||||
color: $white-light;
|
|
||||||
border: 1px solid $white-light;
|
|
||||||
border-radius: 1em;
|
|
||||||
font-family: $regular_font;
|
|
||||||
font-size: 9px;
|
|
||||||
line-height: 17px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-notes-collapse {
|
.diff-notes-collapse {
|
||||||
|
|
|
@ -95,10 +95,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-sidebar {
|
.right-sidebar {
|
||||||
a {
|
a,
|
||||||
|
.btn-link {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.issuable-header-text {
|
.issuable-header-text {
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
}
|
}
|
||||||
|
@ -215,6 +220,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assign-yourself .btn-link {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.light {
|
.light {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
@ -239,6 +248,10 @@
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assignee .user-list .avatar {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
@ -301,6 +314,10 @@
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-avatar-counter {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.todo-undone {
|
.todo-undone {
|
||||||
color: $gl-link-color;
|
color: $gl-link-color;
|
||||||
}
|
}
|
||||||
|
@ -309,10 +326,15 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar:hover {
|
.avatar:hover,
|
||||||
|
.avatar-counter:hover {
|
||||||
border-color: $issuable-sidebar-color;
|
border-color: $issuable-sidebar-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-counter:hover {
|
||||||
|
color: $issuable-sidebar-color;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-clipboard {
|
.btn-clipboard {
|
||||||
border: none;
|
border: none;
|
||||||
color: $issuable-sidebar-color;
|
color: $issuable-sidebar-color;
|
||||||
|
@ -322,6 +344,17 @@
|
||||||
color: $gl-text-color;
|
color: $gl-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.multiple-users {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-avatar-counter {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-collapsed-user {
|
.sidebar-collapsed-user {
|
||||||
|
@ -332,6 +365,37 @@
|
||||||
.issuable-header-btn {
|
.issuable-header-btn {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.multiple-users {
|
||||||
|
height: 24px;
|
||||||
|
margin-bottom: 17px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:first-child {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:last-child {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
@ -383,6 +447,12 @@
|
||||||
margin: -5px;
|
margin: -5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.participants-author {
|
.participants-author {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
|
@ -400,13 +470,39 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.participants-more {
|
.user-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px;
|
||||||
|
flex-basis: 20%;
|
||||||
|
|
||||||
|
.user-link {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants-more,
|
||||||
|
.user-list-more {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
|
|
||||||
a {
|
a,
|
||||||
|
.btn-link {
|
||||||
color: $gl-text-color-secondary;
|
color: $gl-text-color-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
outline: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
@extend a:hover;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:focus {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.issuable-form-padding-top {
|
.issuable-form-padding-top {
|
||||||
|
@ -499,6 +595,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issuable-list li,
|
||||||
|
.issue-info-container .controls {
|
||||||
|
.avatar-counter {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
min-width: 16px;
|
||||||
|
line-height: 14px;
|
||||||
|
height: 16px;
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.time_tracker {
|
.time_tracker {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
|
|
|
@ -66,6 +66,7 @@ module IssuableActions
|
||||||
:milestone_id,
|
:milestone_id,
|
||||||
:state_event,
|
:state_event,
|
||||||
:subscription_event,
|
:subscription_event,
|
||||||
|
assignee_ids: [],
|
||||||
label_ids: [],
|
label_ids: [],
|
||||||
add_label_ids: [],
|
add_label_ids: [],
|
||||||
remove_label_ids: []
|
remove_label_ids: []
|
||||||
|
|
|
@ -43,7 +43,7 @@ module IssuableCollections
|
||||||
end
|
end
|
||||||
|
|
||||||
def issues_collection
|
def issues_collection
|
||||||
issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace)
|
issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace)
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_requests_collection
|
def merge_requests_collection
|
||||||
|
|
|
@ -82,7 +82,7 @@ module Projects
|
||||||
labels: true,
|
labels: true,
|
||||||
only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
|
only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
|
||||||
include: {
|
include: {
|
||||||
assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
|
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
|
||||||
milestone: { only: [:id, :title] }
|
milestone: { only: [:id, :title] }
|
||||||
},
|
},
|
||||||
user: current_user
|
user: current_user
|
||||||
|
|
|
@ -67,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
||||||
|
|
||||||
def new
|
def new
|
||||||
params[:issue] ||= ActionController::Parameters.new(
|
params[:issue] ||= ActionController::Parameters.new(
|
||||||
assignee_id: ""
|
assignee_ids: ""
|
||||||
)
|
)
|
||||||
build_params = issue_params.merge(
|
build_params = issue_params.merge(
|
||||||
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
|
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
|
||||||
|
@ -150,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
||||||
if @issue.valid?
|
if @issue.valid?
|
||||||
render json: @issue.to_json(methods: [:task_status, :task_status_short],
|
render json: @issue.to_json(methods: [:task_status, :task_status_short],
|
||||||
include: { milestone: {},
|
include: { milestone: {},
|
||||||
assignee: { only: [:name, :username], methods: [:avatar_url] },
|
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
|
||||||
labels: { methods: :text_color } })
|
labels: { methods: :text_color } })
|
||||||
else
|
else
|
||||||
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
|
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
@ -284,7 +284,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
||||||
def issue_params
|
def issue_params
|
||||||
params.require(:issue).permit(
|
params.require(:issue).permit(
|
||||||
:title, :assignee_id, :position, :description, :confidential,
|
:title, :assignee_id, :position, :description, :confidential,
|
||||||
:milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: []
|
:milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -231,7 +231,7 @@ class IssuableFinder
|
||||||
when 'created-by-me', 'authored'
|
when 'created-by-me', 'authored'
|
||||||
items.where(author_id: current_user.id)
|
items.where(author_id: current_user.id)
|
||||||
when 'assigned-to-me'
|
when 'assigned-to-me'
|
||||||
items.where(assignee_id: current_user.id)
|
items.assigned_to(current_user)
|
||||||
else
|
else
|
||||||
items
|
items
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder
|
||||||
IssuesFinder.not_restricted_by_confidentiality(current_user)
|
IssuesFinder.not_restricted_by_confidentiality(current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def by_assignee(items)
|
||||||
|
if assignee
|
||||||
|
items.assigned_to(assignee)
|
||||||
|
elsif no_assignee?
|
||||||
|
items.unassigned
|
||||||
|
elsif assignee_id? || assignee_username? # assignee not found
|
||||||
|
items.none
|
||||||
|
else
|
||||||
|
items
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.not_restricted_by_confidentiality(user)
|
def self.not_restricted_by_confidentiality(user)
|
||||||
return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank?
|
return Issue.where('issues.confidential IS NOT TRUE') if user.blank?
|
||||||
|
|
||||||
return Issue.all if user.admin?
|
return Issue.all if user.admin?
|
||||||
|
|
||||||
Issue.where('
|
Issue.where('
|
||||||
issues.confidential IS NULL
|
issues.confidential IS NOT TRUE
|
||||||
OR issues.confidential IS FALSE
|
|
||||||
OR (issues.confidential = TRUE
|
OR (issues.confidential = TRUE
|
||||||
AND (issues.author_id = :user_id
|
AND (issues.author_id = :user_id
|
||||||
OR issues.assignee_id = :user_id
|
OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
|
||||||
OR issues.project_id IN(:project_ids)))',
|
OR issues.project_id IN(:project_ids)))',
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
|
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
|
||||||
|
|
|
@ -15,4 +15,36 @@ module FormHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def issue_dropdown_options(issuable, has_multiple_assignees = true)
|
||||||
|
options = {
|
||||||
|
toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data',
|
||||||
|
title: 'Select assignee',
|
||||||
|
filter: true,
|
||||||
|
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee',
|
||||||
|
placeholder: 'Search users',
|
||||||
|
data: {
|
||||||
|
first_user: current_user&.username,
|
||||||
|
null_user: true,
|
||||||
|
current_user: true,
|
||||||
|
project_id: issuable.project.try(:id),
|
||||||
|
field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]",
|
||||||
|
default_label: 'Assignee',
|
||||||
|
'max-select': 1,
|
||||||
|
'dropdown-header': 'Assignee',
|
||||||
|
multi_select: true,
|
||||||
|
'input-meta': 'name',
|
||||||
|
'always-show-selectbox': true,
|
||||||
|
current_user_info: current_user.to_json(only: [:id, :name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_multiple_assignees
|
||||||
|
options[:title] = 'Select assignee(s)'
|
||||||
|
options[:data][:'dropdown-header'] = 'Assignee(s)'
|
||||||
|
options[:data].delete(:'max-select')
|
||||||
|
end
|
||||||
|
|
||||||
|
options
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -63,6 +63,16 @@ module IssuablesHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def users_dropdown_label(selected_users)
|
||||||
|
if selected_users.length == 0
|
||||||
|
"Unassigned"
|
||||||
|
elsif selected_users.length == 1
|
||||||
|
selected_users[0].name
|
||||||
|
else
|
||||||
|
"#{selected_users[0].name} + #{selected_users.length - 1} more"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def user_dropdown_label(user_id, default_label)
|
def user_dropdown_label(user_id, default_label)
|
||||||
return default_label if user_id.nil?
|
return default_label if user_id.nil?
|
||||||
return "Unassigned" if user_id == "0"
|
return "Unassigned" if user_id == "0"
|
||||||
|
|
|
@ -11,10 +11,12 @@ module Emails
|
||||||
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
|
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id)
|
def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id)
|
||||||
setup_issue_mail(issue_id, recipient_id)
|
setup_issue_mail(issue_id, recipient_id)
|
||||||
|
|
||||||
@previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
|
@previous_assignees = []
|
||||||
|
@previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
|
||||||
|
|
||||||
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
|
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,6 @@ module Issuable
|
||||||
cache_markdown_field :description, issuable_state_filter_enabled: true
|
cache_markdown_field :description, issuable_state_filter_enabled: true
|
||||||
|
|
||||||
belongs_to :author, class_name: "User"
|
belongs_to :author, class_name: "User"
|
||||||
belongs_to :assignee, class_name: "User"
|
|
||||||
belongs_to :updated_by, class_name: "User"
|
belongs_to :updated_by, class_name: "User"
|
||||||
belongs_to :milestone
|
belongs_to :milestone
|
||||||
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do
|
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do
|
||||||
|
@ -65,11 +64,8 @@ module Issuable
|
||||||
validates :title, presence: true, length: { maximum: 255 }
|
validates :title, presence: true, length: { maximum: 255 }
|
||||||
|
|
||||||
scope :authored, ->(user) { where(author_id: user) }
|
scope :authored, ->(user) { where(author_id: user) }
|
||||||
scope :assigned_to, ->(u) { where(assignee_id: u.id)}
|
|
||||||
scope :recent, -> { reorder(id: :desc) }
|
scope :recent, -> { reorder(id: :desc) }
|
||||||
scope :order_position_asc, -> { reorder(position: :asc) }
|
scope :order_position_asc, -> { reorder(position: :asc) }
|
||||||
scope :assigned, -> { where("assignee_id IS NOT NULL") }
|
|
||||||
scope :unassigned, -> { where("assignee_id IS NULL") }
|
|
||||||
scope :of_projects, ->(ids) { where(project_id: ids) }
|
scope :of_projects, ->(ids) { where(project_id: ids) }
|
||||||
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
|
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
|
||||||
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
|
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
|
||||||
|
@ -92,23 +88,14 @@ module Issuable
|
||||||
attr_mentionable :description
|
attr_mentionable :description
|
||||||
|
|
||||||
participant :author
|
participant :author
|
||||||
participant :assignee
|
|
||||||
participant :notes_with_associations
|
participant :notes_with_associations
|
||||||
|
|
||||||
strip_attributes :title
|
strip_attributes :title
|
||||||
|
|
||||||
acts_as_paranoid
|
acts_as_paranoid
|
||||||
|
|
||||||
after_save :update_assignee_cache_counts, if: :assignee_id_changed?
|
|
||||||
after_save :record_metrics, unless: :imported?
|
after_save :record_metrics, unless: :imported?
|
||||||
|
|
||||||
def update_assignee_cache_counts
|
|
||||||
# make sure we flush the cache for both the old *and* new assignees(if they exist)
|
|
||||||
previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was
|
|
||||||
previous_assignee&.update_cache_counts
|
|
||||||
assignee&.update_cache_counts
|
|
||||||
end
|
|
||||||
|
|
||||||
# We want to use optimistic lock for cases when only title or description are involved
|
# We want to use optimistic lock for cases when only title or description are involved
|
||||||
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
|
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
|
||||||
def locking_enabled?
|
def locking_enabled?
|
||||||
|
@ -237,10 +224,6 @@ module Issuable
|
||||||
today? && created_at == updated_at
|
today? && created_at == updated_at
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_being_reassigned?
|
|
||||||
assignee_id_changed?
|
|
||||||
end
|
|
||||||
|
|
||||||
def open?
|
def open?
|
||||||
opened? || reopened?
|
opened? || reopened?
|
||||||
end
|
end
|
||||||
|
@ -269,7 +252,11 @@ module Issuable
|
||||||
# DEPRECATED
|
# DEPRECATED
|
||||||
repository: project.hook_attrs.slice(:name, :url, :description, :homepage)
|
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
|
hook_data
|
||||||
end
|
end
|
||||||
|
@ -331,11 +318,6 @@ module Issuable
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def assignee_or_author?(user)
|
|
||||||
# We're comparing IDs here so we don't need to load any associations.
|
|
||||||
author_id == user.id || assignee_id == user.id
|
|
||||||
end
|
|
||||||
|
|
||||||
def record_metrics
|
def record_metrics
|
||||||
metrics = self.metrics || create_metrics
|
metrics = self.metrics || create_metrics
|
||||||
metrics.record!
|
metrics.record!
|
||||||
|
|
|
@ -40,7 +40,7 @@ module Milestoneish
|
||||||
def issues_visible_to_user(user)
|
def issues_visible_to_user(user)
|
||||||
memoize_per_user(user, :issues_visible_to_user) do
|
memoize_per_user(user, :issues_visible_to_user) do
|
||||||
IssuesFinder.new(user, issues_finder_params)
|
IssuesFinder.new(user, issues_finder_params)
|
||||||
.execute.where(milestone_id: milestoneish_ids)
|
.execute.includes(:assignees).where(milestone_id: milestoneish_ids)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -86,7 +86,7 @@ class GlobalMilestone
|
||||||
end
|
end
|
||||||
|
|
||||||
def issues
|
def issues
|
||||||
@issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels)
|
@issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels)
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_requests
|
def merge_requests
|
||||||
|
@ -94,7 +94,7 @@ class GlobalMilestone
|
||||||
end
|
end
|
||||||
|
|
||||||
def participants
|
def participants
|
||||||
@participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq
|
@participants ||= milestones.map(&:participants).flatten.uniq
|
||||||
end
|
end
|
||||||
|
|
||||||
def labels
|
def labels
|
||||||
|
|
|
@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base
|
||||||
|
|
||||||
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
|
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
|
||||||
|
|
||||||
|
has_many :issue_assignees
|
||||||
|
has_many :assignees, class_name: "User", through: :issue_assignees
|
||||||
|
|
||||||
validates :project, presence: true
|
validates :project, presence: true
|
||||||
|
|
||||||
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
|
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
|
||||||
|
|
||||||
|
scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
|
||||||
|
scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
|
||||||
|
scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)}
|
||||||
|
|
||||||
scope :without_due_date, -> { where(due_date: nil) }
|
scope :without_due_date, -> { where(due_date: nil) }
|
||||||
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
|
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
|
||||||
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
|
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
|
||||||
|
@ -37,13 +44,15 @@ class Issue < ActiveRecord::Base
|
||||||
|
|
||||||
scope :created_after, -> (datetime) { where("created_at >= ?", datetime) }
|
scope :created_after, -> (datetime) { where("created_at >= ?", datetime) }
|
||||||
|
|
||||||
scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) }
|
scope :include_associations, -> { includes(:labels, project: :namespace) }
|
||||||
|
|
||||||
after_save :expire_etag_cache
|
after_save :expire_etag_cache
|
||||||
|
|
||||||
attr_spammable :title, spam_title: true
|
attr_spammable :title, spam_title: true
|
||||||
attr_spammable :description, spam_description: true
|
attr_spammable :description, spam_description: true
|
||||||
|
|
||||||
|
participant :assignees
|
||||||
|
|
||||||
state_machine :state, initial: :opened do
|
state_machine :state, initial: :opened do
|
||||||
event :close do
|
event :close do
|
||||||
transition [:reopened, :opened] => :closed
|
transition [:reopened, :opened] => :closed
|
||||||
|
@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def hook_attrs
|
def hook_attrs
|
||||||
|
assignee_ids = self.assignee_ids
|
||||||
|
|
||||||
attrs = {
|
attrs = {
|
||||||
total_time_spent: total_time_spent,
|
total_time_spent: total_time_spent,
|
||||||
human_total_time_spent: human_total_time_spent,
|
human_total_time_spent: human_total_time_spent,
|
||||||
human_time_estimate: human_time_estimate
|
human_time_estimate: human_time_estimate,
|
||||||
|
assignee_ids: assignee_ids,
|
||||||
|
assignee_id: assignee_ids.first # This key is deprecated
|
||||||
}
|
}
|
||||||
|
|
||||||
attributes.merge!(attrs)
|
attributes.merge!(attrs)
|
||||||
|
@ -114,6 +127,22 @@ class Issue < ActiveRecord::Base
|
||||||
"id DESC")
|
"id DESC")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns a Hash of attributes to be used for Twitter card metadata
|
||||||
|
def card_attributes
|
||||||
|
{
|
||||||
|
'Author' => author.try(:name),
|
||||||
|
'Assignee' => assignee_list
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def assignee_or_author?(user)
|
||||||
|
author_id == user.id || assignees.exists?(user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def assignee_list
|
||||||
|
assignees.map(&:name).to_sentence
|
||||||
|
end
|
||||||
|
|
||||||
# `from` argument can be a Namespace or Project.
|
# `from` argument can be a Namespace or Project.
|
||||||
def to_reference(from = nil, full: false)
|
def to_reference(from = nil, full: false)
|
||||||
reference = "#{self.class.reference_prefix}#{iid}"
|
reference = "#{self.class.reference_prefix}#{iid}"
|
||||||
|
@ -248,7 +277,7 @@ class Issue < ActiveRecord::Base
|
||||||
true
|
true
|
||||||
elsif confidential?
|
elsif confidential?
|
||||||
author == user ||
|
author == user ||
|
||||||
assignee == user ||
|
assignees.include?(user) ||
|
||||||
project.team.member?(user, Gitlab::Access::REPORTER)
|
project.team.member?(user, Gitlab::Access::REPORTER)
|
||||||
else
|
else
|
||||||
project.public? ||
|
project.public? ||
|
||||||
|
|
|
@ -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
|
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
|
||||||
|
|
||||||
|
belongs_to :assignee, class_name: "User"
|
||||||
|
|
||||||
serialize :merge_params, Hash
|
serialize :merge_params, Hash
|
||||||
|
|
||||||
after_create :ensure_merge_request_diff, unless: :importing?
|
after_create :ensure_merge_request_diff, unless: :importing?
|
||||||
|
@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base
|
||||||
|
|
||||||
scope :join_project, -> { joins(:target_project) }
|
scope :join_project, -> { joins(:target_project) }
|
||||||
scope :references_project, -> { references(:target_project) }
|
scope :references_project, -> { references(:target_project) }
|
||||||
|
scope :assigned, -> { where("assignee_id IS NOT NULL") }
|
||||||
|
scope :unassigned, -> { where("assignee_id IS NULL") }
|
||||||
|
scope :assigned_to, ->(u) { where(assignee_id: u.id)}
|
||||||
|
|
||||||
|
participant :assignee
|
||||||
|
|
||||||
after_save :keep_around_commit
|
after_save :keep_around_commit
|
||||||
|
after_save :update_assignee_cache_counts, if: :assignee_id_changed?
|
||||||
|
|
||||||
def self.reference_prefix
|
def self.reference_prefix
|
||||||
'!'
|
'!'
|
||||||
|
@ -177,6 +185,30 @@ class MergeRequest < ActiveRecord::Base
|
||||||
work_in_progress?(title) ? title : "WIP: #{title}"
|
work_in_progress?(title) ? title : "WIP: #{title}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_assignee_cache_counts
|
||||||
|
# make sure we flush the cache for both the old *and* new assignees(if they exist)
|
||||||
|
previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was
|
||||||
|
previous_assignee&.update_cache_counts
|
||||||
|
assignee&.update_cache_counts
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns a Hash of attributes to be used for Twitter card metadata
|
||||||
|
def card_attributes
|
||||||
|
{
|
||||||
|
'Author' => author.try(:name),
|
||||||
|
'Assignee' => assignee.try(:name)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# This method is needed for compatibility with issues to not mess view and other code
|
||||||
|
def assignees
|
||||||
|
Array(assignee)
|
||||||
|
end
|
||||||
|
|
||||||
|
def assignee_or_author?(user)
|
||||||
|
author_id == user.id || assignee_id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
# `from` argument can be a Namespace or Project.
|
# `from` argument can be a Namespace or Project.
|
||||||
def to_reference(from = nil, full: false)
|
def to_reference(from = nil, full: false)
|
||||||
reference = "#{self.class.reference_prefix}#{iid}"
|
reference = "#{self.class.reference_prefix}#{iid}"
|
||||||
|
|
|
@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base
|
||||||
has_many :issues
|
has_many :issues
|
||||||
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
|
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
|
||||||
has_many :merge_requests
|
has_many :merge_requests
|
||||||
has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee
|
|
||||||
has_many :events, as: :target, dependent: :destroy
|
has_many :events, as: :target, dependent: :destroy
|
||||||
|
|
||||||
scope :active, -> { with_state(:active) }
|
scope :active, -> { with_state(:active) }
|
||||||
|
@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def participants
|
||||||
|
User.joins(assigned_issues: :milestone).where("milestones.id = ?", id)
|
||||||
|
end
|
||||||
|
|
||||||
def self.sort(method)
|
def self.sort(method)
|
||||||
case method.to_s
|
case method.to_s
|
||||||
when 'due_date_asc'
|
when 'due_date_asc'
|
||||||
|
|
|
@ -100,6 +100,10 @@ class User < ActiveRecord::Base
|
||||||
has_many :award_emoji, dependent: :destroy
|
has_many :award_emoji, dependent: :destroy
|
||||||
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
|
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
|
||||||
|
|
||||||
|
has_many :issue_assignees
|
||||||
|
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
|
||||||
|
has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest"
|
||||||
|
|
||||||
# Issues that a user owns are expected to be moved to the "ghost" user before
|
# Issues that a user owns are expected to be moved to the "ghost" user before
|
||||||
# the user is destroyed. If the user owns any issues during deletion, this
|
# the user is destroyed. If the user owns any issues during deletion, this
|
||||||
# should be treated as an exceptional condition.
|
# should be treated as an exceptional condition.
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
class IssuableEntity < Grape::Entity
|
class IssuableEntity < Grape::Entity
|
||||||
expose :id
|
expose :id
|
||||||
expose :iid
|
expose :iid
|
||||||
expose :assignee_id
|
|
||||||
expose :author_id
|
expose :author_id
|
||||||
expose :description
|
expose :description
|
||||||
expose :lock_version
|
expose :lock_version
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
class IssueEntity < IssuableEntity
|
class IssueEntity < IssuableEntity
|
||||||
expose :branch_name
|
expose :branch_name
|
||||||
expose :confidential
|
expose :confidential
|
||||||
|
expose :assignees, using: API::Entities::UserBasic
|
||||||
expose :due_date
|
expose :due_date
|
||||||
expose :moved_to_id
|
expose :moved_to_id
|
||||||
expose :project_id
|
expose :project_id
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
class MergeRequestEntity < IssuableEntity
|
class MergeRequestEntity < IssuableEntity
|
||||||
|
expose :assignee_id
|
||||||
expose :in_progress_merge_commit_sha
|
expose :in_progress_merge_commit_sha
|
||||||
expose :locked_at
|
expose :locked_at
|
||||||
expose :merge_commit_sha
|
expose :merge_commit_sha
|
||||||
|
|
|
@ -7,10 +7,14 @@ module Issuable
|
||||||
ids = params.delete(:issuable_ids).split(",")
|
ids = params.delete(:issuable_ids).split(",")
|
||||||
items = model_class.where(id: ids)
|
items = model_class.where(id: ids)
|
||||||
|
|
||||||
%i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key|
|
%i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key|
|
||||||
params.delete(key) unless params[key].present?
|
params.delete(key) unless params[key].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if params[:assignee_ids] == [IssuableFinder::NONE.to_s]
|
||||||
|
params[:assignee_ids] = []
|
||||||
|
end
|
||||||
|
|
||||||
items.each do |issuable|
|
items.each do |issuable|
|
||||||
next unless can?(current_user, :"update_#{type}", issuable)
|
next unless can?(current_user, :"update_#{type}", issuable)
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
class IssuableBaseService < BaseService
|
class IssuableBaseService < BaseService
|
||||||
private
|
private
|
||||||
|
|
||||||
def create_assignee_note(issuable)
|
|
||||||
SystemNoteService.change_assignee(
|
|
||||||
issuable, issuable.project, current_user, issuable.assignee)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_milestone_note(issuable)
|
def create_milestone_note(issuable)
|
||||||
SystemNoteService.change_milestone(
|
SystemNoteService.change_milestone(
|
||||||
issuable, issuable.project, current_user, issuable.milestone)
|
issuable, issuable.project, current_user, issuable.milestone)
|
||||||
|
@ -53,6 +48,7 @@ class IssuableBaseService < BaseService
|
||||||
params.delete(:add_label_ids)
|
params.delete(:add_label_ids)
|
||||||
params.delete(:remove_label_ids)
|
params.delete(:remove_label_ids)
|
||||||
params.delete(:label_ids)
|
params.delete(:label_ids)
|
||||||
|
params.delete(:assignee_ids)
|
||||||
params.delete(:assignee_id)
|
params.delete(:assignee_id)
|
||||||
params.delete(:due_date)
|
params.delete(:due_date)
|
||||||
end
|
end
|
||||||
|
@ -77,7 +73,7 @@ class IssuableBaseService < BaseService
|
||||||
def assignee_can_read?(issuable, assignee_id)
|
def assignee_can_read?(issuable, assignee_id)
|
||||||
new_assignee = User.find_by_id(assignee_id)
|
new_assignee = User.find_by_id(assignee_id)
|
||||||
|
|
||||||
return false unless new_assignee.present?
|
return false unless new_assignee
|
||||||
|
|
||||||
ability_name = :"read_#{issuable.to_ability_name}"
|
ability_name = :"read_#{issuable.to_ability_name}"
|
||||||
resource = issuable.persisted? ? issuable : project
|
resource = issuable.persisted? ? issuable : project
|
||||||
|
@ -207,6 +203,7 @@ class IssuableBaseService < BaseService
|
||||||
filter_params(issuable)
|
filter_params(issuable)
|
||||||
old_labels = issuable.labels.to_a
|
old_labels = issuable.labels.to_a
|
||||||
old_mentioned_users = issuable.mentioned_users.to_a
|
old_mentioned_users = issuable.mentioned_users.to_a
|
||||||
|
old_assignees = issuable.assignees.to_a
|
||||||
|
|
||||||
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
|
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
|
||||||
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
|
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
|
||||||
|
@ -222,7 +219,13 @@ class IssuableBaseService < BaseService
|
||||||
handle_common_system_notes(issuable, old_labels: old_labels)
|
handle_common_system_notes(issuable, old_labels: old_labels)
|
||||||
end
|
end
|
||||||
|
|
||||||
handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users)
|
handle_changes(
|
||||||
|
issuable,
|
||||||
|
old_labels: old_labels,
|
||||||
|
old_mentioned_users: old_mentioned_users,
|
||||||
|
old_assignees: old_assignees
|
||||||
|
)
|
||||||
|
|
||||||
after_update(issuable)
|
after_update(issuable)
|
||||||
issuable.create_new_cross_references!(current_user)
|
issuable.create_new_cross_references!(current_user)
|
||||||
execute_hooks(issuable, 'update')
|
execute_hooks(issuable, 'update')
|
||||||
|
@ -272,7 +275,7 @@ class IssuableBaseService < BaseService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def has_changes?(issuable, old_labels: [])
|
def has_changes?(issuable, old_labels: [], old_assignees: [])
|
||||||
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
|
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
|
||||||
|
|
||||||
attrs_changed = valid_attrs.any? do |attr|
|
attrs_changed = valid_attrs.any? do |attr|
|
||||||
|
@ -281,7 +284,9 @@ class IssuableBaseService < BaseService
|
||||||
|
|
||||||
labels_changed = issuable.labels != old_labels
|
labels_changed = issuable.labels != old_labels
|
||||||
|
|
||||||
attrs_changed || labels_changed
|
assignees_changed = issuable.assignees != old_assignees
|
||||||
|
|
||||||
|
attrs_changed || labels_changed || assignees_changed
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_common_system_notes(issuable, old_labels: [])
|
def handle_common_system_notes(issuable, old_labels: [])
|
||||||
|
|
|
@ -9,11 +9,33 @@ module Issues
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def create_assignee_note(issue, old_assignees)
|
||||||
|
SystemNoteService.change_issue_assignees(
|
||||||
|
issue, issue.project, current_user, old_assignees)
|
||||||
|
end
|
||||||
|
|
||||||
def execute_hooks(issue, action = 'open')
|
def execute_hooks(issue, action = 'open')
|
||||||
issue_data = hook_data(issue, action)
|
issue_data = hook_data(issue, action)
|
||||||
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
|
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
|
||||||
issue.project.execute_hooks(issue_data, hooks_scope)
|
issue.project.execute_hooks(issue_data, hooks_scope)
|
||||||
issue.project.execute_services(issue_data, hooks_scope)
|
issue.project.execute_services(issue_data, hooks_scope)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filter_assignee(issuable)
|
||||||
|
return if params[:assignee_ids].blank?
|
||||||
|
|
||||||
|
# The number of assignees is limited by one for GitLab CE
|
||||||
|
params[:assignee_ids] = params[:assignee_ids][0, 1]
|
||||||
|
|
||||||
|
assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
|
||||||
|
|
||||||
|
if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
|
||||||
|
params[:assignee_ids] = []
|
||||||
|
elsif assignee_ids.any?
|
||||||
|
params[:assignee_ids] = assignee_ids
|
||||||
|
else
|
||||||
|
params.delete(:assignee_ids)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,8 +12,12 @@ module Issues
|
||||||
spam_check(issue, current_user)
|
spam_check(issue, current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_changes(issue, old_labels: [], old_mentioned_users: [])
|
def handle_changes(issue, options)
|
||||||
if has_changes?(issue, old_labels: old_labels)
|
old_labels = options[:old_labels] || []
|
||||||
|
old_mentioned_users = options[:old_mentioned_users] || []
|
||||||
|
old_assignees = options[:old_assignees] || []
|
||||||
|
|
||||||
|
if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
|
||||||
todo_service.mark_pending_todos_as_done(issue, current_user)
|
todo_service.mark_pending_todos_as_done(issue, current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -26,9 +30,9 @@ module Issues
|
||||||
create_milestone_note(issue)
|
create_milestone_note(issue)
|
||||||
end
|
end
|
||||||
|
|
||||||
if issue.previous_changes.include?('assignee_id')
|
if issue.assignees != old_assignees
|
||||||
create_assignee_note(issue)
|
create_assignee_note(issue, old_assignees)
|
||||||
notification_service.reassigned_issue(issue, current_user)
|
notification_service.reassigned_issue(issue, current_user, old_assignees)
|
||||||
todo_service.reassigned_issue(issue, current_user)
|
todo_service.reassigned_issue(issue, current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -26,15 +26,22 @@ module Members
|
||||||
|
|
||||||
def unassign_issues_and_merge_requests(member)
|
def unassign_issues_and_merge_requests(member)
|
||||||
if member.is_a?(GroupMember)
|
if member.is_a?(GroupMember)
|
||||||
IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
|
issue_ids = IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
|
||||||
execute.
|
execute.pluck(:id)
|
||||||
update_all(assignee_id: nil)
|
|
||||||
|
IssueAssignee.destroy_all(issue_id: issue_ids, user_id: member.user_id)
|
||||||
|
|
||||||
MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
|
MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
|
||||||
execute.
|
execute.
|
||||||
update_all(assignee_id: nil)
|
update_all(assignee_id: nil)
|
||||||
else
|
else
|
||||||
project = member.source
|
project = member.source
|
||||||
project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil)
|
|
||||||
|
IssueAssignee.destroy_all(
|
||||||
|
user_id: member.user_id,
|
||||||
|
issue_id: project.issues.opened.assigned_to(member.user).select(:id)
|
||||||
|
)
|
||||||
|
|
||||||
project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil)
|
project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil)
|
||||||
member.user.update_cache_counts
|
member.user.update_cache_counts
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,7 @@ module MergeRequests
|
||||||
@assignable_issues ||= begin
|
@assignable_issues ||= begin
|
||||||
if current_user == merge_request.author
|
if current_user == merge_request.author
|
||||||
closes_issues.select do |issue|
|
closes_issues.select do |issue|
|
||||||
!issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue)
|
!issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
|
@ -14,7 +14,7 @@ module MergeRequests
|
||||||
|
|
||||||
def execute
|
def execute
|
||||||
assignable_issues.each do |issue|
|
assignable_issues.each do |issue|
|
||||||
Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue)
|
Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue)
|
||||||
end
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
@ -38,6 +38,11 @@ module MergeRequests
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def create_assignee_note(merge_request)
|
||||||
|
SystemNoteService.change_assignee(
|
||||||
|
merge_request, merge_request.project, current_user, merge_request.assignee)
|
||||||
|
end
|
||||||
|
|
||||||
# Returns all origin and fork merge requests from `@project` satisfying passed arguments.
|
# Returns all origin and fork merge requests from `@project` satisfying passed arguments.
|
||||||
def merge_requests_for(source_branch, mr_states: [:opened, :reopened])
|
def merge_requests_for(source_branch, mr_states: [:opened, :reopened])
|
||||||
MergeRequest
|
MergeRequest
|
||||||
|
|
|
@ -21,7 +21,10 @@ module MergeRequests
|
||||||
update(merge_request)
|
update(merge_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_changes(merge_request, old_labels: [], old_mentioned_users: [])
|
def handle_changes(merge_request, options)
|
||||||
|
old_labels = options[:old_labels] || []
|
||||||
|
old_mentioned_users = options[:old_mentioned_users] || []
|
||||||
|
|
||||||
if has_changes?(merge_request, old_labels: old_labels)
|
if has_changes?(merge_request, old_labels: old_labels)
|
||||||
todo_service.mark_pending_todos_as_done(merge_request, current_user)
|
todo_service.mark_pending_todos_as_done(merge_request, current_user)
|
||||||
end
|
end
|
||||||
|
|
|
@ -19,9 +19,14 @@ class NotificationRecipientService
|
||||||
# Re-assign is considered as a mention of the new assignee so we add the
|
# Re-assign is considered as a mention of the new assignee so we add the
|
||||||
# new assignee to the list of recipients after we rejected users with
|
# new assignee to the list of recipients after we rejected users with
|
||||||
# the "on mention" notification level
|
# the "on mention" notification level
|
||||||
if [:reassign_merge_request, :reassign_issue].include?(custom_action)
|
case custom_action
|
||||||
|
when :reassign_merge_request
|
||||||
recipients << previous_assignee if previous_assignee
|
recipients << previous_assignee if previous_assignee
|
||||||
recipients << target.assignee
|
recipients << target.assignee
|
||||||
|
when :reassign_issue
|
||||||
|
previous_assignees = Array(previous_assignee)
|
||||||
|
recipients.concat(previous_assignees)
|
||||||
|
recipients.concat(target.assignees)
|
||||||
end
|
end
|
||||||
|
|
||||||
recipients = reject_muted_users(recipients)
|
recipients = reject_muted_users(recipients)
|
||||||
|
|
|
@ -66,8 +66,25 @@ class NotificationService
|
||||||
# * issue new assignee if their notification level is not Disabled
|
# * issue new assignee if their notification level is not Disabled
|
||||||
# * users with custom level checked with "reassign issue"
|
# * users with custom level checked with "reassign issue"
|
||||||
#
|
#
|
||||||
def reassigned_issue(issue, current_user)
|
def reassigned_issue(issue, current_user, previous_assignees = [])
|
||||||
reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email)
|
recipients = NotificationRecipientService.new(issue.project).build_recipients(
|
||||||
|
issue,
|
||||||
|
current_user,
|
||||||
|
action: "reassign",
|
||||||
|
previous_assignee: previous_assignees
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_assignee_ids = previous_assignees.map(&:id)
|
||||||
|
|
||||||
|
recipients.each do |recipient|
|
||||||
|
mailer.send(
|
||||||
|
:reassigned_issue_email,
|
||||||
|
recipient.id,
|
||||||
|
issue.id,
|
||||||
|
previous_assignee_ids,
|
||||||
|
current_user.id
|
||||||
|
).deliver_later
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# When we add labels to an issue we should send an email to:
|
# When we add labels to an issue we should send an email to:
|
||||||
|
@ -367,10 +384,10 @@ class NotificationService
|
||||||
end
|
end
|
||||||
|
|
||||||
def previous_record(object, attribute)
|
def previous_record(object, attribute)
|
||||||
if object && attribute
|
return unless object && attribute
|
||||||
if object.previous_changes.include?(attribute)
|
|
||||||
object.previous_changes[attribute].first
|
if object.previous_changes.include?(attribute)
|
||||||
end
|
object.previous_changes[attribute].first
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -91,32 +91,47 @@ module SlashCommands
|
||||||
end
|
end
|
||||||
|
|
||||||
desc 'Assign'
|
desc 'Assign'
|
||||||
explanation do |user|
|
explanation do |users|
|
||||||
"Assigns #{user.to_reference}." if user
|
"Assigns #{users.map(&:to_reference).to_sentence}." if users.any?
|
||||||
end
|
end
|
||||||
params '@user'
|
params '@user'
|
||||||
condition do
|
condition do
|
||||||
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
|
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
|
||||||
end
|
end
|
||||||
parse_params do |assignee_param|
|
parse_params do |assignee_param|
|
||||||
extract_references(assignee_param, :user).first ||
|
users = extract_references(assignee_param, :user)
|
||||||
User.find_by(username: assignee_param)
|
|
||||||
|
if users.empty?
|
||||||
|
users = User.where(username: assignee_param.split(' ').map(&:strip))
|
||||||
|
end
|
||||||
|
|
||||||
|
users
|
||||||
end
|
end
|
||||||
command :assign do |user|
|
command :assign do |users|
|
||||||
@updates[:assignee_id] = user.id if user
|
next if users.empty?
|
||||||
|
|
||||||
|
if issuable.is_a?(Issue)
|
||||||
|
@updates[:assignee_ids] = users.map(&:id)
|
||||||
|
else
|
||||||
|
@updates[:assignee_id] = users.last.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
desc 'Remove assignee'
|
desc 'Remove assignee'
|
||||||
explanation do
|
explanation do
|
||||||
"Removes assignee #{issuable.assignee.to_reference}."
|
"Removes assignee #{issuable.assignees.first.to_reference}."
|
||||||
end
|
end
|
||||||
condition do
|
condition do
|
||||||
issuable.persisted? &&
|
issuable.persisted? &&
|
||||||
issuable.assignee_id? &&
|
issuable.assignees.any? &&
|
||||||
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
|
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
|
||||||
end
|
end
|
||||||
command :unassign do
|
command :unassign do
|
||||||
@updates[:assignee_id] = nil
|
if issuable.is_a?(Issue)
|
||||||
|
@updates[:assignee_ids] = []
|
||||||
|
else
|
||||||
|
@updates[:assignee_id] = nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
desc 'Set milestone'
|
desc 'Set milestone'
|
||||||
|
|
|
@ -49,6 +49,44 @@ module SystemNoteService
|
||||||
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
|
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Called when the assignees of an Issue is changed or removed
|
||||||
|
#
|
||||||
|
# issue - Issue object
|
||||||
|
# project - Project owning noteable
|
||||||
|
# author - User performing the change
|
||||||
|
# assignees - Users being assigned, or nil
|
||||||
|
#
|
||||||
|
# Example Note text:
|
||||||
|
#
|
||||||
|
# "removed all assignees"
|
||||||
|
#
|
||||||
|
# "assigned to @user1 additionally to @user2"
|
||||||
|
#
|
||||||
|
# "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5"
|
||||||
|
#
|
||||||
|
# "assigned to @user1 and @user2"
|
||||||
|
#
|
||||||
|
# Returns the created Note object
|
||||||
|
def change_issue_assignees(issue, project, author, old_assignees)
|
||||||
|
body =
|
||||||
|
if issue.assignees.any? && old_assignees.any?
|
||||||
|
unassigned_users = old_assignees - issue.assignees
|
||||||
|
added_users = issue.assignees.to_a - old_assignees
|
||||||
|
|
||||||
|
text_parts = []
|
||||||
|
text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
|
||||||
|
text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
|
||||||
|
|
||||||
|
text_parts.join(' and ')
|
||||||
|
elsif old_assignees.any?
|
||||||
|
"removed all assignees"
|
||||||
|
elsif issue.assignees.any?
|
||||||
|
"assigned to #{issue.assignees.map(&:to_reference).to_sentence}"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_note(NoteSummary.new(issue, project, author, body, action: 'assignee'))
|
||||||
|
end
|
||||||
|
|
||||||
# Called when one or more labels on a Noteable are added and/or removed
|
# Called when one or more labels on a Noteable are added and/or removed
|
||||||
#
|
#
|
||||||
# noteable - Noteable object
|
# noteable - Noteable object
|
||||||
|
|
|
@ -251,9 +251,9 @@ class TodoService
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_assignment_todo(issuable, author)
|
def create_assignment_todo(issuable, author)
|
||||||
if issuable.assignee
|
if issuable.assignees.any?
|
||||||
attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED)
|
attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED)
|
||||||
create_todos(issuable.assignee, attributes)
|
create_todos(issuable.assignees, attributes)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -23,10 +23,19 @@ xml.entry do
|
||||||
end
|
end
|
||||||
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.assignee do
|
||||||
xml.name issue.assignee.name
|
xml.name issue.assignees.first.name
|
||||||
xml.email issue.assignee_public_email
|
xml.email issue.assignees.first.public_email
|
||||||
end
|
end
|
||||||
end
|
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
|
%p.details
|
||||||
#{link_to @issue.author_name, user_url(@issue.author)} created an issue:
|
#{link_to @issue.author_name, user_url(@issue.author)} created an issue:
|
||||||
|
|
||||||
- if @issue.assignee_id.present?
|
- if @issue.assignees.any?
|
||||||
%p
|
%p
|
||||||
Assignee: #{@issue.assignee_name}
|
Assignee: #{@issue.assignee_list}
|
||||||
|
|
||||||
- if @issue.description
|
- if @issue.description
|
||||||
%div
|
%div
|
||||||
|
|
|
@ -2,6 +2,6 @@ New Issue was created.
|
||||||
|
|
||||||
Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
|
Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
|
||||||
Author: <%= @issue.author_name %>
|
Author: <%= @issue.author_name %>
|
||||||
Assignee: <%= @issue.assignee_name %>
|
Assignee: <%= @issue.assignee_list %>
|
||||||
|
|
||||||
<%= @issue.description %>
|
<%= @issue.description %>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue