diff options
Diffstat (limited to 'app/assets/javascripts')
42 files changed, 1563 insertions, 276 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> + `; } }); }; |