diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-17 19:05:49 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-17 19:05:49 +0300 |
commit | 43a25d93ebdabea52f99b05e15b06250cd8f07d7 (patch) | |
tree | dceebdc68925362117480a5d672bcff122fb625b /app/assets/javascripts/boards | |
parent | 20c84b99005abd1c82101dfeff264ac50d2df211 (diff) |
Add latest changes from gitlab-org/gitlab@16-0-stable-eev16.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/boards')
31 files changed, 939 insertions, 461 deletions
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 c5411ec313a..90f7059da86 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -1,13 +1,24 @@ <script> -import { GlFormRadio, GlFormRadioGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { + GlTooltipDirective as GlTooltip, + GlButton, + GlCollapsibleListbox, + GlIcon, +} from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; +import { __ } from '~/locale'; export default { + i18n: { + value: __('Value'), + noResults: __('No matching results'), + }, components: { BoardAddNewColumnForm, - GlFormRadio, - GlFormRadioGroup, + GlButton, + GlCollapsibleListbox, + GlIcon, }, directives: { GlTooltip, @@ -17,6 +28,7 @@ export default { return { selectedId: null, selectedLabel: null, + selectedIdValid: true, }; }, computed: { @@ -25,6 +37,15 @@ export default { columnForSelected() { return this.getListByLabelId(this.selectedId); }, + items() { + return ( + this.labels.map((i) => ({ + ...i, + text: i.title, + value: i.id, + })) || [] + ); + }, }, created() { this.filterItems(); @@ -33,6 +54,7 @@ export default { ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']), addList() { if (!this.selectedLabel) { + this.selectedIdValid = false; return; } @@ -61,53 +83,67 @@ export default { this.selectedLabel = { ...label }; } }, + onHide() { + this.searchValue = ''; + this.$emit('filter-items', ''); + this.$emit('hide'); + }, }, }; </script> <template> <board-add-new-column-form - :loading="labelsLoading" - :none-selected="__('Select a label')" - :search-placeholder="__('Search labels')" - :selected-id="selectedId" + :selected-id-valid="selectedIdValid" @filter-items="filterItems" @add-list="addList" > - <template #selected> - <template v-if="selectedLabel"> - <span - class="dropdown-label-box gl-top-0 gl-flex-shrink-0" - :style="{ - backgroundColor: selectedLabel.color, - }" - ></span> - <div class="gl-text-truncate">{{ selectedLabel.title }}</div> - </template> - </template> - - <template #items> - <gl-form-radio-group - v-if="labels.length > 0" - class="gl-overflow-y-auto gl-px-5 gl-pt-3" - :checked="selectedId" - @change="setSelectedItem" + <template #dropdown> + <gl-collapsible-listbox + class="gl-mb-3 gl-max-w-full" + :items="items" + searchable + :search-placeholder="__('Search labels')" + :searching="labelsLoading" + :selected="selectedId" + :no-results-text="$options.i18n.noResults" + @select="setSelectedItem" + @search="filterItems" + @hidden="onHide" > - <label - v-for="label in labels" - :key="label.id" - class="gl-display-flex gl-mb-5 gl-font-weight-normal gl-overflow-break-word" - > - <gl-form-radio :value="label.id" /> - <span - class="dropdown-label-box gl-top-0 gl-flex-shrink-0" - :style="{ - backgroundColor: label.color, - }" - ></span> - <span>{{ label.title }}</span> - </label> - </gl-form-radio-group> + <template #toggle> + <gl-button + class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-truncate" + :class="{ 'gl-inset-border-1-red-400!': !selectedIdValid }" + button-text-classes="gl-display-flex" + > + <template v-if="selectedLabel"> + <span + class="dropdown-label-box gl-top-0 gl-flex-shrink-0" + :style="{ + backgroundColor: selectedLabel.color, + }" + ></span> + <div class="gl-text-truncate">{{ selectedLabel.title }}</div> + </template> + + <template v-else>{{ __('Select a label') }}</template> + <gl-icon class="dropdown-chevron gl-ml-2" name="chevron-down" /> + </gl-button> + </template> + + <template #list-item="{ item }"> + <label class="gl-display-flex gl-font-weight-normal gl-overflow-break-word gl-mb-0"> + <span + class="dropdown-label-box gl-top-0 gl-flex-shrink-0" + :style="{ + backgroundColor: item.color, + }" + ></span> + <span>{{ item.title }}</span> + </label> + </template> + </gl-collapsible-listbox> </template> </board-add-new-column-form> </template> 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 1899d42fa4d..259423df07f 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,12 +1,5 @@ <script> -import { - GlButton, - GlDropdown, - GlFormGroup, - GlIcon, - GlSearchBoxByType, - GlSkeletonLoader, -} from '@gitlab/ui'; +import { GlButton, GlFormGroup } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __ } from '~/locale'; @@ -15,81 +8,34 @@ export default { add: __('Add to board'), cancel: __('Cancel'), newList: __('New list'), - noResults: __('No matching results'), scope: __('Scope'), scopeDescription: __('Issues must match this scope to appear in this list.'), - selected: __('Selected'), requiredFieldFeedback: __('This field is required.'), }, components: { GlButton, - GlDropdown, GlFormGroup, - GlIcon, - GlSearchBoxByType, - GlSkeletonLoader, }, props: { - loading: { - type: Boolean, - required: true, - }, searchLabel: { type: String, required: false, default: null, }, - noneSelected: { - type: String, - required: true, - }, - searchPlaceholder: { - type: String, + selectedIdValid: { + type: Boolean, required: true, }, - selectedId: { - type: [Number, String], - required: false, - default: null, - }, }, data() { return { searchValue: '', - selectedIdValid: true, }; }, - computed: { - toggleClassList() { - return `gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate ${ - this.selectedIdValid ? '' : 'gl-inset-border-1-red-400!' - }`; - }, - }, - watch: { - selectedId(val) { - if (val) { - this.$refs.dropdown.hide(true); - this.selectedIdValid = true; - } - }, - }, methods: { ...mapActions(['setAddColumnFormVisibility']), - setFocus() { - this.$refs.searchBox.focusInput(); - }, - onHide() { - this.searchValue = ''; - this.$emit('filter-items', ''); - this.$emit('hide'); - }, onSubmit() { - if (!this.selectedId) { - this.selectedIdValid = false; - } else { - this.$emit('add-list'); - } + this.$emit('add-list'); }, }, }; @@ -126,44 +72,7 @@ export default { :state="selectedIdValid" :invalid-feedback="$options.i18n.requiredFieldFeedback" > - <gl-dropdown - ref="dropdown" - class="gl-mb-3 gl-max-w-full" - :toggle-class="toggleClassList" - boundary="viewport" - @shown="setFocus" - @hide="onHide" - > - <template #button-content> - <slot name="selected"> - <div>{{ noneSelected }}</div> - </slot> - <gl-icon class="dropdown-chevron gl-flex-shrink-0" name="chevron-down" /> - </template> - - <template #header> - <gl-search-box-by-type - ref="searchBox" - v-model="searchValue" - debounce="250" - class="gl-mt-0!" - :placeholder="searchPlaceholder" - @input="$emit('filter-items', $event)" - /> - </template> - - <div v-if="loading" class="gl-px-5"> - <gl-skeleton-loader :width="400" :height="172"> - <rect width="380" height="20" x="10" y="15" rx="4" /> - <rect width="280" height="20" x="10" y="50" rx="4" /> - <rect width="330" height="20" x="10" y="85" rx="4" /> - </gl-skeleton-loader> - </div> - - <slot v-else name="items"> - <p class="gl-mx-5">{{ $options.i18n.noResults }}</p> - </slot> - </gl-dropdown> + <slot name="dropdown"></slot> </gl-form-group> </div> <div class="gl-display-flex gl-mb-4"> diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index d41fc1e9300..3a247819850 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -1,24 +1,106 @@ <script> import { mapGetters } from 'vuex'; -import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; import BoardContent from '~/boards/components/board_content.vue'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; import BoardTopBar from '~/boards/components/board_top_bar.vue'; +import { listsQuery } from 'ee_else_ce/boards/constants'; +import { formatBoardLists } from 'ee_else_ce/boards/boards_util'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; export default { + i18n: { + fetchError: s__( + 'Boards|An error occurred while fetching the board lists. Please reload the page.', + ), + }, components: { BoardContent, BoardSettingsSidebar, BoardTopBar, }, - inject: ['initialBoardId'], + inject: [ + 'fullPath', + 'initialBoardId', + 'initialFilterParams', + 'isIssueBoard', + 'isGroupBoard', + 'issuableType', + 'boardType', + 'isApolloBoard', + ], data() { return { + activeListId: '', boardId: this.initialBoardId, + filterParams: { ...this.initialFilterParams }, + isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by), + apolloError: null, }; }, + apollo: { + activeBoardItem: { + query: activeBoardItemQuery, + variables() { + return { + isIssue: this.isIssueBoard, + }; + }, + result({ data: { activeBoardItem } }) { + if (activeBoardItem) { + this.setActiveId(''); + } + }, + skip() { + return !this.isApolloBoard; + }, + }, + boardListsApollo: { + query() { + return listsQuery[this.issuableType].query; + }, + variables() { + return this.listQueryVariables; + }, + skip() { + return !this.isApolloBoard; + }, + update(data) { + const { lists } = data[this.boardType].board; + return formatBoardLists(lists); + }, + error() { + this.apolloError = this.$options.i18n.fetchError; + }, + }, + }, + computed: { ...mapGetters(['isSidebarOpen']), + listQueryVariables() { + return { + ...(this.isIssueBoard && { + isGroup: this.isGroupBoard, + isProject: !this.isGroupBoard, + }), + fullPath: this.fullPath, + boardId: this.boardId, + filters: this.filterParams, + }; + }, + isSwimlanesOn() { + return (gon?.licensed_features?.swimlanes && this.isShowingEpicsSwimlanes) ?? false; + }, + isAnySidebarOpen() { + if (this.isApolloBoard) { + return this.activeBoardItem?.id || this.activeListId; + } + return this.isSidebarOpen; + }, + activeList() { + return this.activeListId ? this.boardListsApollo[this.activeListId] : undefined; + }, }, created() { window.addEventListener('popstate', refreshCurrentPage); @@ -27,17 +109,47 @@ export default { window.removeEventListener('popstate', refreshCurrentPage); }, methods: { + setActiveId(id) { + this.activeListId = id; + }, switchBoard(id) { this.boardId = id; + this.setActiveId(''); + }, + setFilters(filters) { + const filterParams = { ...filters }; + if (filterParams.groupBy) delete filterParams.groupBy; + this.filterParams = filterParams; }, }, }; </script> <template> - <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }"> - <board-top-bar :board-id="boardId" @switchBoard="switchBoard" /> - <board-content :board-id="boardId" /> - <board-settings-sidebar /> + <div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }"> + <board-top-bar + :board-id="boardId" + :is-swimlanes-on="isSwimlanesOn" + @switchBoard="switchBoard" + @setFilters="setFilters" + @toggleSwimlanes="isShowingEpicsSwimlanes = $event" + /> + <board-content + v-if="!isApolloBoard || boardListsApollo" + :board-id="boardId" + :is-swimlanes-on="isSwimlanesOn" + :filter-params="filterParams" + :board-lists-apollo="boardListsApollo" + :apollo-error="apolloError" + @setActiveList="setActiveId" + /> + <board-settings-sidebar + v-if="!isApolloBoard || activeList" + :list="activeList" + :list-id="activeListId" + :board-id="boardId" + :query-variables="listQueryVariables" + @unsetActiveId="setActiveId('')" + /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 3071c1f334e..18495f285da 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,6 +1,8 @@ <script> import { mapActions, mapState } from 'vuex'; import Tracking from '~/tracking'; +import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import BoardCardInner from './board_card_inner.vue'; export default { @@ -9,7 +11,7 @@ export default { BoardCardInner, }, mixins: [Tracking.mixin()], - inject: ['disabled', 'isApolloBoard'], + inject: ['disabled', 'isIssueBoard', 'isApolloBoard'], props: { list: { type: Object, @@ -37,14 +39,30 @@ export default { default: true, }, }, + apollo: { + activeBoardItem: { + query: activeBoardItemQuery, + variables() { + return { + isIssue: this.isIssueBoard, + }; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, computed: { ...mapState(['selectedBoardItems', 'activeId']), + activeItemId() { + return this.isApolloBoard ? this.activeBoardItem?.id : this.activeId; + }, isActive() { - return this.item.id === this.activeId; + return this.item.id === this.activeItemId; }, multiSelectVisible() { return ( - !this.activeId && + !this.activeItemId && this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1 ); }, @@ -83,10 +101,23 @@ export default { if (isMultiSelect && gon?.features?.boardMultiSelect) { this.toggleBoardItemMultiSelection(this.item); } else { - this.toggleBoardItem({ boardItem: this.item }); + if (this.isApolloBoard) { + this.toggleItem(); + } else { + this.toggleBoardItem({ boardItem: this.item }); + } this.track('click_card', { label: 'right_sidebar' }); } }, + toggleItem() { + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { + boardItem: this.item, + isIssue: this.isIssueBoard, + }, + }); + }, }, }; </script> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 88f51c71e06..befd04c29ae 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -275,7 +275,7 @@ export default { <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" /> <span v-if="item.referencePath" - class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary" + class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary" :class="{ 'gl-font-base': isEpicBoard }" > <work-item-type-icon diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 708e1539c6e..b2054d76e95 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -20,6 +20,10 @@ export default { type: String, required: true, }, + filters: { + type: Object, + required: true, + }, }, computed: { ...mapState(['filterParams', 'highlightedLists']), @@ -33,11 +37,14 @@ export default { isListDraggable() { return isListDraggable(this.list); }, + filtersToUse() { + return this.isApolloBoard ? this.filters : this.filterParams; + }, }, watch: { filterParams: { handler() { - if (this.list.id && !this.list.collapsed) { + if (!this.isApolloBoard && this.list.id && !this.list.collapsed) { this.fetchItemsForList({ listId: this.list.id }); } }, @@ -46,7 +53,7 @@ export default { }, 'list.id': { handler(id) { - if (id) { + if (!this.isApolloBoard && id) { this.fetchItemsForList({ listId: this.list.id }); } }, @@ -83,13 +90,18 @@ export default { class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50" :class="{ 'board-column-highlighted': highlighted }" > - <board-list-header :list="list" /> + <board-list-header + :list="list" + :filter-params="filtersToUse" + :board-id="boardId" + @setActiveList="$emit('setActiveList', $event)" + /> <board-list ref="board-list" :board-id="boardId" :board-items="listItems" :list="list" - :filter-params="filterParams" + :filter-params="filtersToUse" /> </div> </div> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 8a37719eae8..8304dfef527 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,23 +1,15 @@ <script> import { GlAlert } from '@gitlab/ui'; -import { breakpoints } from '@gitlab/ui/dist/utils'; -import { sortBy, throttle } from 'lodash'; +import { sortBy } from 'lodash'; import Draggable from 'vuedraggable'; -import { mapState, mapGetters, mapActions } from 'vuex'; -import { contentTop } from '~/lib/utils/common_utils'; -import { s__ } from '~/locale'; -import { formatBoardLists } from 'ee_else_ce/boards/boards_util'; +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, listsQuery } from 'ee_else_ce/boards/constants'; +import { DraggableItemTypes } from 'ee_else_ce/boards/constants'; import BoardColumn from './board_column.vue'; export default { - i18n: { - fetchError: s__( - 'Boards|An error occurred while fetching the board lists. Please reload the page.', - ), - }, draggableItemTypes: DraggableItemTypes, components: { BoardAddNewColumn, @@ -28,73 +20,41 @@ export default { EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, }, - inject: [ - 'canAdminList', - 'boardType', - 'fullPath', - 'issuableType', - 'isIssueBoard', - 'isEpicBoard', - 'isGroupBoard', - 'disabled', - 'isApolloBoard', - ], + inject: ['canAdminList', 'isIssueBoard', 'isEpicBoard', 'disabled', 'isApolloBoard'], props: { boardId: { type: String, required: true, }, + filterParams: { + type: Object, + required: true, + }, + isSwimlanesOn: { + type: Boolean, + required: true, + }, + boardListsApollo: { + type: Object, + required: false, + default: () => {}, + }, + apolloError: { + type: String, + required: false, + default: null, + }, }, data() { return { boardHeight: null, - boardListsApollo: {}, - apolloError: null, - updatedBoardId: this.boardId, }; }, - apollo: { - boardListsApollo: { - query() { - return listsQuery[this.issuableType].query; - }, - variables() { - return this.queryVariables; - }, - skip() { - return !this.isApolloBoard; - }, - update(data) { - const { lists } = data[this.boardType].board; - return formatBoardLists(lists); - }, - result() { - // this allows us to delay fetching lists when we switch a board to fetch the actual board lists - // instead of fetching lists for the "previous" board - this.updatedBoardId = this.boardId; - }, - error() { - this.apolloError = this.$options.i18n.fetchError; - }, - }, - }, computed: { ...mapState(['boardLists', 'error', 'addColumnForm']), - ...mapGetters(['isSwimlanesOn']), addColumnFormVisible() { return this.addColumnForm?.visible; }, - queryVariables() { - return { - ...(this.isIssueBoard && { - isGroup: this.isGroupBoard, - isProject: !this.isGroupBoard, - }), - fullPath: this.fullPath, - boardId: this.boardId, - filterParams: this.filterParams, - }; - }, boardListsToUse() { const lists = this.isApolloBoard ? this.boardListsApollo : this.boardLists; return sortBy([...Object.values(lists)], 'position'); @@ -126,18 +86,11 @@ export default { return this.isApolloBoard ? this.apolloError : this.error; }, }, - mounted() { - this.setBoardHeight(); - - this.resizeObserver = new ResizeObserver( - throttle(() => { - this.setBoardHeight(); - }, 150), - ); - this.resizeObserver.observe(document.body); + created() { + eventHub.$on('updateBoard', this.refetchLists); }, - unmounted() { - this.resizeObserver.disconnect(); + beforeDestroy() { + eventHub.$off('updateBoard', this.refetchLists); }, methods: { ...mapActions(['moveList', 'unsetError']), @@ -145,19 +98,19 @@ export default { const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); }, - setBoardHeight() { - if (window.innerWidth < breakpoints.md) { - this.boardHeight = `${window.innerHeight - contentTop()}px`; - } else { - this.boardHeight = `${window.innerHeight - this.$el.getBoundingClientRect().top}px`; - } + refetchLists() { + this.$apollo.queries.boardListsApollo.refetch(); }, }, }; </script> <template> - <div v-cloak data-qa-selector="boards_list"> + <div + v-cloak + data-qa-selector="boards_list" + class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-min-h-0" + > <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="unsetError"> {{ errorToDisplay }} </gl-alert> @@ -166,8 +119,7 @@ export default { v-if="!isSwimlanesOn" ref="list" v-bind="draggableOptions" - class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-scroll" - :style="{ height: boardHeight }" + class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-auto" @end="moveList" > <board-column @@ -176,8 +128,10 @@ export default { ref="board" :board-id="boardId" :list="list" + :filters="filterParams" :data-draggable-item-type="$options.draggableItemTypes.list" :class="{ 'gl-xs-display-none!': addColumnFormVisible }" + @setActiveList="$emit('setActiveList', $event)" /> <transition name="slide" @after-enter="afterFormEnters"> @@ -188,9 +142,11 @@ export default { <epics-swimlanes v-else-if="boardListsToUse.length" ref="swimlanes" + :board-id="boardId" :lists="boardListsToUse" :can-admin-list="canAdminList" - :style="{ height: boardHeight }" + :filters="filterParams" + @setActiveList="$emit('setActiveList', $event)" /> <board-content-sidebar v-if="isIssueBoard" data-testid="issue-boards-sidebar" /> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 6227f185eda..1b97214ff8b 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -3,12 +3,14 @@ import { GlDrawer } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; import { mapState, mapActions, mapGetters } from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; +import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; import { __, sprintf } from '~/locale'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; -import { BoardType, ISSUABLE, INCIDENT } from '~/boards/constants'; +import { INCIDENT } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/issues/constants'; +import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; @@ -16,8 +18,6 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; -import { LabelType } from '~/sidebar/components/labels/labels_select_widget/constants'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -40,7 +40,6 @@ export default { SidebarWeightWidget: () => import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), }, - mixins: [glFeatureFlagMixin()], inject: { multipleAssigneesFeatureAvailable: { default: false, @@ -72,33 +71,48 @@ export default { isGroupBoard: { default: false, }, + isApolloBoard: { + default: false, + }, }, inheritAttrs: false, + apollo: { + activeBoardCard: { + query: activeBoardItemQuery, + variables: { + isIssue: true, + }, + update(data) { + if (!data.activeBoardItem?.id) { + return { id: '', iid: '' }; + } + return { + ...data.activeBoardItem, + assignees: data.activeBoardItem.assignees?.nodes || [], + }; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, computed: { - ...mapGetters([ - 'isSidebarOpen', - 'activeBoardItem', - 'groupPathForActiveIssue', - 'projectPathForActiveIssue', - ]), + ...mapGetters(['activeBoardItem']), ...mapState(['sidebarType']), - isIssuableSidebar() { - return this.sidebarType === ISSUABLE; + activeBoardIssuable() { + return this.isApolloBoard ? this.activeBoardCard : this.activeBoardItem; }, - isIncidentSidebar() { - return this.activeBoardItem.type === INCIDENT; + isSidebarOpen() { + return Boolean(this.activeBoardIssuable?.id); }, - showSidebar() { - return this.isIssuableSidebar && this.isSidebarOpen; + isIncidentSidebar() { + return this.activeBoardIssuable?.type === INCIDENT; }, sidebarTitle() { return this.isIncidentSidebar ? __('Incident details') : __('Issue details'); }, - fullPath() { - return this.activeBoardItem?.referencePath?.split('#')[0] || ''; - }, parentType() { - return this.isGroupBoard ? BoardType.group : BoardType.project; + return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT; }, createLabelTitle() { return sprintf(__('Create %{workspace} label'), { @@ -114,13 +128,21 @@ export default { return this.isGroupBoard ? this.groupPathForActiveIssue : this.projectPathForActiveIssue; }, labelType() { - return this.isGroupBoard ? LabelType.group : LabelType.project; + return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT; }, labelsFilterPath() { return this.isGroupBoard ? this.labelsFilterBasePath.replace(':project_path', this.projectPathForActiveIssue) : this.labelsFilterBasePath; }, + groupPathForActiveIssue() { + const { referencePath = '' } = this.activeBoardIssuable; + return referencePath.slice(0, referencePath.lastIndexOf('/')); + }, + projectPathForActiveIssue() { + const { referencePath = '' } = this.activeBoardIssuable; + return referencePath.slice(0, referencePath.indexOf('#')); + }, }, methods: { ...mapActions([ @@ -132,7 +154,19 @@ export default { 'setActiveItemHealthStatus', ]), handleClose() { - this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); + if (this.isApolloBoard) { + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { + boardItem: null, + }, + }); + } else { + this.toggleBoardItem({ + boardItem: this.activeBoardIssuable, + sidebarType: this.sidebarType, + }); + } }, handleUpdateSelectedLabels({ labels, id }) { this.setActiveBoardItemLabels({ @@ -144,7 +178,7 @@ export default { }, handleLabelRemove(removeLabelId) { this.setActiveBoardItemLabels({ - iid: this.activeBoardItem.iid, + iid: this.activeBoardIssuable.iid, projectPath: this.projectPathForActiveIssue, removeLabelIds: [removeLabelId], }); @@ -157,7 +191,7 @@ export default { <mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append> <gl-drawer v-bind="$attrs" - :open="showSidebar" + :open="isSidebarOpen" class="boards-sidebar" variant="sidebar" @close="handleClose" @@ -168,25 +202,27 @@ export default { <template #header> <sidebar-todo-widget class="gl-mt-3" - :issuable-id="activeBoardItem.id" - :issuable-iid="activeBoardItem.iid" - :full-path="fullPath" + :issuable-id="activeBoardIssuable.id" + :issuable-iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" /> </template> <template #default> - <board-sidebar-title data-testid="sidebar-title" /> + <board-sidebar-title :active-item="activeBoardIssuable" data-testid="sidebar-title" /> <sidebar-assignees-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :initial-assignees="activeBoardItem.assignees" + v-if="activeBoardItem.assignees" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" + :initial-assignees="activeBoardIssuable.assignees" :allow-multiple-assignees="multipleAssigneesFeatureAvailable" :editable="canUpdate" - @assignees-updated="setAssignees" + @assignees-updated="!isApolloBoard && setAssignees($event)" /> <sidebar-dropdown-widget v-if="epicFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" + :key="`epic-${activeBoardItem.iid}`" + :iid="activeBoardIssuable.iid" issuable-attribute="epic" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" @@ -195,7 +231,8 @@ export default { /> <div> <sidebar-dropdown-widget - :iid="activeBoardItem.iid" + :key="`milestone-${activeBoardItem.iid}`" + :iid="activeBoardIssuable.iid" issuable-attribute="milestone" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="projectPathForActiveIssue" @@ -204,7 +241,8 @@ export default { /> <sidebar-iteration-widget v-if="iterationFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" + :key="`iteration-${activeBoardItem.iid}`" + :iid="activeBoardIssuable.iid" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" :issuable-type="issuableType" @@ -214,14 +252,14 @@ export default { </div> <board-sidebar-time-tracker /> <sidebar-date-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" data-testid="sidebar-due-date" /> <sidebar-labels-widget class="block labels" - :iid="activeBoardItem.iid" + :iid="activeBoardIssuable.iid" :full-path="projectPathForActiveIssue" :allow-label-remove="allowLabelEdit" :allow-multiselect="true" @@ -233,40 +271,40 @@ export default { workspace-type="project" :issuable-type="issuableType" :label-create-type="labelType" - @onLabelRemove="handleLabelRemove" - @updateSelectedLabels="handleUpdateSelectedLabels" + @onLabelRemove="!isApolloBoard && handleLabelRemove($event)" + @updateSelectedLabels="!isApolloBoard && handleUpdateSelectedLabels($event)" > {{ __('None') }} </sidebar-labels-widget> <sidebar-severity-widget v-if="isIncidentSidebar" - :iid="activeBoardItem.iid" - :project-path="fullPath" - :initial-severity="activeBoardItem.severity" + :iid="activeBoardIssuable.iid" + :project-path="projectPathForActiveIssue" + :initial-severity="activeBoardIssuable.severity" /> <sidebar-weight-widget v-if="weightFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @weightUpdated="setActiveItemWeight($event)" + @weightUpdated="!isApolloBoard && setActiveItemWeight($event)" /> <sidebar-health-status-widget v-if="healthStatusFeatureAvailable" - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @statusUpdated="setActiveItemHealthStatus($event)" + @statusUpdated="!isApolloBoard && setActiveItemHealthStatus($event)" /> <sidebar-confidentiality-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @confidentialityUpdated="setActiveItemConfidential($event)" + @confidentialityUpdated="!isApolloBoard && setActiveItemConfidential($event)" /> <sidebar-subscriptions-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" data-testid="sidebar-notifications" /> diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 1bc5d910561..b5d3613ca27 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -1,7 +1,7 @@ <script> import { pickBy, isEmpty, mapValues } from 'lodash'; import { mapActions } from 'vuex'; -import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId, isGid, convertToGraphQLId } from '~/graphql_shared/utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -22,7 +22,8 @@ import { TOKEN_TYPE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { AssigneeFilterType } from '~/boards/constants'; +import { AssigneeFilterType, GroupByParamType } from 'ee_else_ce/boards/constants'; +import { TYPENAME_ITERATION } from '~/graphql_shared/constants'; import eventHub from '../eventhub'; export default { @@ -30,8 +31,13 @@ export default { search: __('Search'), }, components: { FilteredSearch }, - inject: ['initialFilterParams'], + inject: ['initialFilterParams', 'isApolloBoard'], props: { + isSwimlanesOn: { + type: Boolean, + required: false, + default: false, + }, tokens: { type: Array, required: true, @@ -320,6 +326,7 @@ export default { release_tag: releaseTag, confidential, health_status: healthStatus, + group_by: this.isSwimlanesOn ? GroupByParamType.epic : undefined, }, (value) => { if (value || value === false) { @@ -334,11 +341,23 @@ export default { }, ); }, + formattedFilterParams() { + const filtersCopy = { ...this.filterParams }; + if (this.filterParams?.iterationId) { + filtersCopy.iterationId = convertToGraphQLId( + TYPENAME_ITERATION, + this.filterParams.iterationId, + ); + } + + return filtersCopy; + }, }, created() { eventHub.$on('updateTokens', this.updateTokens); if (!isEmpty(this.eeFilters)) { this.filterParams = this.eeFilters; + this.$emit('setFilters', this.formattedFilterParams); } }, beforeDestroy() { @@ -349,6 +368,7 @@ export default { updateTokens() { const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); this.filterParams = convertObjectPropsToCamelCase(rawFilterParams, {}); + this.$emit('setFilters', this.formattedFilterParams); this.filteredSearchKey += 1; }, handleFilter(filters) { @@ -360,7 +380,11 @@ export default { replace: true, }); - this.performSearch(); + if (this.isApolloBoard) { + this.$emit('setFilters', this.formattedFilterParams); + } else { + this.performSearch(); + } }, getFilterParams(filters = []) { const notFilters = filters.filter((item) => item.value.operator === '!='); @@ -373,7 +397,6 @@ export default { generateParams(filters = []) { const filterParams = {}; const labels = []; - const plainText = []; filters.forEach((filter) => { switch (filter.type) { @@ -415,7 +438,9 @@ export default { filterParams.confidential = filter.value.data; break; case FILTERED_SEARCH_TERM: - if (filter.value.data) plainText.push(filter.value.data); + if (filter.value.data) { + filterParams.search = filter.value.data; + } break; case TOKEN_TYPE_HEALTH: filterParams.healthStatus = filter.value.data; @@ -429,10 +454,6 @@ export default { filterParams.labelName = labels; } - if (plainText.length) { - filterParams.search = plainText.join(' '); - } - return filterParams; }, }, @@ -444,6 +465,7 @@ export default { :key="filteredSearchKey" class="gl-w-full" namespace="" + terms-as-tokens :tokens="tokens" :search-input-placeholder="$options.i18n.search" :initial-filter-value="getFilteredSearchValue" diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index a71bde54a8f..604e71f5993 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -4,6 +4,7 @@ import { mapActions, mapState } from 'vuex'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; +import eventHub from '~/boards/eventhub'; import { formType } from '../constants'; import createBoardMutation from '../graphql/board_create.mutation.graphql'; @@ -57,6 +58,9 @@ export default { isProjectBoard: { default: false, }, + isApolloBoard: { + default: false, + }, }, props: { canAdminBoard: { @@ -124,14 +128,12 @@ export default { primaryProps() { return { text: this.buttonText, - attributes: [ - { - variant: this.buttonKind, - disabled: this.submitDisabled, - loading: this.isLoading, - 'data-qa-selector': 'save_changes_button', - }, - ], + attributes: { + variant: this.buttonKind, + disabled: this.submitDisabled, + loading: this.isLoading, + 'data-qa-selector': 'save_changes_button', + }, }; }, cancelProps() { @@ -213,7 +215,15 @@ export default { } else { try { const board = await this.createOrUpdateBoard(); - this.setBoard(board); + if (this.isApolloBoard) { + if (this.board.id) { + eventHub.$emit('updateBoard', board); + } else { + this.$emit('addBoard', board); + } + } else { + this.setBoard(board); + } this.cancel(); const param = getParameterByName('group_by') @@ -278,7 +288,7 @@ export default { @hide.prevent > <gl-alert - v-if="error" + v-if="!isApolloBoard && error" class="gl-mb-3" variant="danger" :dismissible="true" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 6f2b35f5191..5f082066ad4 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; import { mapActions, mapState } from 'vuex'; +import { STATUS_CLOSED } from '~/issues/constants'; import { sprintf, __ } from '~/locale'; import { defaultSortableOptions } from '~/sortable/constants'; import { sortableStart, sortableEnd } from '~/sortable/utils'; @@ -59,6 +60,10 @@ export default { type: Array, required: true, }, + filterParams: { + type: Object, + required: true, + }, }, data() { return { @@ -108,7 +113,7 @@ export default { }, }, computed: { - ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']), + ...mapState(['pageInfoByListId', 'listsFlags', 'isUpdateIssueOrderInProgress']), boardListItems() { return this.isApolloBoard ? this.currentList?.[`${this.issuableType}s`].nodes || [] @@ -125,7 +130,7 @@ export default { }; }, listItemsCount() { - return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount; + return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount; }, paginatedIssueText() { return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), { @@ -154,10 +159,10 @@ export default { return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore; }, epicCreateFormVisible() { - return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm; + return this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showEpicForm; }, issueCreateFormVisible() { - return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm; + return !this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showIssueForm; }, listRef() { // When list is draggable, the reference to the list needs to be accessed differently @@ -260,6 +265,10 @@ export default { this.showIssueForm = !this.showIssueForm; } }, + isObservableItem(index) { + // observe every 6 item of 10 to achieve smooth loading state + return index !== 0 && index % 6 === 0; + }, onReachingListBottom() { if (!this.loadingMore && this.hasNextPage) { this.showCount = true; @@ -393,8 +402,14 @@ export default { :list="list" :list-items-length="boardListItems.length" /> + <gl-intersection-observer + v-if="isObservableItem(index)" + data-testid="board-card-gl-io" + @appear="onReachingListBottom" + /> </board-card> - <gl-intersection-observer @appear="onReachingListBottom"> + <div> + <!-- for supporting previous structure with intersection observer --> <li v-if="showCount" class="board-list-count gl-text-center gl-text-secondary gl-py-4" @@ -404,12 +419,11 @@ export default { v-if="loadingMore" size="sm" :label="$options.i18n.loadingMoreboardItems" - data-testid="count-loading-icon" /> <span v-if="showingAllItems">{{ showingAllItemsText }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> - </gl-intersection-observer> + </div> </component> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 749fae0c426..1b711feb686 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -1,38 +1,48 @@ <script> import { GlButton, - GlButtonGroup, GlLabel, GlTooltip, GlIcon, GlSprintf, GlTooltipDirective, + GlDisclosureDropdown, } from '@gitlab/ui'; -import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import { isListDraggable } from '~/boards/boards_util'; import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { n__, s__, __ } from '~/locale'; +import { n__, s__ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; +import { TYPE_ISSUE } from '~/issues/constants'; import { formatDate } from '~/lib/utils/datetime_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; +import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; import AccessorUtilities from '~/lib/utils/accessor'; -import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants'; +import { + inactiveId, + LIST, + ListType, + toggleFormEventPrefix, + updateListQueries, + toggleCollapsedMutations, +} from 'ee_else_ce/boards/constants'; import eventHub from '../eventhub'; import ItemCount from './item_count.vue'; export default { i18n: { - newIssue: __('New issue'), - newEpic: s__('Boards|New epic'), - listSettings: __('List settings'), + newIssue: s__('Boards|Create new issue'), + listActions: s__('Boards|List actions'), + newEpic: s__('Boards|Create new epic'), + listSettings: s__('Boards|Edit list settings'), expand: s__('Boards|Expand'), collapse: s__('Boards|Collapse'), }, components: { - GlButtonGroup, + GlDisclosureDropdown, GlButton, GlLabel, GlTooltip, @@ -63,6 +73,12 @@ export default { disabled: { default: true, }, + issuableType: { + default: TYPE_ISSUE, + }, + isApolloBoard: { + default: false, + }, }, props: { list: { @@ -75,16 +91,26 @@ export default { required: false, default: false, }, + filterParams: { + type: Object, + required: true, + }, + boardId: { + type: String, + required: true, + }, }, computed: { - ...mapState(['activeId', 'filterParams', 'boardId']), - ...mapGetters(['isSwimlanesOn']), + ...mapState(['activeId']), isLoggedIn() { return Boolean(this.currentUserId); }, listType() { return this.list.listType; }, + itemsCount() { + return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount; + }, listAssignee() { return this.list?.assignee?.username || ''; }, @@ -111,7 +137,10 @@ export default { }, showListHeaderActions() { if (this.isLoggedIn) { - return this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown; + return ( + (this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown) && + !this.list.collapsed + ); } return false; }, @@ -162,6 +191,50 @@ export default { canShowTotalWeight() { return this.weightFeatureAvailable && !this.isLoading; }, + actionListItems() { + const items = []; + + if (this.isNewIssueShown) { + const newIssueText = this.$options.i18n.newIssue; + items.push({ + text: newIssueText, + action: this.showNewIssueForm, + extraAttrs: { + 'data-testid': 'newIssueBtn', + title: newIssueText, + 'aria-label': newIssueText, + }, + }); + } + + if (this.isNewEpicShown) { + const newEpicText = this.$options.i18n.newEpic; + items.push({ + text: newEpicText, + action: this.showNewEpicForm, + extraAttrs: { + 'data-testid': 'newEpicBtn', + title: newEpicText, + 'aria-label': newEpicText, + }, + }); + } + + if (this.isSettingsShown) { + const listSettingsText = this.$options.i18n.listSettings; + items.push({ + text: listSettingsText, + action: this.openSidebarSettings, + extraAttrs: { + 'data-testid': 'settingsBtn', + title: listSettingsText, + 'aria-label': listSettingsText, + }, + }); + } + + return items; + }, }, apollo: { boardList: { @@ -175,34 +248,43 @@ export default { context: { isSingleRequest: true, }, - skip() { - return this.isEpicBoard; - }, }, }, created() { const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`)); if ((!this.isLoggedIn || this.isEpicBoard) && localCollapsed) { - this.toggleListCollapsed({ listId: this.list.id, collapsed: true }); + this.updateLocalCollapsedStatus(true); } }, methods: { ...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']), + closeListActions() { + this.$refs.headerListActions?.close(); + }, openSidebarSettings() { if (this.activeId === inactiveId) { sidebarEventHub.$emit('sidebar.closeAll'); } - this.setActiveId({ id: this.list.id, sidebarType: LIST }); + if (this.isApolloBoard) { + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { boardItem: null }, + }); + this.$emit('setActiveList', this.list.id); + } else { + this.setActiveId({ id: this.list.id, sidebarType: LIST }); + } this.track('click_button', { label: 'list_settings' }); + + this.closeListActions(); }, showScopedLabels(label) { return this.scopedLabelsAvailable && isScopedLabel(label); }, - showNewIssueForm() { - if (this.isSwimlanesOn) { + if (this.isSwimlanesHeader) { eventHub.$emit('open-unassigned-lane'); this.$nextTick(() => { eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); @@ -210,18 +292,22 @@ 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; - this.toggleListCollapsed({ listId: this.list.id, collapsed }); + this.updateLocalCollapsedStatus(collapsed); if (!this.isLoggedIn) { - this.addToLocalStorage(); + this.addToLocalStorage(collapsed); } else { - this.updateListFunction(); + this.updateListFunction(collapsed); } // When expanding/collapsing, the tooltip on the caret button sometimes stays open. @@ -233,13 +319,37 @@ export default { property: collapsed ? 'closed' : 'open', }); }, - addToLocalStorage() { + addToLocalStorage(collapsed) { if (AccessorUtilities.canUseLocalStorage()) { - localStorage.setItem(`${this.uniqueKey}.collapsed`, this.list.collapsed); + localStorage.setItem(`${this.uniqueKey}.collapsed`, collapsed); } }, - updateListFunction() { - this.updateList({ listId: this.list.id, collapsed: this.list.collapsed }); + async updateListFunction(collapsed) { + if (this.isApolloBoard) { + try { + await this.$apollo.mutate({ + mutation: updateListQueries[this.issuableType].mutation, + variables: { + listId: this.list.id, + collapsed, + }, + optimisticResponse: { + updateBoardList: { + __typename: 'UpdateBoardListPayload', + errors: [], + list: { + ...this.list, + collapsed, + }, + }, + }, + }); + } catch { + this.$emit('error'); + } + } else { + this.updateList({ listId: this.list.id, collapsed }); + } }, /** * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619 @@ -251,6 +361,19 @@ export default { const due = formatDate(dueDate, 'mmm d, yyyy', true); return `${start} - ${due}`; }, + updateLocalCollapsedStatus(collapsed) { + if (this.isApolloBoard) { + this.$apollo.mutate({ + mutation: toggleCollapsedMutations[this.issuableType].mutation, + variables: { + list: this.list, + collapsed, + }, + }); + } else { + this.toggleListCollapsed({ listId: this.list.id, collapsed }); + } + }, }, }; </script> @@ -364,7 +487,7 @@ export default { <div v-if="list.maxIssueCount !== 0"> • <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> - <template #issuesSize>{{ itemsTooltipLabel }}</template> + <template #issuesSize>{{ itemsCount }}</template> <template #maxIssueCount>{{ list.maxIssueCount }}</template> </gl-sprintf> </div> @@ -392,7 +515,7 @@ export default { <gl-icon class="gl-mr-2" :name="countIcon" :size="14" /> <item-count v-if="!isLoading" - :items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount" + :items-size="itemsCount" :max-issue-count="list.maxIssueCount" /> </span> @@ -407,44 +530,24 @@ export default { <!-- EE end --> </span> </div> - <gl-button-group v-if="showListHeaderActions" class="board-list-button-group gl-pl-2"> - <gl-button - v-if="isNewIssueShown" - v-show="!list.collapsed" - ref="newIssueBtn" - v-gl-tooltip.hover - :aria-label="$options.i18n.newIssue" - :title="$options.i18n.newIssue" - class="no-drag" - size="small" - icon="plus" - @click="showNewIssueForm" - /> - - <gl-button - v-if="isNewEpicShown" - v-show="!list.collapsed" - v-gl-tooltip.hover - :aria-label="$options.i18n.newEpic" - :title="$options.i18n.newEpic" - class="no-drag" - size="small" - icon="plus" - @click="showNewEpicForm" - /> - - <gl-button - v-if="isSettingsShown" - ref="settingsBtn" - v-gl-tooltip.hover - :aria-label="$options.i18n.listSettings" - class="no-drag" - size="small" - :title="$options.i18n.listSettings" - icon="settings" - @click="openSidebarSettings" - /> - </gl-button-group> + <gl-disclosure-dropdown + v-if="showListHeaderActions" + ref="headerListActions" + v-gl-tooltip.hover.top="{ + title: $options.i18n.listActions, + boundary: 'viewport', + }" + data-testid="header-list-actions" + class="gl-py-2 gl-ml-3" + :aria-label="$options.i18n.listActions" + :title="$options.i18n.listActions" + category="tertiary" + icon="ellipsis_v" + :text-sr-only="true" + :items="actionListItems" + no-caret + placement="right" + /> </h3> </header> </template> diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index c0c2699b63d..23e0f2510a7 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -1,8 +1,15 @@ <script> +import produce from 'immer'; import { GlButton, GlDrawer, GlLabel, GlModal, GlModalDirective } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; import { mapActions, mapState, mapGetters } from 'vuex'; -import { LIST, ListType, ListTypeTitles } from '~/boards/constants'; +import { + LIST, + ListType, + ListTypeTitles, + listsQuery, + deleteListQueries, +} from 'ee_else_ce/boards/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; @@ -31,8 +38,34 @@ export default { GlModal: GlModalDirective, }, mixins: [glFeatureFlagMixin(), Tracking.mixin()], - inject: ['canAdminList', 'scopedLabelsAvailable', 'isIssueBoard'], + inject: [ + 'boardType', + 'canAdminList', + 'issuableType', + 'scopedLabelsAvailable', + 'isIssueBoard', + 'isApolloBoard', + ], inheritAttrs: false, + props: { + listId: { + type: String, + required: true, + }, + boardId: { + type: String, + required: true, + }, + list: { + type: Object, + required: false, + default: () => null, + }, + queryVariables: { + type: Object, + required: true, + }, + }, data() { return { ListType, @@ -45,8 +78,11 @@ export default { isWipLimitsOn() { return this.glFeatures.wipLimits && this.isIssueBoard; }, + activeListId() { + return this.isApolloBoard ? this.listId : this.activeId; + }, activeList() { - return this.boardLists[this.activeId] || {}; + return (this.isApolloBoard ? this.list : this.boardLists[this.activeId]) || {}; }, activeListLabel() { return this.activeList.label; @@ -58,27 +94,60 @@ export default { return ListTypeTitles[ListType.label]; }, showSidebar() { + if (this.isApolloBoard) { + return Boolean(this.listId); + } return this.sidebarType === LIST && this.isSidebarOpen; }, }, created() { - eventHub.$on('sidebar.closeAll', this.unsetActiveId); + eventHub.$on('sidebar.closeAll', this.unsetActiveListId); }, beforeDestroy() { - eventHub.$off('sidebar.closeAll', this.unsetActiveId); + eventHub.$off('sidebar.closeAll', this.unsetActiveListId); }, methods: { ...mapActions(['unsetActiveId', 'removeList']), handleModalPrimary() { - this.deleteBoard(); + this.deleteBoardList(); }, showScopedLabels(label) { return this.scopedLabelsAvailable && isScopedLabel(label); }, - deleteBoard() { + async deleteBoardList() { this.track('click_button', { label: 'remove_list' }); - this.removeList(this.activeId); - this.unsetActiveId(); + if (this.isApolloBoard) { + await this.deleteList(this.activeListId); + } else { + this.removeList(this.activeId); + } + this.unsetActiveListId(); + }, + unsetActiveListId() { + if (this.isApolloBoard) { + this.$emit('unsetActiveId'); + } else { + this.unsetActiveId(); + } + }, + async deleteList(listId) { + await this.$apollo.mutate({ + mutation: deleteListQueries[this.issuableType].mutation, + variables: { + listId, + }, + update: (store) => { + store.updateQuery( + { query: listsQuery[this.issuableType].query, variables: this.queryVariables }, + (sourceData) => + produce(sourceData, (draftData) => { + draftData[this.boardType].board.lists.nodes = draftData[ + this.boardType + ].board.lists.nodes.filter((list) => list.id !== listId); + }), + ); + }, + }); }, }, }; @@ -91,7 +160,7 @@ export default { class="js-board-settings-sidebar gl-absolute" :open="showSidebar" variant="sidebar" - @close="unsetActiveId" + @close="unsetActiveListId" > <template #title> <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24"> @@ -109,7 +178,7 @@ export default { </gl-button> </div> </template> - <template v-if="isSidebarOpen"> + <template v-if="showSidebar"> <div v-if="boardListType === ListType.label"> <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label> <gl-label @@ -127,6 +196,7 @@ export default { <board-settings-sidebar-wip-limit v-if="isWipLimitsOn" :max-issue-count="activeList.maxIssueCount" + :active-list-id="activeListId" /> </template> </gl-drawer> @@ -136,11 +206,11 @@ export default { size="sm" :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { text: $options.i18n.modalAction, - attributes: [{ variant: 'danger' }], + attributes: { variant: 'danger' }, } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :action-secondary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { text: $options.i18n.modalCancel, - attributes: [{ variant: 'default' }], + attributes: { variant: 'default' }, } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @primary="handleModalPrimary" > diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index 2e20ed70bb0..c186346b2ac 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, }, + isSwimlanesOn: { + type: Boolean, + required: true, + }, }, data() { return { @@ -56,10 +60,28 @@ export default { return !this.isApolloBoard; }, update(data) { - return data.workspace.board; + const { board } = data.workspace; + return { + ...board, + labels: board.labels?.nodes, + }; }, }, }, + computed: { + hasScope() { + if (this.board.labels?.length > 0) { + return true; + } + let hasScope = false; + ['assignee', 'iterationCadence', 'iteration', 'milestone', 'weight'].forEach((attr) => { + if (this.board[attr] !== null && this.board[attr] !== undefined) { + hasScope = true; + } + }); + return hasScope; + }, + }, }; </script> @@ -73,15 +95,28 @@ export default { > <boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" /> <new-board-button /> - <issue-board-filtered-search v-if="isIssueBoard" /> - <epic-board-filtered-search v-else /> + <issue-board-filtered-search + v-if="isIssueBoard" + :board="board" + :is-swimlanes-on="isSwimlanesOn" + @setFilters="$emit('setFilters', $event)" + /> + <epic-board-filtered-search + v-else + :board="board" + @setFilters="$emit('setFilters', $event)" + /> </div> <div class="filter-dropdown-container gl-md-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-align-items-flex-start" > <toggle-labels /> - <toggle-epics-swimlanes v-if="swimlanesFeatureAvailable && isSignedIn" /> - <config-toggle /> + <toggle-epics-swimlanes + v-if="swimlanesFeatureAvailable && isSignedIn" + :is-swimlanes-on="isSwimlanesOn" + @toggleSwimlanes="$emit('toggleSwimlanes', $event)" + /> + <config-toggle :board-has-scope="hasScope" /> <board-add-new-column-trigger v-if="canAdminList" /> <toggle-focus /> </div> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index a1a49386b37..fddb58c45fe 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -8,6 +8,7 @@ import { GlDropdownItem, GlModalDirective, } from '@gitlab/ui'; +import { produce } from 'immer'; import { throttle } from 'lodash'; import { mapActions, mapState } from 'vuex'; @@ -89,6 +90,9 @@ export default { parentType() { return this.boardType; }, + boardQuery() { + return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery; + }, loading() { return this.loadingRecentBoards || this.loadingBoards; }, @@ -140,6 +144,9 @@ export default { }, methods: { ...mapActions(['setError', 'fetchBoard', 'unsetActiveId']), + fullBoardId(boardId) { + return fullBoardId(boardId); + }, showPage(page) { this.currentPage = page; }, @@ -155,9 +162,6 @@ export default { name: node.name, })); }, - boardQuery() { - return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery; - }, recentBoardsQuery() { return this.isGroupBoard ? groupRecentBoardsQuery : projectRecentBoardsQuery; }, @@ -191,6 +195,29 @@ export default { }, }); }, + addBoard(board) { + const { defaultClient: store } = this.$apollo.provider.clients; + + const sourceData = store.readQuery({ + query: this.boardQuery, + variables: { fullPath: this.fullPath }, + }); + + const newData = produce(sourceData, (draftState) => { + draftState[this.parentType].boards.edges = [ + ...draftState[this.parentType].boards.edges, + { node: board }, + ]; + }); + + store.writeQuery({ + query: this.boardQuery, + variables: { fullPath: this.fullPath }, + data: newData, + }); + + this.$emit('switchBoard', board.id); + }, isScrolledUp() { const { content } = this.$refs; @@ -226,14 +253,13 @@ export default { boardType: this.boardType, }); }, - fullBoardId(boardId) { - return fullBoardId(boardId); - }, async switchBoard(boardId, e) { if (isMetaKey(e)) { window.open(`${this.boardBaseUrl}/${boardId}`, '_blank'); } else if (this.isApolloBoard) { + // Epic board ID is supported in EE version of this file this.$emit('switchBoard', this.fullBoardId(boardId)); + updateHistory({ url: `${this.boardBaseUrl}/${boardId}` }); } else { this.unsetActiveId(); this.fetchCurrentBoard(boardId); @@ -357,6 +383,7 @@ export default { :weights="weights" :current-board="boardToUse" :current-page="currentPage" + @addBoard="addBoard" @cancel="cancel" /> </span> diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index 7002fd44294..dd3b9472879 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -16,6 +16,13 @@ export default { }, mixins: [Tracking.mixin()], inject: ['canAdminList'], + props: { + boardHasScope: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { ...mapGetters(['hasScope']), buttonText() { @@ -40,7 +47,7 @@ export default { v-gl-modal-directive="'board-config-modal'" v-gl-tooltip :title="tooltipTitle" - :class="{ 'dot-highlight': hasScope }" + :class="{ 'dot-highlight': hasScope || boardHasScope }" data-qa-selector="boards_config_button" @click.prevent="showPage" > diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index 7749391ec6f..3c056f296e1 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -1,12 +1,11 @@ <script> import { GlFilteredSearchToken } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { mapActions } from 'vuex'; import { orderBy } from 'lodash'; import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; -import issueBoardFilters from '~/boards/issue_board_filters'; +import issueBoardFilters from 'ee_else_ce/boards/issue_board_filters'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; @@ -47,11 +46,23 @@ export default { }, components: { BoardFilteredSearch }, inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'isGroupBoard'], + props: { + board: { + type: Object, + required: false, + default: () => {}, + }, + isSwimlanesOn: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { tokensCE() { const { issue, incident } = this.$options.i18n; const { types } = this.$options; - const { fetchUsers, fetchLabels } = issueBoardFilters( + const { fetchUsers, fetchLabels, fetchMilestones } = issueBoardFilters( this.$apollo, this.fullPath, this.isGroupBoard, @@ -135,7 +146,7 @@ export default { token: MilestoneToken, unique: true, shouldSkipSort: true, - fetchMilestones: this.fetchMilestones, + fetchMilestones, }, { icon: 'issues', @@ -176,7 +187,6 @@ export default { }, }, methods: { - ...mapActions(['fetchMilestones']), preloadedUsers() { return gon?.current_user_id ? [ @@ -194,5 +204,11 @@ export default { </script> <template> - <board-filtered-search data-testid="issue-board-filtered-search" :tokens="tokens" /> + <board-filtered-search + data-testid="issue-board-filtered-search" + :tokens="tokens" + :board="board" + :is-swimlanes-on="isSwimlanesOn" + @setFilters="$emit('setFilters', $event)" + /> </template> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index c3f7c7d3ca2..1f28974afd1 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -95,9 +95,12 @@ export default { class="board-card-info-icon gl-mr-2" name="calendar" /> - <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ - body - }}</time> + <time + :class="{ 'text-danger': isPastDue }" + datetime="date" + class="gl-font-sm board-card-info-text" + >{{ body }}</time + > </span> <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement"> <span class="bold">{{ __('Due date') }}</span> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index bc12717a92d..611e875fa40 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -38,7 +38,7 @@ export default { <span> <span ref="issueTimeEstimate" class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help"> <gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" /> - <time class="board-card-info-text">{{ timeEstimate }}</time> + <time class="gl-font-sm board-card-info-text">{{ timeEstimate }}</time> </span> <gl-tooltip :target="() => $refs.issueTimeEstimate" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index 43a2b13b81c..020edcb01b8 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -5,6 +5,7 @@ import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.v import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; +import { titleQueries } from 'ee_else_ce/boards/constants'; export default { components: { @@ -19,6 +20,13 @@ export default { directives: { autofocusonshow, }, + inject: ['fullPath', 'issuableType', 'isEpicBoard', 'isApolloBoard'], + props: { + activeItem: { + type: Object, + required: true, + }, + }, data() { return { title: '', @@ -27,7 +35,10 @@ export default { }; }, computed: { - ...mapGetters({ item: 'activeBoardItem' }), + ...mapGetters(['activeBoardItem']), + item() { + return this.isApolloBoard ? this.activeItem : this.activeBoardItem; + }, pendingChangesStorageKey() { return this.getPendingChangesKey(this.item); }, @@ -67,8 +78,9 @@ export default { }, async setPendingState() { const pendingChanges = localStorage.getItem(this.pendingChangesStorageKey); + const shouldOpen = pendingChanges !== this.title; - if (pendingChanges) { + if (pendingChanges && shouldOpen) { this.title = pendingChanges; this.showChangesAlert = true; await this.$nextTick(); @@ -83,6 +95,26 @@ export default { this.showChangesAlert = false; localStorage.removeItem(this.pendingChangesStorageKey); }, + async setActiveBoardItemTitle() { + if (!this.isApolloBoard) { + await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath }); + return; + } + const { fullPath, issuableType, isEpicBoard, title } = this; + const workspacePath = isEpicBoard + ? { groupPath: fullPath } + : { projectPath: this.projectPath }; + await this.$apollo.mutate({ + mutation: titleQueries[issuableType].mutation, + variables: { + input: { + ...workspacePath, + iid: String(this.item.iid), + title, + }, + }, + }); + }, async setTitle() { this.$refs.sidebarItem.collapse(); @@ -92,7 +124,7 @@ export default { try { this.loading = true; - await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath }); + await this.setActiveBoardItemTitle(); localStorage.removeItem(this.pendingChangesStorageKey); this.showChangesAlert = false; } catch (e) { diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 712e3e1ac4a..7fe89ffbb52 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,25 +1,18 @@ import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; -import { TYPE_ISSUE } from '~/issues/constants'; +import { TYPE_EPIC, TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; 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 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 groupBoardQuery from './graphql/group_board.query.graphql'; import projectBoardQuery from './graphql/project_board.query.graphql'; import listIssuesQuery from './graphql/lists_issues.query.graphql'; -/* eslint-disable-next-line @gitlab/require-i18n-strings */ -export const AssigneeIdParamValues = ['Any', 'None']; - -export const issuableTypes = { - issue: 'issue', - epic: 'epic', -}; - export const BoardType = { project: 'project', group: 'group', @@ -64,10 +57,10 @@ export const INCIDENT = 'INCIDENT'; export const flashAnimationDuration = 2000; export const boardQuery = { - [BoardType.group]: { + [WORKSPACE_GROUP]: { query: groupBoardQuery, }, - [BoardType.project]: { + [WORKSPACE_PROJECT]: { query: projectBoardQuery, }, }; @@ -84,6 +77,12 @@ export const updateListQueries = { }, }; +export const toggleCollapsedMutations = { + [TYPE_ISSUE]: { + mutation: toggleListCollapsedMutation, + }, +}; + export const deleteListQueries = { [TYPE_ISSUE]: { mutation: destroyBoardListMutation, @@ -94,7 +93,7 @@ export const titleQueries = { [TYPE_ISSUE]: { mutation: issueSetTitleMutation, }, - [issuableTypes.epic]: { + [TYPE_EPIC]: { mutation: updateEpicTitleMutation, }, }; @@ -103,7 +102,7 @@ export const subscriptionQueries = { [TYPE_ISSUE]: { mutation: issueSetSubscriptionMutation, }, - [issuableTypes.epic]: { + [TYPE_EPIC]: { mutation: updateEpicSubscriptionMutation, }, }; @@ -143,6 +142,7 @@ export const MilestoneFilterType = { started: 'Started', upcoming: 'Upcoming', }; +/* eslint-enable @gitlab/require-i18n-strings */ export const DraggableItemTypes = { card: 'card', @@ -155,7 +155,6 @@ export const MilestoneIDs = { }; export default { - BoardType, ListType, }; @@ -178,3 +177,5 @@ export const BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS = [ action: () => {}, }, ]; + +export const GroupByParamType = {}; diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql index 06e8c8783de..e987ee08df2 100644 --- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql @@ -3,6 +3,7 @@ query BoardLists( $fullPath: ID! $boardId: BoardID! + $listId: ListID $filters: BoardIssueInput $isGroup: Boolean = false $isProject: Boolean = false @@ -12,7 +13,7 @@ query BoardLists( board(id: $boardId) { id hideBacklogList - lists(issueFilters: $filters) { + lists(issueFilters: $filters, id: $listId) { nodes { ...BoardListFragment } @@ -24,7 +25,7 @@ query BoardLists( board(id: $boardId) { id hideBacklogList - lists(issueFilters: $filters) { + lists(issueFilters: $filters, id: $listId) { nodes { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql new file mode 100644 index 00000000000..81b1b68a038 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql @@ -0,0 +1,7 @@ +#import "ee_else_ce/boards/graphql/issue.fragment.graphql" + +query activeBoardItem { + activeBoardItem @client { + ...Issue + } +} diff --git a/app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql b/app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql new file mode 100644 index 00000000000..890152989eb --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql @@ -0,0 +1,9 @@ +#import "ee_else_ce/boards/graphql/board_list.fragment.graphql" + +mutation toggleListCollapsed($list: BoardList!, $collapsed: Boolean!) { + clientToggleListCollapsed(list: $list, collapsed: $collapsed) @client { + list { + ...BoardListFragment + } + } +} diff --git a/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql new file mode 100644 index 00000000000..cce558c649e --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql @@ -0,0 +1,7 @@ +#import "ee_else_ce/boards/graphql/issue.fragment.graphql" + +mutation setActiveBoardItem($boardItem: Issue) { + setActiveBoardItem(boardItem: $boardItem) @client { + ...Issue + } +} diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql index 9e6c26063e9..14811b435e1 100644 --- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql @@ -1,5 +1,5 @@ query GroupBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) { - group(fullPath: $fullPath) { + workspace: group(fullPath: $fullPath) { id milestones( includeAncestors: true diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql index 02aa08f90ef..9af92a6ff2d 100644 --- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql @@ -1,5 +1,5 @@ query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) { - project(fullPath: $fullPath) { + workspace: project(fullPath: $fullPath) { id milestones( searchTitle: $searchTerm diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 4c6f341828c..67388284d31 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -3,9 +3,8 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import BoardApp from '~/boards/components/board_app.vue'; import '~/boards/filters/due_date_filters'; -import { BoardType } from '~/boards/constants'; import store from '~/boards/stores'; -import { TYPE_ISSUE } from '~/issues/constants'; +import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import { NavigationType, isLoggedIn, @@ -68,8 +67,8 @@ function mountBoardApp(el) { initialFilterParams, boardBaseUrl: el.dataset.boardBaseUrl, boardType, - isGroupBoard: boardType === BoardType.group, - isProjectBoard: boardType === BoardType.project, + isGroupBoard: boardType === WORKSPACE_GROUP, + isProjectBoard: boardType === WORKSPACE_PROJECT, currentUserId: gon.current_user_id || null, boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null, labelsManagePath: el.dataset.labelsManagePath, diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js index 7e9b68778d5..27efb3f775c 100644 --- a/app/assets/javascripts/boards/issue_board_filters.js +++ b/app/assets/javascripts/boards/issue_board_filters.js @@ -1,5 +1,7 @@ import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql'; import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql'; +import groupBoardMilestonesQuery from './graphql/group_board_milestones.query.graphql'; +import projectBoardMilestonesQuery from './graphql/project_board_milestones.query.graphql'; import boardLabels from './graphql/board_labels.query.graphql'; export default function issueBoardFilters(apollo, fullPath, isGroupBoard) { @@ -37,8 +39,27 @@ export default function issueBoardFilters(apollo, fullPath, isGroupBoard) { .then(transformLabels); }; + const fetchMilestones = (searchTerm) => { + const variables = { + fullPath, + searchTerm, + }; + + const query = isGroupBoard ? groupBoardMilestonesQuery : projectBoardMilestonesQuery; + + return apollo + .query({ + query, + variables, + }) + .then(({ data }) => { + return data.workspace?.milestones.nodes; + }); + }; + return { fetchLabels, fetchUsers, + fetchMilestones, }; } diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 1b4e6334723..a144054d680 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/browser'; import { sortBy } from 'lodash'; import { - BoardType, ListType, inactiveId, flashAnimationDuration, @@ -34,7 +33,7 @@ import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_defe import { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client'; -import { TYPE_ISSUE } from '~/issues/constants'; +import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -61,7 +60,7 @@ export default { return gqlClient .query({ - query: boardType === BoardType.group ? groupBoardQuery : projectBoardQuery, + query: boardType === WORKSPACE_GROUP ? groupBoardQuery : projectBoardQuery, variables, }) .then(({ data }) => { @@ -139,8 +138,8 @@ export default { boardId: fullBoardId, filters: filterParams, ...(issuableType === TYPE_ISSUE && { - isGroup: boardType === BoardType.group, - isProject: boardType === BoardType.project, + isGroup: boardType === WORKSPACE_GROUP, + isProject: boardType === WORKSPACE_PROJECT, }), }; @@ -234,8 +233,8 @@ export default { const variables = { fullPath, searchTerm, - isGroup: boardType === BoardType.group, - isProject: boardType === BoardType.project, + isGroup: boardType === WORKSPACE_GROUP, + isProject: boardType === WORKSPACE_PROJECT, }; commit(types.RECEIVE_LABELS_REQUEST); @@ -268,10 +267,10 @@ export default { }; let query; - if (boardType === BoardType.project) { + if (boardType === WORKSPACE_PROJECT) { query = projectBoardMilestonesQuery; } - if (boardType === BoardType.group) { + if (boardType === WORKSPACE_GROUP) { query = groupBoardMilestonesQuery; } @@ -286,8 +285,8 @@ export default { variables, }) .then(({ data }) => { - const errors = data[boardType]?.errors; - const milestones = data[boardType]?.milestones.nodes; + const errors = data.workspace?.errors; + const milestones = data.workspace?.milestones.nodes; if (errors?.[0]) { throw new Error(errors[0]); @@ -431,8 +430,8 @@ export default { boardId: fullBoardId, id: listId, filters: filterParams, - isGroup: boardType === BoardType.group, - isProject: boardType === BoardType.project, + isGroup: boardType === WORKSPACE_GROUP, + isProject: boardType === WORKSPACE_PROJECT, first: DEFAULT_BOARD_LIST_ITEMS_SIZE, after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined, }; @@ -710,7 +709,7 @@ export default { ) => { const input = formatIssueInput(issueInput, boardConfig); - if (boardType === BoardType.project) { + if (boardType === WORKSPACE_PROJECT) { input.projectPath = fullPath; } diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index fef5862f319..505c011b034 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,18 +1,19 @@ import { cloneDeep, pull, union } from 'lodash'; import Vue from 'vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_EPIC } from '~/issues/constants'; import { s__, __ } from '~/locale'; import { formatIssue } from '../boards_util'; -import { issuableTypes } from '../constants'; import * as mutationTypes from './mutation_types'; const updateListItemsCount = ({ state, listId, value }) => { const list = state.boardLists[listId]; - if (state.issuableType === issuableTypes.epic) { - Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value }); - } else { - Vue.set(state.boardLists, listId, { ...list }); + if (state.issuableType === TYPE_EPIC) { + const listItem = cloneDeep(state.boardLists[listId]); + listItem.metadataepicsCount += value; + Vue.set(state.boardLists[listId], listId, listItem); } + Vue.set(state.boardLists, listId, { ...list }); }; export const removeItemFromList = ({ state, listId, itemId, reordering = false }) => { |