summaryrefslogtreecommitdiff
path: root/app/assets
diff options
context:
space:
mode:
authorValery Sizov <valery@gitlab.com>2017-05-04 15:11:15 +0300
committerValery Sizov <valery@gitlab.com>2017-05-04 17:11:53 +0300
commit387c4b2c21a44360386a9b8ce6849e7f1b8a3de9 (patch)
tree446b8338efe8ad22ca03b00b2dc72b22c4174e02 /app/assets
parent68c12e15cc236548918f91393ebef3c06c124814 (diff)
downloadgitlab-ce-387c4b2c21a44360386a9b8ce6849e7f1b8a3de9.tar.gz
Backport of multiple_assignees_feature [ci skip]
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/javascripts/blob/target_branch_dropdown.js4
-rw-r--r--app/assets/javascripts/blob/template_selector.js7
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js2
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js1
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js61
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js104
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js4
-rw-r--r--app/assets/javascripts/boards/models/assignee.js (renamed from app/assets/javascripts/boards/models/user.js)6
-rw-r--r--app/assets/javascripts/boards/models/issue.js32
-rw-r--r--app/assets/javascripts/gl_dropdown.js56
-rw-r--r--app/assets/javascripts/issuable/issuable_bundle.js1
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js12
-rw-r--r--app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js66
-rw-r--r--app/assets/javascripts/issue_status_select.js4
-rw-r--r--app/assets/javascripts/issues_bulk_assignment.js3
-rw-r--r--app/assets/javascripts/labels_select.js7
-rw-r--r--app/assets/javascripts/main.js1
-rw-r--r--app/assets/javascripts/members.js19
-rw-r--r--app/assets/javascripts/milestone_select.js23
-rw-r--r--app/assets/javascripts/namespace_select.js3
-rw-r--r--app/assets/javascripts/project.js3
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js5
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_dropdown.js3
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.js41
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.js224
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js84
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js97
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js98
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js17
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.js44
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js10
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js45
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js15
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js163
-rw-r--r--app/assets/javascripts/sidebar/event_hub.js3
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js28
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js21
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js38
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js52
-rw-r--r--app/assets/javascripts/subbable_resource.js51
-rw-r--r--app/assets/javascripts/subscription_select.js4
-rw-r--r--app/assets/javascripts/users_select.js377
-rw-r--r--app/assets/stylesheets/framework/avatar.scss11
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss12
-rw-r--r--app/assets/stylesheets/framework/lists.scss1
-rw-r--r--app/assets/stylesheets/pages/boards.scss74
-rw-r--r--app/assets/stylesheets/pages/diff.scss9
-rw-r--r--app/assets/stylesheets/pages/issuable.scss125
48 files changed, 1768 insertions, 303 deletions
diff --git a/app/assets/javascripts/blob/target_branch_dropdown.js b/app/assets/javascripts/blob/target_branch_dropdown.js
index 216f069ef71..d52d69b1274 100644
--- a/app/assets/javascripts/blob/target_branch_dropdown.js
+++ b/app/assets/javascripts/blob/target_branch_dropdown.js
@@ -37,8 +37,8 @@ class TargetBranchDropDown {
}
return SELECT_ITEM_MSG;
},
- clicked(item, el, e) {
- e.preventDefault();
+ clicked(options) {
+ options.e.preventDefault();
self.onClick.call(self);
},
fieldName: self.fieldName,
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index d7c1c32efbd..888883163c5 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -24,7 +24,7 @@ export default class TemplateSelector {
search: {
fields: ['name'],
},
- clicked: (item, el, e) => this.fetchFileTemplate(item, el, e),
+ clicked: options => this.fetchFileTemplate(options),
text: item => item.name,
});
}
@@ -51,7 +51,10 @@ export default class TemplateSelector {
return this.$dropdownContainer.removeClass('hidden');
}
- fetchFileTemplate(item, el, e) {
+ fetchFileTemplate(options) {
+ const { e } = options;
+ const item = options.selectedObj;
+
e.preventDefault();
return this.requestFile(item);
}
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
index b6dee8177d2..dbbfb6de3ea 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/boards_bundle.js
@@ -11,7 +11,7 @@ require('./models/issue');
require('./models/label');
require('./models/list');
require('./models/milestone');
-require('./models/user');
+require('./models/assignee');
require('./stores/boards_store');
require('./stores/modal_store');
require('./services/board_service');
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
index 0fa85b6fe14..1ce95b62138 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ b/app/assets/javascripts/boards/components/board_new_issue.js
@@ -26,6 +26,7 @@ export default {
title: this.title,
labels,
subscribed: true,
+ assignees: [],
});
this.list.newIssue(issue)
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index f0066d4ec5d..317cef9f227 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -3,8 +3,13 @@
/* global MilestoneSelect */
/* global LabelsSelect */
/* global Sidebar */
+/* global Flash */
import Vue from 'vue';
+import eventHub from '../../sidebar/event_hub';
+
+import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
+import Assignees from '../../sidebar/components/assignees/assignees';
require('./sidebar/remove_issue');
@@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
detail: Store.detail,
issue: {},
list: {},
+ loadingAssignees: false,
};
},
computed: {
@@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({
this.issue = this.detail.issue;
this.list = this.detail.list;
+
+ this.$nextTick(() => {
+ this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
+ });
},
deep: true
},
@@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({
$('.right-sidebar').getNiceScroll().resize();
});
}
- }
+
+ this.issue = this.detail.issue;
+ this.list = this.detail.list;
+ },
+ deep: true
},
methods: {
closeSidebar () {
this.detail.issue = {};
- }
+ },
+ assignSelf () {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
+
+ this.addAssignee(this.currentUser);
+ this.saveAssignees();
+ },
+ removeAssignee (a) {
+ gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a);
+ },
+ addAssignee (a) {
+ gl.issueBoards.BoardsStore.detail.issue.addAssignee(a);
+ },
+ removeAllAssignees () {
+ gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees();
+ },
+ saveAssignees () {
+ this.loadingAssignees = true;
+
+ gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
+ .then(() => {
+ this.loadingAssignees = false;
+ })
+ .catch(() => {
+ this.loadingAssignees = false;
+ return new Flash('An error occurred while saving assignees');
+ });
+ },
+ },
+ created () {
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
},
mounted () {
new IssuableContext(this.currentUser);
@@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
},
components: {
removeBtn: gl.issueBoards.RemoveIssueBtn,
+ 'assignee-title': AssigneeTitle,
+ assignees: Assignees,
},
});
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index fc154ee7b8b..2f06d186c50 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({
default: false,
},
},
+ data() {
+ return {
+ limitBeforeCounter: 3,
+ maxRender: 4,
+ maxCounter: 99,
+ };
+ },
computed: {
- cardUrl() {
- return `${this.issueLinkBase}/${this.issue.id}`;
+ numberOverLimit() {
+ return this.issue.assignees.length - this.limitBeforeCounter;
},
- assigneeUrl() {
- return `${this.rootPath}${this.issue.assignee.username}`;
+ assigneeCounterTooltip() {
+ return `${this.assigneeCounterLabel} more`;
+ },
+ assigneeCounterLabel() {
+ if (this.numberOverLimit > this.maxCounter) {
+ return `${this.maxCounter}+`;
+ }
+
+ return `+${this.numberOverLimit}`;
},
- assigneeUrlTitle() {
- return `Assigned to ${this.issue.assignee.name}`;
+ shouldRenderCounter() {
+ if (this.issue.assignees.length <= this.maxRender) {
+ return false;
+ }
+
+ return this.issue.assignees.length > this.numberOverLimit;
},
- avatarUrlTitle() {
- return `Avatar for ${this.issue.assignee.name}`;
+ cardUrl() {
+ return `${this.issueLinkBase}/${this.issue.id}`;
},
issueId() {
return `#${this.issue.id}`;
@@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({
},
},
methods: {
+ isIndexLessThanlimit(index) {
+ return index < this.limitBeforeCounter;
+ },
+ shouldRenderAssignee(index) {
+ // Eg. maxRender is 4,
+ // Render up to all 4 assignees if there are only 4 assigness
+ // Otherwise render up to the limitBeforeCounter
+ if (this.issue.assignees.length <= this.maxRender) {
+ return index < this.maxRender;
+ }
+
+ return index < this.limitBeforeCounter;
+ },
+ assigneeUrl(assignee) {
+ return `${this.rootPath}${assignee.username}`;
+ },
+ assigneeUrlTitle(assignee) {
+ return `Assigned to ${assignee.name}`;
+ },
+ avatarUrlTitle(assignee) {
+ return `Avatar for ${assignee.name}`;
+ },
showLabel(label) {
if (!this.list) return true;
@@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({
{{ issueId }}
</span>
</h4>
- <a
- class="card-assignee has-tooltip js-no-trigger"
- :href="assigneeUrl"
- :title="assigneeUrlTitle"
- v-if="issue.assignee"
- data-container="body"
- >
- <img
- class="avatar avatar-inline s20 js-no-trigger"
- :src="issue.assignee.avatar"
- width="20"
- height="20"
- :alt="avatarUrlTitle"
- />
- </a>
+ <div class="card-assignee">
+ <a
+ class="has-tooltip js-no-trigger"
+ :href="assigneeUrl(assignee)"
+ :title="assigneeUrlTitle(assignee)"
+ v-for="(assignee, index) in issue.assignees"
+ v-if="shouldRenderAssignee(index)"
+ data-container="body"
+ data-placement="bottom"
+ >
+ <img
+ class="avatar avatar-inline s20"
+ :src="assignee.avatarUrl"
+ width="20"
+ height="20"
+ :alt="avatarUrlTitle(assignee)"
+ />
+ </a>
+ <span
+ class="avatar-counter has-tooltip"
+ :title="assigneeCounterTooltip"
+ v-if="shouldRenderCounter"
+ >
+ {{ assigneeCounterLabel }}
+ </span>
+ </div>
</div>
- <div class="card-footer" v-if="showLabelFooter">
+ <div
+ class="card-footer"
+ v-if="showLabelFooter"
+ >
<button
- class="label color-label has-tooltip js-no-trigger"
+ class="label color-label has-tooltip"
v-for="label in issue.labels"
type="button"
v-if="showLabel(label)"
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index 7e3bb79af1d..f29b6caa1ac 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => {
filterable: true,
selectable: true,
multiSelect: true,
- clicked (label, $el, e) {
+ clicked (options) {
+ const { e } = options;
+ const label = options.selectedObj;
e.preventDefault();
if (!Store.findList('title', label.title)) {
diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/assignee.js
index 8e9de4d4cbb..ee0f4e608c9 100644
--- a/app/assets/javascripts/boards/models/user.js
+++ b/app/assets/javascripts/boards/models/assignee.js
@@ -1,12 +1,12 @@
/* eslint-disable no-unused-vars */
-class ListUser {
+class ListAssignee {
constructor(user) {
this.id = user.id;
this.name = user.name;
this.username = user.username;
- this.avatar = user.avatar_url;
+ this.avatarUrl = user.avatar_url;
}
}
-window.ListUser = ListUser;
+window.ListAssignee = ListAssignee;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index d6175069e37..76ebd4c9eab 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -1,7 +1,7 @@
/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */
/* global ListLabel */
/* global ListMilestone */
-/* global ListUser */
+/* global ListAssignee */
import Vue from 'vue';
@@ -14,14 +14,10 @@ class ListIssue {
this.dueDate = obj.due_date;
this.subscribed = obj.subscribed;
this.labels = [];
+ this.assignees = [];
this.selected = false;
- this.assignee = false;
this.position = obj.relative_position || Infinity;
- if (obj.assignee) {
- this.assignee = new ListUser(obj.assignee);
- }
-
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
}
@@ -29,6 +25,8 @@ class ListIssue {
obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label));
});
+
+ this.assignees = obj.assignees.map(a => new ListAssignee(a));
}
addLabel (label) {
@@ -51,6 +49,26 @@ class ListIssue {
labels.forEach(this.removeLabel.bind(this));
}
+ addAssignee (assignee) {
+ if (!this.findAssignee(assignee)) {
+ this.assignees.push(new ListAssignee(assignee));
+ }
+ }
+
+ findAssignee (findAssignee) {
+ return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ }
+
+ removeAssignee (removeAssignee) {
+ if (removeAssignee) {
+ this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ }
+ }
+
+ removeAllAssignees () {
+ this.assignees = [];
+ }
+
getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
}
@@ -60,7 +78,7 @@ class ListIssue {
issue: {
milestone_id: this.milestone ? this.milestone.id : null,
due_date: this.dueDate,
- assignee_id: this.assignee ? this.assignee.id : null,
+ assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0],
label_ids: this.labels.map((label) => label.id)
}
};
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index a03f1202a6d..0c9eb84f0eb 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -255,7 +255,8 @@ GitLabDropdown = (function() {
}
};
// Remote data
- })(this)
+ })(this),
+ instance: this,
});
}
}
@@ -269,6 +270,7 @@ GitLabDropdown = (function() {
remote: this.options.filterRemote,
query: this.options.data,
keys: searchFields,
+ instance: this,
elements: (function(_this) {
return function() {
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
@@ -343,21 +345,26 @@ GitLabDropdown = (function() {
}
this.dropdown.on("click", selector, function(e) {
var $el, selected, selectedObj, isMarking;
- $el = $(this);
+ $el = $(e.currentTarget);
selected = self.rowClicked($el);
selectedObj = selected ? selected[0] : null;
isMarking = selected ? selected[1] : null;
- if (self.options.clicked) {
- self.options.clicked(selectedObj, $el, e, isMarking);
+ if (this.options.clicked) {
+ this.options.clicked.call(this, {
+ selectedObj,
+ $el,
+ e,
+ isMarking,
+ });
}
// Update label right after all modifications in dropdown has been done
- if (self.options.toggleLabel) {
- self.updateLabel(selectedObj, $el, self);
+ if (this.options.toggleLabel) {
+ this.updateLabel(selectedObj, $el, this);
}
$el.trigger('blur');
- });
+ }.bind(this));
}
}
@@ -439,15 +446,34 @@ GitLabDropdown = (function() {
}
};
+ GitLabDropdown.prototype.filteredFullData = function() {
+ return this.fullData.filter(r => typeof r === 'object'
+ && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
+ && !Object.prototype.hasOwnProperty.call(r, 'header')
+ );
+ };
+
GitLabDropdown.prototype.opened = function(e) {
var contentHtml;
this.resetRows();
this.addArrowKeyEvent();
+ const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
+ const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
+ const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
+
// Makes indeterminate items effective
- if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) {
+ if (this.fullData && hasFilterBulkUpdate) {
this.parseData(this.fullData);
}
+
+ // Process the data to make sure rendered data
+ // matches the correct layout
+ if (this.fullData && hasMultiSelect && this.options.processData) {
+ const inputValue = this.filterInput.val();
+ this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
+ }
+
contentHtml = $('.dropdown-content', this.dropdown).html();
if (this.remote && contentHtml === "") {
this.remote.execute();
@@ -709,6 +735,11 @@ GitLabDropdown = (function() {
if (this.options.inputId != null) {
$input.attr('id', this.options.inputId);
}
+
+ if (this.options.inputMeta) {
+ $input.attr('data-meta', selectedObject[this.options.inputMeta]);
+ }
+
return this.dropdown.before($input);
};
@@ -829,7 +860,14 @@ GitLabDropdown = (function() {
if (instance == null) {
instance = null;
}
- return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance));
+
+ let toggleText = this.options.toggleLabel(selected, el, instance);
+ if (this.options.updateLabel) {
+ // Option to override the dropdown label text
+ toggleText = this.options.updateLabel;
+ }
+
+ return $(this.el).find(".dropdown-toggle-text").text(toggleText);
};
GitLabDropdown.prototype.clearField = function(field, isInput) {
diff --git a/app/assets/javascripts/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js
deleted file mode 100644
index e927cc0077c..00000000000
--- a/app/assets/javascripts/issuable/issuable_bundle.js
+++ /dev/null
@@ -1 +0,0 @@
-require('./time_tracking/time_tracking_bundle');
diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js
deleted file mode 100644
index b081adf5e64..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-no-tracking-pane', {
- name: 'time-tracking-no-tracking-pane',
- template: `
- <div class='time-tracking-no-tracking-pane'>
- <span class='no-value'>No estimate or time spent</span>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js
deleted file mode 100644
index 1689a69e1ed..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-require('./components/time_tracker');
-require('../../smart_interval');
-require('../../subbable_resource');
-
-Vue.use(VueResource);
-
-(() => {
- /* This Vue instance represents what will become the parent instance for the
- * sidebar. It will be responsible for managing `issuable` state and propagating
- * changes to sidebar components. We will want to create a separate service to
- * interface with the server at that point.
- */
-
- class IssuableTimeTracking {
- constructor(issuableJSON) {
- const parsedIssuable = JSON.parse(issuableJSON);
- return this.initComponent(parsedIssuable);
- }
-
- initComponent(parsedIssuable) {
- this.parentInstance = new Vue({
- el: '#issuable-time-tracker',
- data: {
- issuable: parsedIssuable,
- },
- methods: {
- fetchIssuable() {
- return gl.IssuableResource.get.call(gl.IssuableResource, {
- type: 'GET',
- url: gl.IssuableResource.endpoint,
- });
- },
- updateState(data) {
- this.issuable = data;
- },
- subscribeToUpdates() {
- gl.IssuableResource.subscribe(data => this.updateState(data));
- },
- listenForSlashCommands() {
- $(document).on('ajax:success', '.gfm-form', (e, data) => {
- const subscribedCommands = ['spend_time', 'time_estimate'];
- const changedCommands = data.commands_changes
- ? Object.keys(data.commands_changes)
- : [];
- if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
- this.fetchIssuable();
- }
- });
- },
- },
- created() {
- this.fetchIssuable();
- },
- mounted() {
- this.subscribeToUpdates();
- this.listenForSlashCommands();
- },
- });
- }
- }
-
- gl.IssuableTimeTracking = IssuableTimeTracking;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js
index b2cfd3ef2a3..56cb536dcde 100644
--- a/app/assets/javascripts/issue_status_select.js
+++ b/app/assets/javascripts/issue_status_select.js
@@ -19,8 +19,8 @@
return label;
};
})(this),
- clicked: function(item, $el, e) {
- return e.preventDefault();
+ clicked: function(options) {
+ return options.e.preventDefault();
},
id: function(obj, el) {
return $(el).data("id");
diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js
index e0ebd36a65c..fee3429e2b8 100644
--- a/app/assets/javascripts/issues_bulk_assignment.js
+++ b/app/assets/javascripts/issues_bulk_assignment.js
@@ -88,7 +88,10 @@
const formData = {
update: {
state_event: this.form.find('input[name="update[state_event]"]').val(),
+ // For Merge Requests
assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
+ // For Issues
+ assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 9a60f5464df..ac5ce84e31b 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -330,7 +330,10 @@
},
multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(label, $el, e, isMarking) {
+ clicked: function(options) {
+ const { $el, e, isMarking } = options;
+ const label = options.selectedObj;
+
var isIssueIndex, isMRIndex, page, boardsModel;
var fadeOutLoader = () => {
$loading.fadeOut();
@@ -352,7 +355,7 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
_this.enableBulkLabelDropdown();
- _this.setDropdownData($dropdown, isMarking, this.id(label));
+ _this.setDropdownData($dropdown, isMarking, label.id);
return;
}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index be3c2c9fbb1..1b0d5fc92e3 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -158,7 +158,6 @@ import './single_file_diff';
import './smart_interval';
import './snippets_list';
import './star';
-import './subbable_resource';
import './subscription';
import './subscription_select';
import './syntax_highlight';
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index e3f367a11eb..bfc4e551168 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -31,8 +31,27 @@
toggleLabel(selected, $el) {
return $el.text();
},
+<<<<<<< HEAD
clicked: (selected, $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
},
});
});
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index bebd0aa357e..0ca7aedb5bd 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -121,7 +121,30 @@
return $value.css('display', '');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
+<<<<<<< HEAD
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;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index b98e6121967..36bc1257cef 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -58,7 +58,8 @@
});
}
- NamespaceSelect.prototype.onSelectItem = function(item, el, e) {
+ NamespaceSelect.prototype.onSelectItem = function(options) {
+ const { e } = options;
return e.preventDefault();
};
diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js
index f944fcc5a58..738e710deb9 100644
--- a/app/assets/javascripts/project.js
+++ b/app/assets/javascripts/project.js
@@ -112,7 +112,8 @@ import Cookies from 'js-cookie';
toggleLabel: function(obj, $el) {
return $el.text().trim();
},
- clicked: function(selected, $el, e) {
+ clicked: function(options) {
+ const { e } = options;
e.preventDefault();
if ($('input[name="ref"]').length) {
var $form = $dropdown.closest('form');
diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
index e7fff57ff45..1266d70f073 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -19,7 +19,10 @@
return 'Select';
}
},
- clicked(item, $el, e) {
+ clicked(opts) {
+ const { $el, e } = opts;
+ const item = opts.selectedObj;
+
e.preventDefault();
onSelect();
}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
index 1d4bb8a13d6..bc6110fcd4e 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
@@ -35,7 +35,8 @@ class ProtectedBranchDropdown {
return _.escape(protectedBranch.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (item, $el, e) => {
+ clicked: (options) => {
+ const { $el, e } = options;
e.preventDefault();
this.onSelect();
}
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
new file mode 100644
index 00000000000..a9ad3708514
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
@@ -0,0 +1,41 @@
+export default {
+ name: 'AssigneeTitle',
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ numberOfAssignees: {
+ type: Number,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ assigneeTitle() {
+ const assignees = this.numberOfAssignees;
+ return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
+ },
+ },
+ template: `
+ <div class="title hide-collapsed">
+ {{assigneeTitle}}
+ <i
+ v-if="loading"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin block-loading"
+ />
+ <a
+ v-if="editable"
+ class="edit-link pull-right"
+ href="#"
+ >
+ Edit
+ </a>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js
new file mode 100644
index 00000000000..88d7650f40a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js
@@ -0,0 +1,224 @@
+export default {
+ name: 'Assignees',
+ data() {
+ return {
+ defaultRenderCount: 5,
+ defaultMaxCounter: 99,
+ showLess: true,
+ };
+ },
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ firstUser() {
+ return this.users[0];
+ },
+ hasMoreThanTwoAssignees() {
+ return this.users.length > 2;
+ },
+ hasMoreThanOneAssignee() {
+ return this.users.length > 1;
+ },
+ hasAssignees() {
+ return this.users.length > 0;
+ },
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ hasOneUser() {
+ return this.users.length === 1;
+ },
+ renderShowMoreSection() {
+ return this.users.length > this.defaultRenderCount;
+ },
+ numberOfHiddenAssignees() {
+ return this.users.length - this.defaultRenderCount;
+ },
+ isHiddenAssignees() {
+ return this.numberOfHiddenAssignees > 0;
+ },
+ hiddenAssigneesLabel() {
+ return `+ ${this.numberOfHiddenAssignees} more`;
+ },
+ collapsedTooltipTitle() {
+ const maxRender = Math.min(this.defaultRenderCount, this.users.length);
+ const renderUsers = this.users.slice(0, maxRender);
+ const names = renderUsers.map(u => u.name);
+
+ if (this.users.length > maxRender) {
+ names.push(`+ ${this.users.length - maxRender} more`);
+ }
+
+ return names.join(', ');
+ },
+ sidebarAvatarCounter() {
+ let counter = `+${this.users.length - 1}`;
+
+ if (this.users.length > this.defaultMaxCounter) {
+ counter = `${this.defaultMaxCounter}+`;
+ }
+
+ return counter;
+ },
+ },
+ methods: {
+ assignSelf() {
+ this.$emit('assign-self');
+ },
+ toggleShowLess() {
+ this.showLess = !this.showLess;
+ },
+ renderAssignee(index) {
+ return !this.showLess || (index < this.defaultRenderCount && this.showLess);
+ },
+ avatarUrl(user) {
+ return user.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>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
new file mode 100644
index 00000000000..1488a66c695
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
@@ -0,0 +1,84 @@
+/* global Flash */
+
+import AssigneeTitle from './assignee_title';
+import Assignees from './assignees';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'SidebarAssignees',
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ loading: false,
+ field: '',
+ };
+ },
+ components: {
+ 'assignee-title': AssigneeTitle,
+ assignees: Assignees,
+ },
+ methods: {
+ assignSelf() {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
+
+ this.mediator.assignYourself();
+ this.saveAssignees();
+ },
+ saveAssignees() {
+ this.loading = true;
+
+ function setLoadingFalse() {
+ this.loading = false;
+ }
+
+ this.mediator.saveAssignees(this.field)
+ .then(setLoadingFalse.bind(this))
+ .catch(() => {
+ setLoadingFalse();
+ return new Flash('Error occurred when saving assignees');
+ });
+ },
+ },
+ created() {
+ this.removeAssignee = this.store.removeAssignee.bind(this.store);
+ this.addAssignee = this.store.addAssignee.bind(this.store);
+ this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store);
+
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeMount() {
+ this.field = this.$el.dataset.field;
+ },
+ template: `
+ <div>
+ <assignee-title
+ :number-of-assignees="store.assignees.length"
+ :loading="loading"
+ :editable="store.editable"
+ />
+ <assignees
+ class="value"
+ :root-path="store.rootPath"
+ :users="store.assignees"
+ :editable="store.editable"
+ @assign-self="assignSelf"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
new file mode 100644
index 00000000000..0da265053bd
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
@@ -0,0 +1,97 @@
+import stopwatchSvg from 'icons/_icon_stopwatch.svg';
+
+import '../../../lib/utils/pretty_time';
+
+export default {
+ name: 'time-tracking-collapsed-state',
+ props: {
+ showComparisonState: {
+ type: Boolean,
+ required: true,
+ },
+ showSpentOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showEstimateOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showNoTimeTrackingState: {
+ type: Boolean,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ timeSpent() {
+ return this.abbreviateTime(this.timeSpentHumanReadable);
+ },
+ timeEstimate() {
+ return this.abbreviateTime(this.timeEstimateHumanReadable);
+ },
+ divClass() {
+ if (this.showComparisonState) {
+ return 'compare';
+ } else if (this.showEstimateOnlyState) {
+ return 'estimate-only';
+ } else if (this.showSpentOnlyState) {
+ return 'spend-only';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-tracking';
+ }
+
+ return '';
+ },
+ spanClass() {
+ if (this.showComparisonState) {
+ return '';
+ } else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
+ return 'bold';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-value';
+ }
+
+ return '';
+ },
+ text() {
+ if (this.showComparisonState) {
+ return `${this.timeSpent} / ${this.timeEstimate}`;
+ } else if (this.showEstimateOnlyState) {
+ return `-- / ${this.timeEstimate}`;
+ } else if (this.showSpentOnlyState) {
+ return `${this.timeSpent} / --`;
+ } else if (this.showNoTimeTrackingState) {
+ return 'None';
+ }
+
+ return '';
+ },
+ },
+ methods: {
+ abbreviateTime(timeStr) {
+ return gl.utils.prettyTime.abbreviateTime(timeStr);
+ },
+ },
+ template: `
+ <div class="sidebar-collapsed-icon">
+ ${stopwatchSvg}
+ <div class="time-tracking-collapsed-summary">
+ <div :class="divClass">
+ <span :class="spanClass">
+ {{ text }}
+ </span>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
new file mode 100644
index 00000000000..40f5c89c5bb
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
@@ -0,0 +1,98 @@
+import '../../../lib/utils/pretty_time';
+
+const prettyTime = gl.utils.prettyTime;
+
+export default {
+ name: 'time-tracking-comparison-pane',
+ props: {
+ timeSpent: {
+ type: Number,
+ required: true,
+ },
+ timeEstimate: {
+ type: Number,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ parsedRemaining() {
+ const diffSeconds = this.timeEstimate - this.timeSpent;
+ return prettyTime.parseSeconds(diffSeconds);
+ },
+ timeRemainingHumanReadable() {
+ return prettyTime.stringifyTime(this.parsedRemaining);
+ },
+ timeRemainingTooltip() {
+ const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
+ return `${prefix} ${this.timeRemainingHumanReadable}`;
+ },
+ /* Diff values for comparison meter */
+ timeRemainingMinutes() {
+ return this.timeEstimate - this.timeSpent;
+ },
+ timeRemainingPercent() {
+ return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
+ },
+ timeRemainingStatusClass() {
+ return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
+ },
+ /* Parsed time values */
+ parsedEstimate() {
+ return prettyTime.parseSeconds(this.timeEstimate);
+ },
+ parsedSpent() {
+ return prettyTime.parseSeconds(this.timeSpent);
+ },
+ },
+ template: `
+ <div class="time-tracking-comparison-pane">
+ <div
+ class="compare-meter"
+ data-toggle="tooltip"
+ data-placement="top"
+ role="timeRemainingDisplay"
+ :aria-valuenow="timeRemainingTooltip"
+ :title="timeRemainingTooltip"
+ :data-original-title="timeRemainingTooltip"
+ :class="timeRemainingStatusClass"
+ >
+ <div
+ class="meter-container"
+ role="timeSpentPercent"
+ :aria-valuenow="timeRemainingPercent"
+ >
+ <div
+ :style="{ width: timeRemainingPercent }"
+ class="meter-fill"
+ />
+ </div>
+ <div class="compare-display-container">
+ <div class="compare-display pull-left">
+ <span class="compare-label">
+ Spent
+ </span>
+ <span class="compare-value spent">
+ {{ timeSpentHumanReadable }}
+ </span>
+ </div>
+ <div class="compare-display estimated pull-right">
+ <span class="compare-label">
+ Est
+ </span>
+ <span class="compare-value">
+ {{ timeEstimateHumanReadable }}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
new file mode 100644
index 00000000000..ad1b9179db0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
@@ -0,0 +1,17 @@
+export default {
+ name: 'time-tracking-estimate-only-pane',
+ props: {
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-estimate-only-pane">
+ <span class="bold">
+ Estimated:
+ </span>
+ {{ timeEstimateHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
new file mode 100644
index 00000000000..b2a77462fe0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
@@ -0,0 +1,44 @@
+export default {
+ name: 'time-tracking-help-state',
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ href() {
+ return `${this.rootPath}help/workflow/time_tracking.md`;
+ },
+ },
+ template: `
+ <div class="time-tracking-help-state">
+ <div class="time-tracking-info">
+ <h4>
+ Track time with slash commands
+ </h4>
+ <p>
+ Slash commands can be used in the issues description and comment boxes.
+ </p>
+ <p>
+ <code>
+ /estimate
+ </code>
+ will update the estimated time with the latest command.
+ </p>
+ <p>
+ <code>
+ /spend
+ </code>
+ will update the sum of the time spent.
+ </p>
+ <a
+ class="btn btn-default learn-more-button"
+ :href="href"
+ >
+ Learn more
+ </a>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
new file mode 100644
index 00000000000..d1dd1dcdd27
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
@@ -0,0 +1,10 @@
+export default {
+ name: 'time-tracking-no-tracking-pane',
+ template: `
+ <div class="time-tracking-no-tracking-pane">
+ <span class="no-value">
+ No estimate or time spent
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
new file mode 100644
index 00000000000..e2dba1fb0c2
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
@@ -0,0 +1,45 @@
+import '~/smart_interval';
+
+import timeTracker from './time_tracker';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+export default {
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ };
+ },
+ components: {
+ 'issuable-time-tracker': timeTracker,
+ },
+ methods: {
+ listenForSlashCommands() {
+ $(document).on('ajax:success', '.gfm-form', (e, data) => {
+ const subscribedCommands = ['spend_time', 'time_estimate'];
+ const changedCommands = data.commands_changes
+ ? Object.keys(data.commands_changes)
+ : [];
+ if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
+ this.mediator.fetch();
+ }
+ });
+ },
+ },
+ mounted() {
+ this.listenForSlashCommands();
+ },
+ template: `
+ <div class="block">
+ <issuable-time-tracker
+ :time_estimate="store.timeEstimate"
+ :time_spent="store.totalTimeSpent"
+ :human_time_estimate="store.humanTimeEstimate"
+ :human_time_spent="store.humanTotalTimeSpent"
+ :rootPath="store.rootPath"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
new file mode 100644
index 00000000000..bf987562647
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
@@ -0,0 +1,15 @@
+export default {
+ name: 'time-tracking-spent-only-pane',
+ props: {
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-spend-only-pane">
+ <span class="bold">Spent:</span>
+ {{ timeSpentHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
new file mode 100644
index 00000000000..ed0d71a4f79
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
@@ -0,0 +1,163 @@
+import timeTrackingHelpState from './help_state';
+import timeTrackingCollapsedState from './collapsed_state';
+import timeTrackingSpentOnlyPane from './spent_only_pane';
+import timeTrackingNoTrackingPane from './no_tracking_pane';
+import timeTrackingEstimateOnlyPane from './estimate_only_pane';
+import timeTrackingComparisonPane from './comparison_pane';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'issuable-time-tracker',
+ props: {
+ time_estimate: {
+ type: Number,
+ required: true,
+ },
+ time_spent: {
+ type: Number,
+ required: true,
+ },
+ human_time_estimate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ human_time_spent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showHelp: false,
+ };
+ },
+ components: {
+ 'time-tracking-collapsed-state': timeTrackingCollapsedState,
+ 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
+ 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
+ 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
+ 'time-tracking-comparison-pane': timeTrackingComparisonPane,
+ 'time-tracking-help-state': timeTrackingHelpState,
+ },
+ computed: {
+ timeSpent() {
+ return this.time_spent;
+ },
+ timeEstimate() {
+ return this.time_estimate;
+ },
+ timeEstimateHumanReadable() {
+ return this.human_time_estimate;
+ },
+ timeSpentHumanReadable() {
+ return this.human_time_spent;
+ },
+ hasTimeSpent() {
+ return !!this.timeSpent;
+ },
+ hasTimeEstimate() {
+ return !!this.timeEstimate;
+ },
+ showComparisonState() {
+ return this.hasTimeEstimate && this.hasTimeSpent;
+ },
+ showEstimateOnlyState() {
+ return this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showSpentOnlyState() {
+ return this.hasTimeSpent && !this.hasTimeEstimate;
+ },
+ showNoTimeTrackingState() {
+ return !this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showHelpState() {
+ return !!this.showHelp;
+ },
+ },
+ methods: {
+ toggleHelpState(show) {
+ this.showHelp = show;
+ },
+ update(data) {
+ this.time_estimate = data.time_estimate;
+ this.time_spent = data.time_spent;
+ this.human_time_estimate = data.human_time_estimate;
+ this.human_time_spent = data.human_time_spent;
+ },
+ },
+ created() {
+ eventHub.$on('timeTracker:updateData', this.update);
+ },
+ template: `
+ <div
+ class="time_tracker time-tracking-component-wrap"
+ v-cloak
+ >
+ <time-tracking-collapsed-state
+ :show-comparison-state="showComparisonState"
+ :show-no-time-tracking-state="showNoTimeTrackingState"
+ :show-help-state="showHelpState"
+ :show-spent-only-state="showSpentOnlyState"
+ :show-estimate-only-state="showEstimateOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <div class="title hide-collapsed">
+ Time tracking
+ <div
+ class="help-button pull-right"
+ v-if="!showHelpState"
+ @click="toggleHelpState(true)"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ />
+ </div>
+ <div
+ class="close-help-button pull-right"
+ v-if="showHelpState"
+ @click="toggleHelpState(false)"
+ >
+ <i
+ class="fa fa-close"
+ aria-hidden="true"
+ />
+ </div>
+ </div>
+ <div class="time-tracking-content hide-collapsed">
+ <time-tracking-estimate-only-pane
+ v-if="showEstimateOnlyState"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <time-tracking-spent-only-pane
+ v-if="showSpentOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ />
+ <time-tracking-no-tracking-pane
+ v-if="showNoTimeTrackingState"
+ />
+ <time-tracking-comparison-pane
+ v-if="showComparisonState"
+ :time-estimate="timeEstimate"
+ :time-spent="timeSpent"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <transition name="help-state-toggle">
+ <time-tracking-help-state
+ v-if="showHelpState"
+ :rootPath="rootPath"
+ />
+ </transition>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/sidebar/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
new file mode 100644
index 00000000000..5a82d01dc41
--- /dev/null
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class SidebarService {
+ constructor(endpoint) {
+ if (!SidebarService.singleton) {
+ this.endpoint = endpoint;
+
+ SidebarService.singleton = this;
+ }
+
+ return SidebarService.singleton;
+ }
+
+ get() {
+ return Vue.http.get(this.endpoint);
+ }
+
+ update(key, data) {
+ return Vue.http.put(this.endpoint, {
+ [key]: data,
+ }, {
+ emulateJSON: true,
+ });
+ }
+}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
new file mode 100644
index 00000000000..2ce53c2ed30
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
+import sidebarAssignees from './components/assignees/sidebar_assignees';
+
+import Mediator from './sidebar_mediator';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mediator = new Mediator(gl.sidebarOptions);
+ mediator.fetch();
+
+ const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
+
+ // Only create the sidebarAssignees vue app if it is found in the DOM
+ // We currently do not use sidebarAssignees for the MR page
+ if (sidebarAssigneesEl) {
+ new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
+ }
+
+ new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
+});
+
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
new file mode 100644
index 00000000000..c13f3391f0d
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -0,0 +1,38 @@
+/* global Flash */
+
+import Service from './services/sidebar_service';
+import Store from './stores/sidebar_store';
+
+export default class SidebarMediator {
+ constructor(options) {
+ if (!SidebarMediator.singleton) {
+ this.store = new Store(options);
+ this.service = new Service(options.endpoint);
+ SidebarMediator.singleton = this;
+ }
+
+ return SidebarMediator.singleton;
+ }
+
+ assignYourself() {
+ this.store.addAssignee(this.store.currentUser);
+ }
+
+ saveAssignees(field) {
+ const selected = this.store.assignees.map(u => u.id);
+
+ // If there are no ids, that means we have to unassign (which is id = 0)
+ // And it only accepts an array, hence [0]
+ return this.service.update(field, selected.length === 0 ? [0] : selected);
+ }
+
+ fetch() {
+ this.service.get()
+ .then((response) => {
+ const data = response.json();
+ this.store.processAssigneeData(data);
+ this.store.processTimeTrackingData(data);
+ })
+ .catch(() => new Flash('Error occured when fetching sidebar data'));
+ }
+}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
new file mode 100644
index 00000000000..94408c4d715
--- /dev/null
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -0,0 +1,52 @@
+export default class SidebarStore {
+ constructor(store) {
+ if (!SidebarStore.singleton) {
+ const { currentUser, rootPath, editable } = store;
+ this.currentUser = currentUser;
+ this.rootPath = rootPath;
+ this.editable = editable;
+ this.timeEstimate = 0;
+ this.totalTimeSpent = 0;
+ this.humanTimeEstimate = '';
+ this.humanTimeSpent = '';
+ this.assignees = [];
+
+ SidebarStore.singleton = this;
+ }
+
+ return SidebarStore.singleton;
+ }
+
+ 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 = [];
+ }
+}
diff --git a/app/assets/javascripts/subbable_resource.js b/app/assets/javascripts/subbable_resource.js
deleted file mode 100644
index d8191605128..00000000000
--- a/app/assets/javascripts/subbable_resource.js
+++ /dev/null
@@ -1,51 +0,0 @@
-(() => {
-/*
-* SubbableResource can be extended to provide a pubsub-style service for one-off REST
-* calls. Subscribe by passing a callback or render method you will use to handle responses.
- *
-* */
-
- class SubbableResource {
- constructor(resourcePath) {
- this.endpoint = resourcePath;
-
- // TODO: Switch to axios.create
- this.resource = $.ajax;
- this.subscribers = [];
- }
-
- subscribe(callback) {
- this.subscribers.push(callback);
- }
-
- publish(newResponse) {
- const responseCopy = _.extend({}, newResponse);
- this.subscribers.forEach((fn) => {
- fn(responseCopy);
- });
- return newResponse;
- }
-
- get(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- post(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- put(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- delete(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
- }
-
- gl.SubbableResource = SubbableResource;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js
index 8b25f43ffc7..0cd591c7320 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/subscription_select.js
@@ -19,8 +19,8 @@
return label;
};
})(this),
- clicked: function(item, $el, e) {
- return e.preventDefault();
+ clicked: function(options) {
+ return options.e.preventDefault();
},
id: function(obj, el) {
return $(el).data("id");
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 68cf9ced3ef..7828bf86f60 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */
/* global Issuable */
-/* global ListUser */
+
+import eventHub from './sidebar/event_hub';
(function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
@@ -54,42 +55,92 @@
selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null;
selectedId = $dropdown.data('selected') || selectedIdDefault;
- var updateIssueBoardsIssue = function () {
- $loading.removeClass('hidden').fadeIn();
- gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
- .then(function () {
- $loading.fadeOut();
- })
- .catch(function () {
- $loading.fadeOut();
- });
+ const assignYourself = function () {
+ const unassignedSelected = $dropdown.closest('.selectbox')
+ .find(`input[name='${$dropdown.data('field-name')}'][value=0]`);
+
+ if (unassignedSelected) {
+ unassignedSelected.remove();
+ }
+
+ // Save current selected user to the DOM
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = $dropdown.data('field-name');
+
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+
+ if (currentUserInfo) {
+ input.value = currentUserInfo.id;
+ input.dataset.meta = currentUserInfo.name;
+ } else if (_this.currentUser) {
+ input.value = _this.currentUser.id;
+ }
+
+ $dropdown.before(input);
+ };
+
+ if ($block[0]) {
+ $block[0].addEventListener('assignYourself', assignYourself);
+ }
+
+ const getSelectedUserInputs = function() {
+ return $selectbox
+ .find(`input[name="${$dropdown.data('field-name')}"]`);
+ };
+
+ const getSelected = function() {
+ return getSelectedUserInputs()
+ .map((index, input) => parseInt(input.value, 10))
+ .get();
+ };
+
+ const getMultiSelectDropdownTitle = function(selectedUser, isSelected) {
+ const selectedUsers = getSelected()
+ .filter(u => u !== 0);
+
+ const firstUser = getSelectedUserInputs()
+ .map((index, input) => ({
+ name: input.dataset.meta,
+ value: parseInt(input.value, 10),
+ }))
+ .filter(u => u.id !== 0)
+ .get(0);
+
+ if (selectedUsers.length === 0) {
+ return 'Unassigned';
+ } else if (selectedUsers.length === 1) {
+ return firstUser.name;
+ } else if (isSelected) {
+ const otherSelected = selectedUsers.filter(s => s !== selectedUser.id);
+ return `${selectedUser.name} + ${otherSelected.length} more`;
+ } else {
+ return `${firstUser.name} + ${selectedUsers.length - 1} more`;
+ }
};
$('.assign-to-me-link').on('click', (e) => {
e.preventDefault();
$(e.currentTarget).hide();
- const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
- $input.val(gon.current_user_id);
- selectedId = $input.val();
- $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
- });
-
- $block.on('click', '.js-assign-yourself', function(e) {
- e.preventDefault();
- if ($dropdown.hasClass('js-issue-board-sidebar')) {
- gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
- id: _this.currentUser.id,
- username: _this.currentUser.username,
- name: _this.currentUser.name,
- avatar_url: _this.currentUser.avatar_url
- }));
+ if ($dropdown.data('multiSelect')) {
+ assignYourself();
- updateIssueBoardsIssue();
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+ $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default');
} else {
- return assignTo(_this.currentUser.id);
+ const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
+ $input.val(gon.current_user_id);
+ selectedId = $input.val();
+ $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
}
});
+
+ $block.on('click', '.js-assign-yourself', (e) => {
+ e.preventDefault();
+ return assignTo(_this.currentUser.id);
+ });
+
assignTo = function(selected) {
var data;
data = {};
@@ -97,6 +148,7 @@
data[abilityName].assignee_id = selected != null ? selected : null;
$loading.removeClass('hidden').fadeIn();
$dropdown.trigger('loading.gl.dropdown');
+
return $.ajax({
type: 'PUT',
dataType: 'json',
@@ -106,7 +158,6 @@
var user;
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
- $selectbox.hide();
if (data.assignee) {
user = {
name: data.assignee.name,
@@ -133,51 +184,90 @@
var isAuthorFilter;
isAuthorFilter = $('.js-author-search');
return _this.users(term, options, function(users) {
- var anyUser, index, j, len, name, obj, showDivider;
- if (term.length === 0) {
- showDivider = 0;
- if (firstUser) {
- // Move current user to the front of the list
- for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
- obj = users[index];
- if (obj.username === firstUser) {
- users.splice(index, 1);
- users.unshift(obj);
- break;
- }
+ // GitLabDropdownFilter returns this.instance
+ // GitLabDropdownRemote returns this.options.instance
+ const glDropdown = this.instance || this.options.instance;
+ glDropdown.options.processData(term, users, callback);
+ }.bind(this));
+ },
+ processData: function(term, users, callback) {
+ let anyUser;
+ let index;
+ let j;
+ let len;
+ let name;
+ let obj;
+ let showDivider;
+ if (term.length === 0) {
+ showDivider = 0;
+ if (firstUser) {
+ // Move current user to the front of the list
+ for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
+ obj = users[index];
+ if (obj.username === firstUser) {
+ users.splice(index, 1);
+ users.unshift(obj);
+ break;
}
}
- if (showNullUser) {
- showDivider += 1;
- users.unshift({
- beforeDivider: true,
- name: 'Unassigned',
- id: 0
- });
- }
- if (showAnyUser) {
- showDivider += 1;
- name = showAnyUser;
- if (name === true) {
- name = 'Any User';
- }
- anyUser = {
- beforeDivider: true,
- name: name,
- id: null
- };
- users.unshift(anyUser);
+ }
+ if (showNullUser) {
+ showDivider += 1;
+ users.unshift({
+ beforeDivider: true,
+ name: 'Unassigned',
+ id: 0
+ });
+ }
+ if (showAnyUser) {
+ showDivider += 1;
+ name = showAnyUser;
+ if (name === true) {
+ name = 'Any User';
}
+ anyUser = {
+ beforeDivider: true,
+ name: name,
+ id: null
+ };
+ users.unshift(anyUser);
}
+
if (showDivider) {
- users.splice(showDivider, 0, "divider");
+ users.splice(showDivider, 0, 'divider');
}
- callback(users);
- if (showMenuAbove) {
- $dropdown.data('glDropdown').positionMenuAbove();
+ if ($dropdown.hasClass('js-multiselect')) {
+ const selected = getSelected().filter(i => i !== 0);
+
+ if (selected.length > 0) {
+ if ($dropdown.data('dropdown-header')) {
+ showDivider += 1;
+ users.splice(showDivider, 0, {
+ header: $dropdown.data('dropdown-header'),
+ });
+ }
+
+ const selectedUsers = users
+ .filter(u => selected.indexOf(u.id) !== -1)
+ .sort((a, b) => a.name > b.name);
+
+ users = users.filter(u => selected.indexOf(u.id) === -1);
+
+ selectedUsers.forEach((selectedUser) => {
+ showDivider += 1;
+ users.splice(showDivider, 0, selectedUser);
+ });
+
+ users.splice(showDivider + 1, 0, 'divider');
+ }
}
- });
+ }
+
+ callback(users);
+ if (showMenuAbove) {
+ $dropdown.data('glDropdown').positionMenuAbove();
+ }
},
filterable: true,
filterRemote: true,
@@ -186,7 +276,22 @@
},
selectable: true,
fieldName: $dropdown.data('field-name'),
- toggleLabel: function(selected, el) {
+ toggleLabel: function(selected, el, glDropdown) {
+ const inputValue = glDropdown.filterInput.val();
+
+ if (this.multiSelect && inputValue === '') {
+ // Remove non-users from the fullData array
+ const users = glDropdown.filteredFullData();
+ const callback = glDropdown.parseData.bind(glDropdown);
+
+ // Update the data model
+ this.processData(inputValue, users, callback);
+ }
+
+ if (this.multiSelect) {
+ return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active'));
+ }
+
if (selected && 'id' in selected && $(el).hasClass('is-active')) {
$dropdown.find('.dropdown-toggle-text').removeClass('is-default');
if (selected.text) {
@@ -200,15 +305,92 @@
}
},
defaultLabel: defaultLabel,
- inputId: 'issue_assignee_id',
hidden: function(e) {
- $selectbox.hide();
- // display:block overrides the hide-collapse rule
- return $value.css('display', '');
+ if ($dropdown.hasClass('js-multiselect')) {
+ eventHub.$emit('sidebar.saveAssignees');
+ }
+
+ if (!$dropdown.data('always-show-selectbox')) {
+ $selectbox.hide();
+
+ // Recalculate where .value is because vue might have changed it
+ $block = $selectbox.closest('.block');
+ $value = $block.find('.value');
+ // display:block overrides the hide-collapse rule
+ $value.css('display', '');
+ }
},
+<<<<<<< HEAD
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(user, $el, e) {
var isIssueIndex, isMRIndex, page, selected, isSelecting;
+=======
+ multiSelect: $dropdown.hasClass('js-multiselect'),
+ inputMeta: $dropdown.data('input-meta'),
+ clicked: function(options) {
+ const { $el, e, isMarking } = options;
+ const user = options.selectedObj;
+
+ if ($dropdown.hasClass('js-multiselect')) {
+ const isActive = $el.hasClass('is-active');
+ const previouslySelected = $dropdown.closest('.selectbox')
+ .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]");
+
+ // Enables support for limiting the number of users selected
+ // Automatically removes the first on the list if more users are selected
+ const maxSelect = $dropdown.data('max-select');
+ if (maxSelect) {
+ const selected = getSelected();
+
+ if (selected.length > maxSelect) {
+ const firstSelectedId = selected[0];
+ const firstSelected = $dropdown.closest('.selectbox')
+ .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`);
+
+ firstSelected.remove();
+ eventHub.$emit('sidebar.removeAssignee', {
+ id: firstSelectedId,
+ });
+ }
+ }
+
+ if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') {
+ // Unassigned selected
+ previouslySelected.each((index, element) => {
+ const id = parseInt(element.value, 10);
+ element.remove();
+ });
+ eventHub.$emit('sidebar.removeAllAssignees');
+ } else if (isActive) {
+ // user selected
+ eventHub.$emit('sidebar.addAssignee', user);
+
+ // Remove unassigned selection (if it was previously selected)
+ const unassignedSelected = $dropdown.closest('.selectbox')
+ .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
+
+ if (unassignedSelected) {
+ unassignedSelected.remove();
+ }
+ } else {
+ if (previouslySelected.length === 0) {
+ // Select unassigned because there is no more selected users
+ this.addInput($dropdown.data('field-name'), 0, {});
+ }
+
+ // User unselected
+ eventHub.$emit('sidebar.removeAssignee', user);
+ }
+
+ if (getSelected().find(u => u === gon.current_user_id)) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
+ }
+ }
+
+ var isIssueIndex, isMRIndex, page, selected;
+>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
@@ -229,6 +411,7 @@
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
+<<<<<<< HEAD
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
if (user.id && isSelecting) {
gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
@@ -243,6 +426,9 @@
updateIssueBoardsIssue();
} else {
+=======
+ } else if (!$dropdown.hasClass('js-multiselect')) {
+>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
return assignTo(selected);
}
@@ -256,29 +442,54 @@
selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault;
}
$el.find('.is-active').removeClass('is-active');
- $el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active');
+
+ function highlightSelected(id) {
+ $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active');
+ }
+
+ if ($selectbox[0]) {
+ getSelected().forEach(selectedId => highlightSelected(selectedId));
+ } else {
+ highlightSelected(selectedId);
+ }
},
+ updateLabel: $dropdown.data('dropdown-title'),
renderRow: function(user) {
- var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username;
+ var avatar, img, listClosingTags, listWithName, listWithUserName, username;
username = user.username ? "@" + user.username : "";
avatar = user.avatar_url ? user.avatar_url : false;
- selected = user.id === parseInt(selectedId, 10) ? "is-active" : "";
+
+ let selected = user.id === parseInt(selectedId, 10);
+
+ if (this.multiSelect) {
+ const fieldName = this.fieldName;
+ const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']");
+
+ if (field.length) {
+ selected = true;
+ }
+ }
+
img = "";
if (user.beforeDivider != null) {
- "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>";
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
} else {
if (avatar) {
- img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />";
+ img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
}
}
- // split into three parts so we can remove the username section if nessesary
- listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>";
- listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>";
- listClosingTags = "</a> </li>";
- if (username === '') {
- listWithUserName = '';
- }
- return listWithName + listWithUserName + listClosingTags;
+
+ return `
+ <li data-user-id=${user.id}>
+ <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'>
+ ${img}
+ <strong class='dropdown-menu-user-full-name'>
+ ${user.name}
+ </strong>
+ ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''}
+ </a>
+ </li>
+ `;
}
});
};
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index 3f5b78ed445..91c1ebd5a7d 100644
--- a/app/assets/stylesheets/framework/avatar.scss
+++ b/app/assets/stylesheets/framework/avatar.scss
@@ -93,3 +93,14 @@
align-self: center;
}
}
+
+.avatar-counter {
+ background-color: $gray-darkest;
+ color: $white-light;
+ border: 1px solid $border-color;
+ border-radius: 1em;
+ font-family: $regular_font;
+ font-size: 9px;
+ line-height: 16px;
+ text-align: center;
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 73ded9f30d4..856989bccf1 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -251,11 +251,9 @@
}
.dropdown-header {
- color: $gl-text-color;
+ color: $gl-text-color-secondary;
font-size: 13px;
- font-weight: 600;
line-height: 22px;
- text-transform: capitalize;
padding: 0 16px;
}
@@ -337,8 +335,8 @@
.dropdown-menu-user {
.avatar {
float: left;
- width: 30px;
- height: 30px;
+ width: 2 * $gl-padding;
+ height: 2 * $gl-padding;
margin: 0 10px 0 0;
}
}
@@ -381,6 +379,7 @@
.dropdown-menu-selectable {
a {
padding-left: 26px;
+ position: relative;
&.is-indeterminate,
&.is-active {
@@ -406,6 +405,9 @@
&.is-active::before {
content: "\f00c";
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
}
}
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 15dc0aa6a52..c9a25946ffd 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -255,6 +255,7 @@ ul.controls {
.avatar-inline {
margin-left: 0;
margin-right: 0;
+ margin-bottom: 0;
}
}
}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 0be1c215959..68d7ab4bf84 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -207,8 +207,13 @@
margin-bottom: 5px;
}
- &.is-active {
+ &.is-active,
+ &.is-active .card-assignee:hover a {
background-color: $row-hover;
+
+ &:first-child:not(:only-child) {
+ box-shadow: -10px 0 10px 1px $row-hover;
+ }
}
.label {
@@ -224,7 +229,7 @@
}
.card-title {
- margin: 0;
+ margin: 0 30px 0 0;
font-size: 1em;
line-height: inherit;
@@ -240,10 +245,69 @@
min-height: 20px;
.card-assignee {
- margin-left: auto;
- margin-right: 5px;
- padding-left: 10px;
+ display: flex;
+ justify-content: flex-end;
+ position: absolute;
+ right: 15px;
height: 20px;
+ width: 20px;
+
+ .avatar-counter {
+ display: none;
+ vertical-align: middle;
+ min-width: 20px;
+ line-height: 19px;
+ height: 20px;
+ padding-left: 2px;
+ padding-right: 2px;
+ border-radius: 2em;
+ }
+
+ img {
+ vertical-align: top;
+ }
+
+ a {
+ position: relative;
+ margin-left: -15px;
+ }
+
+ a:nth-child(1) {
+ z-index: 3;
+ }
+
+ a:nth-child(2) {
+ z-index: 2;
+ }
+
+ a:nth-child(3) {
+ z-index: 1;
+ }
+
+ a:nth-child(4) {
+ display: none;
+ }
+
+ &:hover {
+ .avatar-counter {
+ display: inline-block;
+ }
+
+ a {
+ position: static;
+ background-color: $white-light;
+ transition: background-color 0s;
+ margin-left: auto;
+
+ &:nth-child(4) {
+ display: block;
+ }
+
+ &:first-child:not(:only-child) {
+ box-shadow: -10px 0 10px 1px $white-light;
+ }
+ }
+ }
}
.avatar {
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index feefaad8a15..77f2638683a 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -570,14 +570,7 @@
.diff-comments-more-count,
.diff-notes-collapse {
- background-color: $gray-darkest;
- color: $white-light;
- border: 1px solid $white-light;
- border-radius: 1em;
- font-family: $regular_font;
- font-size: 9px;
- line-height: 17px;
- text-align: center;
+ @extend .avatar-counter;
}
.diff-notes-collapse {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index ad6eb9f6fe0..485ea369f3d 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -95,10 +95,15 @@
}
.right-sidebar {
- a {
+ a,
+ .btn-link {
color: inherit;
}
+ .btn-link {
+ outline: none;
+ }
+
.issuable-header-text {
margin-top: 7px;
}
@@ -215,6 +220,10 @@
}
}
+ .assign-yourself .btn-link {
+ padding-left: 0;
+ }
+
.light {
font-weight: normal;
}
@@ -239,6 +248,10 @@
margin-left: 0;
}
+ .assignee .user-list .avatar {
+ margin: 0;
+ }
+
.username {
display: block;
margin-top: 4px;
@@ -301,6 +314,10 @@
margin-top: 0;
}
+ .sidebar-avatar-counter {
+ padding-top: 2px;
+ }
+
.todo-undone {
color: $gl-link-color;
}
@@ -309,10 +326,15 @@
display: none;
}
- .avatar:hover {
+ .avatar:hover,
+ .avatar-counter:hover {
border-color: $issuable-sidebar-color;
}
+ .avatar-counter:hover {
+ color: $issuable-sidebar-color;
+ }
+
.btn-clipboard {
border: none;
color: $issuable-sidebar-color;
@@ -322,6 +344,17 @@
color: $gl-text-color;
}
}
+
+ &.multiple-users {
+ display: flex;
+ justify-content: center;
+ }
+ }
+
+ .sidebar-avatar-counter {
+ width: 24px;
+ height: 24px;
+ border-radius: 12px;
}
.sidebar-collapsed-user {
@@ -332,6 +365,37 @@
.issuable-header-btn {
display: none;
}
+
+ .multiple-users {
+ height: 24px;
+ margin-bottom: 17px;
+ margin-top: 4px;
+ padding-bottom: 4px;
+
+ .btn-link {
+ padding: 0;
+ border: 0;
+
+ .avatar {
+ margin: 0;
+ }
+ }
+
+ .btn-link:first-child {
+ position: absolute;
+ left: 10px;
+ z-index: 1;
+ }
+
+ .btn-link:last-child {
+ position: absolute;
+ right: 10px;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
}
a {
@@ -380,17 +444,21 @@
}
.participants-list {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
margin: -5px;
}
+.user-list {
+ display: flex;
+ flex-wrap: wrap;
+}
+
.participants-author {
- display: inline-block;
+ flex-basis: 14%;
padding: 5px;
- &:nth-of-type(7n) {
- padding-right: 0;
- }
-
.author_link {
display: block;
}
@@ -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-left: 5px;
- a {
+ a,
+ .btn-link {
color: $gl-text-color-secondary;
}
+
+ .btn-link {
+ outline: none;
+ padding: 0;
+ }
+
+ .btn-link:hover {
+ @extend a:hover;
+ text-decoration: none;
+ }
+
+ .btn-link:focus {
+ text-decoration: none;
+ }
}
.issuable-form-padding-top {
@@ -499,6 +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 {
padding-bottom: 0;
border-bottom: 0;