diff options
Diffstat (limited to 'app/assets/javascripts/boards/components/board_list.vue')
-rw-r--r-- | app/assets/javascripts/boards/components/board_list.vue | 480 |
1 files changed, 137 insertions, 343 deletions
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1f87b563e73..b6e4d0980fa 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,27 +1,24 @@ <script> -import { Sortable, MultiDrag } from 'sortablejs'; +import Draggable from 'vuedraggable'; +import { mapActions, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; -import boardNewIssue from './board_new_issue.vue'; -import boardCard from './board_card.vue'; +import defaultSortableConfig from '~/sortable/sortable_config'; +import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options'; +import BoardNewIssue from './board_new_issue.vue'; +import BoardCard from './board_card.vue'; import eventHub from '../eventhub'; -import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { - getBoardSortableDefaultOptions, - sortableStart, - sortableEnd, -} from '../mixins/sortable_default_options'; - -// This component is being replaced in favor of './board_list_new.vue' for GraphQL boards - -Sortable.mount(new MultiDrag()); export default { name: 'BoardList', + i18n: { + loadingIssues: __('Loading issues'), + loadingMoreissues: __('Loading more issues'), + showingAllIssues: __('Showing all issues'), + }, components: { - boardCard, - boardNewIssue, + BoardCard, + BoardNewIssue, GlLoadingIcon, }, props: { @@ -37,55 +34,67 @@ export default { type: Array, required: true, }, + canAdminList: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { scrollOffset: 250, - filters: boardsStore.state.filters, showCount: false, showIssueForm: false, }; }, computed: { + ...mapState(['pageInfoByListId', 'listsFlags']), paginatedIssueText() { return sprintf(__('Showing %{pageSize} of %{total} issues'), { - pageSize: this.list.issues.length, - total: this.list.issuesSize, + pageSize: this.issues.length, + total: this.list.issuesCount, }); }, issuesSizeExceedsMax() { - return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; + return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount; + }, + hasNextPage() { + return this.pageInfoByListId[this.list.id].hasNextPage; }, loading() { - return this.list.loading; + return this.listsFlags[this.list.id]?.isLoading; + }, + loadingMore() { + return this.listsFlags[this.list.id]?.isLoadingMore; + }, + listRef() { + // When list is draggable, the reference to the list needs to be accessed differently + return this.canAdminList ? this.$refs.list.$el : this.$refs.list; + }, + showingAllIssues() { + return this.issues.length === this.list.issuesCount; + }, + treeRootWrapper() { + return this.canAdminList ? Draggable : 'ul'; + }, + treeRootOptions() { + const options = { + ...defaultSortableConfig, + fallbackOnBody: false, + group: 'board-list', + tag: 'ul', + 'ghost-class': 'board-card-drag-active', + 'data-list-id': this.list.id, + value: this.issues, + }; + + return this.canAdminList ? options : {}; }, }, watch: { - filters: { - handler() { - this.list.loadingMore = false; - this.$refs.list.scrollTop = 0; - }, - deep: true, - }, issues() { this.$nextTick(() => { - if ( - this.scrollHeight() <= this.listHeight() && - this.list.issuesSize > this.list.issues.length && - this.list.isExpanded - ) { - this.list.page += 1; - this.list.getIssues(false).catch(() => { - // TODO: handle request error - }); - } - - if (this.scrollHeight() > Math.ceil(this.listHeight())) { - this.showCount = true; - } else { - this.showCount = false; - } + this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); }); }, }, @@ -94,315 +103,90 @@ export default { eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, mounted() { - // TODO: Use Draggable in ./board_list_new.vue to drag & drop issue - // https://gitlab.com/gitlab-org/gitlab/-/issues/218164 - const multiSelectOpts = { - multiDrag: true, - selectedClass: 'js-multi-select', - 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', - /** - * Dynamically determine between which containers - * items can be moved or copied as - * Assignee lists (EE feature) require this behavior - */ - pull: (to, from, dragEl, e) => { - // As per Sortable's docs, `to` should provide - // reference to exact sortable container on which - // we're trying to drag element, but either it is - // a library's bug or our markup structure is too complex - // that `to` never points to correct container - // See https://github.com/RubaXa/Sortable/issues/1037 - // - // So we use `e.target` which is always accurate about - // which element we're currently dragging our card upon - // So from there, we can get reference to actual container - // and thus the container type to enable Copy or Move - if (e.target) { - const containerEl = - e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list'); - const toBoardType = containerEl.dataset.boardType; - const cloneActions = { - label: ['milestone', 'assignee'], - assignee: ['milestone', 'label'], - milestone: ['label', 'assignee'], - }; - - if (toBoardType) { - const fromBoardType = this.list.type; - // For each list we check if the destination list is - // a the list were we should clone the issue - const shouldClone = Object.entries(cloneActions).some( - entry => fromBoardType === entry[0] && entry[1].includes(toBoardType), - ); - - if (shouldClone) { - return 'clone'; - } - } - } - - return true; - }, - revertClone: true, - }, - onStart: e => { - const card = this.$refs.issue[e.oldIndex]; - - card.showDetail = false; - - const { list } = card; - - const issue = list.findIssue(Number(e.item.dataset.issueId)); - - boardsStore.startMoving(list, issue); - - this.$root.$emit('bv::hide::tooltip'); - - sortableStart(); - }, - onAdd: e => { - 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)), - ); - - 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, - e.oldIndex, - e.newIndex, - 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 ( - issues.filter(Boolean).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); - // Scroll event on list to load more - this.$refs.list.addEventListener('scroll', this.onScroll); + this.listRef.addEventListener('scroll', this.onScroll); }, beforeDestroy() { eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); - this.$refs.list.removeEventListener('scroll', this.onScroll); + this.listRef.removeEventListener('scroll', this.onScroll); }, methods: { + ...mapActions(['fetchIssuesForList', 'moveIssue']), listHeight() { - return this.$refs.list.getBoundingClientRect().height; + return this.listRef.getBoundingClientRect().height; }, scrollHeight() { - return this.$refs.list.scrollHeight; + return this.listRef.scrollHeight; }, scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); + return this.listRef.scrollTop + this.listHeight(); }, scrollToTop() { - this.$refs.list.scrollTop = 0; + this.listRef.scrollTop = 0; }, loadNextPage() { - const getIssues = this.list.nextPage(); - const loadingDone = () => { - this.list.loadingMore = false; - }; - - if (getIssues) { - this.list.loadingMore = true; - getIssues.then(loadingDone).catch(loadingDone); - } + this.fetchIssuesForList({ listId: this.list.id, fetchNext: true }); }, toggleForm() { this.showIssueForm = !this.showIssueForm; }, onScroll() { - if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) { - this.loadNextPage(); + window.requestAnimationFrame(() => { + if ( + !this.loadingMore && + this.scrollTop() > this.scrollHeight() - this.scrollOffset && + this.hasNextPage + ) { + this.loadNextPage(); + } + }); + }, + handleDragOnStart() { + sortableStart(); + }, + handleDragOnEnd(params) { + sortableEnd(); + const { newIndex, oldIndex, from, to, item } = params; + const { issueId, issueIid, issuePath } = item.dataset; + const { children } = to; + let moveBeforeId; + let moveAfterId; + + const getIssueId = (el) => Number(el.dataset.issueId); + + // If issue is being moved within the same list + if (from === to) { + if (newIndex > oldIndex && children.length > 1) { + // If issue is being moved down we look for the issue that ends up before + moveBeforeId = getIssueId(children[newIndex]); + } else if (newIndex < oldIndex && children.length > 1) { + // If issue is being moved up we look for the issue that ends up after + moveAfterId = getIssueId(children[newIndex]); + } else { + // If issue remains in the same list at the same position we do nothing + return; + } + } else { + // We look for the issue that ends up before the moved issue if it exists + if (children[newIndex - 1]) { + moveBeforeId = getIssueId(children[newIndex - 1]); + } + // We look for the issue that ends up after the moved issue if it exists + if (children[newIndex]) { + moveAfterId = getIssueId(children[newIndex]); + } } + + this.moveIssue({ + issueId, + issueIid, + issuePath, + fromListId: from.dataset.listId, + toListId: to.dataset.listId, + moveBeforeId, + moveAfterId, + }); }, }, }; @@ -410,21 +194,31 @@ export default { <template> <div - :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }" - class="board-list-component position-relative h-100" + v-show="!list.collapsed" + class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column" data-qa-selector="board_list_cards_area" > - <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')"> + <div + v-if="loading" + class="gl-mt-4 gl-text-center" + :aria-label="$options.i18n.loadingIssues" + data-testid="board_list_loading" + > <gl-loading-icon /> </div> - <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> - <ul + <board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" /> + <component + :is="treeRootWrapper" v-show="!loading" ref="list" + v-bind="treeRootOptions" :data-board="list.id" - :data-board-type="list.type" - :class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }" - class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list" + :data-board-type="list.listType" + :class="{ 'bg-danger-100': issuesSizeExceedsMax }" + class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" + data-testid="tree-root-wrapper" + @start="handleDragOnStart" + @end="handleDragOnEnd" > <board-card v-for="(issue, index) in issues" @@ -435,11 +229,11 @@ export default { :issue="issue" :disabled="disabled" /> - <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> + <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> + <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" /> + <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> - </ul> + </component> </div> </template> |