Commit 387c4b2c authored by Valery Sizov's avatar Valery Sizov

Backport of multiple_assignees_feature [ci skip]

parent 68c12e15
...@@ -37,8 +37,8 @@ class TargetBranchDropDown { ...@@ -37,8 +37,8 @@ class TargetBranchDropDown {
} }
return SELECT_ITEM_MSG; return SELECT_ITEM_MSG;
}, },
clicked(item, el, e) { clicked(options) {
e.preventDefault(); options.e.preventDefault();
self.onClick.call(self); self.onClick.call(self);
}, },
fieldName: self.fieldName, fieldName: self.fieldName,
......
...@@ -24,7 +24,7 @@ export default class TemplateSelector { ...@@ -24,7 +24,7 @@ export default class TemplateSelector {
search: { search: {
fields: ['name'], fields: ['name'],
}, },
clicked: (item, el, e) => this.fetchFileTemplate(item, el, e), clicked: options => this.fetchFileTemplate(options),
text: item => item.name, text: item => item.name,
}); });
} }
...@@ -51,7 +51,10 @@ export default class TemplateSelector { ...@@ -51,7 +51,10 @@ export default class TemplateSelector {
return this.$dropdownContainer.removeClass('hidden'); return this.$dropdownContainer.removeClass('hidden');
} }
fetchFileTemplate(item, el, e) { fetchFileTemplate(options) {
const { e } = options;
const item = options.selectedObj;
e.preventDefault(); e.preventDefault();
return this.requestFile(item); return this.requestFile(item);
} }
......
...@@ -11,7 +11,7 @@ require('./models/issue'); ...@@ -11,7 +11,7 @@ require('./models/issue');
require('./models/label'); require('./models/label');
require('./models/list'); require('./models/list');
require('./models/milestone'); require('./models/milestone');
require('./models/user'); require('./models/assignee');
require('./stores/boards_store'); require('./stores/boards_store');
require('./stores/modal_store'); require('./stores/modal_store');
require('./services/board_service'); require('./services/board_service');
......
...@@ -26,6 +26,7 @@ export default { ...@@ -26,6 +26,7 @@ export default {
title: this.title, title: this.title,
labels, labels,
subscribed: true, subscribed: true,
assignees: [],
}); });
this.list.newIssue(issue) this.list.newIssue(issue)
......
...@@ -3,8 +3,13 @@ ...@@ -3,8 +3,13 @@
/* global MilestoneSelect */ /* global MilestoneSelect */
/* global LabelsSelect */ /* global LabelsSelect */
/* global Sidebar */ /* global Sidebar */
/* global Flash */
import Vue from 'vue'; import Vue from 'vue';
import eventHub from '../../sidebar/event_hub';
import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
import Assignees from '../../sidebar/components/assignees/assignees';
require('./sidebar/remove_issue'); require('./sidebar/remove_issue');
...@@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ ...@@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
detail: Store.detail, detail: Store.detail,
issue: {}, issue: {},
list: {}, list: {},
loadingAssignees: false,
}; };
}, },
computed: { computed: {
...@@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({ ...@@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({
this.issue = this.detail.issue; this.issue = this.detail.issue;
this.list = this.detail.list; this.list = this.detail.list;
this.$nextTick(() => {
this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
});
}, },
deep: true deep: true
}, },
...@@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({ ...@@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({
$('.right-sidebar').getNiceScroll().resize(); $('.right-sidebar').getNiceScroll().resize();
}); });
} }
}
this.issue = this.detail.issue;
this.list = this.detail.list;
},
deep: true
}, },
methods: { methods: {
closeSidebar () { closeSidebar () {
this.detail.issue = {}; this.detail.issue = {};
} },
assignSelf () {
// Notify gl dropdown that we are now assigning to current user
this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
this.addAssignee(this.currentUser);
this.saveAssignees();
},
removeAssignee (a) {
gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a);
},
addAssignee (a) {
gl.issueBoards.BoardsStore.detail.issue.addAssignee(a);
},
removeAllAssignees () {
gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees();
},
saveAssignees () {
this.loadingAssignees = true;
gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
.then(() => {
this.loadingAssignees = false;
})
.catch(() => {
this.loadingAssignees = false;
return new Flash('An error occurred while saving assignees');
});
},
},
created () {
// Get events from glDropdown
eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
eventHub.$on('sidebar.addAssignee', this.addAssignee);
eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
},
beforeDestroy() {
eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
eventHub.$off('sidebar.addAssignee', this.addAssignee);
eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
}, },
mounted () { mounted () {
new IssuableContext(this.currentUser); new IssuableContext(this.currentUser);
...@@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ ...@@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
}, },
components: { components: {
removeBtn: gl.issueBoards.RemoveIssueBtn, removeBtn: gl.issueBoards.RemoveIssueBtn,
'assignee-title': AssigneeTitle,
assignees: Assignees,
}, },
}); });
...@@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({
default: false, default: false,
}, },
}, },
data() {
return {
limitBeforeCounter: 3,
maxRender: 4,
maxCounter: 99,
};
},
computed: { computed: {
cardUrl() { numberOverLimit() {
return `${this.issueLinkBase}/${this.issue.id}`; return this.issue.assignees.length - this.limitBeforeCounter;
}, },
assigneeUrl() { assigneeCounterTooltip() {
return `${this.rootPath}${this.issue.assignee.username}`; return `${this.assigneeCounterLabel} more`;
},
assigneeCounterLabel() {
if (this.numberOverLimit > this.maxCounter) {
return `${this.maxCounter}+`;
}
return `+${this.numberOverLimit}`;
}, },
assigneeUrlTitle() { shouldRenderCounter() {
return `Assigned to ${this.issue.assignee.name}`; if (this.issue.assignees.length <= this.maxRender) {
return false;
}
return this.issue.assignees.length > this.numberOverLimit;
}, },
avatarUrlTitle() { cardUrl() {
return `Avatar for ${this.issue.assignee.name}`; return `${this.issueLinkBase}/${this.issue.id}`;
}, },
issueId() { issueId() {
return `#${this.issue.id}`; return `#${this.issue.id}`;
...@@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({
}, },
}, },
methods: { methods: {
isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter;
},
shouldRenderAssignee(index) {
// Eg. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
// Otherwise render up to the limitBeforeCounter
if (this.issue.assignees.length <= this.maxRender) {
return index < this.maxRender;
}
return index < this.limitBeforeCounter;
},
assigneeUrl(assignee) {
return `${this.rootPath}${assignee.username}`;
},
assigneeUrlTitle(assignee) {
return `Assigned to ${assignee.name}`;
},
avatarUrlTitle(assignee) {
return `Avatar for ${assignee.name}`;
},
showLabel(label) { showLabel(label) {
if (!this.list) return true; if (!this.list) return true;
...@@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({
{{ issueId }} {{ issueId }}
</span> </span>
</h4> </h4>
<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.avatarUrl"
/> 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 = () => { ...@@ -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)) {
......
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
class ListUser { class ListAssignee {
constructor(user) { constructor(user) {
this.id = user.id; this.id = user.id;
this.name = user.name; this.name = user.name;
this.username = user.username; this.username = user.username;
this.avatar = user.avatar_url; this.avatarUrl = user.avatar_url;
} }
} }
window.ListUser = ListUser; window.ListAssignee = ListAssignee;
/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ /* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */
/* global ListLabel */ /* global ListLabel */
/* global ListMilestone */ /* global ListMilestone */
/* global ListUser */ /* global ListAssignee */
import Vue from 'vue'; import Vue from 'vue';
...@@ -14,14 +14,10 @@ class ListIssue { ...@@ -14,14 +14,10 @@ class ListIssue {
this.dueDate = obj.due_date; this.dueDate = obj.due_date;
this.subscribed = obj.subscribed; this.subscribed = obj.subscribed;
this.labels = []; this.labels = [];
this.assignees = [];
this.selected = false; this.selected = false;
this.assignee = false;
this.position = obj.relative_position || Infinity; this.position = obj.relative_position || Infinity;
if (obj.assignee) {
this.assignee = new ListUser(obj.assignee);
}
if (obj.milestone) { if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone); this.milestone = new ListMilestone(obj.milestone);
} }
...@@ -29,6 +25,8 @@ class ListIssue { ...@@ -29,6 +25,8 @@ class ListIssue {
obj.labels.forEach((label) => { obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label)); this.labels.push(new ListLabel(label));
}); });
this.assignees = obj.assignees.map(a => new ListAssignee(a));
} }
addLabel (label) { addLabel (label) {
...@@ -51,6 +49,26 @@ class ListIssue { ...@@ -51,6 +49,26 @@ class ListIssue {
labels.forEach(this.removeLabel.bind(this)); labels.forEach(this.removeLabel.bind(this));
} }
addAssignee (assignee) {
if (!this.findAssignee(assignee)) {
this.assignees.push(new ListAssignee(assignee));
}
}
findAssignee (findAssignee) {
return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
}
removeAssignee (removeAssignee) {
if (removeAssignee) {
this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
}
}
removeAllAssignees () {
this.assignees = [];
}
getLists () { getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
} }
...@@ -60,7 +78,7 @@ class ListIssue { ...@@ -60,7 +78,7 @@ class ListIssue {
issue: { issue: {
milestone_id: this.milestone ? this.milestone.id : null, milestone_id: this.milestone ? this.milestone.id : null,
due_date: this.dueDate, due_date: this.dueDate,
assignee_id: this.assignee ? this.assignee.id : null, assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0],
label_ids: this.labels.map((label) => label.id) label_ids: this.labels.map((label) => label.id)
} }
}; };
......
...@@ -255,7 +255,8 @@ GitLabDropdown = (function() { ...@@ -255,7 +255,8 @@ GitLabDropdown = (function() {
} }
}; };
// Remote data // Remote data
})(this) })(this),
instance: this,
}); });
} }
} }
...@@ -269,6 +270,7 @@ GitLabDropdown = (function() { ...@@ -269,6 +270,7 @@ GitLabDropdown = (function() {
remote: this.options.filterRemote, remote: this.options.filterRemote,
query: this.options.data, query: this.options.data,
keys: searchFields, keys: searchFields,
instance: this,
elements: (function(_this) { elements: (function(_this) {
return function() { return function() {
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
...@@ -343,21 +345,26 @@ GitLabDropdown = (function() { ...@@ -343,21 +345,26 @@ GitLabDropdown = (function() {
} }
this.dropdown.on("click", selector, function(e) { this.dropdown.on("click", selector, function(e) {
var $el, selected, selectedObj, isMarking; var $el, selected, selectedObj, isMarking;
$el = $(this); $el = $(e.currentTarget);
selected = self.rowClicked($el); selected = self.rowClicked($el);
selectedObj = selected ? selected[0] : null; selectedObj = selected ? selected[0] : null;
isMarking = selected ? selected[1] : null; isMarking = selected ? selected[1] : null;
if (self.options.clicked) { if (this.options.clicked) {
self.options.clicked(selectedObj, $el, e, isMarking); this.options.clicked.call(this, {
selectedObj,
$el,
e,
isMarking,
});
} }
// Update label right after all modifications in dropdown has been done // Update label right after all modifications in dropdown has been done
if (self.options.toggleLabel) { if (this.options.toggleLabel) {
self.updateLabel(selectedObj, $el, self); this.updateLabel(selectedObj, $el, this);
} }
$el.trigger('blur'); $el.trigger('blur');
}); }.bind(this));
} }
} }
...@@ -439,15 +446,34 @@ GitLabDropdown = (function() { ...@@ -439,15 +446,34 @@ GitLabDropdown = (function() {
} }
}; };
GitLabDropdown.prototype.filteredFullData = function() {
return this.fullData.filter(r => typeof r === 'object'
&& !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
&& !Object.prototype.hasOwnProperty.call(r, 'header')
);
};
GitLabDropdown.prototype.opened = function(e) { GitLabDropdown.prototype.opened = function(e) {
var contentHtml; var contentHtml;
this.resetRows(); this.resetRows();
this.addArrowKeyEvent(); this.addArrowKeyEvent();
const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
// Makes indeterminate items effective // Makes indeterminate items effective
if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { if (this.fullData && hasFilterBulkUpdate) {
this.parseData(this.fullData); this.parseData(this.fullData);
} }
// Process the data to make sure rendered data
// matches the correct layout
if (this.fullData && hasMultiSelect && this.options.processData) {
const inputValue = this.filterInput.val();
this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
}
contentHtml = $('.dropdown-content', this.dropdown).html(); contentHtml = $('.dropdown-content', this.dropdown).html();
if (this.remote && contentHtml === "") { if (this.remote && contentHtml === "") {
this.remote.execute(); this.remote.execute();
...@@ -709,6 +735,11 @@ GitLabDropdown = (function() { ...@@ -709,6 +735,11 @@ GitLabDropdown = (function() {
if (this.options.inputId != null) { if (this.options.inputId != null) {
$input.attr('id', this.options.inputId); $input.attr('id', this.options.inputId);
} }
if (this.options.inputMeta) {
$input.attr('data-meta', selectedObject[this.options.inputMeta]);
}
return this.dropdown.before($input); return this.dropdown.before($input);
}; };
...@@ -829,7 +860,14 @@ GitLabDropdown = (function() { ...@@ -829,7 +860,14 @@ GitLabDropdown = (function() {
if (instance == null) { if (instance == null) {
instance = null; instance = null;
} }
return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance));
let toggleText = this.options.toggleLabel(selected, el, instance);
if (this.options.updateLabel) {
// Option to override the dropdown label text
toggleText = this.options.updateLabel;
}
return $(this.el).find(".dropdown-toggle-text").text(toggleText);
}; };
GitLabDropdown.prototype.clearField = function(field, isInput) { GitLabDropdown.prototype.clearField = function(field, isInput) {
......
require('./time_tracking/time_tracking_bundle');
import Vue from 'vue';
(() => {
Vue.component('time-tracking-no-tracking-pane', {
name: 'time-tracking-no-tracking-pane',
template: `
<div class='time-tracking-no-tracking-pane'>
<span class='no-value'>No estimate or time spent</span>
</div>
`,
});
})();
import Vue from 'vue';
import VueResource from 'vue-resource';
require('./components/time_tracker');
require('../../smart_interval');
require('../../subbable_resource');
Vue.use(VueResource);
(() => {
/* This Vue instance represents what will become the parent instance for the
* sidebar. It will be responsible for managing `issuable` state and propagating
* changes to sidebar components. We will want to create a separate service to
* interface with the server at that point.
*/
class IssuableTimeTracking {
constructor(issuableJSON) {
const parsedIssuable = JSON.parse(issuableJSON);
return this.initComponent(parsedIssuable);
}
initComponent(parsedIssuable) {
this.parentInstance = new Vue({
el: '#issuable-time-tracker',
data: {
issuable: parsedIssuable,
},
methods: {
fetchIssuable() {
return gl.IssuableResource.get.call(gl.IssuableResource, {
type: 'GET',
url: gl.IssuableResource.endpoint,
});
},
updateState(data) {
this.issuable = data;
},
subscribeToUpdates() {
gl.IssuableResource.subscribe(data => this.updateState(data));
},
listenForSlashCommands() {
$(document).on('ajax:success', '.gfm-form', (e, data) => {
const subscribedCommands = ['spend_time', 'time_estimate'];
const changedCommands = data.commands_changes
? Object.keys(data.commands_changes)
: [];
if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
this.fetchIssuable();
}
});
},
},
created() {
this.fetchIssuable();
},
mounted() {
this.subscribeToUpdates();
this.listenForSlashCommands();
},
});
}
}
gl.IssuableTimeTracking = IssuableTimeTracking;
})(window.gl || (window.gl = {}));
...@@ -19,8 +19,8 @@ ...@@ -19,8 +19,8 @@
return label; return label;
}; };
})(this), })(this),
clicked: function(item, $el, e) { clicked: function(options) {
return e.preventDefault(); return options.e.preventDefault();
}, },
id: function(obj, el) { id: function(obj, el) {
return $(el).data("id"); return $(el).data("id");
......
...@@ -88,7 +88,10 @@ ...@@ -88,7 +88,10 @@
const formData = { const formData = {
update: { update: {
state_event: this.form.find('input[name="update[state_event]"]').val(), state_event: this.form.find('input[name="update[state_event]"]').val(),
// For Merge Requests
assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
// For Issues
assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
......
...@@ -330,7 +330,10 @@ ...@@ -330,7 +330,10 @@
}, },
multiSelect: $dropdown.hasClass('js-multiselect'), multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'), vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(label, $el, e, isMarking) { clicked: function(options) {
const { $el, e, isMarking } = options;
const label = options.selectedObj;
var isIssueIndex, isMRIndex, page, boardsModel; var isIssueIndex, isMRIndex, page, boardsModel;
var fadeOutLoader = () => { var fadeOutLoader = () => {
$loading.fadeOut(); $loading.fadeOut();
...@@ -352,7 +355,7 @@ ...@@ -352,7 +355,7 @@
if ($dropdown.hasClass('js-filter-bulk-update')) { if ($dropdown.hasClass('js-filter-bulk-update')) {
_this.enableBulkLabelDropdown(); _this.enableBulkLabelDropdown();
_this.setDropdownData($dropdown, isMarking, this.id(label)); _this.setDropdownData($dropdown, isMarking, label.id);
return; return;
} }
......
...@@ -158,7 +158,6 @@ import './single_file_diff'; ...@@ -158,7 +158,6 @@ import './single_file_diff';
import './smart_interval'; import './smart_interval';
import './snippets_list'; import './snippets_list';
import './star'; import './star';
import './subbable_resource';
import './subscription'; import './subscription';
import './subscription_select'; import './subscription_select';
import './syntax_highlight'; import './syntax_highlight';
......
...@@ -31,8 +31,27 @@ ...@@ -31,8 +31,27 @@
toggleLabel(selected, $el) { toggleLabel(selected, $el) {
return $el.text(); return $el.text();
}, },
<<<<<<< HEAD
clicked: (selected, $link) => { clicked: (selected, $link) => {
this.formSubmit(null, $link); this.formSubmit(null, $link);
=======
clicked: (options) => {
const $link = options.$el;
if (!$link.data('revert')) {
this.formSubmit(null, $link);
} else {
const { $memberListItem, $toggle, $dateInput } = this.getMemberListItems($link);
$toggle.disable();
$dateInput.disable();
this.overrideLdap($memberListItem, $link.data('endpoint'), false).fail(() => {
$toggle.enable();
$dateInput.enable();
});
}
>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master
}, },
}); });
}); });
......
...@@ -121,7 +121,30 @@ ...@@ -121,7 +121,30 @@
return $value.css('display', ''); return $value.css('display', '');
}, },
vue: $dropdown.hasClass('js-issue-board-sidebar'), vue: $dropdown.hasClass('js-issue-board-sidebar'),
<<<<<<< HEAD
clicked: function(selected, $el, e) { clicked: function(selected, $el, e) {
=======
hideRow: function(milestone) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone) {
return milestone !== gl.issueBoards.BoardsStore.state.currentBoard.milestone.title;
}
return false;
},
isSelectable: function() {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone_id) {
return false;
}
return true;
},
clicked: function(options) {
const { $el, e } = options;
let selected = options.selectedObj;
>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master
var data, isIssueIndex, isMRIndex, page, boardsStore; var data, isIssueIndex, isMRIndex, page, boardsStore;
page = $('body').data('page'); page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index'; isIssueIndex = page === 'projects:issues:index';
......
...@@ -58,7 +58,8 @@ ...@@ -58,7 +58,8 @@
}); });
} }
NamespaceSelect.prototype.onSelectItem = function(item, el, e) { NamespaceSelect.prototype.onSelectItem = function(options) {
const { e } = options;
return e.preventDefault(); return e.preventDefault();
}; };
......
...@@ -112,7 +112,8 @@ import Cookies from 'js-cookie'; ...@@ -112,7 +112,8 @@ import Cookies from 'js-cookie';
toggleLabel: function(obj, $el) { toggleLabel: function(obj, $el) {
return $el.text().trim(); return $el.text().trim();
}, },
clicked: function(selected, $el, e) { clicked: function(options) {
const { e } = options;
e.preventDefault(); e.preventDefault();
if ($('input[name="ref"]').length) { if ($('input[name="ref"]').length) {
var $form = $dropdown.closest('form'); var $form = $dropdown.closest('form');
......
...@@ -19,7 +19,10 @@ ...@@ -19,7 +19,10 @@
return 'Select'; return 'Select';
} }
}, },
clicked(item, $el, e) { clicked(opts) {
const { $el, e } = opts;
const item = opts.selectedObj;
e.preventDefault(); e.preventDefault();
onSelect(); onSelect();
} }
......
...@@ -35,7 +35,8 @@ class ProtectedBranchDropdown { ...@@ -35,7 +35,8 @@ class ProtectedBranchDropdown {
return _.escape(protectedBranch.id); return _.escape(protectedBranch.id);
}, },
onFilter: this.toggleCreateNewButton.bind(this), onFilter: this.toggleCreateNewButton.bind(this),
clicked: (item, $el, e) => { clicked: (options) => {
const { $el, e } = options;
e.preventDefault(); e.preventDefault();
this.onSelect(); this.onSelect();
} }
......
export default {
name: 'AssigneeTitle',
props: {
loading: {
type: Boolean,
required: false,
default: false,
},
numberOfAssignees: {
type: Number,
required: true,
},
editable: {
type: Boolean,
required: true,
},
},
computed: {
assigneeTitle() {
const assignees = this.numberOfAssignees;
return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
},
},
template: `
<div class="title hide-collapsed">
{{assigneeTitle}}
<i
v-if="loading"
aria-hidden="true"
class="fa fa-spinner fa-spin block-loading"
/>
<a
v-if="editable"
class="edit-link pull-right"
href="#"
>
Edit
</a>
</div>
`,
};
export default {
name: 'Assignees',
data() {
return {
defaultRenderCount: 5,
defaultMaxCounter: 99,
showLess: true,
};
},
props: {
rootPath: {
type: String,
required: true,
},
users: {
type: Array,
required: true,
},
editable: {
type: Boolean,
required: true,
},
},
computed: {
firstUser() {
return this.users[0];
},
hasMoreThanTwoAssignees() {
return this.users.length > 2;
},
hasMoreThanOneAssignee() {
return this.users.length > 1;
},
hasAssignees() {
return this.users.length > 0;
},
hasNoUsers() {
return !this.users.length;
},
hasOneUser() {
return this.users.length === 1;
},
renderShowMoreSection() {
return this.users.length > this.defaultRenderCount;
},
numberOfHiddenAssignees() {
return this.users.length - this.defaultRenderCount;
},
isHiddenAssignees() {
return this.numberOfHiddenAssignees > 0;
},
hiddenAssigneesLabel() {
return `+ ${this.numberOfHiddenAssignees} more`;
},
collapsedTooltipTitle() {
const maxRender = Math.min(this.defaultRenderCount, this.users.length);
const renderUsers = this.users.slice(0, maxRender);
const names = renderUsers.map(u => u.name);
if (this.users.length > maxRender) {
names.push(`+ ${this.users.length - maxRender} more`);
}
return names.join(', ');
},
sidebarAvatarCounter() {
let counter = `+${this.users.length - 1}`;
if (this.users.length > this.defaultMaxCounter) {
counter = `${this.defaultMaxCounter}+`;
}
return counter;
},
},
methods: {
assignSelf() {
this.$emit('assign-self');
},
toggleShowLess() {
this.showLess = !this.showLess;
},
renderAssignee(index) {
return !this.showLess || (index < this.defaultRenderCount && this.showLess);
},
avatarUrl(user) {
return user.avatarUrl || user.avatar_url;
},
assigneeUrl(user) {
return `${this.rootPath}${user.username}`;
},
assigneeAlt(user) {
return `${user.name}'s avatar`;
},
assigneeUsername(user) {
return `@${user.username}`;
},
shouldRenderCollapsedAssignee(index) {
const firstTwo = this.users.length <= 2 && index <= 2;
return index === 0 || firstTwo;
},
},
template: `
<div>
<div
class="sidebar-collapsed-icon sidebar-collapsed-user"
:class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
data-container="body"
data-placement="left"
:title="collapsedTooltipTitle"
>
<i
v-if="hasNoUsers"
aria-label="No Assignee"
class="fa fa-user"
/>
<button
type="button"
class="btn-link"
v-for="(user, index) in users"
v-if="shouldRenderCollapsedAssignee(index)"
>
<img
width="24"
class="avatar avatar-inline s24"
:alt="assigneeAlt(user)"
:src="avatarUrl(user)"
/>
<span class="author">
{{ user.name }}
</span>
</button>
<button
v-if="hasMoreThanTwoAssignees"
class="btn-link"
type="button"
>
<span
class="avatar-counter sidebar-avatar-counter"
>
{{ sidebarAvatarCounter }}
</span>
</button>
</div>
<div class="value hide-collapsed">
<template v-if="hasNoUsers">
<span class="assign-yourself no-value">
No assignee
<template v-if="editable">
-
<button
type="button"
class="btn-link"
@click="assignSelf"
>
assign yourself
</button>
</template>
</span>
</template>
<template v-else-if="hasOneUser">
<a
class="author_link bold"
:href="assigneeUrl(firstUser)"
>
<img
width="32"
class="avatar avatar-inline s32"
:alt="assigneeAlt(firstUser)"
:src="avatarUrl(firstUser)"
/>
<span class="author">
{{ firstUser.name }}
</span>
<span class="username">
{{ assigneeUsername(firstUser) }}
</span>
</a>
</template>
<template v-else>
<div class="user-list">
<div
class="user-item"
v-for="(user, index) in users"
v-if="renderAssignee(index)"
>
<a
class="user-link has-tooltip"
data-placement="bottom"
:href="assigneeUrl(user)"
:data-title="user.name"
>
<img
width="32"
class="avatar avatar-inline s32"
:alt="assigneeAlt(user)"
:src="avatarUrl(user)"
/>
</a>
</div>
</div>
<div
v-if="renderShowMoreSection"
class="user-list-more"
>
<button
type="button"
class="btn-link"
@click="toggleShowLess"
>
<template v-if="showLess">
{{ hiddenAssigneesLabel }}
</template>
<template v-else>
- show less
</template>
</button>
</div>
</template>
</div>
</div>
`,
};
/* global Flash */
import AssigneeTitle from './assignee_title';
import Assignees from './assignees';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
export default {
name: 'SidebarAssignees',
data() {
return {
mediator: new Mediator(),
store: new Store(),
loading: false,
field: '',
};
},
components: {
'assignee-title': AssigneeTitle,
assignees: Assignees,
},
methods: {
assignSelf() {
// Notify gl dropdown that we are now assigning to current user
this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
this.mediator.assignYourself();
this.saveAssignees();
},
saveAssignees() {
this.loading = true;
function setLoadingFalse() {
this.loading = false;
}
this.mediator.saveAssignees(this.field)
.then(setLoadingFalse.bind(this))
.catch(() => {
setLoadingFalse();
return new Flash('Error occurred when saving assignees');
});
},
},
created() {
this.removeAssignee = this.store.removeAssignee.bind(this.store);
this.addAssignee = this.store.addAssignee.bind(this.store);
this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store);
// Get events from glDropdown
eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
eventHub.$on('sidebar.addAssignee', this.addAssignee);
eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
},
beforeDestroy() {
eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
eventHub.$off('sidebar.addAssignee', this.addAssignee);
eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
},
beforeMount() {
this.field = this.$el.dataset.field;
},
template: `
<div>
<assignee-title
:number-of-assignees="store.assignees.length"
:loading="loading"
:editable="store.editable"
/>
<assignees
class="value"
:root-path="store.rootPath"
:users="store.assignees"
:editable="store.editable"
@assign-self="assignSelf"
/>
</div>
`,
};
import stopwatchSvg from 'icons/_icon_stopwatch.svg';
import '../../../lib/utils/pretty_time';
export default {
name: 'time-tracking-collapsed-state',
props: {
showComparisonState: {
type: Boolean,
required: true,
},
showSpentOnlyState: {
type: Boolean,
required: true,
},
showEstimateOnlyState: {
type: Boolean,
required: true,
},
showNoTimeTrackingState: {
type: Boolean,
required: true,
},
timeSpentHumanReadable: {
type: String,
required: false,
default: '',
},
timeEstimateHumanReadable: {
type: String,
required: false,
default: '',
},
},
computed: {
timeSpent() {
return this.abbreviateTime(this.timeSpentHumanReadable);
},
timeEstimate() {
return this.abbreviateTime(this.timeEstimateHumanReadable);
},
divClass() {
if (this.showComparisonState) {
return 'compare';
} else if (this.showEstimateOnlyState) {
return 'estimate-only';
} else if (this.showSpentOnlyState) {
return 'spend-only';
} else if (this.showNoTimeTrackingState) {
return 'no-tracking';
}
return '';
},
spanClass() {
if (this.showComparisonState) {
return '';
} else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
return 'bold';
} else if (this.showNoTimeTrackingState) {
return 'no-value';
}
return '';
},
text() {
if (this.showComparisonState) {
return `${this.timeSpent} / ${this.timeEstimate}`;
} else if (this.showEstimateOnlyState) {
return `-- / ${this.timeEstimate}`;
} else if (this.showSpentOnlyState) {
return `${this.timeSpent} / --`;
} else if (this.showNoTimeTrackingState) {
return 'None';
}
return '';
},
},
methods: {
abbreviateTime(timeStr) {
return gl.utils.prettyTime.abbreviateTime(timeStr);
},
},
template: `
<div class="sidebar-collapsed-icon">
${stopwatchSvg}
<div class="time-tracking-collapsed-summary">
<div :class="divClass">
<span :class="spanClass">
{{ text }}
</span>
</div>
</div>
</div>
`,
};
import '../../../lib/utils/pretty_time';
const prettyTime = gl.utils.prettyTime;
export default {
name: 'time-tracking-comparison-pane',
props: {
timeSpent: {
type: Number,
required: true,
},
timeEstimate: {
type: Number,
required: true,
},
timeSpentHumanReadable: {
type: String,
required: true,
},
timeEstimateHumanReadable: {
type: String,
required: true,
},
},
computed: {
parsedRemaining() {
const diffSeconds = this.timeEstimate - this.timeSpent;
return prettyTime.parseSeconds(diffSeconds);
},
timeRemainingHumanReadable() {
return prettyTime.stringifyTime(this.parsedRemaining);
},
timeRemainingTooltip() {
const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
return `${prefix} ${this.timeRemainingHumanReadable}`;
},
/* Diff values for comparison meter */
timeRemainingMinutes() {
return this.timeEstimate - this.timeSpent;
},
timeRemainingPercent() {
return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
},
timeRemainingStatusClass() {
return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
},
/* Parsed time values */
parsedEstimate() {
return prettyTime.parseSeconds(this.timeEstimate);
},
parsedSpent() {
return prettyTime.parseSeconds(this.timeSpent);
},
},
template: `
<div class="time-tracking-comparison-pane">
<div
class="compare-meter"
data-toggle="tooltip"
data-placement="top"
role="timeRemainingDisplay"
:aria-valuenow="timeRemainingTooltip"
:title="timeRemainingTooltip"
:data-original-title="timeRemainingTooltip"
:class="timeRemainingStatusClass"
>
<div
class="meter-container"
role="timeSpentPercent"
:aria-valuenow="timeRemainingPercent"
>
<div
:style="{ width: timeRemainingPercent }"
class="meter-fill"
/>
</div>
<div class="compare-display-container">
<div class="compare-display pull-left">
<span class="compare-label">
Spent
</span>
<span class="compare-value spent">
{{ timeSpentHumanReadable }}
</span>
</div>
<div class="compare-display estimated pull-right">
<span class="compare-label">
Est
</span>
<span class="compare-value">
{{ timeEstimateHumanReadable }}
</span>
</div>
</div>
</div>
</div>
`,
};
export default {
name: 'time-tracking-estimate-only-pane',
props: {
timeEstimateHumanReadable: {
type: String,
required: true,
},
},
template: `
<div class="time-tracking-estimate-only-pane">
<span class="bold">
Estimated:
</span>
{{ timeEstimateHumanReadable }}
</div>
`,
};
export default {
name: 'time-tracking-help-state',
props: {
rootPath: {
type: String,
required: true,
},
},
computed: {
href() {
return `${this.rootPath}help/workflow/time_tracking.md`;
},
},
template: `
<div class="time-tracking-help-state">
<div class="time-tracking-info">
<h4>
Track time with slash commands
</h4>
<p>
Slash commands can be used in the issues description and comment boxes.
</p>
<p>
<code>
/estimate
</code>
will update the estimated time with the latest command.
</p>
<p>
<code>
/spend
</code>
will update the sum of the time spent.
</p>
<a
class="btn btn-default learn-more-button"
:href="href"
>
Learn more
</a>
</div>
</div>
`,
};
export default {
name: 'time-tracking-no-tracking-pane',
template: `
<div class="time-tracking-no-tracking-pane">
<span class="no-value">
No estimate or time spent
</span>
</div>
`,
};
import '~/smart_interval';
import timeTracker from './time_tracker';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
export default {
data() {
return {
mediator: new Mediator(),
store: new Store(),
};
},
components: {
'issuable-time-tracker': timeTracker,
},
methods: {
listenForSlashCommands() {
$(document).on('ajax:success', '.gfm-form', (e, data) => {
const subscribedCommands = ['spend_time', 'time_estimate'];
const changedCommands = data.commands_changes
? Object.keys(data.commands_changes)
: [];
if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
this.mediator.fetch();
}
});
},
},
mounted() {
this.listenForSlashCommands();
},
template: `
<div class="block">
<issuable-time-tracker
:time_estimate="store.timeEstimate"
:time_spent="store.totalTimeSpent"
:human_time_estimate="store.humanTimeEstimate"
:human_time_spent="store.humanTotalTimeSpent"
:rootPath="store.rootPath"
/>
</div>
`,
};
export default {
name: 'time-tracking-spent-only-pane',
props: {
timeSpentHumanReadable: {
type: String,
required: true,
},
},
template: `
<div class="time-tracking-spend-only-pane">
<span class="bold">Spent:</span>
{{ timeSpentHumanReadable }}
</div>
`,
};
import timeTrackingHelpState from './help_state';
import timeTrackingCollapsedState from './collapsed_state';
import timeTrackingSpentOnlyPane from './spent_only_pane';
import timeTrackingNoTrackingPane from './no_tracking_pane';
import timeTrackingEstimateOnlyPane from './estimate_only_pane';
import timeTrackingComparisonPane from './comparison_pane';
import eventHub from '../../event_hub';
export default {
name: 'issuable-time-tracker',
props: {
time_estimate: {
type: Number,
required: true,
},
time_spent: {
type: Number,
required: true,
},
human_time_estimate: {
type: String,
required: false,
default: '',
},
human_time_spent: {
type: String,
required: false,
default: '',
},
rootPath: {
type: String,
required: true,
},
},
data() {
return {
showHelp: false,
};
},
components: {
'time-tracking-collapsed-state': timeTrackingCollapsedState,
'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
'time-tracking-comparison-pane': timeTrackingComparisonPane,
'time-tracking-help-state': timeTrackingHelpState,
},
computed: {
timeSpent() {
return this.time_spent;
},
timeEstimate() {
return this.time_estimate;
},
timeEstimateHumanReadable() {
return this.human_time_estimate;
},
timeSpentHumanReadable() {
return this.human_time_spent;
},
hasTimeSpent() {
return !!this.timeSpent;
},
hasTimeEstimate() {
return !!this.timeEstimate;
},
showComparisonState() {
return this.hasTimeEstimate && this.hasTimeSpent;
},
showEstimateOnlyState() {
return this.hasTimeEstimate && !this.hasTimeSpent;
},
showSpentOnlyState() {
return this.hasTimeSpent && !this.hasTimeEstimate;
},
showNoTimeTrackingState() {
return !this.hasTimeEstimate && !this.hasTimeSpent;
},
showHelpState() {
return !!this.showHelp;
},
},
methods: {
toggleHelpState(show) {
this.showHelp = show;
},
update(data) {
this.time_estimate = data.time_estimate;
this.time_spent = data.time_spent;
this.human_time_estimate = data.human_time_estimate;
this.human_time_spent = data.human_time_spent;
},
},
created() {
eventHub.$on('timeTracker:updateData', this.update);
},
template: `
<div
class="time_tracker time-tracking-component-wrap"
v-cloak
>
<time-tracking-collapsed-state
:show-comparison-state="showComparisonState"
:show-no-time-tracking-state="showNoTimeTrackingState"
:show-help-state="showHelpState"
:show-spent-only-state="showSpentOnlyState"
:show-estimate-only-state="showEstimateOnlyState"
:time-spent-human-readable="timeSpentHumanReadable"
:time-estimate-human-readable="timeEstimateHumanReadable"
/>
<div class="title hide-collapsed">
Time tracking
<div
class="help-button pull-right"
v-if="!showHelpState"
@click="toggleHelpState(true)"
>
<i
class="fa fa-question-circle"
aria-hidden="true"
/>
</div>
<div
class="close-help-button pull-right"
v-if="showHelpState"
@click="toggleHelpState(false)"
>
<i
class="fa fa-close"
aria-hidden="true"
/>
</div>
</div>
<div class="time-tracking-content hide-collapsed">
<time-tracking-estimate-only-pane
v-if="showEstimateOnlyState"
:time-estimate-human-readable="timeEstimateHumanReadable"
/>
<time-tracking-spent-only-pane
v-if="showSpentOnlyState"
:time-spent-human-readable="timeSpentHumanReadable"
/>
<time-tracking-no-tracking-pane
v-if="showNoTimeTrackingState"
/>
<time-tracking-comparison-pane
v-if="showComparisonState"
:time-estimate="timeEstimate"
:time-spent="timeSpent"
:time-spent-human-readable="timeSpentHumanReadable"
:time-estimate-human-readable="timeEstimateHumanReadable"
/>
<transition name="help-state-toggle">
<time-tracking-help-state
v-if="showHelpState"
:rootPath="rootPath"
/>
</transition>
</div>
</div>
`,
};
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class SidebarService {
constructor(endpoint) {
if (!SidebarService.singleton) {
this.endpoint = endpoint;
SidebarService.singleton = this;
}
return SidebarService.singleton;
}
get() {
return Vue.http.get(this.endpoint);
}
update(key, data) {
return Vue.http.put(this.endpoint, {
[key]: data,
}, {
emulateJSON: true,
});
}
}
import Vue from 'vue';
import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
import sidebarAssignees from './components/assignees/sidebar_assignees';
import Mediator from './sidebar_mediator';
document.addEventListener('DOMContentLoaded', () => {
const mediator = new Mediator(gl.sidebarOptions);
mediator.fetch();
const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
// Only create the sidebarAssignees vue app if it is found in the DOM
// We currently do not use sidebarAssignees for the MR page
if (sidebarAssigneesEl) {
new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
}
new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
});
/* global Flash */
import Service from './services/sidebar_service';
import Store from './stores/sidebar_store';
export default class SidebarMediator {
constructor(options) {
if (!SidebarMediator.singleton) {
this.store = new Store(options);
this.service = new Service(options.endpoint);
SidebarMediator.singleton = this;
}
return SidebarMediator.singleton;
}
assignYourself() {
this.store.addAssignee(this.store.currentUser);
}
saveAssignees(field) {
const selected = this.store.assignees.map(u => u.id);
// If there are no ids, that means we have to unassign (which is id = 0)
// And it only accepts an array, hence [0]
return this.service.update(field, selected.length === 0 ? [0] : selected);
}
fetch() {
this.service.get()
.then((response) => {
const data = response.json();
this.store.processAssigneeData(data);
this.store.processTimeTrackingData(data);
})
.catch(() => new Flash('Error occured when fetching sidebar data'));
}
}
export default class SidebarStore {
constructor(store) {
if (!SidebarStore.singleton) {
const { currentUser, rootPath, editable } = store;
this.currentUser = currentUser;
this.rootPath = rootPath;
this.editable = editable;
this.timeEstimate = 0;
this.totalTimeSpent = 0;
this.humanTimeEstimate = '';
this.humanTimeSpent = '';
this.assignees = [];
SidebarStore.singleton = this;
}
return SidebarStore.singleton;
}
processAssigneeData(data) {
if (data.assignees) {
this.assignees = data.assignees;
}
}
processTimeTrackingData(data) {
this.timeEstimate = data.time_estimate;
this.totalTimeSpent = data.total_time_spent;
this.humanTimeEstimate = data.human_time_estimate;
this.humanTotalTimeSpent = data.human_total_time_spent;
}
addAssignee(assignee) {
if (!this.findAssignee(assignee)) {
this.assignees.push(assignee);
}
}
findAssignee(findAssignee) {
return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
}
removeAssignee(removeAssignee) {
if (removeAssignee) {
this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
}
}
removeAllAssignees() {
this.assignees = [];
}
}
(() => {
/*
* SubbableResource can be extended to provide a pubsub-style service for one-off REST
* calls. Subscribe by passing a callback or render method you will use to handle responses.
*
* */
class SubbableResource {
constructor(resourcePath) {
this.endpoint = resourcePath;
// TODO: Switch to axios.create
this.resource = $.ajax;
this.subscribers = [];
}
subscribe(callback) {
this.subscribers.push(callback);
}
publish(newResponse) {
const responseCopy = _.extend({}, newResponse);
this.subscribers.forEach((fn) => {
fn(responseCopy);
});
return newResponse;
}
get(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
post(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
put(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
delete(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
}
gl.SubbableResource = SubbableResource;
})(window.gl || (window.gl = {}));
...@@ -19,8 +19,8 @@ ...@@ -19,8 +19,8 @@
return label; return label;
}; };
})(this), })(this),
clicked: function(item, $el, e) { clicked: function(options) {
return e.preventDefault(); return options.e.preventDefault();
}, },
id: function(obj, el) { id: function(obj, el) {
return $(el).data("id"); return $(el).data("id");
......
This diff is collapsed.
...@@ -93,3 +93,14 @@ ...@@ -93,3 +93,14 @@
align-self: center; align-self: center;
} }
} }
.avatar-counter {
background-color: $gray-darkest;
color: $white-light;
border: 1px solid $border-color;
border-radius: 1em;
font-family: $regular_font;
font-size: 9px;
line-height: 16px;
text-align: center;
}
...@@ -251,11 +251,9 @@ ...@@ -251,11 +251,9 @@
} }
.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;
} }
...@@ -337,8 +335,8 @@ ...@@ -337,8 +335,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 +379,7 @@ ...@@ -381,6 +379,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 +405,9 @@ ...@@ -406,6 +405,9 @@
&.is-active::before { &.is-active::before {
content: "\f00c"; content: "\f00c";
position: absolute;
top: 50%;
transform: translateY(-50%);
} }
} }
} }
......
...@@ -255,6 +255,7 @@ ul.controls { ...@@ -255,6 +255,7 @@ ul.controls {
.avatar-inline { .avatar-inline {
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
margin-bottom: 0;
} }
} }
} }
......
...@@ -207,8 +207,13 @@ ...@@ -207,8 +207,13 @@
margin-bottom: 5px; margin-bottom: 5px;
} }
&.is-active { &.is-active,
&.is-active .card-assignee:hover a {
background-color: $row-hover; background-color: $row-hover;
&:first-child:not(:only-child) {
box-shadow: -10px 0 10px 1px $row-hover;
}
} }
.label { .label {
...@@ -224,7 +229,7 @@ ...@@ -224,7 +229,7 @@
} }
.card-title { .card-title {
margin: 0; margin: 0 30px 0 0;
font-size: 1em; font-size: 1em;
line-height: inherit; line-height: inherit;
...@@ -240,10 +245,69 @@ ...@@ -240,10 +245,69 @@
min-height: 20px; min-height: 20px;
.card-assignee { .card-assignee {
margin-left: auto; display: flex;
margin-right: 5px; justify-content: flex-end;
padding-left: 10px; position: absolute;
right: 15px;
height: 20px; 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 @@ ...@@ -570,14 +570,7 @@
.diff-comments-more-count, .diff-comments-more-count,
.diff-notes-collapse { .diff-notes-collapse {
background-color: $gray-darkest; @extend .avatar-counter;
color: $white-light;
border: 1px solid $white-light;
border-radius: 1em;
font-family: $regular_font;
font-size: 9px;
line-height: 17px;
text-align: center;
} }
.diff-notes-collapse { .diff-notes-collapse {
......
...@@ -95,10 +95,15 @@ ...@@ -95,10 +95,15 @@
} }
.right-sidebar { .right-sidebar {
a { a,
.btn-link {
color: inherit; color: inherit;
} }
.btn-link {
outline: none;
}
.issuable-header-text { .issuable-header-text {
margin-top: 7px; margin-top: 7px;
} }
...@@ -215,6 +220,10 @@ ...@@ -215,6 +220,10 @@
} }
} }
.assign-yourself .btn-link {
padding-left: 0;
}
.light { .light {
font-weight: normal; font-weight: normal;
} }
...@@ -239,6 +248,10 @@ ...@@ -239,6 +248,10 @@
margin-left: 0; margin-left: 0;
} }
.assignee .user-list .avatar {
margin: 0;
}
.username { .username {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
...@@ -301,6 +314,10 @@ ...@@ -301,6 +314,10 @@
margin-top: 0; margin-top: 0;
} }
.sidebar-avatar-counter {
padding-top: 2px;
}
.todo-undone { .todo-undone {
color: $gl-link-color; color: $gl-link-color;
} }
...@@ -309,10 +326,15 @@ ...@@ -309,10 +326,15 @@
display: none; display: none;
} }
.avatar:hover { .avatar:hover,
.avatar-counter:hover {
border-color: $issuable-sidebar-color; border-color: $issuable-sidebar-color;
} }
.avatar-counter:hover {
color: $issuable-sidebar-color;
}
.btn-clipboard { .btn-clipboard {
border: none; border: none;
color: $issuable-sidebar-color; color: $issuable-sidebar-color;
...@@ -322,6 +344,17 @@ ...@@ -322,6 +344,17 @@
color: $gl-text-color; color: $gl-text-color;
} }
} }
&.multiple-users {
display: flex;
justify-content: center;
}
}
.sidebar-avatar-counter {
width: 24px;
height: 24px;
border-radius: 12px;
} }
.sidebar-collapsed-user { .sidebar-collapsed-user {
...@@ -332,6 +365,37 @@ ...@@ -332,6 +365,37 @@
.issuable-header-btn { .issuable-header-btn {
display: none; display: none;
} }
.multiple-users {
height: 24px;
margin-bottom: 17px;
margin-top: 4px;
padding-bottom: 4px;
.btn-link {
padding: 0;
border: 0;
.avatar {
margin: 0;
}
}
.btn-link:first-child {
position: absolute;
left: 10px;
z-index: 1;
}
.btn-link:last-child {
position: absolute;
right: 10px;
&:hover {
text-decoration: none;
}
}
}
} }
a { a {
...@@ -380,17 +444,21 @@ ...@@ -380,17 +444,21 @@
} }
.participants-list { .participants-list {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: -5px; margin: -5px;
} }
.user-list {
display: flex;
flex-wrap: wrap;
}
.participants-author { .participants-author {
display: inline-block; flex-basis: 14%;
padding: 5px; padding: 5px;
&:nth-of-type(7n) {
padding-right: 0;
}
.author_link { .author_link {
display: block; display: block;
} }
...@@ -400,13 +468,39 @@ ...@@ -400,13 +468,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 +593,19 @@ ...@@ -499,6 +593,19 @@
} }
} }
.issuable-list li,
.issue-info-container .controls {
.avatar-counter {
display: inline-block;
vertical-align: middle;
min-width: 16px;
line-height: 14px;
height: 16px;
padding-left: 2px;
padding-right: 2px;
}
}
.time_tracker { .time_tracker {
padding-bottom: 0; padding-bottom: 0;
border-bottom: 0; border-bottom: 0;
......
...@@ -66,6 +66,7 @@ module IssuableActions ...@@ -66,6 +66,7 @@ module IssuableActions
:milestone_id, :milestone_id,
:state_event, :state_event,
:subscription_event, :subscription_event,
assignee_ids: [],
label_ids: [], label_ids: [],
add_label_ids: [], add_label_ids: [],
remove_label_ids: [] remove_label_ids: []
......
...@@ -43,7 +43,7 @@ module IssuableCollections ...@@ -43,7 +43,7 @@ module IssuableCollections
end end
def issues_collection def issues_collection
issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace) issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace)
end end
def merge_requests_collection def merge_requests_collection
......
...@@ -82,7 +82,7 @@ module Projects ...@@ -82,7 +82,7 @@ module Projects
labels: true, labels: true,
only: [:id, :iid, :title, :confidential, :due_date, :relative_position], only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
include: { include: {
assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] } milestone: { only: [:id, :title] }
}, },
user: current_user user: current_user
......
...@@ -67,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -67,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController
def new def new
params[:issue] ||= ActionController::Parameters.new( params[:issue] ||= ActionController::Parameters.new(
assignee_id: "" assignee_ids: ""
) )
build_params = issue_params.merge( build_params = issue_params.merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
...@@ -150,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -150,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController
if @issue.valid? if @issue.valid?
render json: @issue.to_json(methods: [:task_status, :task_status_short], render json: @issue.to_json(methods: [:task_status, :task_status_short],
include: { milestone: {}, include: { milestone: {},
assignee: { only: [:name, :username], methods: [:avatar_url] }, assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
labels: { methods: :text_color } }) labels: { methods: :text_color } })
else else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
...@@ -275,7 +275,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -275,7 +275,7 @@ class Projects::IssuesController < Projects::ApplicationController
def issue_params def issue_params
params.require(:issue).permit( params.require(:issue).permit(
:title, :assignee_id, :position, :description, :confidential, :title, :assignee_id, :position, :description, :confidential,
:milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [] :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [],
) )
end end
......
...@@ -231,7 +231,7 @@ class IssuableFinder ...@@ -231,7 +231,7 @@ class IssuableFinder
when 'created-by-me', 'authored' when 'created-by-me', 'authored'
items.where(author_id: current_user.id) items.where(author_id: current_user.id)
when 'assigned-to-me' when 'assigned-to-me'
items.where(assignee_id: current_user.id) items.assigned_to(current_user)
else else
items items
end end
......
...@@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder ...@@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder
IssuesFinder.not_restricted_by_confidentiality(current_user) IssuesFinder.not_restricted_by_confidentiality(current_user)
end end
def by_assignee(items)
if assignee
items.assigned_to(assignee)
elsif no_assignee?
items.unassigned
elsif assignee_id? || assignee_username? # assignee not found
items.none
else
items
end
end
def self.not_restricted_by_confidentiality(user) def self.not_restricted_by_confidentiality(user)
return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank? return Issue.where('issues.confidential IS NOT TRUE') if user.blank?
return Issue.all if user.admin? return Issue.all if user.admin?
Issue.where(' Issue.where('
issues.confidential IS NULL issues.confidential IS NOT TRUE
OR issues.confidential IS FALSE
OR (issues.confidential = TRUE OR (issues.confidential = TRUE
AND (issues.author_id = :user_id AND (issues.author_id = :user_id
OR issues.assignee_id = :user_id OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
OR issues.project_id IN(:project_ids)))', OR issues.project_id IN(:project_ids)))',
user_id: user.id, user_id: user.id,
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
......
...@@ -15,4 +15,37 @@ module FormHelper ...@@ -15,4 +15,37 @@ 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',
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',
}
}
if has_multiple_assignees
options[:toggle_class] += ' js-multiselect js-save-user-data'
options[:title] = 'Select assignee(s)'
options[:data][:multi_select] = true
options[:data][:'input-meta'] = 'name'
options[:data][:'always-show-selectbox'] = true
options[:data][:current_user_info] = current_user.to_json(only: [:id, :name])
options[:data][:'dropdown-header'] = 'Assignee(s)'
options[:data].delete(:'max-select')
end
options
end
end end
...@@ -63,6 +63,16 @@ module IssuablesHelper ...@@ -63,6 +63,16 @@ module IssuablesHelper
end end
end end
def users_dropdown_label(selected_users)
if selected_users.length == 0
"Unassigned"
elsif selected_users.length == 1
selected_users[0].name
else
"#{selected_users[0].name} + #{selected_users.length - 1} more"
end
end
def user_dropdown_label(user_id, default_label) def user_dropdown_label(user_id, default_label)
return default_label if user_id.nil? return default_label if user_id.nil?
return "Unassigned" if user_id == "0" return "Unassigned" if user_id == "0"
......
...@@ -11,10 +11,12 @@ module Emails ...@@ -11,10 +11,12 @@ module Emails
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end end
def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id) def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id)
setup_issue_mail(issue_id, recipient_id) setup_issue_mail(issue_id, recipient_id)
@previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id @previous_assignees = []
@previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
end end
......
...@@ -26,7 +26,6 @@ module Issuable ...@@ -26,7 +26,6 @@ module Issuable
cache_markdown_field :description, issuable_state_filter_enabled: true cache_markdown_field :description, issuable_state_filter_enabled: true
belongs_to :author, class_name: "User" belongs_to :author, class_name: "User"
belongs_to :assignee, class_name: "User"
belongs_to :updated_by, class_name: "User" belongs_to :updated_by, class_name: "User"
belongs_to :milestone belongs_to :milestone
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do
...@@ -65,11 +64,8 @@ module Issuable ...@@ -65,11 +64,8 @@ module Issuable
validates :title, presence: true, length: { maximum: 255 } validates :title, presence: true, length: { maximum: 255 }
scope :authored, ->(user) { where(author_id: user) } scope :authored, ->(user) { where(author_id: user) }
scope :assigned_to, ->(u) { where(assignee_id: u.id)}
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :order_position_asc, -> { reorder(position: :asc) } scope :order_position_asc, -> { reorder(position: :asc) }
scope :assigned, -> { where("assignee_id IS NOT NULL") }
scope :unassigned, -> { where("assignee_id IS NULL") }
scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
...@@ -92,7 +88,6 @@ module Issuable ...@@ -92,7 +88,6 @@ 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
...@@ -102,13 +97,6 @@ module Issuable ...@@ -102,13 +97,6 @@ module Issuable
after_save :update_assignee_cache_counts, if: :assignee_id_changed? 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 +225,6 @@ module Issuable ...@@ -237,10 +225,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 +253,11 @@ module Issuable ...@@ -269,7 +253,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 +319,6 @@ module Issuable ...@@ -331,11 +319,6 @@ module Issuable
false false
end end
def assignee_or_author?(user)
# We're comparing IDs here so we don't need to load any associations.
author_id == user.id || assignee_id == user.id
end
def record_metrics def record_metrics
metrics = self.metrics || create_metrics metrics = self.metrics || create_metrics
metrics.record! metrics.record!
......
...@@ -40,7 +40,7 @@ module Milestoneish ...@@ -40,7 +40,7 @@ module Milestoneish
def issues_visible_to_user(user) def issues_visible_to_user(user)
memoize_per_user(user, :issues_visible_to_user) do memoize_per_user(user, :issues_visible_to_user) do
IssuesFinder.new(user, issues_finder_params) IssuesFinder.new(user, issues_finder_params)
.execute.where(milestone_id: milestoneish_ids) .execute.includes(:assignees).where(milestone_id: milestoneish_ids)
end end
end end
......
...@@ -36,7 +36,7 @@ class GlobalMilestone ...@@ -36,7 +36,7 @@ class GlobalMilestone
closed = count_by_state(milestones_by_state_and_title, 'closed') closed = count_by_state(milestones_by_state_and_title, 'closed')
all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
{ {
opened: opened, opened: opened,
closed: closed, closed: closed,
all: all all: all
...@@ -86,7 +86,7 @@ class GlobalMilestone ...@@ -86,7 +86,7 @@ class GlobalMilestone
end end
def issues def issues
@issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels) @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels)
end end
def merge_requests def merge_requests
...@@ -94,7 +94,7 @@ class GlobalMilestone ...@@ -94,7 +94,7 @@ class GlobalMilestone
end end
def participants def participants
@participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq @participants ||= milestones.map(&:participants).flatten.uniq
end end
def labels def labels
......
...@@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base ...@@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
has_many :issue_assignees
has_many :assignees, class_name: "User", through: :issue_assignees
validates :project, presence: true validates :project, presence: true
scope :in_projects, ->(project_ids) { where(project_id: project_ids) } scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)}
scope :without_due_date, -> { where(due_date: nil) } scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_before, ->(date) { where('issues.due_date < ?', date) }
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
...@@ -37,13 +44,15 @@ class Issue < ActiveRecord::Base ...@@ -37,13 +44,15 @@ class Issue < ActiveRecord::Base
scope :created_after, -> (datetime) { where("created_at >= ?", datetime) } scope :created_after, -> (datetime) { where("created_at >= ?", datetime) }
scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) } scope :include_associations, -> { includes(:labels, project: :namespace) }
after_save :expire_etag_cache after_save :expire_etag_cache
attr_spammable :title, spam_title: true attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true attr_spammable :description, spam_description: true
participant :assignees
state_machine :state, initial: :opened do state_machine :state, initial: :opened do
event :close do event :close do
transition [:reopened, :opened] => :closed transition [:reopened, :opened] => :closed
...@@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base ...@@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base
end end
def hook_attrs def hook_attrs
assignee_ids = self.assignee_ids
attrs = { attrs = {
total_time_spent: total_time_spent, total_time_spent: total_time_spent,
human_total_time_spent: human_total_time_spent, human_total_time_spent: human_total_time_spent,
human_time_estimate: human_time_estimate human_time_estimate: human_time_estimate,
assignee_ids: assignee_ids,
assignee_id: assignee_ids.first # This key is deprecated
} }
attributes.merge!(attrs) attributes.merge!(attrs)
...@@ -114,6 +127,22 @@ class Issue < ActiveRecord::Base ...@@ -114,6 +127,22 @@ class Issue < ActiveRecord::Base
"id DESC") "id DESC")
end end
# Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes
{
'Author' => author.try(:name),
'Assignee' => assignee_list
}
end
def assignee_or_author?(user)
author_id == user.id || assignees.exists?(user.id)
end
def assignee_list
assignees.map(&:name).to_sentence
end
# `from` argument can be a Namespace or Project. # `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false) def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}" reference = "#{self.class.reference_prefix}#{iid}"
...@@ -248,7 +277,7 @@ class Issue < ActiveRecord::Base ...@@ -248,7 +277,7 @@ class Issue < ActiveRecord::Base
true true
elsif confidential? elsif confidential?
author == user || author == user ||
assignee == user || assignees.include?(user) ||
project.team.member?(user, Gitlab::Access::REPORTER) project.team.member?(user, Gitlab::Access::REPORTER)
else else
project.public? || project.public? ||
......
class IssueAssignee < ActiveRecord::Base
extend Gitlab::CurrentSettings
belongs_to :issue
belongs_to :assignee, class_name: "User", foreign_key: :user_id
after_create :update_assignee_cache_counts
after_destroy :update_assignee_cache_counts
# EE-specific
after_create :update_elasticsearch_index
after_destroy :update_elasticsearch_index
# EE-specific
def update_assignee_cache_counts
assignee&.update_cache_counts
end
def update_elasticsearch_index
if current_application_settings.elasticsearch_indexing?
ElasticIndexerWorker.perform_async(
:update,
'Issue',
issue.id,
changed_fields: ['assignee_ids']
)
end
end
end
...@@ -17,6 +17,8 @@ class MergeRequest < ActiveRecord::Base ...@@ -17,6 +17,8 @@ class MergeRequest < ActiveRecord::Base
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
belongs_to :assignee, class_name: "User"
serialize :merge_params, Hash serialize :merge_params, Hash
after_create :ensure_merge_request_diff, unless: :importing? after_create :ensure_merge_request_diff, unless: :importing?
...@@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base ...@@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base
scope :join_project, -> { joins(:target_project) } scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) } scope :references_project, -> { references(:target_project) }
scope :assigned, -> { where("assignee_id IS NOT NULL") }
scope :unassigned, -> { where("assignee_id IS NULL") }
scope :assigned_to, ->(u) { where(assignee_id: u.id)}
participant :assignee
after_save :keep_around_commit after_save :keep_around_commit
after_save :update_assignee_cache_counts, if: :assignee_id_changed?
def self.reference_prefix def self.reference_prefix
'!' '!'
...@@ -177,6 +185,30 @@ class MergeRequest < ActiveRecord::Base ...@@ -177,6 +185,30 @@ class MergeRequest < ActiveRecord::Base
work_in_progress?(title) ? title : "WIP: #{title}" work_in_progress?(title) ? title : "WIP: #{title}"
end end
def update_assignee_cache_counts
# make sure we flush the cache for both the old *and* new assignees(if they exist)
previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was
previous_assignee&.update_cache_counts
assignee&.update_cache_counts
end
# Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes
{
'Author' => author.try(:name),
'Assignee' => assignee.try(:name)
}
end
# This method is needed for compatibility with issues to not mess view and other code
def assignees
Array(assignee)
end
def assignee_or_author?(user)
author_id == user.id || assignee_id == user.id
end
# `from` argument can be a Namespace or Project. # `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false) def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}" reference = "#{self.class.reference_prefix}#{iid}"
......
...@@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base ...@@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base
has_many :issues has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests has_many :merge_requests
has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee
has_many :events, as: :target, dependent: :destroy has_many :events, as: :target, dependent: :destroy
scope :active, -> { with_state(:active) } scope :active, -> { with_state(:active) }
...@@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base ...@@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base
end end
end end
def participants
User.joins(assigned_issues: :milestone).where("milestones.id = ?", id)
end
def self.sort(method) def self.sort(method)
case method.to_s case method.to_s
when 'due_date_asc' when 'due_date_asc'
......
...@@ -89,6 +89,7 @@ class User < ActiveRecord::Base ...@@ -89,6 +89,7 @@ class User < ActiveRecord::Base
has_many :subscriptions, dependent: :destroy has_many :subscriptions, dependent: :destroy
has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event"
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy
has_one :abuse_report, dependent: :destroy, foreign_key: :user_id has_one :abuse_report, dependent: :destroy, foreign_key: :user_id
has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport"
has_many :spam_logs, dependent: :destroy has_many :spam_logs, dependent: :destroy
...@@ -99,6 +100,10 @@ class User < ActiveRecord::Base ...@@ -99,6 +100,10 @@ class User < ActiveRecord::Base
has_many :award_emoji, dependent: :destroy has_many :award_emoji, dependent: :destroy
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
has_many :issue_assignees
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest"
# Issues that a user owns are expected to be moved to the "ghost" user before # Issues that a user owns are expected to be moved to the "ghost" user before
# the user is destroyed. If the user owns any issues during deletion, this # the user is destroyed. If the user owns any issues during deletion, this
# should be treated as an exceptional condition. # should be treated as an exceptional condition.
......
class IssuableEntity < Grape::Entity class IssuableEntity < Grape::Entity
expose :id expose :id
expose :iid expose :iid
expose :assignee_id
expose :author_id expose :author_id
expose :description expose :description
expose :lock_version expose :lock_version
......
class IssueEntity < IssuableEntity class IssueEntity < IssuableEntity
expose :branch_name expose :branch_name
expose :confidential expose :confidential
expose :assignees, using: API::Entities::UserBasic
expose :due_date expose :due_date
expose :moved_to_id expose :moved_to_id
expose :project_id expose :project_id
......
class MergeRequestEntity < IssuableEntity class MergeRequestEntity < IssuableEntity
expose :assignee_id
expose :in_progress_merge_commit_sha expose :in_progress_merge_commit_sha
expose :locked_at expose :locked_at
expose :merge_commit_sha expose :merge_commit_sha
......
...@@ -7,10 +7,14 @@ module Issuable ...@@ -7,10 +7,14 @@ module Issuable
ids = params.delete(:issuable_ids).split(",") ids = params.delete(:issuable_ids).split(",")
items = model_class.where(id: ids) items = model_class.where(id: ids)
%i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key| %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key|
params.delete(key) unless params[key].present? params.delete(key) unless params[key].present?
end end
if params[:assignee_ids] == [IssuableFinder::NONE.to_s]
params[:assignee_ids] = []
end
items.each do |issuable| items.each do |issuable|
next unless can?(current_user, :"update_#{type}", issuable) next unless can?(current_user, :"update_#{type}", issuable)
......
class IssuableBaseService < BaseService class IssuableBaseService < BaseService
private private
def create_assignee_note(issuable)
SystemNoteService.change_assignee(
issuable, issuable.project, current_user, issuable.assignee)
end
def create_milestone_note(issuable) def create_milestone_note(issuable)
SystemNoteService.change_milestone( SystemNoteService.change_milestone(
issuable, issuable.project, current_user, issuable.milestone) issuable, issuable.project, current_user, issuable.milestone)
...@@ -53,6 +48,7 @@ class IssuableBaseService < BaseService ...@@ -53,6 +48,7 @@ class IssuableBaseService < BaseService
params.delete(:add_label_ids) params.delete(:add_label_ids)
params.delete(:remove_label_ids) params.delete(:remove_label_ids)
params.delete(:label_ids) params.delete(:label_ids)
params.delete(:assignee_ids)
params.delete(:assignee_id) params.delete(:assignee_id)
params.delete(:due_date) params.delete(:due_date)
end end
...@@ -77,7 +73,7 @@ class IssuableBaseService < BaseService ...@@ -77,7 +73,7 @@ class IssuableBaseService < BaseService
def assignee_can_read?(issuable, assignee_id) def assignee_can_read?(issuable, assignee_id)
new_assignee = User.find_by_id(assignee_id) new_assignee = User.find_by_id(assignee_id)
return false unless new_assignee.present? return false unless new_assignee
ability_name = :"read_#{issuable.to_ability_name}" ability_name = :"read_#{issuable.to_ability_name}"
resource = issuable.persisted? ? issuable : project resource = issuable.persisted? ? issuable : project
...@@ -207,6 +203,7 @@ class IssuableBaseService < BaseService ...@@ -207,6 +203,7 @@ class IssuableBaseService < BaseService
filter_params(issuable) filter_params(issuable)
old_labels = issuable.labels.to_a old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.to_a old_mentioned_users = issuable.mentioned_users.to_a
old_assignees = issuable.assignees.to_a
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
...@@ -222,7 +219,13 @@ class IssuableBaseService < BaseService ...@@ -222,7 +219,13 @@ class IssuableBaseService < BaseService
handle_common_system_notes(issuable, old_labels: old_labels) handle_common_system_notes(issuable, old_labels: old_labels)
end end
handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users) handle_changes(
issuable,
old_labels: old_labels,
old_mentioned_users: old_mentioned_users,
old_assignees: old_assignees
)
after_update(issuable) after_update(issuable)
issuable.create_new_cross_references!(current_user) issuable.create_new_cross_references!(current_user)
execute_hooks(issuable, 'update') execute_hooks(issuable, 'update')
...@@ -272,7 +275,7 @@ class IssuableBaseService < BaseService ...@@ -272,7 +275,7 @@ class IssuableBaseService < BaseService
end end
end end
def has_changes?(issuable, old_labels: []) def has_changes?(issuable, old_labels: [], old_assignees: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
attrs_changed = valid_attrs.any? do |attr| attrs_changed = valid_attrs.any? do |attr|
...@@ -281,7 +284,9 @@ class IssuableBaseService < BaseService ...@@ -281,7 +284,9 @@ class IssuableBaseService < BaseService
labels_changed = issuable.labels != old_labels labels_changed = issuable.labels != old_labels
attrs_changed || labels_changed assignees_changed = issuable.assignees != old_assignees
attrs_changed || labels_changed || assignees_changed
end end
def handle_common_system_notes(issuable, old_labels: []) def handle_common_system_notes(issuable, old_labels: [])
......
...@@ -9,11 +9,30 @@ module Issues ...@@ -9,11 +9,30 @@ 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?
assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
params[:assignee_ids] = []
elsif assignee_ids.any?
params[:assignee_ids] = assignee_ids
else
params.delete(:assignee_ids)
end
end
end end
end end
...@@ -12,8 +12,12 @@ module Issues ...@@ -12,8 +12,12 @@ module Issues
spam_check(issue, current_user) spam_check(issue, current_user)
end end
def handle_changes(issue, old_labels: [], old_mentioned_users: []) def handle_changes(issue, options)
if has_changes?(issue, old_labels: old_labels) old_labels = options[:old_labels] || []
old_mentioned_users = options[:old_mentioned_users] || []
old_assignees = options[:old_assignees] || []
if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
todo_service.mark_pending_todos_as_done(issue, current_user) todo_service.mark_pending_todos_as_done(issue, current_user)
end end
...@@ -26,9 +30,9 @@ module Issues ...@@ -26,9 +30,9 @@ module Issues
create_milestone_note(issue) create_milestone_note(issue)
end end
if issue.previous_changes.include?('assignee_id') if issue.assignees != old_assignees
create_assignee_note(issue) create_assignee_note(issue, old_assignees)
notification_service.reassigned_issue(issue, current_user) notification_service.reassigned_issue(issue, current_user, old_assignees)
todo_service.reassigned_issue(issue, current_user) todo_service.reassigned_issue(issue, current_user)
end end
......
...@@ -4,7 +4,7 @@ module MergeRequests ...@@ -4,7 +4,7 @@ module MergeRequests
@assignable_issues ||= begin @assignable_issues ||= begin
if current_user == merge_request.author if current_user == merge_request.author
closes_issues.select do |issue| closes_issues.select do |issue|
!issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue) !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue)
end end
else else
[] []
...@@ -14,7 +14,7 @@ module MergeRequests ...@@ -14,7 +14,7 @@ module MergeRequests
def execute def execute
assignable_issues.each do |issue| assignable_issues.each do |issue|
Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue) Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue)
end end
{ {
......
...@@ -38,6 +38,11 @@ module MergeRequests ...@@ -38,6 +38,11 @@ module MergeRequests
private private
def create_assignee_note(merge_request)
SystemNoteService.change_assignee(
merge_request, merge_request.project, current_user, merge_request.assignee)
end
# Returns all origin and fork merge requests from `@project` satisfying passed arguments. # Returns all origin and fork merge requests from `@project` satisfying passed arguments.
def merge_requests_for(source_branch, mr_states: [:opened, :reopened]) def merge_requests_for(source_branch, mr_states: [:opened, :reopened])
MergeRequest MergeRequest
......
...@@ -21,7 +21,10 @@ module MergeRequests ...@@ -21,7 +21,10 @@ module MergeRequests
update(merge_request) update(merge_request)
end end
def handle_changes(merge_request, old_labels: [], old_mentioned_users: []) def handle_changes(merge_request, options)
old_labels = options[:old_labels] || []
old_mentioned_users = options[:old_mentioned_users] || []
if has_changes?(merge_request, old_labels: old_labels) if has_changes?(merge_request, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(merge_request, current_user) todo_service.mark_pending_todos_as_done(merge_request, current_user)
end end
......
...@@ -19,9 +19,14 @@ class NotificationRecipientService ...@@ -19,9 +19,14 @@ class NotificationRecipientService
# Re-assign is considered as a mention of the new assignee so we add the # Re-assign is considered as a mention of the new assignee so we add the
# new assignee to the list of recipients after we rejected users with # new assignee to the list of recipients after we rejected users with
# the "on mention" notification level # the "on mention" notification level
if [:reassign_merge_request, :reassign_issue].include?(custom_action) case custom_action
when :reassign_merge_request
recipients << previous_assignee if previous_assignee recipients << previous_assignee if previous_assignee
recipients << target.assignee recipients << target.assignee
when :reassign_issue
previous_assignees = Array(previous_assignee)
recipients.concat(previous_assignees)
recipients.concat(target.assignees)
end end
recipients = reject_muted_users(recipients) recipients = reject_muted_users(recipients)
......
...@@ -66,8 +66,23 @@ class NotificationService ...@@ -66,8 +66,23 @@ 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
)
recipients.each do |recipient|
mailer.send(
:reassigned_issue_email,
recipient.id,
issue.id,
previous_assignees.map(&:id),
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 +382,10 @@ class NotificationService ...@@ -367,10 +382,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
...@@ -88,20 +88,33 @@ module SlashCommands ...@@ -88,20 +88,33 @@ module SlashCommands
current_user.can?(:"admin_#{issuable.to_ability_name}", project) current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end end
command :assign do |assignee_param| command :assign do |assignee_param|
user = extract_references(assignee_param, :user).first user_ids = extract_references(assignee_param, :user).map(&:id)
user ||= User.find_by(username: assignee_param)
@updates[:assignee_id] = user.id if user if user_ids.empty?
user_ids = User.where(username: assignee_param.split(' ').map(&:strip)).pluck(:id)
end
next if user_ids.empty?
if issuable.is_a?(Issue)
@updates[:assignee_ids] = user_ids
else
@updates[:assignee_id] = user_ids.last
end
end end
desc 'Remove assignee' desc 'Remove assignee'
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 ...@@ -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(noteable: issue, project: project, author: author, note: body)
end
# Called when one or more labels on a Noteable are added and/or removed # Called when one or more labels on a Noteable are added and/or removed
# #
# noteable - Noteable object # noteable - Noteable object
......
...@@ -251,9 +251,9 @@ class TodoService ...@@ -251,9 +251,9 @@ class TodoService
end end
def create_assignment_todo(issuable, author) def create_assignment_todo(issuable, author)
if issuable.assignee if issuable.assignees.any?
attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED) attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED)
create_todos(issuable.assignee, attributes) create_todos(issuable.assignees, attributes)
end end
end end
......
...@@ -23,10 +23,12 @@ xml.entry do ...@@ -23,10 +23,12 @@ xml.entry do
end end
end end
if issue.assignee if issue.assignees.any?
xml.assignee do xml.assignees do
xml.name issue.assignee.name issue.assignees.each do |assignee|
xml.email issue.assignee_public_email xml.name assignee.name
xml.email assignee.public_email
end
end end
end end
end end
Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %>
<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %>
Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %>
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
%p.details %p.details
#{link_to @issue.author_name, user_url(@issue.author)} created an issue: #{link_to @issue.author_name, user_url(@issue.author)} created an issue:
- if @issue.assignee_id.present? - if @issue.assignees.any?
%p %p
Assignee: #{@issue.assignee_name} Assignee: #{@issue.assignee_list}
- if @issue.description - if @issue.description
%div %div
......
...@@ -2,6 +2,6 @@ New Issue was created. ...@@ -2,6 +2,6 @@ New Issue was created.
Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
Author: <%= @issue.author_name %> Author: <%= @issue.author_name %>
Assignee: <%= @issue.assignee_name %> Assignee: <%= @issue.assignee_list %>
<%= @issue.description %> <%= @issue.description %>
...@@ -2,6 +2,6 @@ You have been mentioned in an issue. ...@@ -2,6 +2,6 @@ You have been mentioned in an issue.
Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> 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 %>
= render 'reassigned_issuable_email', issuable: @issue %p
Assignee changed
- if @previous_assignees.any?
from
%strong= @previous_assignees.map(&:name).to_sentence
to
- if @issue.assignees.any?
%strong= @issue.assignee_list
- else
%strong Unassigned
<%= render 'reassigned_issuable_email', issuable: @issue %> Reassigned Issue <%= @issue.iid %>
<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %>
Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%>
to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %>
= render 'reassigned_issuable_email', issuable: @merge_request Reassigned Merge Request #{ @merge_request.iid }
= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }])
Assignee changed
- if @previous_assignee
from #{@previous_assignee.name}
to
= @merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'
<%= render 'reassigned_issuable_email', issuable: @merge_request %> Reassigned Merge Request <%= @merge_request.iid %>
<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %>
Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %>
.block.assignee .block.assignee{ ref: "assigneeBlock" }
.title.hide-collapsed %template{ "v-if" => "issue.assignees" }
Assignee %assignee-title{ ":number-of-assignees" => "issue.assignees.length",
- if can?(current_user, :admin_issue, @project) ":loading" => "loadingAssignees",
= icon("spinner spin", class: "block-loading") ":editable" => can?(current_user, :admin_issue, @project) }
= link_to "Edit", "#", class: "edit-link pull-right" %assignees.value{ "root-path" => "#{root_url}",
.value.hide-collapsed ":users" => "issue.assignees",
%span.assign-yourself.no-value{ "v-if" => "!issue.assignee" } ":editable" => can?(current_user, :admin_issue, @project),
No assignee "@assign-self" => "assignSelf" }
- if can?(current_user, :admin_issue, @project)
\-
%a.js-assign-yourself{ href: "#" }
assign yourself
%a.author_link.bold{ ":href" => "'#{root_url}' + issue.assignee.username",
"v-if" => "issue.assignee" }
%img.avatar.avatar-inline.s32{ ":src" => "issue.assignee.avatar",
width: "32", alt: "Avatar" }
%span.author
{{ issue.assignee.name }}
%span.username
= precede "@" do
{{ issue.assignee.username }}
- if can?(current_user, :admin_issue, @project) - if can?(current_user, :admin_issue, @project)
.selectbox.hide-collapsed .selectbox.hide-collapsed
%input{ type: "hidden", %input{ type: "hidden",
name: "issue[assignee_id]", name: "issue[assignee_ids][]",
id: "issue_assignee_id", ":value" => "assignee.id",
":value" => "issue.assignee.id", "v-if" => "issue.assignees",
"v-if" => "issue.assignee" } "v-for" => "assignee in issue.assignees" }
.dropdown .dropdown
<<<<<<< HEAD
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", null_user_default: "true" }, %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", null_user_default: "true" },
=======
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", dropdown: { header: 'Assignee(s)'} },
>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master
":data-issuable-id" => "issue.id", ":data-issuable-id" => "issue.id",
":data-selected" => "assigneeId", ":data-selected" => "assigneeId",
":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
Select assignee Select assignee(s)
= icon("chevron-down") = icon("chevron-down")
.dropdown-menu.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
= dropdown_title("Assign to") = dropdown_title("Assign to")
= dropdown_filter("Search users") = dropdown_filter("Search users")
= dropdown_content = dropdown_content
......
...@@ -13,9 +13,9 @@ ...@@ -13,9 +13,9 @@
%li %li
CLOSED CLOSED
- if issue.assignee - if issue.assignees.any?
%li %li
= link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") = render 'shared/issuable/assignees', project: @project, issue: issue
= render 'shared/issuable_meta_data', issuable: issue = render 'shared/issuable_meta_data', issuable: issue
......
- max_render = 3
- max = [max_render, issue.assignees.length].min
- issue.assignees.each_with_index do |assignee, index|
- if index < max
= link_to_member(@project, assignee, name: false, title: "Assigned to :name")
- if issue.assignees.length > max_render
- counter = issue.assignees.length - max_render
%span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } }
- if counter < 99
= "+#{counter}"
- else
99+
...@@ -12,9 +12,9 @@ ...@@ -12,9 +12,9 @@
- participants.each do |participant| - participants.each do |participant|
.participants-author.js-participants-author .participants-author.js-participants-author
= link_to_member(@project, participant, name: false, size: 24) = link_to_member(@project, participant, name: false, size: 24)
- if participants_extra > 0 - if participants_extra > 0
.participants-more .hide-collapsed.participants-more
%a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } }
+ #{participants_extra} more + #{participants_extra} more
:javascript :javascript
IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row}; IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row};
...@@ -124,8 +124,17 @@ ...@@ -124,8 +124,17 @@
%li %li
%a{ href: "#", data: { id: "close" } } Closed %a{ href: "#", data: { id: "close" } } Closed
.filter-item.inline .filter-item.inline
- if type == :issues
- field_name = "update[assignee_ids][]"
- else
- field_name = "update[assignee_id]"
= dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
<<<<<<< HEAD
placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } })
=======
placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master
.filter-item.inline .filter-item.inline
= dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } }) = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } })
.filter-item.inline.labels-filter .filter-item.inline.labels-filter
......
- todo = issuable_todo(issuable) - todo = issuable_todo(issuable)
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('issuable') = page_specific_javascript_bundle_tag('sidebar')
%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header .block.issuable-sidebar-header
- if current_user - if current_user
...@@ -20,36 +20,59 @@ ...@@ -20,36 +20,59 @@
.block.todo.hide-expanded .block.todo.hide-expanded
= render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true
.block.assignee .block.assignee
.sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } - if issuable.instance_of?(Issue)
- if issuable.assignee #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } }
= link_to_member(@project, issuable.assignee, size: 24) - else
- else .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) }
= icon('user', 'aria-hidden': 'true') - if issuable.assignee
.title.hide-collapsed = link_to_member(@project, issuable.assignee, size: 24)
Assignee - else
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') = icon('user', 'aria-hidden': 'true')
- if can_edit_issuable .title.hide-collapsed
= link_to 'Edit', '#', class: 'edit-link pull-right' Assignee
.value.hide-collapsed = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if issuable.assignee - if can_edit_issuable
= link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do = link_to 'Edit', '#', class: 'edit-link pull-right'
- if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) .value.hide-collapsed
%span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } - if issuable.assignee
= icon('exclamation-triangle', 'aria-hidden': 'true') = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do
%span.username - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee)
= issuable.assignee.to_reference %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
- else = icon('exclamation-triangle', 'aria-hidden': 'true')
%span.assign-yourself.no-value %span.username
No assignee = issuable.assignee.to_reference
- if can_edit_issuable - else
\- %span.assign-yourself.no-value
%a.js-assign-yourself{ href: '#' } No assignee
assign yourself - if can_edit_issuable
\-
%a.js-assign-yourself{ href: '#' }
assign yourself
.selectbox.hide-collapsed .selectbox.hide-collapsed
<<<<<<< HEAD
= f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id'
= dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, null_user_default: true, selected: issuable.assignee_id } }) = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, null_user_default: true, selected: issuable.assignee_id } })
=======
- issuable.assignees.each do |assignee|
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil
>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master
- options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }
- if issuable.instance_of?(Issue)
- if issuable.assignees.length == 0
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil
- title = 'Select assignee(s)'
- options[:toggle_class] += ' js-multiselect js-save-user-data'
- options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]"
- options[:data][:multi_select] = true
- options[:data]['dropdown-title'] = title
- options[:data]['dropdown-header'] = 'Assignee(s)'
- else
- title = 'Select assignee'
= dropdown_tag(title, options: options)
.block.milestone .block.milestone
.sidebar-collapsed-icon .sidebar-collapsed-icon
= icon('clock-o', 'aria-hidden': 'true') = icon('clock-o', 'aria-hidden': 'true')
...@@ -75,11 +98,10 @@ ...@@ -75,11 +98,10 @@
= dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }}) = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }})
- if issuable.has_attribute?(:time_estimate) - if issuable.has_attribute?(:time_estimate)
#issuable-time-tracker.block #issuable-time-tracker.block
%issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'docs-url' => help_page_path('workflow/time_tracking.md') } // Fallback while content is loading
// Fallback while content is loading .title.hide-collapsed
.title.hide-collapsed Time tracking
Time tracking = icon('spinner spin', 'aria-hidden': 'true')
= icon('spinner spin', 'aria-hidden': 'true')
- if issuable.has_attribute?(:due_date) - if issuable.has_attribute?(:due_date)
.block.due_date .block.due_date
.sidebar-collapsed-icon .sidebar-collapsed-icon
...@@ -169,8 +191,13 @@ ...@@ -169,8 +191,13 @@
= clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
:javascript :javascript
gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}'); gl.sidebarOptions = {
new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}"); endpoint: "#{issuable_json_path(issuable)}",
editable: #{can_edit_issuable ? true : false},
currentUser: #{current_user.to_json(only: [:username, :id, :name], methods: :avatar_url)},
rootPath: "#{root_path}"
};
new MilestoneSelect('{"full_path":"#{@project.full_path}"}'); new MilestoneSelect('{"full_path":"#{@project.full_path}"}');
new LabelsSelect(); new LabelsSelect();
new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}'); new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');
......
- issue = issuable
.block.assignee
.sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) }
- if issue.assignees.any?
- issue.assignees.each do |assignee|
= link_to_member(@project, assignee, size: 24)
- else
= icon('user', 'aria-hidden': 'true')
.title.hide-collapsed
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right'
.value.hide-collapsed
- if issue.assignees.any?
- issue.assignees.each do |assignee|
= link_to_member(@project, assignee, size: 32, extra_class: 'bold') do
%span.username
= assignee.to_reference
- else
%span.assign-yourself.no-value
No assignee
- if can_edit_issuable
\-
%a.js-assign-yourself{ href: '#' }
assign yourself
.selectbox.hide-collapsed
= f.hidden_field 'assignee_ids', value: issuable.assignee_ids, id: 'issue_assignee_ids'
= dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } })
- merge_request = issuable
.block.assignee
.sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) }
- if merge_request.assignee
= link_to_member(@project, merge_request.assignee, size: 24)
- else
= icon('user', 'aria-hidden': 'true')
.title.hide-collapsed
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right'
.value.hide-collapsed
- if merge_request.assignee
= link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do
- unless merge_request.can_be_merged_by?(merge_request.assignee)
%span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
= icon('exclamation-triangle', 'aria-hidden': 'true')
%span.username
= merge_request.assignee.to_reference
- else
%span.assign-yourself.no-value
No assignee
- if can_edit_issuable
\-
%a.js-assign-yourself{ href: '#' }
assign yourself
.selectbox.hide-collapsed
= f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id'
= dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project&.id, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } })
...@@ -10,13 +10,24 @@ ...@@ -10,13 +10,24 @@
.row .row
%div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") }
.form-group.issue-assignee .form-group.issue-assignee
= form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - if issuable.is_a?(Issue)
.col-sm-10{ class: ("col-lg-8" if has_due_date) } = form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
.issuable-form-select-holder .col-sm-10{ class: ("col-lg-8" if has_due_date) }
= form.hidden_field :assignee_id .issuable-form-select-holder.selectbox
= dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - issuable.assignees.each do |assignee|
placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name }
= link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
= dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable, true))
= link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
- else
= form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
.issuable-form-select-holder
= form.hidden_field :assignee_id
= dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
= link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
.form-group.issue-milestone .form-group.issue-milestone
= form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}"
.col-sm-10{ class: ("col-lg-8" if has_due_date) } .col-sm-10{ class: ("col-lg-8" if has_due_date) }
......
-# @project is present when viewing Project's milestone -# @project is present when viewing Project's milestone
- project = @project || issuable.project - project = @project || issuable.project
- namespace = @project_namespace || project.namespace.becomes(Namespace) - namespace = @project_namespace || project.namespace.becomes(Namespace)
- assignee = issuable.assignee - assignees = issuable.assignees
- issuable_type = issuable.class.table_name - issuable_type = issuable.class.table_name
- base_url_args = [namespace, project] - base_url_args = [namespace, project]
- issuable_type_args = base_url_args + [issuable_type] - issuable_type_args = base_url_args + [issuable_type]
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
- render_colored_label(label) - render_colored_label(label)
%span.assignee-icon %span.assignee-icon
- if assignee - assignees.each do |assignee|
= link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }), = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }),
class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do
- image_tag(avatar_icon(issuable.assignee, 16), class: "avatar s16", alt: '') - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '')
---
title: Update issue board cards design
merge_request: 10353
author:
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment