diff options
Diffstat (limited to 'app/assets/javascripts/boards')
-rw-r--r-- | app/assets/javascripts/boards/components/board_card.vue | 20 | ||||
-rw-r--r-- | app/assets/javascripts/boards/components/board_list.vue | 218 | ||||
-rw-r--r-- | app/assets/javascripts/boards/constants.js | 11 | ||||
-rw-r--r-- | app/assets/javascripts/boards/index.js | 18 | ||||
-rw-r--r-- | app/assets/javascripts/boards/models/list.js | 92 | ||||
-rw-r--r-- | app/assets/javascripts/boards/services/board_service.js | 10 | ||||
-rw-r--r-- | app/assets/javascripts/boards/stores/boards_store.js | 148 |
7 files changed, 498 insertions, 19 deletions
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index faf722f61af..12d68256598 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -42,12 +42,19 @@ export default { return { showDetail: false, detailIssue: boardsStore.detail, + multiSelect: boardsStore.multiSelect, }; }, computed: { issueDetailVisible() { return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id; }, + multiSelectVisible() { + return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1; + }, + canMultiSelect() { + return gon.features && gon.features.multiSelectBoard; + }, }, methods: { mouseDown() { @@ -58,14 +65,20 @@ export default { }, showIssue(e) { if (e.target.classList.contains('js-no-trigger')) return; - if (this.showDetail) { this.showDetail = false; + // If CMD or CTRL is clicked + const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey); + if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) { - eventHub.$emit('clearDetailIssue'); + eventHub.$emit('clearDetailIssue', isMultiSelect); + + if (isMultiSelect) { + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); + } } else { - eventHub.$emit('newDetailIssue', this.issue); + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); boardsStore.setListDetail(this.list); } } @@ -77,6 +90,7 @@ export default { <template> <li :class="{ + 'multi-select': multiSelectVisible, 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index de41698ca04..1273fcc6a91 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,12 +1,22 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ -import Sortable from 'sortablejs'; +import { Sortable, MultiDrag } from 'sortablejs'; import { GlLoadingIcon } from '@gitlab/ui'; +import _ from 'underscore'; import boardNewIssue from './board_new_issue.vue'; import boardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; -import { getBoardSortableDefaultOptions, sortableStart } from '../mixins/sortable_default_options'; +import { sprintf, __ } from '~/locale'; +import createFlash from '~/flash'; +import { + getBoardSortableDefaultOptions, + sortableStart, + sortableEnd, +} from '../mixins/sortable_default_options'; + +if (gon.features && gon.features.multiSelectBoard) { + Sortable.mount(new MultiDrag()); +} export default { name: 'BoardList', @@ -54,6 +64,14 @@ export default { showIssueForm: false, }; }, + computed: { + paginatedIssueText() { + return sprintf(__('Showing %{pageSize} of %{total} issues'), { + pageSize: this.list.issues.length, + total: this.list.issuesSize, + }); + }, + }, watch: { filters: { handler() { @@ -87,11 +105,20 @@ export default { eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, mounted() { + const multiSelectOpts = {}; + if (gon.features && gon.features.multiSelectBoard) { + multiSelectOpts.multiDrag = true; + multiSelectOpts.selectedClass = 'js-multi-select'; + multiSelectOpts.animation = 500; + } + const options = getBoardSortableDefaultOptions({ scroll: true, disabled: this.disabled, filter: '.board-list-count, .is-disabled', dataIdAttr: 'data-issue-id', + removeCloneOnHide: false, + ...multiSelectOpts, group: { name: 'issues', /** @@ -145,25 +172,66 @@ export default { card.showDetail = false; const { list } = card; + const issue = list.findIssue(Number(e.item.dataset.issueId)); + boardsStore.startMoving(list, issue); sortableStart(); }, onAdd: e => { - boardsStore.moveIssueToList( - boardsStore.moving.list, - this.list, - boardsStore.moving.issue, - e.newIndex, - ); + const { items = [], newIndicies = [] } = e; + if (items.length) { + // Not using e.newIndex here instead taking a min of all + // the newIndicies. Basically we have to find that during + // a drop what is the index we're going to start putting + // all the dropped elements from. + const newIndex = Math.min(...newIndicies.map(obj => obj.index).filter(i => i !== -1)); + const issues = items.map(item => + boardsStore.moving.list.findIssue(Number(item.dataset.issueId)), + ); - this.$nextTick(() => { - e.item.remove(); - }); + boardsStore.moveMultipleIssuesToList({ + listFrom: boardsStore.moving.list, + listTo: this.list, + issues, + newIndex, + }); + } else { + boardsStore.moveIssueToList( + boardsStore.moving.list, + this.list, + boardsStore.moving.issue, + e.newIndex, + ); + this.$nextTick(() => { + e.item.remove(); + }); + } }, onUpdate: e => { const sortedArray = this.sortable.toArray().filter(id => id !== '-1'); + + const { items = [], newIndicies = [], oldIndicies = [] } = e; + if (items.length) { + const newIndex = Math.min(...newIndicies.map(obj => obj.index)); + const issues = items.map(item => + boardsStore.moving.list.findIssue(Number(item.dataset.issueId)), + ); + boardsStore.moveMultipleIssuesInList({ + list: this.list, + issues, + oldIndicies: oldIndicies.map(obj => obj.index), + newIndex, + idArray: sortedArray, + }); + e.items.forEach(el => { + Sortable.utils.deselect(el); + }); + boardsStore.clearMultiSelect(); + return; + } + boardsStore.moveIssueInList( this.list, boardsStore.moving.issue, @@ -172,9 +240,133 @@ export default { sortedArray, ); }, + onEnd: e => { + const { items = [], clones = [], to } = e; + + // This is not a multi select operation + if (!items.length && !clones.length) { + sortableEnd(); + return; + } + + let toList; + if (to) { + const containerEl = to.closest('.js-board-list'); + toList = boardsStore.findList('id', Number(containerEl.dataset.board)); + } + + /** + * onEnd is called irrespective if the cards were moved in the + * same list or the other list. Don't remove items if it's same list. + */ + const isSameList = toList && toList.id === this.list.id; + + if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) { + const issues = items.map(item => this.list.findIssue(Number(item.dataset.issueId))); + + if (_.compact(issues).length && !boardsStore.issuesAreContiguous(this.list, issues)) { + const indexes = []; + const ids = this.list.issues.map(i => i.id); + issues.forEach(issue => { + const index = ids.indexOf(issue.id); + if (index > -1) { + indexes.push(index); + } + }); + + // Descending sort because splice would cause index discrepancy otherwise + const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1)); + + sortedIndexes.forEach(i => { + /** + * **setTimeout and splice each element one-by-one in a loop + * is intended.** + * + * The problem here is all the indexes are in the list but are + * non-contiguous. Due to that, when we splice all the indexes, + * at once, Vue -- during a re-render -- is unable to find reference + * nodes and the entire app crashes. + * + * If the indexes are contiguous, this piece of code is not + * executed. If it is, this is a possible regression. Only when + * issue indexes are far apart, this logic should ever kick in. + */ + setTimeout(() => { + this.list.issues.splice(i, 1); + }, 0); + }); + } + } + + if (!toList) { + createFlash(__('Something went wrong while performing the action.')); + } + + if (!isSameList) { + boardsStore.clearMultiSelect(); + + // Since Vue's list does not re-render the same keyed item, we'll + // remove `multi-select` class to express it's unselected + if (clones && clones.length) { + clones.forEach(el => el.classList.remove('multi-select')); + } + + // Due to some bug which I am unable to figure out + // Sortable does not deselect some pending items from the + // source list. + // We'll just do it forcefully here. + Array.from(document.querySelectorAll('.js-multi-select') || []).forEach(item => { + Sortable.utils.deselect(item); + }); + + /** + * SortableJS leaves all the moving items "as is" on the DOM. + * Vue picks up and rehydrates the DOM, but we need to explicity + * remove the "trash" items from the DOM. + * + * This is in parity to the logic on single item move from a list/in + * a list. For reference, look at the implementation of onAdd method. + */ + this.$nextTick(() => { + if (items && items.length) { + items.forEach(item => { + item.remove(); + }); + } + }); + } + sortableEnd(); + }, onMove(e) { return !e.related.classList.contains('board-list-count'); }, + onSelect(e) { + const { + item: { classList }, + } = e; + + if ( + classList && + classList.contains('js-multi-select') && + !classList.contains('multi-select') + ) { + Sortable.utils.deselect(e.item); + } + }, + onDeselect: e => { + const { + item: { dataset, classList }, + } = e; + + if ( + classList && + classList.contains('multi-select') && + !classList.contains('js-multi-select') + ) { + const issue = this.list.findIssue(Number(dataset.issueId)); + boardsStore.toggleMultiSelect(issue); + } + }, }); this.sortable = Sortable.create(this.$refs.list, options); @@ -260,7 +452,7 @@ export default { <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> - <span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span> + <span v-else>{{ paginatedIssueText }}</span> </li> </ul> </div> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js new file mode 100644 index 00000000000..3c66c7a0660 --- /dev/null +++ b/app/assets/javascripts/boards/constants.js @@ -0,0 +1,11 @@ +export const ListType = { + assignee: 'assignee', + milestone: 'milestone', + backlog: 'backlog', + closed: 'closed', + label: 'label', +}; + +export default { + ListType, +}; diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index da2669e7cde..befca70eeae 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -146,7 +146,7 @@ export default () => { updateTokens() { this.filterManager.updateTokens(); }, - updateDetailIssue(newIssue) { + updateDetailIssue(newIssue, multiSelect = false) { const { sidebarInfoEndpoint } = newIssue; if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { newIssue.setFetchingState('subscriptions', true); @@ -185,9 +185,23 @@ export default () => { }); } + if (multiSelect) { + boardsStore.toggleMultiSelect(newIssue); + + if (boardsStore.detail.issue) { + boardsStore.clearDetailIssue(); + return; + } + + return; + } + boardsStore.setIssueDetail(newIssue); }, - clearDetailIssue() { + clearDetailIssue(multiSelect = false) { + if (multiSelect) { + boardsStore.clearMultiSelect(); + } boardsStore.clearDetailIssue(); }, toggleSubscription(id) { diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index b3e56a34c28..1e213c324eb 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -5,6 +5,7 @@ import ListLabel from './label'; import ListAssignee from './assignee'; import ListIssue from 'ee_else_ce/boards/models/issue'; import { urlParamsToObject } from '~/lib/utils/common_utils'; +import flash from '~/flash'; import boardsStore from '../stores/boards_store'; import ListMilestone from './milestone'; @@ -176,6 +177,53 @@ class List { }); } + addMultipleIssues(issues, listFrom, newIndex) { + let moveBeforeId = null; + let moveAfterId = null; + + const listHasIssues = issues.every(issue => this.findIssue(issue.id)); + + if (!listHasIssues) { + if (newIndex !== undefined) { + if (this.issues[newIndex - 1]) { + moveBeforeId = this.issues[newIndex - 1].id; + } + + if (this.issues[newIndex]) { + moveAfterId = this.issues[newIndex].id; + } + + this.issues.splice(newIndex, 0, ...issues); + } else { + this.issues.push(...issues); + } + + if (this.label) { + issues.forEach(issue => issue.addLabel(this.label)); + } + + if (this.assignee) { + if (listFrom && listFrom.type === 'assignee') { + issues.forEach(issue => issue.removeAssignee(listFrom.assignee)); + } + issues.forEach(issue => issue.addAssignee(this.assignee)); + } + + if (IS_EE && this.milestone) { + if (listFrom && listFrom.type === 'milestone') { + issues.forEach(issue => issue.removeMilestone(listFrom.milestone)); + } + issues.forEach(issue => issue.addMilestone(this.milestone)); + } + + if (listFrom) { + this.issuesSize += issues.length; + + this.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId); + } + } + } + addIssue(issue, listFrom, newIndex) { let moveBeforeId = null; let moveAfterId = null; @@ -230,6 +278,23 @@ class List { }); } + moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) { + oldIndicies.reverse().forEach(index => { + this.issues.splice(index, 1); + }); + this.issues.splice(newIndex, 0, ...issues); + + gl.boardService + .moveMultipleIssues({ + ids: issues.map(issue => issue.id), + fromListId: null, + toListId: null, + moveBeforeId, + moveAfterId, + }) + .catch(() => flash(__('Something went wrong while moving issues.'))); + } + updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { gl.boardService .moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId) @@ -238,10 +303,37 @@ class List { }); } + updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) { + gl.boardService + .moveMultipleIssues({ + ids: issues.map(issue => issue.id), + fromListId: listFrom.id, + toListId: this.id, + moveBeforeId, + moveAfterId, + }) + .catch(() => flash(__('Something went wrong while moving issues.'))); + } + findIssue(id) { return this.issues.find(issue => issue.id === id); } + removeMultipleIssues(removeIssues) { + const ids = removeIssues.map(issue => issue.id); + + this.issues = this.issues.filter(issue => { + const matchesRemove = ids.includes(issue.id); + + if (matchesRemove) { + this.issuesSize -= 1; + issue.removeLabel(this.label); + } + + return !matchesRemove; + }); + } + removeIssue(removeIssue) { this.issues = this.issues.filter(issue => { const matchesRemove = removeIssue.id === issue.id; diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index 0d11db89511..03369febb4a 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -48,6 +48,16 @@ export default class BoardService { return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId); } + moveMultipleIssues({ + ids, + fromListId = null, + toListId = null, + moveBeforeId = null, + moveAfterId = null, + }) { + return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }); + } + newIssue(id, issue) { return boardsStore.newIssue(id, issue); } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 6da1cca9628..8b737d1dab0 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -11,6 +11,7 @@ import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../eventhub'; +import { ListType } from '../constants'; const boardsStore = { disabled: false, @@ -39,6 +40,7 @@ const boardsStore = { issue: {}, list: {}, }, + multiSelect: { list: [] }, setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) { const listsEndpointGenerate = `${listsEndpoint}/generate.json`; @@ -51,7 +53,6 @@ const boardsStore = { recentBoardsEndpoint: `${recentBoardsEndpoint}.json`, }; }, - create() { this.state.lists = []; this.filter.path = getUrlParamsArray().join('&'); @@ -134,6 +135,107 @@ const boardsStore = { Object.assign(this.moving, { list, issue }); }, + moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) { + const issueTo = issues.map(issue => listTo.findIssue(issue.id)); + const issueLists = _.flatten(issues.map(issue => issue.getLists())); + const listLabels = issueLists.map(list => list.label); + + const hasMoveableIssues = _.compact(issueTo).length > 0; + + if (!hasMoveableIssues) { + // Check if target list assignee is already present in this issue + if ( + listTo.type === ListType.assignee && + listFrom.type === ListType.assignee && + issues.some(issue => issue.findAssignee(listTo.assignee)) + ) { + const targetIssues = issues.map(issue => listTo.findIssue(issue.id)); + targetIssues.forEach(targetIssue => targetIssue.removeAssignee(listFrom.assignee)); + } else if (listTo.type === 'milestone') { + const currentMilestones = issues.map(issue => issue.milestone); + const currentLists = this.state.lists + .filter(list => list.type === 'milestone' && list.id !== listTo.id) + .filter(list => + list.issues.some(listIssue => issues.some(issue => listIssue.id === issue.id)), + ); + + issues.forEach(issue => { + currentMilestones.forEach(milestone => { + issue.removeMilestone(milestone); + }); + }); + + issues.forEach(issue => { + issue.addMilestone(listTo.milestone); + }); + + currentLists.forEach(currentList => { + issues.forEach(issue => { + currentList.removeIssue(issue); + }); + }); + + listTo.addMultipleIssues(issues, listFrom, newIndex); + } else { + // Add to new lists issues if it doesn't already exist + listTo.addMultipleIssues(issues, listFrom, newIndex); + } + } else { + listTo.updateMultipleIssues(issues, listFrom); + issues.forEach(issue => { + issue.removeLabel(listFrom.label); + }); + } + + if (listTo.type === ListType.closed && listFrom.type !== ListType.backlog) { + issueLists.forEach(list => { + issues.forEach(issue => { + list.removeIssue(issue); + }); + }); + + issues.forEach(issue => { + issue.removeLabels(listLabels); + }); + } else if (listTo.type === ListType.backlog && listFrom.type === ListType.assignee) { + issues.forEach(issue => { + issue.removeAssignee(listFrom.assignee); + }); + issueLists.forEach(list => { + issues.forEach(issue => { + list.removeIssue(issue); + }); + }); + } else if (listTo.type === ListType.backlog && listFrom.type === ListType.milestone) { + issues.forEach(issue => { + issue.removeMilestone(listFrom.milestone); + }); + issueLists.forEach(list => { + issues.forEach(issue => { + list.removeIssue(issue); + }); + }); + } else if ( + this.shouldRemoveIssue(listFrom, listTo) && + this.issuesAreContiguous(listFrom, issues) + ) { + listFrom.removeMultipleIssues(issues); + } + }, + + issuesAreContiguous(list, issues) { + // When there's only 1 issue selected, we can return early. + if (issues.length === 1) return true; + + // Create list of ids for issues involved. + const listIssueIds = list.issues.map(issue => issue.id); + const movedIssueIds = issues.map(issue => issue.id); + + // Check if moved issue IDs is sub-array + // of source list issue IDs (i.e. contiguous selection). + return listIssueIds.join('|').includes(movedIssueIds.join('|')); + }, + moveIssueToList(listFrom, listTo, issue, newIndex) { const issueTo = listTo.findIssue(issue.id); const issueLists = issue.getLists(); @@ -195,6 +297,17 @@ const boardsStore = { list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); }, + moveMultipleIssuesInList({ list, issues, oldIndicies, newIndex, idArray }) { + const beforeId = parseInt(idArray[newIndex - 1], 10) || null; + const afterId = parseInt(idArray[newIndex + issues.length], 10) || null; + list.moveMultipleIssues({ + issues, + oldIndicies, + newIndex, + moveBeforeId: beforeId, + moveAfterId: afterId, + }); + }, findList(key, val, type = 'label') { const filteredList = this.state.lists.filter(list => { const byType = type @@ -260,6 +373,10 @@ const boardsStore = { }`; }, + generateMultiDragPath(boardId) { + return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues/bulk_move`; + }, + all() { return axios.get(this.state.endpoints.listsEndpoint); }, @@ -309,6 +426,16 @@ const boardsStore = { }); }, + moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) { + return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), { + from_list_id: fromListId, + to_list_id: toListId, + move_before_id: moveBeforeId, + move_after_id: moveAfterId, + ids, + }); + }, + newIssue(id, issue) { return axios.post(this.generateIssuesPath(id), { issue, @@ -379,6 +506,25 @@ const boardsStore = { setCurrentBoard(board) { this.state.currentBoard = board; }, + + toggleMultiSelect(issue) { + const selectedIssueIds = this.multiSelect.list.map(issue => issue.id); + const index = selectedIssueIds.indexOf(issue.id); + + if (index === -1) { + this.multiSelect.list.push(issue); + return; + } + + this.multiSelect.list = [ + ...this.multiSelect.list.slice(0, index), + ...this.multiSelect.list.slice(index + 1), + ]; + }, + + clearMultiSelect() { + this.multiSelect.list = []; + }, }; BoardsStoreEE.initEESpecific(boardsStore); |