diff options
Diffstat (limited to 'app/assets/javascripts/boards')
17 files changed, 741 insertions, 138 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 3a22b06c72e..bf77aa4996c 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,18 +1,18 @@ -import { sortBy, cloneDeep } from 'lodash'; +import { sortBy, cloneDeep, find, inRange } from 'lodash'; import { TYPENAME_BOARD, TYPENAME_ITERATION, TYPENAME_MILESTONE, TYPENAME_USER, } from '~/graphql_shared/constants'; -import { isGid, convertToGraphQLId } from '~/graphql_shared/utils'; +import { isGid, convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType, boardQuery, -} from './constants'; +} from 'ee_else_ce/boards/constants'; export function getMilestone() { return null; @@ -30,6 +30,17 @@ export function updateListPosition(listObj) { return { ...listObj, position }; } +export function calculateNewPosition(listPosition, initialPosition, targetPosition) { + if ( + listPosition === null || + !(inRange(listPosition, initialPosition, targetPosition) || listPosition === targetPosition) + ) { + return listPosition; + } + const offset = initialPosition < targetPosition ? -1 : 1; + return listPosition + offset; +} + export function formatBoardLists(lists) { return lists.nodes.reduce((map, list) => { return { @@ -191,6 +202,38 @@ export function moveItemListHelper(item, fromList, toList) { return updatedItem; } +export function moveItemVariables({ + iid, + epicId, + fromListId, + toListId, + moveBeforeId, + moveAfterId, + isIssue, + boardId, + itemToMove, +}) { + if (isIssue) { + return { + iid, + boardId, + projectPath: itemToMove.referencePath.split(/[#]/)[0], + moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined, + moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined, + fromListId: getIdFromGraphQLId(fromListId), + toListId: getIdFromGraphQLId(toListId), + }; + } + return { + epicId, + boardId, + moveBeforeId, + moveAfterId, + fromListId, + toListId, + }; +} + export function isListDraggable(list) { return list.listType !== ListType.backlog && list.listType !== ListType.closed; } @@ -318,6 +361,13 @@ export function getBoardQuery(boardType) { return boardQuery[boardType].query; } +export function getListByTypeId(lists, type, id) { + // type can be assignee/label/milestone/iteration + if (type && id) return find(lists, (l) => l.listType === ListType[type] && l[type]?.id === id); + + return null; +} + export default { getMilestone, formatIssue, diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue index 90f7059da86..985b9798b36 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -1,4 +1,6 @@ <script> +import produce from 'immer'; +import { debounce } from 'lodash'; import { GlTooltipDirective as GlTooltip, GlButton, @@ -6,8 +8,12 @@ import { GlIcon, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; import { __ } from '~/locale'; +import { createListMutations, listsQuery, BoardType, ListType } from 'ee_else_ce/boards/constants'; +import boardLabelsQuery from '../graphql/board_labels.query.graphql'; +import { getListByTypeId } from '../boards_util'; export default { i18n: { @@ -23,60 +29,150 @@ export default { directives: { GlTooltip, }, - inject: ['scopedLabelsAvailable'], + inject: ['scopedLabelsAvailable', 'issuableType', 'fullPath', 'boardType', 'isApolloBoard'], + props: { + listQueryVariables: { + type: Object, + required: true, + }, + boardId: { + type: String, + required: true, + }, + lists: { + type: Object, + required: true, + }, + }, data() { return { selectedId: null, selectedLabel: null, selectedIdValid: true, + labelsApollo: [], + searchTerm: '', }; }, + apollo: { + labelsApollo: { + query: boardLabelsQuery, + variables() { + return { + fullPath: this.fullPath, + searchTerm: this.searchTerm, + isGroup: this.boardType === BoardType.group, + isProject: this.boardType === BoardType.project, + }; + }, + update(data) { + return data[this.boardType].labels.nodes; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, computed: { ...mapState(['labels', 'labelsLoading']), ...mapGetters(['getListByLabelId']), + labelsToUse() { + return this.isApolloBoard ? this.labelsApollo : this.labels; + }, + isLabelsLoading() { + return this.isApolloBoard ? this.$apollo.queries.labelsApollo.loading : this.labelsLoading; + }, columnForSelected() { + if (this.isApolloBoard) { + return getListByTypeId(this.lists, ListType.label, this.selectedId); + } return this.getListByLabelId(this.selectedId); }, items() { - return ( - this.labels.map((i) => ({ - ...i, - text: i.title, - value: i.id, - })) || [] - ); + return (this.labelsToUse || []).map((i) => ({ + ...i, + text: i.title, + value: i.id, + })); }, }, created() { - this.filterItems(); + if (!this.isApolloBoard) { + this.filterItems(); + } }, methods: { - ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']), + ...mapActions(['createList', 'fetchLabels', 'highlightList']), + createListApollo({ labelId }) { + return this.$apollo.mutate({ + mutation: createListMutations[this.issuableType].mutation, + variables: { + labelId, + boardId: this.boardId, + }, + update: ( + store, + { + data: { + boardListCreate: { list }, + }, + }, + ) => { + const sourceData = store.readQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + }); + const data = produce(sourceData, (draftData) => { + draftData[this.boardType].board.lists.nodes.push(list); + }); + store.writeQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + data, + }); + this.$emit('highlight-list', list.id); + }, + }); + }, addList() { if (!this.selectedLabel) { this.selectedIdValid = false; return; } - this.setAddColumnFormVisibility(false); - if (this.columnForSelected) { const listId = this.columnForSelected.id; - this.highlightList(listId); + if (this.isApolloBoard) { + this.$emit('highlight-list', listId); + } else { + this.highlightList(listId); + } return; } - this.createList({ labelId: this.selectedId }); + if (this.isApolloBoard) { + this.createListApollo({ labelId: this.selectedId }); + } else { + this.createList({ labelId: this.selectedId }); + } + + this.$emit('setAddColumnFormVisibility', false); }, filterItems(searchTerm) { this.fetchLabels(searchTerm); }, + onSearch: debounce(function debouncedSearch(searchTerm) { + this.searchTerm = searchTerm; + if (!this.isApolloBoard) { + this.filterItems(searchTerm); + } + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + setSelectedItem(selectedId) { this.selectedId = selectedId; - const label = this.labels.find(({ id }) => id === selectedId); + const label = this.labelsToUse.find(({ id }) => id === selectedId); if (!selectedId || !label) { this.selectedLabel = null; } else { @@ -95,8 +191,8 @@ export default { <template> <board-add-new-column-form :selected-id-valid="selectedIdValid" - @filter-items="filterItems" @add-list="addList" + @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)" > <template #dropdown> <gl-collapsible-listbox @@ -104,11 +200,11 @@ export default { :items="items" searchable :search-placeholder="__('Search labels')" - :searching="labelsLoading" + :searching="isLabelsLoading" :selected="selectedId" :no-results-text="$options.i18n.noResults" @select="setSelectedItem" - @search="filterItems" + @search="onSearch" @hidden="onHide" > <template #toggle> diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue index 259423df07f..419d0b41d69 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlFormGroup } from '@gitlab/ui'; -import { mapActions } from 'vuex'; import { __ } from '~/locale'; export default { @@ -33,7 +32,6 @@ export default { }; }, methods: { - ...mapActions(['setAddColumnFormVisibility']), onSubmit() { this.$emit('add-list'); }, @@ -83,9 +81,11 @@ export default { @click="onSubmit" >{{ $options.i18n.add }}</gl-button > - <gl-button data-testid="cancelAddNewColumn" @click="setAddColumnFormVisibility(false)">{{ - $options.i18n.cancel - }}</gl-button> + <gl-button + data-testid="cancelAddNewColumn" + @click="$emit('setAddColumnFormVisibility', false)" + >{{ $options.i18n.cancel }}</gl-button + > </div> </div> </div> diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue index 14c84d3c4e5..d91c8ab4727 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; import Tracking from '~/tracking'; @@ -12,16 +11,20 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [Tracking.mixin()], + props: { + isNewListShowing: { + type: Boolean, + required: true, + }, + }, computed: { - ...mapState({ isNewListShowing: ({ addColumnForm }) => addColumnForm.visible }), tooltip() { return this.isNewListShowing ? __('The list creation wizard is already open') : ''; }, }, methods: { - ...mapActions(['setAddColumnFormVisibility']), handleClick() { - this.setAddColumnFormVisibility(true); + this.$emit('setAddColumnFormVisibility', true); this.track('click_button', { label: 'create_list' }); }, }, diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 3a247819850..0b9243c07c5 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -35,6 +35,7 @@ export default { activeListId: '', boardId: this.initialBoardId, filterParams: { ...this.initialFilterParams }, + addColumnFormVisible: false, isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by), apolloError: null, }; @@ -79,6 +80,7 @@ export default { computed: { ...mapGetters(['isSidebarOpen']), listQueryVariables() { + if (this.filterParams.groupBy) delete this.filterParams.groupBy; return { ...(this.isIssueBoard && { isGroup: this.isGroupBoard, @@ -129,19 +131,24 @@ export default { <div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }"> <board-top-bar :board-id="boardId" + :add-column-form-visible="addColumnFormVisible" :is-swimlanes-on="isSwimlanesOn" @switchBoard="switchBoard" @setFilters="setFilters" + @setAddColumnFormVisibility="addColumnFormVisible = $event" @toggleSwimlanes="isShowingEpicsSwimlanes = $event" /> <board-content v-if="!isApolloBoard || boardListsApollo" :board-id="boardId" + :add-column-form-visible="addColumnFormVisible" :is-swimlanes-on="isSwimlanesOn" :filter-params="filterParams" :board-lists-apollo="boardListsApollo" :apollo-error="apolloError" + :list-query-variables="listQueryVariables" @setActiveList="setActiveId" + @setAddColumnFormVisibility="addColumnFormVisible = $event" /> <board-settings-sidebar v-if="!isApolloBoard || activeList" diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue index f58f7838576..19eddbfdd68 100644 --- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue +++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue @@ -14,6 +14,7 @@ export default { GlDisclosureDropdown, }, mixins: [Tracking.mixin()], + inject: ['isApolloBoard'], props: { item: { type: Object, @@ -83,16 +84,20 @@ export default { }); }, moveToPosition({ positionInList }) { - this.moveItem({ - itemId: this.item.id, - itemIid: this.item.iid, - itemPath: this.item.referencePath, - fromListId: this.list.id, - toListId: this.list.id, - positionInList, - atIndex: this.index, - allItemsLoadedInList: !this.listHasNextPage, - }); + if (this.isApolloBoard) { + this.$emit('moveToPosition', positionInList); + } else { + this.moveItem({ + itemId: this.item.id, + itemIid: this.item.iid, + itemPath: this.item.referencePath, + fromListId: this.list.id, + toListId: this.list.id, + positionInList, + atIndex: this.index, + allItemsLoadedInList: !this.listHasNextPage, + }); + } }, selectMoveAction({ text }) { if (text === BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION) { diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index b2054d76e95..2ee0b4593d6 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -24,12 +24,20 @@ export default { type: Object, required: true, }, + highlightedListsApollo: { + type: Array, + required: false, + default: () => [], + }, }, computed: { ...mapState(['filterParams', 'highlightedLists']), ...mapGetters(['getBoardItemsByList']), + highlightedListsToUse() { + return this.isApolloBoard ? this.highlightedListsApollo : this.highlightedLists; + }, highlighted() { - return this.highlightedLists.includes(this.list.id); + return this.highlightedListsToUse.includes(this.list.id); }, listItems() { return this.isApolloBoard ? [] : this.getBoardItemsByList(this.list.id); diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 8304dfef527..a51e4ddc8f8 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,12 +1,19 @@ <script> import { GlAlert } from '@gitlab/ui'; import { sortBy } from 'lodash'; +import produce from 'immer'; import Draggable from 'vuedraggable'; import { mapState, mapActions } from 'vuex'; import eventHub from '~/boards/eventhub'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import { defaultSortableOptions } from '~/sortable/constants'; -import { DraggableItemTypes } from 'ee_else_ce/boards/constants'; +import { + DraggableItemTypes, + flashAnimationDuration, + listsQuery, + updateListQueries, +} from 'ee_else_ce/boards/constants'; +import { calculateNewPosition } from 'ee_else_ce/boards/boards_util'; import BoardColumn from './board_column.vue'; export default { @@ -20,7 +27,15 @@ export default { EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, }, - inject: ['canAdminList', 'isIssueBoard', 'isEpicBoard', 'disabled', 'isApolloBoard'], + inject: [ + 'boardType', + 'canAdminList', + 'isIssueBoard', + 'isEpicBoard', + 'disabled', + 'issuableType', + 'isApolloBoard', + ], props: { boardId: { type: String, @@ -44,16 +59,25 @@ export default { required: false, default: null, }, + listQueryVariables: { + type: Object, + required: true, + }, + addColumnFormVisible: { + type: Boolean, + required: true, + }, }, data() { return { boardHeight: null, + highlightedLists: [], }; }, computed: { - ...mapState(['boardLists', 'error', 'addColumnForm']), - addColumnFormVisible() { - return this.addColumnForm?.visible; + ...mapState(['boardLists', 'error']), + boardListsById() { + return this.isApolloBoard ? this.boardListsApollo : this.boardLists; }, boardListsToUse() { const lists = this.isApolloBoard ? this.boardListsApollo : this.boardLists; @@ -101,6 +125,90 @@ export default { refetchLists() { this.$apollo.queries.boardListsApollo.refetch(); }, + highlightList(listId) { + this.highlightedLists.push(listId); + + setTimeout(() => { + this.highlightedLists = this.highlightedLists.filter((id) => id !== listId); + }, flashAnimationDuration); + }, + updateListPosition({ + item: { + dataset: { listId: movedListId, draggableItemType }, + }, + newIndex, + to: { children }, + }) { + if (!this.isApolloBoard) { + this.moveList({ + item: { + dataset: { listId: movedListId, draggableItemType }, + }, + newIndex, + to: { children }, + }); + return; + } + + if (draggableItemType !== DraggableItemTypes.list) { + return; + } + + const displacedListId = children[newIndex].dataset.listId; + + if (movedListId === displacedListId) { + return; + } + const initialPosition = this.boardListsById[movedListId].position; + const targetPosition = this.boardListsById[displacedListId].position; + + try { + this.$apollo.mutate({ + mutation: updateListQueries[this.issuableType].mutation, + variables: { + listId: movedListId, + position: targetPosition, + }, + update: (store) => { + const sourceData = store.readQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + }); + const data = produce(sourceData, (draftData) => { + // for current list, new position is already set by Apollo via automatic update + const affectedNodes = draftData[this.boardType].board.lists.nodes.filter( + (node) => node.id !== movedListId, + ); + affectedNodes.forEach((node) => { + // eslint-disable-next-line no-param-reassign + node.position = calculateNewPosition( + node.position, + initialPosition, + targetPosition, + ); + }); + }); + store.writeQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + data, + }); + }, + optimisticResponse: { + updateBoardList: { + __typename: 'UpdateBoardListPayload', + errors: [], + list: { + ...this.boardListsApollo[movedListId], + position: targetPosition, + }, + }, + }, + }); + } catch { + // handle error + } + }, }, }; </script> @@ -120,7 +228,7 @@ export default { ref="list" v-bind="draggableOptions" class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-auto" - @end="moveList" + @end="updateListPosition" > <board-column v-for="(list, index) in boardListsToUse" @@ -129,13 +237,22 @@ export default { :board-id="boardId" :list="list" :filters="filterParams" + :highlighted-lists-apollo="highlightedLists" :data-draggable-item-type="$options.draggableItemTypes.list" - :class="{ 'gl-xs-display-none!': addColumnFormVisible }" + :class="{ 'gl-display-none! gl-sm-display-inline-block!': addColumnFormVisible }" @setActiveList="$emit('setActiveList', $event)" /> <transition name="slide" @after-enter="afterFormEnters"> - <board-add-new-column v-if="addColumnFormVisible" class="gl-xs-w-full!" /> + <board-add-new-column + v-if="addColumnFormVisible" + class="gl-xs-w-full!" + :board-id="boardId" + :list-query-variables="listQueryVariables" + :lists="boardListsById" + @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)" + @highlight-list="highlightList" + /> </transition> </component> @@ -146,8 +263,21 @@ export default { :lists="boardListsToUse" :can-admin-list="canAdminList" :filters="filterParams" + :highlighted-lists="highlightedLists" @setActiveList="$emit('setActiveList', $event)" - /> + @move-list="updateListPosition" + > + <board-add-new-column + v-if="addColumnFormVisible" + class="gl-sticky gl-top-5" + :filter-params="filterParams" + :list-query-variables="listQueryVariables" + :board-id="boardId" + :lists="boardListsById" + @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)" + @highlight-list="highlightList" + /> + </epics-swimlanes> <board-content-sidebar v-if="isIssueBoard" data-testid="issue-boards-sidebar" /> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 5f082066ad4..af309ba9912 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -9,6 +9,7 @@ import { sortableStart, sortableEnd } from '~/sortable/utils'; import Tracking from '~/tracking'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT_BOARD_LIST_ITEMS_SIZE, toggleFormEventPrefix, @@ -16,6 +17,13 @@ import { listIssuablesQueries, ListType, } from 'ee_else_ce/boards/constants'; +import { + addItemToList, + removeItemFromList, + updateEpicsCount, + updateIssueCountAndWeight, +} from '../graphql/cache_updates'; +import { shouldCloneCard, moveItemVariables } from '../boards_util'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; @@ -37,7 +45,7 @@ export default { GlIntersectionObserver, BoardCardMoveToPosition, }, - mixins: [Tracking.mixin()], + mixins: [Tracking.mixin(), glFeatureFlagMixin()], inject: [ 'isEpicBoard', 'isGroupBoard', @@ -73,6 +81,8 @@ export default { showEpicForm: false, currentList: null, isLoadingMore: false, + toListId: null, + toList: {}, }; }, apollo: { @@ -111,6 +121,29 @@ export default { isSingleRequest: true, }, }, + toList: { + query() { + return listIssuablesQueries[this.issuableType].query; + }, + variables() { + return { + id: this.toListId, + ...this.listQueryVariables, + }; + }, + skip() { + return !this.toListId; + }, + update(data) { + return data[this.boardType].board.lists.nodes[0]; + }, + context: { + isSingleRequest: true, + }, + error() { + // handle error + }, + }, }, computed: { ...mapState(['pageInfoByListId', 'listsFlags', 'isUpdateIssueOrderInProgress']), @@ -205,6 +238,9 @@ export default { showMoveToPosition() { return !this.disabled && this.list.listType !== ListType.closed; }, + shouldCloneCard() { + return shouldCloneCard(this.list.listType, this.toList.listType); + }, }, watch: { boardListItems() { @@ -337,14 +373,169 @@ export default { } } - this.moveItem({ - itemId, - itemIid, - itemPath, - fromListId: from.dataset.listId, - toListId: to.dataset.listId, - moveBeforeId, - moveAfterId, + if (this.isApolloBoard) { + this.moveBoardItem( + { + epicId: itemId, + iid: itemIid, + fromListId: from.dataset.listId, + toListId: to.dataset.listId, + moveBeforeId, + moveAfterId, + }, + newIndex, + ); + } else { + this.moveItem({ + itemId, + itemIid, + itemPath, + fromListId: from.dataset.listId, + toListId: to.dataset.listId, + moveBeforeId, + moveAfterId, + }); + } + }, + isItemInTheList(itemIid) { + const items = this.toList?.[`${this.issuableType}s`]?.nodes || []; + return items.some((item) => item.iid === itemIid); + }, + async moveBoardItem(variables, newIndex) { + const { fromListId, toListId, iid } = variables; + this.toListId = toListId; + await this.$nextTick(); // we need this next tick to retrieve `toList` from Apollo cache + + const itemToMove = this.boardListItems.find((item) => item.iid === iid); + + if (this.shouldCloneCard && this.isItemInTheList(iid)) { + return; + } + + try { + await this.$apollo.mutate({ + mutation: listIssuablesQueries[this.issuableType].moveMutation, + variables: { + ...moveItemVariables({ + ...variables, + isIssue: !this.isEpicBoard, + boardId: this.boardId, + itemToMove, + }), + withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight, + }, + update: (cache, { data: { issuableMoveList } }) => + this.updateCacheAfterMovingItem({ + issuableMoveList, + fromListId, + toListId, + newIndex, + cache, + }), + optimisticResponse: { + issuableMoveList: { + issuable: itemToMove, + errors: [], + }, + }, + }); + } catch { + // handle error + } + }, + updateCacheAfterMovingItem({ issuableMoveList, fromListId, toListId, newIndex, cache }) { + const { issuable } = issuableMoveList; + if (!this.shouldCloneCard) { + removeItemFromList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: fromListId }, + boardType: this.boardType, + id: issuable.id, + issuableType: this.issuableType, + cache, + }); + } + + addItemToList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: toListId }, + issuable, + newIndex, + boardType: this.boardType, + issuableType: this.issuableType, + cache, + }); + + this.updateCountAndWeight({ fromListId, toListId, issuable, cache }); + }, + updateCountAndWeight({ fromListId, toListId, issuable, isAddingIssue, cache }) { + if (!this.isEpicBoard) { + updateIssueCountAndWeight({ + fromListId, + toListId, + filterParams: this.filterParams, + issuable, + shouldClone: isAddingIssue || this.shouldCloneCard, + cache, + }); + } else { + const { issuableType, filterParams } = this; + updateEpicsCount({ + issuableType, + toListId, + fromListId, + filterParams, + issuable, + shouldClone: this.shouldCloneCard, + cache, + }); + } + }, + moveToPosition(positionInList, oldIndex, item) { + this.$apollo.mutate({ + mutation: listIssuablesQueries[this.issuableType].moveMutation, + variables: { + ...moveItemVariables({ + iid: item.iid, + epicId: item.id, + fromListId: this.currentList.id, + toListId: this.currentList.id, + isIssue: !this.isEpicBoard, + boardId: this.boardId, + itemToMove: item, + }), + positionInList, + withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight, + }, + optimisticResponse: { + issuableMoveList: { + issuable: item, + errors: [], + }, + }, + update: (cache, { data: { issuableMoveList } }) => { + const { issuable } = issuableMoveList; + removeItemFromList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: this.currentList.id }, + boardType: this.boardType, + id: issuable.id, + issuableType: this.issuableType, + cache, + }); + if (positionInList === 0 || this.listItemsCount <= this.boardListItems.length) { + const newIndex = positionInList === 0 ? 0 : this.boardListItems.length - 1; + addItemToList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: this.currentList.id }, + issuable, + newIndex, + boardType: this.boardType, + issuableType: this.issuableType, + cache, + }); + } + }, }); }, }, @@ -401,6 +592,7 @@ export default { :index="index" :list="list" :list-items-length="boardListItems.length" + @moveToPosition="moveToPosition($event, index, item)" /> <gl-intersection-observer v-if="isObservableItem(index)" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 1b711feb686..61a9b22bfc5 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -108,6 +108,9 @@ export default { listType() { return this.list.listType; }, + isLabelList() { + return this.listType === ListType.label; + }, itemsCount() { return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount; }, @@ -258,9 +261,6 @@ export default { }, methods: { ...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']), - closeListActions() { - this.$refs.headerListActions?.close(); - }, openSidebarSettings() { if (this.activeId === inactiveId) { sidebarEventHub.$emit('sidebar.closeAll'); @@ -277,8 +277,6 @@ export default { } this.track('click_button', { label: 'list_settings' }); - - this.closeListActions(); }, showScopedLabels(label) { return this.scopedLabelsAvailable && isScopedLabel(label); @@ -292,13 +290,9 @@ export default { } else { eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); } - - this.closeListActions(); }, showNewEpicForm() { eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`); - - this.closeListActions(); }, toggleExpanded() { const collapsed = !this.list.collapsed; @@ -382,7 +376,8 @@ export default { <header :class="{ 'gl-h-full': list.collapsed, - 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base gl-bg-gray-50': isSwimlanesHeader, + 'board-inner gl-bg-gray-50': isSwimlanesHeader, + 'gl-border-t-solid gl-border-4 gl-rounded-top-left-base gl-rounded-top-right-base': isLabelList, }" :style="headerStyle" class="board-header gl-relative" @@ -532,7 +527,6 @@ export default { </div> <gl-disclosure-dropdown v-if="showListHeaderActions" - ref="headerListActions" v-gl-tooltip.hover.top="{ title: $options.i18n.listActions, boundary: 'viewport', diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 23e0f2510a7..0f43aae3936 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -114,10 +114,10 @@ export default { showScopedLabels(label) { return this.scopedLabelsAvailable && isScopedLabel(label); }, - async deleteBoardList() { + deleteBoardList() { this.track('click_button', { label: 'remove_list' }); if (this.isApolloBoard) { - await this.deleteList(this.activeListId); + this.deleteList(this.activeListId); } else { this.removeList(this.activeId); } @@ -157,7 +157,7 @@ export default { <mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append> <gl-drawer v-bind="$attrs" - class="js-board-settings-sidebar gl-absolute" + class="js-board-settings-sidebar gl-absolute boards-sidebar" :open="showSidebar" variant="sidebar" @close="unsetActiveListId" diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index c186346b2ac..fd9043a561f 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -35,6 +35,10 @@ export default { type: String, required: true, }, + addColumnFormVisible: { + type: Boolean, + required: true, + }, isSwimlanesOn: { type: Boolean, required: true, @@ -91,7 +95,7 @@ export default { class="issues-details-filters filtered-search-block gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row row-content-block second-block" > <div - class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full" + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full gl-min-w-0" > <boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" /> <new-board-button /> @@ -117,7 +121,11 @@ export default { @toggleSwimlanes="$emit('toggleSwimlanes', $event)" /> <config-toggle :board-has-scope="hasScope" /> - <board-add-new-column-trigger v-if="canAdminList" /> + <board-add-new-column-trigger + v-if="canAdminList" + :is-new-list-showing="addColumnFormVisible" + @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)" + /> <toggle-focus /> </div> </div> diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 247910301e7..960c8e472b8 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,15 +1,10 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlIntersectionObserver, - GlLoadingIcon, -} from '@gitlab/ui'; -import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import { debounce } from 'lodash'; import { s__ } from '~/locale'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { ListType } from '../constants'; export default { @@ -27,12 +22,7 @@ export default { order_by: 'similarity', }, components: { - GlIntersectionObserver, - GlLoadingIcon, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, }, inject: ['groupId'], props: { @@ -44,6 +34,7 @@ export default { data() { return { initialLoading: true, + selectedProjectId: '', selectedProject: {}, searchTerm: '', }; @@ -51,6 +42,12 @@ export default { computed: { ...mapState(['groupProjectsFlags']), ...mapGetters(['activeGroupProjects']), + projects() { + return this.activeGroupProjects.map((project) => ({ + value: project.id, + text: project.nameWithNamespace, + })); + }, selectedProjectName() { return this.selectedProject.name || this.$options.i18n.dropdownText; }, @@ -73,26 +70,27 @@ export default { }, }, watch: { - searchTerm() { + searchTerm: debounce(function debouncedSearch() { this.fetchGroupProjects({ search: this.searchTerm }); - }, + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), }, mounted() { this.fetchGroupProjects({}); - this.initialLoading = false; }, methods: { ...mapActions(['fetchGroupProjects', 'setSelectedProject']), selectProject(projectId) { + this.selectedProjectId = projectId; this.selectedProject = this.activeGroupProjects.find((project) => project.id === projectId); this.setSelectedProject(this.selectedProject); }, loadMoreProjects() { + if (!this.hasNextPage) return; this.fetchGroupProjects({ search: this.searchTerm, fetchNext: true }); }, - setFocus() { - this.$refs.search.focusInput(); + onSearch(query) { + this.searchTerm = query; }, }, }; @@ -103,45 +101,23 @@ export default { <label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{ $options.i18n.headerTitle }}</label> - <gl-dropdown + <gl-collapsible-listbox + v-model="selectedProjectId" + block + searchable + infinite-scroll data-testid="project-select-dropdown" - :text="selectedProjectName" + :items="projects" + :toggle-text="selectedProjectName" :header-text="$options.i18n.headerTitle" - block - menu-class="gl-w-full!" :loading="initialLoading" - @shown="setFocus" - > - <gl-search-box-by-type - ref="search" - v-model.trim="searchTerm" - debounce="250" - :placeholder="$options.i18n.searchPlaceholder" - /> - <gl-dropdown-item - v-for="project in activeGroupProjects" - v-show="!groupProjectsFlags.isLoading" - :key="project.id" - :name="project.name" - @click="selectProject(project.id)" - > - {{ project.nameWithNamespace }} - </gl-dropdown-item> - <gl-dropdown-text - v-show="groupProjectsFlags.isLoading" - data-testid="dropdown-text-loading-icon" - > - <gl-loading-icon class="gl-mx-auto" size="sm" /> - </gl-dropdown-text> - <gl-dropdown-text - v-if="isFetchResultEmpty && !groupProjectsFlags.isLoading" - data-testid="empty-result-message" - > - <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> - </gl-dropdown-text> - <gl-intersection-observer v-if="hasNextPage" @appear="loadMoreProjects"> - <gl-loading-icon v-if="groupProjectsFlags.isLoadingMore" size="lg" /> - </gl-intersection-observer> - </gl-dropdown> + :searching="groupProjectsFlags.isLoading" + :search-placeholder="$options.i18n.searchPlaceholder" + :no-results-text="$options.i18n.emptySearchResult" + :infinite-scroll-loading="groupProjectsFlags.isLoadingMore" + @select="selectProject" + @search="onSearch" + @bottom-reached="loadMoreProjects" + /> </div> </template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 7fe89ffbb52..d4d1bc7804e 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -3,15 +3,18 @@ import { TYPE_EPIC, TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/iss import { s__, __ } from '~/locale'; import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; +import createBoardListMutation from './graphql/board_list_create.mutation.graphql'; import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql'; import updateBoardListMutation from './graphql/board_list_update.mutation.graphql'; import toggleListCollapsedMutation from './graphql/client/board_toggle_collapsed.mutation.graphql'; import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; +import issueMoveListMutation from './graphql/issue_move_list.mutation.graphql'; import groupBoardQuery from './graphql/group_board.query.graphql'; import projectBoardQuery from './graphql/project_board.query.graphql'; import listIssuesQuery from './graphql/lists_issues.query.graphql'; +import listDeferredQuery from './graphql/board_lists_deferred.query.graphql'; export const BoardType = { project: 'project', @@ -71,6 +74,18 @@ export const listsQuery = { }, }; +export const listsDeferredQuery = { + [TYPE_ISSUE]: { + query: listDeferredQuery, + }, +}; + +export const createListMutations = { + [TYPE_ISSUE]: { + mutation: createBoardListMutation, + }, +}; + export const updateListQueries = { [TYPE_ISSUE]: { mutation: updateBoardListMutation, @@ -110,6 +125,7 @@ export const subscriptionQueries = { export const listIssuablesQueries = { [TYPE_ISSUE]: { query: listIssuesQuery, + moveMutation: issueMoveListMutation, }, }; diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js new file mode 100644 index 00000000000..084809e4e60 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/cache_updates.js @@ -0,0 +1,118 @@ +import produce from 'immer'; +import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; +import { listsDeferredQuery } from 'ee_else_ce/boards/constants'; + +export function removeItemFromList({ query, variables, boardType, id, issuableType, cache }) { + cache.updateQuery({ query, variables }, (sourceData) => + produce(sourceData, (draftData) => { + const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`]; + items.splice( + items.findIndex((item) => item.id === id), + 1, + ); + }), + ); +} + +export function addItemToList({ + query, + variables, + boardType, + issuable, + newIndex, + issuableType, + cache, +}) { + cache.updateQuery({ query, variables }, (sourceData) => + produce(sourceData, (draftData) => { + const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`]; + items.splice(newIndex, 0, issuable); + }), + ); +} + +export function updateIssueCountAndWeight({ + fromListId, + toListId, + filterParams, + issuable: issue, + shouldClone, + cache, +}) { + if (!shouldClone) { + cache.updateQuery( + { + query: listQuery, + variables: { id: fromListId, filters: filterParams }, + }, + ({ boardList }) => ({ + boardList: { + ...boardList, + issuesCount: boardList.issuesCount - 1, + totalWeight: boardList.totalWeight - issue.weight, + }, + }), + ); + } + + cache.updateQuery( + { + query: listQuery, + variables: { id: toListId, filters: filterParams }, + }, + ({ boardList }) => ({ + boardList: { + ...boardList, + issuesCount: boardList.issuesCount + 1, + totalWeight: boardList.totalWeight + issue.weight, + }, + }), + ); +} + +export function updateEpicsCount({ + issuableType, + filterParams, + fromListId, + toListId, + issuable: epic, + shouldClone, + cache, +}) { + const epicWeight = epic.descendantWeightSum.openedIssues + epic.descendantWeightSum.closedIssues; + if (!shouldClone) { + cache.updateQuery( + { + query: listsDeferredQuery[issuableType].query, + variables: { id: fromListId, filters: filterParams }, + }, + ({ epicBoardList }) => ({ + epicBoardList: { + ...epicBoardList, + metadata: { + epicsCount: epicBoardList.metadata.epicsCount - 1, + totalWeight: epicBoardList.metadata.totalWeight - epicWeight, + ...epicBoardList.metadata, + }, + }, + }), + ); + } + + cache.updateQuery( + { + query: listsDeferredQuery[issuableType].query, + variables: { id: toListId, filters: filterParams }, + }, + ({ epicBoardList }) => ({ + epicBoardList: { + ...epicBoardList, + metadata: { + epicsCount: epicBoardList.metadata.epicsCount + 1, + totalWeight: epicBoardList.metadata.totalWeight + epicWeight, + ...epicBoardList.metadata, + }, + }, + }), + ); +} diff --git a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql index 89670760450..4a46d741a78 100644 --- a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql @@ -9,7 +9,7 @@ mutation issueMoveList( $moveBeforeId: ID $moveAfterId: ID ) { - issueMoveList( + issuableMoveList: issueMoveList( input: { projectPath: $projectPath iid: $iid @@ -20,7 +20,7 @@ mutation issueMoveList( moveAfterId: $moveAfterId } ) { - issue { + issuable: issue { ...Issue } errors diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index a144054d680..d96d92948be 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -602,8 +602,8 @@ export default { cache, { data: { - issueMoveList: { - issue: { weight }, + issuableMoveList: { + issuable: { weight }, }, }, }, @@ -661,11 +661,11 @@ export default { }, }); - if (data?.issueMoveList?.errors.length || !data.issueMoveList) { + if (data?.issuableMoveList?.errors.length || !data.issuableMoveList) { throw new Error('issueMoveList empty'); } - commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue }); + commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issuableMoveList.issuable }); commit(types.MUTATE_ISSUE_IN_PROGRESS, false); } catch { commit(types.MUTATE_ISSUE_IN_PROGRESS, false); |