diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 21:18:33 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 21:18:33 +0300 |
commit | f64a639bcfa1fc2bc89ca7db268f594306edfd7c (patch) | |
tree | a2c3c2ebcc3b45e596949db485d6ed18ffaacfa1 /app/assets/javascripts/boards | |
parent | bfbc3e0d6583ea1a91f627528bedc3d65ba4b10f (diff) |
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc40
Diffstat (limited to 'app/assets/javascripts/boards')
48 files changed, 1083 insertions, 546 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 13ad820477f..2cd25f58770 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -36,11 +36,11 @@ export function formatIssue(issue) { } export function formatListIssues(listIssues) { - const issues = {}; - let listIssuesCount; + const boardItems = {}; + let listItemsCount; const listData = listIssues.nodes.reduce((map, list) => { - listIssuesCount = list.issues.count; + listItemsCount = list.issues.count; let sortedIssues = list.issues.edges.map((issueNode) => ({ ...issueNode.node, })); @@ -58,14 +58,14 @@ export function formatListIssues(listIssues) { assignees: i.assignees?.nodes || [], }; - issues[id] = listIssue; + boardItems[id] = listIssue; return id; }), }; }, {}); - return { listData, issues, listIssuesCount }; + return { listData, boardItems, listItemsCount }; } export function formatListsPageInfo(lists) { @@ -113,31 +113,31 @@ export function formatIssueInput(issueInput, boardConfig) { }; } -export function moveIssueListHelper(issue, fromList, toList) { - const updatedIssue = issue; +export function moveItemListHelper(item, fromList, toList) { + const updatedItem = item; if ( toList.listType === ListType.label && - !updatedIssue.labels.find((label) => label.id === toList.label.id) + !updatedItem.labels.find((label) => label.id === toList.label.id) ) { - updatedIssue.labels.push(toList.label); + updatedItem.labels.push(toList.label); } if (fromList?.label && fromList.listType === ListType.label) { - updatedIssue.labels = updatedIssue.labels.filter((label) => fromList.label.id !== label.id); + updatedItem.labels = updatedItem.labels.filter((label) => fromList.label.id !== label.id); } if ( toList.listType === ListType.assignee && - !updatedIssue.assignees.find((assignee) => assignee.id === toList.assignee.id) + !updatedItem.assignees.find((assignee) => assignee.id === toList.assignee.id) ) { - updatedIssue.assignees.push(toList.assignee); + updatedItem.assignees.push(toList.assignee); } if (fromList?.assignee && fromList.listType === ListType.assignee) { - updatedIssue.assignees = updatedIssue.assignees.filter( + updatedItem.assignees = updatedItem.assignees.filter( (assignee) => assignee.id !== fromList.assignee.id, ); } - return updatedIssue; + return updatedItem; } export function isListDraggable(list) { diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue new file mode 100644 index 00000000000..3c7c792b787 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -0,0 +1,143 @@ +<script> +import { + GlFormRadio, + GlFormRadioGroup, + GlLabel, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; +import { ListType } from '~/boards/constants'; +import boardsStore from '~/boards/stores/boards_store'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + BoardAddNewColumnForm, + GlFormRadio, + GlFormRadioGroup, + GlLabel, + }, + directives: { + GlTooltip, + }, + inject: ['scopedLabelsAvailable'], + data() { + return { + selectedId: null, + }; + }, + computed: { + ...mapState(['labels', 'labelsLoading']), + ...mapGetters(['getListByLabelId', 'shouldUseGraphQL']), + selectedLabel() { + if (!this.selectedId) { + return null; + } + return this.labels.find(({ id }) => id === this.selectedId); + }, + columnForSelected() { + return this.getListByLabelId(this.selectedId); + }, + }, + created() { + this.filterItems(); + }, + methods: { + ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']), + highlight(listId) { + if (this.shouldUseGraphQL) { + this.highlightList(listId); + } else { + const list = boardsStore.state.lists.find(({ id }) => id === listId); + list.highlighted = true; + setTimeout(() => { + list.highlighted = false; + }, 2000); + } + }, + addList() { + if (!this.selectedLabel) { + return; + } + + this.setAddColumnFormVisibility(false); + + if (this.columnForSelected) { + const listId = this.columnForSelected.id; + this.highlight(listId); + return; + } + + if (this.shouldUseGraphQL) { + this.createList({ labelId: this.selectedId }); + } else { + const listObj = { + labelId: getIdFromGraphQLId(this.selectedId), + title: this.selectedLabel.title, + position: boardsStore.state.lists.length - 2, + list_type: ListType.label, + label: this.selectedLabel, + }; + + boardsStore.new(listObj); + } + }, + + filterItems(searchTerm) { + this.fetchLabels(searchTerm); + }, + + showScopedLabels(label) { + return this.scopedLabelsAvailable && isScopedLabel(label); + }, + }, +}; +</script> + +<template> + <board-add-new-column-form + :loading="labelsLoading" + :form-description="__('A label list displays issues with the selected label.')" + :search-label="__('Select label')" + :search-placeholder="__('Search labels')" + :selected-id="selectedId" + @filter-items="filterItems" + @add-list="addList" + > + <template slot="selected"> + <gl-label + v-if="selectedLabel" + v-gl-tooltip + :title="selectedLabel.title" + :description="selectedLabel.description" + :background-color="selectedLabel.color" + :scoped="showScopedLabels(selectedLabel)" + /> + </template> + + <template slot="items"> + <gl-form-radio-group + v-if="labels.length > 0" + v-model="selectedId" + class="gl-overflow-y-auto gl-px-5 gl-pt-3" + > + <label + v-for="label in labels" + :key="label.id" + class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal" + > + <gl-form-radio :value="label.id" class="gl-mb-0" /> + <span + class="dropdown-label-box gl-top-0" + :style="{ + backgroundColor: label.color, + }" + ></span> + <span>{{ label.title }}</span> + </label> + </gl-form-radio-group> + </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 new file mode 100644 index 00000000000..d85343a5390 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -0,0 +1,131 @@ +<script> +import { GlButton, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import { __ } from '~/locale'; + +export default { + i18n: { + add: __('Add to board'), + cancel: __('Cancel'), + newList: __('New list'), + noneSelected: __('None'), + noResults: __('No matching results'), + selected: __('Selected'), + }, + components: { + GlButton, + GlFormGroup, + GlSearchBoxByType, + GlSkeletonLoader, + }, + props: { + loading: { + type: Boolean, + required: true, + }, + formDescription: { + type: String, + required: true, + }, + searchLabel: { + type: String, + required: true, + }, + searchPlaceholder: { + type: String, + required: true, + }, + selectedId: { + type: [Number, String], + required: false, + default: null, + }, + }, + data() { + return { + searchValue: '', + }; + }, + methods: { + ...mapActions(['setAddColumnFormVisibility']), + }, +}; +</script> + +<template> + <div + class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0" + data-testid="board-add-new-column" + data-qa-selector="board_add_new_list" + > + <div + class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white" + > + <h3 + class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + data-testid="board-add-column-form-title" + > + {{ $options.i18n.newList }} + </h3> + + <div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden"> + <slot name="select-list-type"> + <div class="gl-mb-5"></div> + </slot> + + <p class="gl-px-5">{{ formDescription }}</p> + + <div class="gl-px-5 gl-pb-4"> + <label class="gl-mb-2">{{ $options.i18n.selected }}</label> + <slot name="selected"> + <div class="gl-text-gray-500">{{ $options.i18n.noneSelected }}</div> + </slot> + </div> + + <gl-form-group + class="gl-mx-5 gl-mb-3" + :label="searchLabel" + label-for="board-available-column-entities" + > + <gl-search-box-by-type + id="board-available-column-entities" + v-model="searchValue" + debounce="250" + :placeholder="searchPlaceholder" + @input="$emit('filter-items', $event)" + /> + </gl-form-group> + + <div v-if="loading" class="gl-px-5"> + <gl-skeleton-loader :width="500" :height="172"> + <rect width="480" height="20" x="10" y="15" rx="4" /> + <rect width="380" height="20" x="10" y="50" rx="4" /> + <rect width="430" 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> + </div> + <div + class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + > + <gl-button + data-testid="cancelAddNewColumn" + class="gl-ml-auto gl-mr-3" + @click="setAddColumnFormVisibility(false)" + >{{ $options.i18n.cancel }}</gl-button + > + <gl-button + data-testid="addNewColumnButton" + :disabled="!selectedId" + variant="confirm" + class="gl-mr-4" + @click="$emit('add-list')" + >{{ $options.i18n.add }}</gl-button + > + </div> + </div> + </div> +</template> 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 85fca589279..7c08e33be7e 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 @@ -13,7 +13,7 @@ export default { </script> <template> - <span class="gl-ml-3 gl-display-flex gl-align-items-center"> + <span class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list"> <gl-button variant="confirm" @click="setAddColumnFormVisibility(true)" >{{ __('Create list') }} </gl-button> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index e6009343626..aacea0b970c 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,14 +1,11 @@ <script> -import sidebarEventHub from '~/sidebar/event_hub'; -import eventHub from '../eventhub'; -import boardsStore from '../stores/boards_store'; -import BoardCardLayout from './board_card_layout.vue'; -import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import BoardCardInner from './board_card_inner.vue'; export default { - name: 'BoardsIssueCard', + name: 'BoardCard', components: { - BoardCardLayout: gon.features?.graphqlBoardLists ? BoardCardLayout : BoardCardLayoutDeprecated, + BoardCardInner, }, props: { list: { @@ -16,34 +13,46 @@ export default { default: () => ({}), required: false, }, - issue: { + item: { type: Object, default: () => ({}), required: false, }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + index: { + type: Number, + default: 0, + required: false, + }, }, - methods: { - // These are methods instead of computed's, because boardsStore is not reactive. + computed: { + ...mapState(['selectedBoardItems', 'activeId']), + ...mapGetters(['isSwimlanesOn']), isActive() { - return this.getActiveId() === this.issue.id; + return this.item.id === this.activeId; }, - getActiveId() { - return boardsStore.detail?.issue?.id; + multiSelectVisible() { + return ( + !this.activeId && + this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1 + ); }, - showIssue({ isMultiSelect }) { - // If no issues are opened, close all sidebars first - if (!this.getActiveId()) { - sidebarEventHub.$emit('sidebar.closeAll'); - } - if (this.isActive()) { - eventHub.$emit('clearDetailIssue', isMultiSelect); + }, + methods: { + ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), + toggleIssue(e) { + // Don't do anything if this happened on a no trigger element + if (e.target.classList.contains('js-no-trigger')) return; - if (isMultiSelect) { - eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); - } + const isMultiSelect = e.ctrlKey || e.metaKey; + if (isMultiSelect) { + this.toggleBoardItemMultiSelection(this.item); } else { - eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); - boardsStore.setListDetail(this.list); + this.toggleBoardItem({ boardItem: this.item }); } }, }, @@ -51,12 +60,22 @@ export default { </script> <template> - <board-card-layout + <li data-qa-selector="board_card" - :issue="issue" - :list="list" - :is-active="isActive()" - v-bind="$attrs" - @show="showIssue" - /> + :class="{ + 'multi-select': multiSelectVisible, + 'user-can-drag': !disabled && item.id, + 'is-disabled': disabled || !item.id, + 'is-active': isActive, + }" + :index="index" + :data-item-id="item.id" + :data-item-iid="item.iid" + :data-item-path="item.referencePath" + data-testid="board_card" + class="board-card gl-p-5 gl-rounded-base" + @mouseup="toggleIssue($event)" + > + <board-card-inner :list="list" :item="item" :update-filters="true" /> + </li> </template> diff --git a/app/assets/javascripts/boards/components/board_card_deprecated.vue b/app/assets/javascripts/boards/components/board_card_deprecated.vue new file mode 100644 index 00000000000..e12a2836f67 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card_deprecated.vue @@ -0,0 +1,61 @@ +<script> +// This component is being replaced in favor of './board_card.vue' for GraphQL boards +import sidebarEventHub from '~/sidebar/event_hub'; +import eventHub from '../eventhub'; +import boardsStore from '../stores/boards_store'; +import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue'; + +export default { + components: { + BoardCardLayout: BoardCardLayoutDeprecated, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + issue: { + type: Object, + default: () => ({}), + required: false, + }, + }, + methods: { + // These are methods instead of computed's, because boardsStore is not reactive. + isActive() { + return this.getActiveId() === this.issue.id; + }, + getActiveId() { + return boardsStore.detail?.issue?.id; + }, + showIssue({ isMultiSelect }) { + // If no issues are opened, close all sidebars first + if (!this.getActiveId()) { + sidebarEventHub.$emit('sidebar.closeAll'); + } + if (this.isActive()) { + eventHub.$emit('clearDetailIssue', isMultiSelect); + + if (isMultiSelect) { + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); + } + } else { + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); + boardsStore.setListDetail(this.list); + } + }, + }, +}; +</script> + +<template> + <board-card-layout + data-qa-selector="board_card" + :issue="issue" + :list="list" + :is-active="isActive()" + v-bind="$attrs" + @show="showIssue" + /> +</template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index e5ea30df767..d4d6b17a589 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -1,8 +1,8 @@ <script> import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { sortBy } from 'lodash'; -import { mapActions, mapState } from 'vuex'; -import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { sprintf, __, n__ } from '~/locale'; @@ -26,10 +26,10 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [issueCardInner], - inject: ['groupId', 'rootPath', 'scopedLabelsAvailable'], + mixins: [boardCardInner], + inject: ['rootPath', 'scopedLabelsAvailable'], props: { - issue: { + item: { type: Object, required: true, }, @@ -53,18 +53,19 @@ export default { }, computed: { ...mapState(['isShowingLabels']), + ...mapGetters(['isEpicBoard']), cappedAssignees() { // e.g. maxRender is 4, // Render up to all 4 assignees if there are only 4 assigness // Otherwise render up to the limitBeforeCounter - if (this.issue.assignees.length <= this.maxRender) { - return this.issue.assignees.slice(0, this.maxRender); + if (this.item.assignees.length <= this.maxRender) { + return this.item.assignees.slice(0, this.maxRender); } - return this.issue.assignees.slice(0, this.limitBeforeCounter); + return this.item.assignees.slice(0, this.limitBeforeCounter); }, numberOverLimit() { - return this.issue.assignees.length - this.limitBeforeCounter; + return this.item.assignees.length - this.limitBeforeCounter; }, assigneeCounterTooltip() { const { numberOverLimit, maxCounter } = this; @@ -79,31 +80,35 @@ export default { return `+${this.numberOverLimit}`; }, shouldRenderCounter() { - if (this.issue.assignees.length <= this.maxRender) { + if (this.item.assignees.length <= this.maxRender) { return false; } - return this.issue.assignees.length > this.numberOverLimit; + return this.item.assignees.length > this.numberOverLimit; }, - issueId() { - if (this.issue.iid) { - return `#${this.issue.iid}`; + itemPrefix() { + return this.isEpicBoard ? '&' : '#'; + }, + + itemId() { + if (this.item.iid) { + return `${this.itemPrefix}${this.item.iid}`; } return false; }, showLabelFooter() { - return this.isShowingLabels && this.issue.labels.find(this.showLabel); + return this.isShowingLabels && this.item.labels.find(this.showLabel); }, - issueReferencePath() { - const { referencePath, groupId } = this.issue; - return !groupId ? referencePath.split('#')[0] : null; + itemReferencePath() { + const { referencePath } = this.item; + return referencePath.split(this.itemPrefix)[0]; }, orderedLabels() { - return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title'); + return sortBy(this.item.labels.filter(this.isNonListLabel), 'title'); }, blockedLabel() { - if (this.issue.blockedByCount) { - return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount); + if (this.item.blockedByCount) { + return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.item.blockedByCount); } return __('Blocked issue'); }, @@ -160,7 +165,7 @@ export default { <div class="gl-display-flex" dir="auto"> <h4 class="board-card-title gl-mb-0 gl-mt-0"> <gl-icon - v-if="issue.blocked" + v-if="item.blocked" v-gl-tooltip name="issue-block" :title="blockedLabel" @@ -169,7 +174,7 @@ export default { data-testid="issue-blocked-icon" /> <gl-icon - v-if="issue.confidential" + v-if="item.confidential" v-gl-tooltip name="eye-slash" :title="__('Confidential')" @@ -177,11 +182,11 @@ export default { :aria-label="__('Confidential')" /> <a - :href="issue.path || issue.webUrl || ''" - :title="issue.title" + :href="item.path || item.webUrl || ''" + :title="item.title" class="js-no-trigger" @mousemove.stop - >{{ issue.title }}</a + >{{ item.title }}</a > </h4> </div> @@ -205,29 +210,30 @@ export default { class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container" > <span - v-if="issue.referencePath" + v-if="item.referencePath" class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" + :class="{ 'gl-font-base': isEpicBoard }" > <tooltip-on-truncate - v-if="issueReferencePath" - :title="issueReferencePath" + v-if="itemReferencePath" + :title="itemReferencePath" placement="bottom" - class="board-issue-path gl-text-truncate gl-font-weight-bold" - >{{ issueReferencePath }}</tooltip-on-truncate + class="board-item-path gl-text-truncate gl-font-weight-bold" + >{{ itemReferencePath }}</tooltip-on-truncate > - #{{ issue.iid }} + {{ itemId }} </span> <span class="board-info-items gl-mt-3 gl-display-inline-block"> <issue-due-date - v-if="issue.dueDate" - :date="issue.dueDate" - :closed="issue.closed || Boolean(issue.closedAt)" + v-if="item.dueDate" + :date="item.dueDate" + :closed="item.closed || Boolean(item.closedAt)" /> - <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> + <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" /> <issue-card-weight - v-if="validIssueWeight" - :weight="issue.weight" - @click="filterByWeight(issue.weight)" + v-if="validIssueWeight(item)" + :weight="item.weight" + @click="filterByWeight(item.weight)" /> </span> </div> diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue deleted file mode 100644 index 5e3c3702519..00000000000 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ /dev/null @@ -1,98 +0,0 @@ -<script> -import { mapActions, mapGetters, mapState } from 'vuex'; -import { ISSUABLE } from '~/boards/constants'; -import IssueCardInner from './issue_card_inner.vue'; - -export default { - name: 'BoardCardLayout', - components: { - IssueCardInner, - }, - props: { - list: { - type: Object, - default: () => ({}), - required: false, - }, - issue: { - type: Object, - default: () => ({}), - required: false, - }, - disabled: { - type: Boolean, - default: false, - required: false, - }, - index: { - type: Number, - default: 0, - required: false, - }, - isActive: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - showDetail: false, - }; - }, - computed: { - ...mapState(['selectedBoardItems']), - ...mapGetters(['isSwimlanesOn']), - multiSelectVisible() { - return this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1; - }, - }, - methods: { - ...mapActions(['setActiveId', 'toggleBoardItemMultiSelection']), - mouseDown() { - this.showDetail = true; - }, - mouseMove() { - this.showDetail = false; - }, - showIssue(e) { - // Don't do anything if this happened on a no trigger element - if (e.target.classList.contains('js-no-trigger')) return; - - const isMultiSelect = e.ctrlKey || e.metaKey; - - if (!isMultiSelect) { - this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE }); - } else { - this.toggleBoardItemMultiSelection(this.issue); - } - - if (this.showDetail || isMultiSelect) { - this.showDetail = false; - } - }, - }, -}; -</script> - -<template> - <li - :class="{ - 'multi-select': multiSelectVisible, - 'user-can-drag': !disabled && issue.id, - 'is-disabled': disabled || !issue.id, - 'is-active': isActive, - }" - :index="index" - :data-issue-id="issue.id" - :data-issue-iid="issue.iid" - :data-issue-path="issue.referencePath" - data-testid="board_card" - class="board-card gl-p-5 gl-rounded-base" - @mousedown="mouseDown" - @mousemove="mouseMove" - @mouseup="showIssue($event)" - > - <issue-card-inner :list="list" :issue="issue" :update-filters="true" /> - </li> -</template> diff --git a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue index f9a726134a3..3381e4c3a7d 100644 --- a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue @@ -3,13 +3,12 @@ import { mapActions, mapGetters } from 'vuex'; import { ISSUABLE } from '~/boards/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import boardsStore from '../stores/boards_store'; -import IssueCardInner from './issue_card_inner.vue'; import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue'; export default { name: 'BoardCardLayout', components: { - IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated, + IssueCardInner: IssueCardInnerDeprecated, }, mixins: [glFeatureFlagMixin()], props: { diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 41b9ee795eb..c9e667d526c 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -32,12 +32,12 @@ export default { }, computed: { ...mapState(['filterParams', 'highlightedLists']), - ...mapGetters(['getIssuesByList']), + ...mapGetters(['getBoardItemsByList']), highlighted() { return this.highlightedLists.includes(this.list.id); }, - listIssues() { - return this.getIssuesByList(this.list.id); + listItems() { + return this.getBoardItemsByList(this.list.id); }, isListDraggable() { return isListDraggable(this.list); @@ -46,11 +46,20 @@ export default { watch: { filterParams: { handler() { - this.fetchIssuesForList({ listId: this.list.id }); + if (this.list.id) { + this.fetchItemsForList({ listId: this.list.id }); + } }, deep: true, immediate: true, }, + 'list.id': { + handler(id) { + if (id) { + this.fetchItemsForList({ listId: this.list.id }); + } + }, + }, highlighted: { handler(highlighted) { if (highlighted) { @@ -63,7 +72,7 @@ export default { }, }, methods: { - ...mapActions(['fetchIssuesForList']), + ...mapActions(['fetchItemsForList']), }, }; </script> @@ -87,7 +96,7 @@ export default { <board-list ref="board-list" :disabled="disabled" - :issues="listIssues" + :board-items="listItems" :list="list" :can-admin-list="canAdminList" /> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 9b10e7d7db5..e9c4237d759 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -3,6 +3,7 @@ import { GlAlert } from '@gitlab/ui'; import { sortBy } from 'lodash'; import Draggable from 'vuedraggable'; import { mapState, mapGetters, mapActions } from 'vuex'; +import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options'; import defaultSortableConfig from '~/sortable/sortable_config'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -11,7 +12,11 @@ import BoardColumnDeprecated from './board_column_deprecated.vue'; export default { components: { - BoardColumn: gon.features?.graphqlBoardLists ? BoardColumn : BoardColumnDeprecated, + BoardAddNewColumn, + BoardColumn: + gon.features?.graphqlBoardLists || gon.features?.epicBoards + ? BoardColumn + : BoardColumnDeprecated, BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, @@ -33,15 +38,18 @@ export default { }, }, computed: { - ...mapState(['boardLists', 'error']), - ...mapGetters(['isSwimlanesOn']), + ...mapState(['boardLists', 'error', 'addColumnForm']), + ...mapGetters(['isSwimlanesOn', 'isEpicBoard']), + addColumnFormVisible() { + return this.addColumnForm?.visible; + }, boardListsToUse() { - return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn + return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard ? sortBy([...Object.values(this.boardLists)], 'position') : this.lists; }, canDragColumns() { - return this.glFeatures.graphqlBoardLists && this.canAdminList; + return !this.isEpicBoard && this.glFeatures.graphqlBoardLists && this.canAdminList; }, boardColumnWrapper() { return this.canDragColumns ? Draggable : 'div'; @@ -62,12 +70,17 @@ export default { }, methods: { ...mapActions(['moveList']), + afterFormEnters() { + const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; + el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); + }, handleDragOnStart() { sortableStart(); }, handleDragOnEnd(params) { sortableEnd(); + if (this.isEpicBoard) return; const { item, newIndex, oldIndex, to } = params; @@ -100,13 +113,17 @@ export default { @end="handleDragOnEnd" > <board-column - v-for="list in boardListsToUse" - :key="list.id" + v-for="(list, index) in boardListsToUse" + :key="index" ref="board" :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> + + <transition name="slide" @after-enter="afterFormEnters"> + <board-add-new-column v-if="addColumnFormVisible" /> + </transition> </component> <epics-swimlanes diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index f65f00bcccc..d8504dcfb0f 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,5 +1,6 @@ <script> import { GlModal } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { getParameterByName } from '~/lib/utils/common_utils'; @@ -106,6 +107,7 @@ export default { }; }, computed: { + ...mapGetters(['isEpicBoard', 'isGroupBoard', 'isProjectBoard']), isNewForm() { return this.currentPage === formType.new; }, @@ -161,42 +163,49 @@ export default { currentMutation() { return this.board.id ? updateBoardMutation : createBoardMutation; }, - mutationVariables() { + baseMutationVariables() { const { board } = this; - /* eslint-disable @gitlab/require-i18n-strings */ - let baseMutationVariables = { + const variables = { name: board.name, hideBacklogList: board.hide_backlog_list, hideClosedList: board.hide_closed_list, }; - if (this.scopedIssueBoardFeatureEnabled) { - baseMutationVariables = { - ...baseMutationVariables, - weight: board.weight, - assigneeId: board.assignee?.id ? convertToGraphQLId('User', board.assignee.id) : null, - milestoneId: - board.milestone?.id || board.milestone?.id === 0 - ? convertToGraphQLId('Milestone', board.milestone.id) - : null, - labelIds: board.labels.map(fullLabelId), - iterationId: board.iteration_id - ? convertToGraphQLId('Iteration', board.iteration_id) - : null, - }; - } - /* eslint-enable @gitlab/require-i18n-strings */ return board.id ? { - ...baseMutationVariables, + ...variables, id: fullBoardId(board.id), } : { - ...baseMutationVariables, - projectPath: this.projectId ? this.fullPath : null, - groupPath: this.groupId ? this.fullPath : null, + ...variables, + projectPath: this.isProjectBoard ? this.fullPath : undefined, + groupPath: this.isGroupBoard ? this.fullPath : undefined, }; }, + boardScopeMutationVariables() { + /* eslint-disable @gitlab/require-i18n-strings */ + return { + weight: this.board.weight, + assigneeId: this.board.assignee?.id + ? convertToGraphQLId('User', this.board.assignee.id) + : null, + milestoneId: + this.board.milestone?.id || this.board.milestone?.id === 0 + ? convertToGraphQLId('Milestone', this.board.milestone.id) + : null, + labelIds: this.board.labels.map(fullLabelId), + iterationId: this.board.iteration_id + ? convertToGraphQLId('Iteration', this.board.iteration_id) + : null, + }; + /* eslint-enable @gitlab/require-i18n-strings */ + }, + mutationVariables() { + return { + ...this.baseMutationVariables, + ...(this.scopedIssueBoardFeatureEnabled ? this.boardScopeMutationVariables : {}), + }; + }, }, mounted() { this.resetFormState(); @@ -208,6 +217,16 @@ export default { setIteration(iterationId) { this.board.iteration_id = iterationId; }, + boardCreateResponse(data) { + return data.createBoard.board.webPath; + }, + boardUpdateResponse(data) { + const path = data.updateBoard.board.webPath; + const param = getParameterByName('group_by') + ? `?group_by=${getParameterByName('group_by')}` + : ''; + return `${path}${param}`; + }, async createOrUpdateBoard() { const response = await this.$apollo.mutate({ mutation: this.currentMutation, @@ -215,14 +234,10 @@ export default { }); if (!this.board.id) { - return response.data.createBoard.board.webPath; + return this.boardCreateResponse(response.data); } - const path = response.data.updateBoard.board.webPath; - const param = getParameterByName('group_by') - ? `?group_by=${getParameterByName('group_by')}` - : ''; - return `${path}${param}`; + return this.boardUpdateResponse(response.data); }, async submit() { if (this.board.name.length === 0) return; @@ -309,7 +324,7 @@ export default { /> <board-scope - v-if="scopedIssueBoardFeatureEnabled" + v-if="scopedIssueBoardFeatureEnabled && !isEpicBoard" :collapse-scope="isNewForm" :board="board" :can-admin-board="canAdminBoard" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 7495b1163be..ae8434be312 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import Draggable from 'vuedraggable'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options'; import { sprintf, __ } from '~/locale'; import defaultSortableConfig from '~/sortable/sortable_config'; @@ -12,9 +12,10 @@ import BoardNewIssue from './board_new_issue.vue'; export default { name: 'BoardList', i18n: { - loadingIssues: __('Loading issues'), - loadingMoreissues: __('Loading more issues'), + loading: __('Loading'), + loadingMoreboardItems: __('Loading more'), showingAllIssues: __('Showing all issues'), + showingAllEpics: __('Showing all epics'), }, components: { BoardCard, @@ -30,7 +31,7 @@ export default { type: Object, required: true, }, - issues: { + boardItems: { type: Array, required: true, }, @@ -49,14 +50,19 @@ export default { }, computed: { ...mapState(['pageInfoByListId', 'listsFlags']), + ...mapGetters(['isEpicBoard']), + listItemsCount() { + return this.isEpicBoard ? this.list.epicsCount : this.list.issuesCount; + }, paginatedIssueText() { - return sprintf(__('Showing %{pageSize} of %{total} issues'), { - pageSize: this.issues.length, - total: this.list.issuesCount, + return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), { + pageSize: this.boardItems.length, + total: this.listItemsCount, + issuableType: this.isEpicBoard ? 'epics' : 'issues', }); }, - issuesSizeExceedsMax() { - return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount; + boardItemsSizeExceedsMax() { + return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount; }, hasNextPage() { return this.pageInfoByListId[this.list.id].hasNextPage; @@ -71,8 +77,13 @@ export default { // When list is draggable, the reference to the list needs to be accessed differently return this.canAdminList ? this.$refs.list.$el : this.$refs.list; }, - showingAllIssues() { - return this.issues.length === this.list.issuesCount; + showingAllItems() { + return this.boardItems.length === this.listItemsCount; + }, + showingAllItemsText() { + return this.isEpicBoard + ? this.$options.i18n.showingAllEpics + : this.$options.i18n.showingAllIssues; }, treeRootWrapper() { return this.canAdminList ? Draggable : 'ul'; @@ -85,14 +96,14 @@ export default { tag: 'ul', 'ghost-class': 'board-card-drag-active', 'data-list-id': this.list.id, - value: this.issues, + value: this.boardItems, }; return this.canAdminList ? options : {}; }, }, watch: { - issues() { + boardItems() { this.$nextTick(() => { this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); }); @@ -112,7 +123,7 @@ export default { this.listRef.removeEventListener('scroll', this.onScroll); }, methods: { - ...mapActions(['fetchIssuesForList', 'moveIssue']), + ...mapActions(['fetchItemsForList', 'moveItem']), listHeight() { return this.listRef.getBoundingClientRect().height; }, @@ -126,7 +137,7 @@ export default { this.listRef.scrollTop = 0; }, loadNextPage() { - this.fetchIssuesForList({ listId: this.list.id, fetchNext: true }); + this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); }, toggleForm() { this.showIssueForm = !this.showIssueForm; @@ -148,40 +159,40 @@ export default { handleDragOnEnd(params) { sortableEnd(); const { newIndex, oldIndex, from, to, item } = params; - const { issueId, issueIid, issuePath } = item.dataset; + const { itemId, itemIid, itemPath } = item.dataset; const { children } = to; let moveBeforeId; let moveAfterId; - const getIssueId = (el) => Number(el.dataset.issueId); + const getItemId = (el) => Number(el.dataset.itemId); - // If issue is being moved within the same list + // If item is being moved within the same list if (from === to) { if (newIndex > oldIndex && children.length > 1) { - // If issue is being moved down we look for the issue that ends up before - moveBeforeId = getIssueId(children[newIndex]); + // If item is being moved down we look for the item that ends up before + moveBeforeId = getItemId(children[newIndex]); } else if (newIndex < oldIndex && children.length > 1) { - // If issue is being moved up we look for the issue that ends up after - moveAfterId = getIssueId(children[newIndex]); + // If item is being moved up we look for the item that ends up after + moveAfterId = getItemId(children[newIndex]); } else { - // If issue remains in the same list at the same position we do nothing + // If item remains in the same list at the same position we do nothing return; } } else { - // We look for the issue that ends up before the moved issue if it exists + // We look for the item that ends up before the moved item if it exists if (children[newIndex - 1]) { - moveBeforeId = getIssueId(children[newIndex - 1]); + moveBeforeId = getItemId(children[newIndex - 1]); } - // We look for the issue that ends up after the moved issue if it exists + // We look for the item that ends up after the moved item if it exists if (children[newIndex]) { - moveAfterId = getIssueId(children[newIndex]); + moveAfterId = getItemId(children[newIndex]); } } - this.moveIssue({ - issueId, - issueIid, - issuePath, + this.moveItem({ + itemId, + itemIid, + itemPath, fromListId: from.dataset.listId, toListId: to.dataset.listId, moveBeforeId, @@ -201,7 +212,7 @@ export default { <div v-if="loading" class="gl-mt-4 gl-text-center" - :aria-label="$options.i18n.loadingIssues" + :aria-label="$options.i18n.loading" data-testid="board_list_loading" > <gl-loading-icon /> @@ -214,24 +225,28 @@ export default { v-bind="treeRootOptions" :data-board="list.id" :data-board-type="list.listType" - :class="{ 'bg-danger-100': issuesSizeExceedsMax }" + :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }" class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" data-testid="tree-root-wrapper" @start="handleDragOnStart" @end="handleDragOnEnd" > <board-card - v-for="(issue, index) in issues" + v-for="(item, index) in boardItems" ref="issue" - :key="issue.id" + :key="item.id" :index="index" :list="list" - :issue="issue" + :item="item" :disabled="disabled" /> <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> - <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" /> - <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span> + <gl-loading-icon + v-if="loadingMore" + :label="$options.i18n.loadingMoreboardItems" + data-testid="count-loading-icon" + /> + <span v-if="showingAllItems">{{ showingAllItemsText }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> </component> diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index 9b4961d362d..d59fbcc1b31 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -11,7 +11,7 @@ import { sortableEnd, } from '../mixins/sortable_default_options'; import boardsStore from '../stores/boards_store'; -import boardCard from './board_card.vue'; +import boardCard from './board_card_deprecated.vue'; import boardNewIssue from './board_new_issue_deprecated.vue'; // This component is being replaced in favor of './board_list.vue' for GraphQL boards diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index a933370427c..6ccaec4a633 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -8,16 +8,16 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { isListDraggable } from '~/boards/boards_util'; -import { isScopedLabel } from '~/lib/utils/common_utils'; +import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { n__, s__, __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; import AccessorUtilities from '../../lib/utils/accessor'; import { inactiveId, LIST, ListType } from '../constants'; import eventHub from '../eventhub'; -import IssueCount from './issue_count.vue'; +import ItemCount from './item_count.vue'; export default { i18n: { @@ -33,7 +33,7 @@ export default { GlTooltip, GlIcon, GlSprintf, - IssueCount, + ItemCount, }, directives: { GlTooltip: GlTooltipDirective, @@ -70,6 +70,7 @@ export default { }, computed: { ...mapState(['activeId']), + ...mapGetters(['isEpicBoard']), isLoggedIn() { return Boolean(this.currentUserId); }, @@ -97,11 +98,14 @@ export default { showListDetails() { return !this.list.collapsed || !this.isSwimlanesHeader; }, - issuesCount() { + itemsCount() { return this.list.issuesCount; }, - issuesTooltipLabel() { - return n__(`%d issue`, `%d issues`, this.issuesCount); + countIcon() { + return 'issues'; + }, + itemsTooltipLabel() { + return n__(`%d issue`, `%d issues`, this.itemsCount); }, chevronTooltip() { return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; @@ -110,7 +114,7 @@ export default { return this.list.collapsed ? 'chevron-down' : 'chevron-right'; }, isNewIssueShown() { - return this.listType === ListType.backlog || this.showListHeaderButton; + return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard; }, isSettingsShown() { return ( @@ -131,8 +135,14 @@ export default { return !this.disabled && isListDraggable(this.list); }, }, + created() { + const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`)); + if ((!this.isLoggedIn || this.isEpicBoard) && localCollapsed) { + this.toggleListCollapsed({ listId: this.list.id, collapsed: true }); + } + }, methods: { - ...mapActions(['updateList', 'setActiveId']), + ...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']), openSidebarSettings() { if (this.activeId === inactiveId) { sidebarEventHub.$emit('sidebar.closeAll'); @@ -148,10 +158,10 @@ export default { eventHub.$emit(`toggle-issue-form-${this.list.id}`); }, toggleExpanded() { - // eslint-disable-next-line vue/no-mutating-props - this.list.collapsed = !this.list.collapsed; + const collapsed = !this.list.collapsed; + this.toggleListCollapsed({ listId: this.list.id, collapsed }); - if (!this.isLoggedIn) { + if (!this.isLoggedIn || this.isEpicBoard) { this.addToLocalStorage(); } else { this.updateListFunction(); @@ -163,7 +173,7 @@ export default { }, addToLocalStorage() { if (AccessorUtilities.isLocalStorageAccessSafe()) { - localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed); + localStorage.setItem(`${this.uniqueKey}.collapsed`, this.list.collapsed); } }, updateListFunction() { @@ -203,6 +213,7 @@ export default { class="board-title-caret no-drag gl-cursor-pointer" category="tertiary" size="small" + data-testid="board-title-caret" @click="toggleExpanded" /> <!-- EE start --> @@ -301,11 +312,11 @@ export default { <div v-if="list.maxIssueCount !== 0"> • <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> - <template #issuesSize>{{ issuesTooltipLabel }}</template> + <template #issuesSize>{{ itemsTooltipLabel }}</template> <template #maxIssueCount>{{ list.maxIssueCount }}</template> </gl-sprintf> </div> - <div v-else>• {{ issuesTooltipLabel }}</div> + <div v-else>• {{ itemsTooltipLabel }}</div> <div v-if="weightFeatureAvailable"> • <gl-sprintf :message="__('%{totalWeight} total weight')"> @@ -323,13 +334,13 @@ export default { }" > <span class="gl-display-inline-flex"> - <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> - <span ref="issueCount" class="issue-count-badge-count"> - <gl-icon class="gl-mr-2" name="issues" /> - <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" /> + <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" /> + <span ref="itemCount" class="issue-count-badge-count"> + <gl-icon class="gl-mr-2" :name="countIcon" /> + <item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" /> </span> <!-- EE start --> - <template v-if="weightFeatureAvailable"> + <template v-if="weightFeatureAvailable && !isEpicBoard"> <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> <gl-icon class="gl-mr-2" name="weight" /> diff --git a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue index ff043d3aa01..429ffd4cd06 100644 --- a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue @@ -17,7 +17,7 @@ import AccessorUtilities from '../../lib/utils/accessor'; import { inactiveId, LIST, ListType } from '../constants'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; -import IssueCount from './issue_count.vue'; +import IssueCount from './item_count.vue'; // This component is being replaced in favor of './board_list_header.vue' for GraphQL boards @@ -308,7 +308,7 @@ export default { <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> <span ref="issueCount" class="issue-count-badge-count"> <gl-icon class="gl-mr-2" name="issues" /> - <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" /> + <issue-count :items-size="issuesCount" :max-issue-count="list.maxIssueCount" /> </span> <!-- The following is only true in EE. --> <template v-if="weightFeatureAvailable"> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 1df154688c8..a81c28733cd 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -32,8 +32,9 @@ export default { }, computed: { ...mapState(['selectedProject']), + ...mapGetters(['isGroupBoard']), disabled() { - if (this.groupId) { + if (this.isGroupBoard) { return this.title === '' || !this.selectedProject.name; } return this.title === ''; @@ -98,7 +99,7 @@ export default { name="issue_title" autocomplete="off" /> - <project-select v-if="groupId" :group-id="groupId" :list="list" /> + <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> <div class="clearfix gl-mt-3"> <gl-button ref="submitButton" diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue index eff87ff110e..16f23dfff0e 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import ListIssue from 'ee_else_ce/boards/models/issue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -31,8 +32,9 @@ export default { }; }, computed: { + ...mapGetters(['isGroupBoard']), disabled() { - if (this.groupId) { + if (this.isGroupBoard) { return this.title === '' || !this.selectedProject.name; } return this.title === ''; @@ -110,7 +112,7 @@ export default { name="issue_title" autocomplete="off" /> - <project-select v-if="groupId" :group-id="groupId" :list="list" /> + <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> <div class="clearfix gl-mt-3"> <gl-button ref="submitButton" diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 6d5a13be3ac..55bc91cbcff 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -20,7 +20,6 @@ import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue' import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; import eventHub from '~/sidebar/event_hub'; import boardsStore from '../stores/boards_store'; -import RemoveBtn from './sidebar/remove_issue.vue'; export default Vue.extend({ components: { @@ -29,7 +28,6 @@ export default Vue.extend({ GlLabel, SidebarEpicsSelect: () => import('ee_component/sidebar/components/sidebar_item_epics_select.vue'), - RemoveBtn, Subscriptions, TimeTracker, SidebarAssigneesWidget, @@ -107,8 +105,8 @@ export default Vue.extend({ closeSidebar() { this.detail.issue = {}; }, - setAssignees(data) { - boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes); + setAssignees(assignees) { + boardsStore.detail.issue.setAssignees(assignees); }, showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 2a064aaa885..5124467136e 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -9,6 +9,9 @@ import { GlModalDirective, } from '@gitlab/ui'; import { throttle } from 'lodash'; +import { mapGetters, mapState } from 'vuex'; + +import BoardForm from 'ee_else_ce/boards/components/board_form.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; @@ -18,8 +21,6 @@ import eventHub from '../eventhub'; import groupQuery from '../graphql/group_boards.query.graphql'; import projectQuery from '../graphql/project_boards.query.graphql'; -import BoardForm from './board_form.vue'; - const MIN_BOARDS_TO_VIEW_RECENT = 10; export default { @@ -109,8 +110,10 @@ export default { }; }, computed: { + ...mapState(['boardType']), + ...mapGetters(['isGroupBoard']), parentType() { - return this.groupId ? 'group' : 'project'; + return this.boardType; }, loading() { return this.loadingRecentBoards || Boolean(this.loadingBoards); @@ -123,6 +126,9 @@ export default { board() { return this.currentBoard; }, + showCreate() { + return this.multipleIssueBoardsAvailable; + }, showDelete() { return this.boards.length > 1; }, @@ -158,6 +164,18 @@ export default { cancel() { this.showPage(''); }, + boardUpdate(data) { + if (!data?.[this.parentType]) { + return []; + } + return data[this.parentType].boards.edges.map(({ node }) => ({ + id: getIdFromGraphQLId(node.id), + name: node.name, + })); + }, + boardQuery() { + return this.isGroupBoard ? groupQuery : projectQuery; + }, loadBoards(toggleDropdown = true) { if (toggleDropdown && this.boards.length > 0) { return; @@ -167,21 +185,14 @@ export default { variables() { return { fullPath: this.fullPath }; }, - query() { - return this.groupId ? groupQuery : projectQuery; - }, + query: this.boardQuery, loadingKey: 'loadingBoards', - update(data) { - if (!data?.[this.parentType]) { - return []; - } - return data[this.parentType].boards.edges.map(({ node }) => ({ - id: getIdFromGraphQLId(node.id), - name: node.name, - })); - }, + update: this.boardUpdate, }); + this.loadRecentBoards(); + }, + loadRecentBoards() { this.loadingRecentBoards = true; // Follow up to fetch recent boards using GraphQL // https://gitlab.com/gitlab-org/gitlab/-/issues/300985 @@ -322,7 +333,7 @@ export default { <gl-dropdown-divider /> <gl-dropdown-item - v-if="multipleIssueBoardsAvailable" + v-if="showCreate" v-gl-modal-directive="'board-config-modal'" data-qa-selector="create_new_board_button" @click.prevent="showPage('new')" diff --git a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue index 33ad46a0d29..85c7b27336b 100644 --- a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue +++ b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue @@ -9,6 +9,7 @@ import { GlModalDirective, } from '@gitlab/ui'; import { throttle } from 'lodash'; +import { mapGetters, mapState } from 'vuex'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -108,8 +109,10 @@ export default { }; }, computed: { + ...mapState(['boardType']), + ...mapGetters(['isGroupBoard']), parentType() { - return this.groupId ? 'group' : 'project'; + return this.boardType; }, loading() { return this.loadingRecentBoards || Boolean(this.loadingBoards); @@ -167,7 +170,7 @@ export default { return { fullPath: this.state.endpoints.fullPath }; }, query() { - return this.groupId ? groupQuery : projectQuery; + return this.isGroupBoard ? groupQuery : projectQuery; }, loadingKey: 'loadingBoards', update(data) { diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue new file mode 100644 index 00000000000..7ec99e51f5b --- /dev/null +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -0,0 +1,64 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { formType } from '~/boards/constants'; +import eventHub from '~/boards/eventhub'; +import { s__, __ } from '~/locale'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModalDirective, + }, + props: { + boardsStore: { + type: Object, + required: true, + }, + canAdminList: { + type: Boolean, + required: true, + }, + hasScope: { + type: Boolean, + required: true, + }, + }, + data() { + return { + state: this.boardsStore.state, + }; + }, + computed: { + buttonText() { + return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope'); + }, + tooltipTitle() { + return this.hasScope ? __("This board's scope is reduced") : ''; + }, + }, + methods: { + showPage() { + eventHub.$emit('showBoardModal', formType.edit); + return this.boardsStore.showPage(formType.edit); + }, + }, +}; +</script> + +<template> + <div class="gl-ml-3 gl-display-flex gl-align-items-center"> + <gl-button + v-gl-modal-directive="'board-config-modal'" + v-gl-tooltip + :title="tooltipTitle" + :class="{ 'dot-highlight': hasScope }" + data-qa-selector="boards_config_button" + @click.prevent="showPage" + > + {{ buttonText }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/filtered_search.vue b/app/assets/javascripts/boards/components/filtered_search.vue new file mode 100644 index 00000000000..8505ea39a6b --- /dev/null +++ b/app/assets/javascripts/boards/components/filtered_search.vue @@ -0,0 +1,54 @@ +<script> +import { mapActions } from 'vuex'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +export default { + i18n: { + search: __('Search'), + }, + components: { FilteredSearch }, + props: { + search: { + type: String, + required: false, + default: '', + }, + }, + computed: { + initialSearch() { + return [{ type: 'filtered-search-term', value: { data: this.search } }]; + }, + }, + methods: { + ...mapActions(['performSearch']), + handleSearch(filters) { + let itemValue = ''; + const [item] = filters; + + if (filters.length === 0) { + itemValue = ''; + } else { + itemValue = item?.value?.data; + } + + historyPushState(setUrlParams({ search: itemValue }, window.location.href)); + + this.performSearch(); + }, + }, +}; +</script> + +<template> + <filtered-search + class="gl-w-full" + namespace="" + :tokens="[]" + :search-input-placeholder="$options.i18n.search" + :initial-filter-value="initialSearch" + @onFilter="handleSearch" + /> +</template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue index 069cc2cda22..2652fac1818 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue @@ -2,7 +2,7 @@ import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { sortBy } from 'lodash'; import { mapState } from 'vuex'; -import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; +import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; @@ -24,7 +24,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [issueCardInner], + mixins: [boardCardInner], inject: ['groupId', 'rootPath'], props: { issue: { @@ -207,7 +207,7 @@ export default { /> <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight - v-if="validIssueWeight" + v-if="validIssueWeight(issue)" :weight="issue.weight" @click="filterByWeight(issue.weight)" /> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 7e3f36c8a17..73ec008c2b6 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -86,7 +86,11 @@ export default { <template> <span> <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number"> - <gl-icon :class="{ 'text-danger': isPastDue }" class="board-card-info-icon" name="calendar" /> + <gl-icon + :class="{ 'text-danger': isPastDue }" + class="board-card-info-icon gl-mr-2" + name="calendar" + /> <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ body }}</time> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index 42d187b9b40..1ab7deebfaf 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -37,7 +37,7 @@ export default { <template> <span> <span ref="issueTimeEstimate" class="board-card-info card-number"> - <gl-icon name="hourglass" class="board-card-info-icon" /> + <gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" /> <time class="board-card-info-text">{{ timeEstimate }}</time> </span> <gl-tooltip diff --git a/app/assets/javascripts/boards/components/issue_count.vue b/app/assets/javascripts/boards/components/item_count.vue index d55f7151d7e..9b1ff254766 100644 --- a/app/assets/javascripts/boards/components/issue_count.vue +++ b/app/assets/javascripts/boards/components/item_count.vue @@ -7,7 +7,7 @@ export default { required: false, default: 0, }, - issuesSize: { + itemsSize: { type: Number, required: false, default: 0, @@ -18,16 +18,16 @@ export default { return this.maxIssueCount !== 0; }, issuesExceedMax() { - return this.isMaxLimitSet && this.issuesSize > this.maxIssueCount; + return this.isMaxLimitSet && this.itemsSize > this.maxIssueCount; }, }, }; </script> <template> - <div class="issue-count text-nowrap"> - <span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }"> - {{ issuesSize }} + <div class="item-count text-nowrap"> + <span :class="{ 'text-danger': issuesExceedMax }" data-testid="board-items-count"> + {{ itemsSize }} </span> <span v-if="isMaxLimitSet" class="js-max-issue-size"> {{ maxIssueCount }} diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index bf69f8140d5..e66cae0ce18 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -2,11 +2,11 @@ import { GlIcon } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import ModalStore from '../../stores/modal_store'; -import IssueCardInner from '../issue_card_inner.vue'; +import BoardCardInner from '../board_card_inner.vue'; export default { components: { - IssueCardInner, + BoardCardInner, GlIcon, }, props: { @@ -126,7 +126,7 @@ export default { class="board-card position-relative p-3 rounded" @click="toggleIssue($event, issue)" > - <issue-card-inner :issue="issue" /> + <board-card-inner :item="issue" /> <gl-icon v-if="issue.selected" :aria-label="'Issue #' + issue.id + ' selected'" diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index cfc1752a828..77b6af77652 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -7,7 +7,7 @@ import { GlIntersectionObserver, GlLoadingIcon, } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; import { ListType } from '../constants'; @@ -49,7 +49,8 @@ export default { }; }, computed: { - ...mapState(['groupProjects', 'groupProjectsFlags']), + ...mapState(['groupProjectsFlags']), + ...mapGetters(['activeGroupProjects']), selectedProjectName() { return this.selectedProject.name || this.$options.i18n.dropdownText; }, @@ -65,7 +66,7 @@ export default { }; }, isFetchResultEmpty() { - return this.groupProjects.length === 0; + return this.activeGroupProjects.length === 0; }, hasNextPage() { return this.groupProjectsFlags.pageInfo?.hasNextPage; @@ -84,7 +85,7 @@ export default { methods: { ...mapActions(['fetchGroupProjects', 'setSelectedProject']), selectProject(projectId) { - this.selectedProject = this.groupProjects.find((project) => project.id === projectId); + this.selectedProject = this.activeGroupProjects.find((project) => project.id === projectId); this.setSelectedProject(this.selectedProject); }, loadMoreProjects() { @@ -113,7 +114,7 @@ export default { :placeholder="$options.i18n.searchPlaceholder" /> <gl-dropdown-item - v-for="project in groupProjects" + v-for="project in activeGroupProjects" v-show="!groupProjectsFlags.isLoading" :key="project.id" :name="project.name" diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue index 5605e9945ea..afe161d9c54 100644 --- a/app/assets/javascripts/boards/components/project_select_deprecated.vue +++ b/app/assets/javascripts/boards/components/project_select_deprecated.vue @@ -25,6 +25,7 @@ export default { with_shared: false, include_subgroups: true, order_by: 'similarity', + archived: false, }, components: { GlLoadingIcon, diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue index aa4fdcf9a94..f01c8e8fa20 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue @@ -64,6 +64,8 @@ export default { v-if="!activeIssue.emailsDisabled" :value="activeIssue.subscribed" :is-loading="loading" + :label="$options.i18n.header.title" + label-position="hidden" data-testid="notification-subscribe-toggle" @change="handleToggleSubscription" /> diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue deleted file mode 100644 index 8d65f3240c8..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue +++ /dev/null @@ -1,88 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import { deprecatedCreateFlash as Flash } from '../../../flash'; -import { __ } from '../../../locale'; -import boardsStore from '../../stores/boards_store'; - -export default { - components: { - GlButton, - }, - props: { - issue: { - type: Object, - required: true, - }, - list: { - type: Object, - required: true, - }, - }, - computed: { - updateUrl() { - return this.issue.path; - }, - }, - methods: { - removeIssue() { - const { issue } = this; - const lists = issue.getLists(); - const req = this.buildPatchRequest(issue, lists); - - const data = { - issue: this.seedPatchRequest(issue, req), - }; - - if (data.issue.label_ids.length === 0) { - data.issue.label_ids = ['']; - } - - // Post the remove data - axios.patch(this.updateUrl, data).catch(() => { - Flash(__('Failed to remove issue from board, please try again.')); - - lists.forEach((list) => { - list.addIssue(issue); - }); - }); - - // Remove from the frontend store - lists.forEach((list) => { - list.removeIssue(issue); - }); - - boardsStore.clearDetailIssue(); - }, - /** - * Build the default patch request. - */ - buildPatchRequest(issue, lists) { - const listLabelIds = lists.map((list) => list.label.id); - - const labelIds = issue.labels - .map((label) => label.id) - .filter((id) => !listLabelIds.includes(id)); - - return { - label_ids: labelIds, - }; - }, - /** - * Seed the given patch request. - * - * (This is overridden in EE) - */ - seedPatchRequest(issue, req) { - return req; - }, - }, -}; -</script> -<template> - <div class="block list"> - <gl-button variant="default" category="secondary" block="block" @click="removeIssue"> - {{ __('Remove from board') }} - </gl-button> - </div> -</template> diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js index 2d1ec238274..7f327c5764d 100644 --- a/app/assets/javascripts/boards/config_toggle.js +++ b/app/assets/javascripts/boards/config_toggle.js @@ -1 +1,24 @@ -export default () => {}; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ConfigToggle from './components/config_toggle.vue'; + +export default (boardsStore) => { + const el = document.querySelector('.js-board-config'); + + if (!el) { + return; + } + + gl.boardConfigToggle = new Vue({ + el, + render(h) { + return h(ConfigToggle, { + props: { + boardsStore, + canAdminList: parseBoolean(el.dataset.canAdminList), + hasScope: parseBoolean(el.dataset.hasScope), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 3ab89b2c9da..65ebfe7be6c 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,5 +1,10 @@ import { __ } from '~/locale'; +export const issuableTypes = { + issue: 'issue', + epic: 'epic', +}; + export const BoardType = { project: 'project', group: 'group', diff --git a/app/assets/javascripts/boards/filtered_search.js b/app/assets/javascripts/boards/filtered_search.js new file mode 100644 index 00000000000..182a2cf3724 --- /dev/null +++ b/app/assets/javascripts/boards/filtered_search.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import store from '~/boards/stores'; +import { queryToObject } from '~/lib/utils/url_utility'; +import FilteredSearch from './components/filtered_search.vue'; + +export default () => { + const queryParams = queryToObject(window.location.search); + const el = document.getElementById('js-board-filtered-search'); + + /* + When https://github.com/vuejs/vue-apollo/pull/1153 is merged and deployed + we can remove apolloProvider option from here. Currently without it its causing + an error + */ + + return new Vue({ + el, + store, + apolloProvider: {}, + render: (createElement) => + createElement(FilteredSearch, { + props: { search: queryParams.search }, + }), + }); +}; diff --git a/app/assets/javascripts/boards/graphql/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql index 42a94419a97..b19a24e8808 100644 --- a/app/assets/javascripts/boards/graphql/board_labels.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql @@ -7,14 +7,14 @@ query BoardLabels( $isProject: Boolean = false ) { group(fullPath: $fullPath) @include(if: $isGroup) { - labels(searchTerm: $searchTerm) { + labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) { nodes { ...Label } } } project(fullPath: $fullPath) @include(if: $isProject) { - labels(searchTerm: $searchTerm) { + labels(searchTerm: $searchTerm, includeAncestorGroups: true) { nodes { ...Label } diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql index f78a21baa7f..3eb23f62940 100644 --- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql @@ -1,21 +1,7 @@ -#import "ee_else_ce/boards/graphql/board_list.fragment.graphql" +#import "./board_list.fragment.graphql" -mutation CreateBoardList( - $boardId: BoardID! - $backlog: Boolean - $labelId: LabelID - $milestoneId: MilestoneID - $assigneeId: UserID -) { - boardListCreate( - input: { - boardId: $boardId - backlog: $backlog - labelId: $labelId - milestoneId: $milestoneId - assigneeId: $assigneeId - } - ) { +mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) { + boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) { list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql index 1afa6e48547..80a37c9943d 100644 --- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql @@ -8,6 +8,7 @@ query getGroupProjects($fullPath: ID!, $search: String, $after: String) { name fullPath nameWithNamespace + archived } pageInfo { ...PageInfo diff --git a/app/assets/javascripts/boards/graphql/users_search.query.graphql b/app/assets/javascripts/boards/graphql/users_search.query.graphql deleted file mode 100644 index ca016495d79..00000000000 --- a/app/assets/javascripts/boards/graphql/users_search.query.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query usersSearch($search: String!) { - users(search: $search) { - nodes { - username - name - webUrl - avatarUrl - id - } - } -} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 859295318ed..ceca5b0a451 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -6,7 +6,6 @@ import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; -import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; import { setWeightFetchingState, setEpicFetchingState, @@ -24,6 +23,7 @@ import '~/boards/models/milestone'; import '~/boards/models/project'; import '~/boards/filters/due_date_filters'; import BoardAddIssuesModal from '~/boards/components/modal/index.vue'; +import { issuableTypes } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import FilteredSearchBoards from '~/boards/filtered_search_boards'; import modalMixin from '~/boards/mixins/modal_mixins'; @@ -40,6 +40,7 @@ import { } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; +import boardConfigToggle from './config_toggle'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; Vue.use(VueApollo); @@ -52,7 +53,6 @@ let issueBoardsApp; export default () => { const $boardApp = document.getElementById('board-app'); - // check for browser back and trigger a hard reload to circumvent browser caching. window.addEventListener('pageshow', (event) => { const isNavTypeBackForward = @@ -72,6 +72,14 @@ export default () => { boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours); } + if (gon?.features?.boardsFilteredSearch) { + import('~/boards/filtered_search') + .then(({ default: initFilteredSearch }) => { + initFilteredSearch(apolloProvider); + }) + .catch(() => {}); + } + // eslint-disable-next-line @gitlab/no-runtime-template-compiler issueBoardsApp = new Vue({ el: $boardApp, @@ -96,6 +104,9 @@ export default () => { ? parseInt($boardApp.dataset.boardWeight, 10) : null, scopedLabelsAvailable: parseBoolean($boardApp.dataset.scopedLabels), + milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable), + assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable), + iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable), }, store, apolloProvider, @@ -124,6 +135,7 @@ export default () => { fullPath: $boardApp.dataset.fullPath, boardType: this.parent, disabled: this.disabled, + issuableType: issuableTypes.issue, boardConfig: { milestoneId: parseInt($boardApp.dataset.boardMilestoneId, 10), milestoneTitle: $boardApp.dataset.boardMilestoneTitle || '', @@ -162,8 +174,15 @@ export default () => { eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { - this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); - this.filterManager.setup(); + if (!gon.features?.boardsFilteredSearch) { + this.filterManager = new FilteredSearchBoards( + boardsStore.filter, + true, + boardsStore.cantEdit, + ); + + this.filterManager.setup(); + } this.performSearch(); @@ -349,7 +368,7 @@ export default () => { toggleFocusMode(ModalStore, boardsStore); toggleLabels(); - if (gon.features?.swimlanes) { + if (gon.licensed_features?.swimlanes) { toggleEpicsSwimlanes(); } diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/board_card_inner.js index 04e971b756d..a6f278f3bc9 100644 --- a/app/assets/javascripts/boards/mixins/issue_card_inner.js +++ b/app/assets/javascripts/boards/mixins/board_card_inner.js @@ -1,10 +1,8 @@ export default { - computed: { + methods: { validIssueWeight() { return false; }, - }, - methods: { filterByWeight() {}, }, }; diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index fa58af24ba2..7f655091cd0 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { mapGetters } from 'vuex'; -import BoardsSelector from '~/boards/components/boards_selector.vue'; +import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; import BoardsSelectorDeprecated from '~/boards/components/boards_selector_deprecated.vue'; import store from '~/boards/stores'; import createDefaultClient from '~/lib/graphql'; @@ -48,10 +48,10 @@ export default (params = {}) => { return { boardsSelectorProps }; }, computed: { - ...mapGetters(['shouldUseGraphQL']), + ...mapGetters(['shouldUseGraphQL', 'isEpicBoard']), }, render(createElement) { - if (this.shouldUseGraphQL) { + if (this.shouldUseGraphQL || this.isEpicBoard) { return createElement(BoardsSelector, { props: this.boardsSelectorProps, }); diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index a7cf1e9e647..19b31ee7291 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,6 +1,13 @@ import { pick } from 'lodash'; +import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; -import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants'; +import { + BoardType, + ListType, + inactiveId, + flashAnimationDuration, + ISSUABLE, +} from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; @@ -15,7 +22,6 @@ import { transformNotFilters, } from '../boards_util'; import boardLabelsQuery from '../graphql/board_labels.query.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 groupProjectsQuery from '../graphql/group_projects.query.graphql'; @@ -79,7 +85,11 @@ export default { } }, - fetchLists: ({ commit, state, dispatch }) => { + fetchLists: ({ dispatch }) => { + dispatch('fetchIssueLists'); + }, + + fetchIssueLists: ({ commit, state, dispatch }) => { const { boardType, filterParams, fullPath, boardId } = state; const variables = { @@ -118,9 +128,13 @@ export default { }, flashAnimationDuration); }, - createList: ( + createList: ({ dispatch }, { backlog, labelId, milestoneId, assigneeId }) => { + dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId }); + }, + + createIssueList: ( { state, commit, dispatch, getters }, - { backlog, labelId, milestoneId, assigneeId }, + { backlog, labelId, milestoneId, assigneeId, iterationId }, ) => { const { boardId } = state; @@ -140,25 +154,29 @@ export default { labelId, milestoneId, assigneeId, + iterationId, }, }) .then(({ data }) => { - if (data?.boardListCreate?.errors.length) { - commit(types.CREATE_LIST_FAILURE); + if (data.boardListCreate?.errors.length) { + commit(types.CREATE_LIST_FAILURE, data.boardListCreate.errors[0]); } else { const list = data.boardListCreate?.list; dispatch('addList', list); dispatch('highlightList', list.id); } }) - .catch(() => commit(types.CREATE_LIST_FAILURE)); + .catch((e) => { + commit(types.CREATE_LIST_FAILURE); + throw e; + }); }, addList: ({ commit }, list) => { commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list)); }, - fetchLabels: ({ state, commit }, searchTerm) => { + fetchLabels: ({ state, commit, getters }, searchTerm) => { const { fullPath, boardType } = state; const variables = { @@ -168,15 +186,29 @@ export default { isProject: boardType === BoardType.project, }; + commit(types.RECEIVE_LABELS_REQUEST); + return gqlClient .query({ query: boardLabelsQuery, variables, }) .then(({ data }) => { - const labels = data[boardType]?.labels.nodes; + let labels = data[boardType]?.labels.nodes; + + if (!getters.shouldUseGraphQL && !getters.isEpicBoard) { + labels = labels.map((label) => ({ + ...label, + id: getIdFromGraphQLId(label.id), + })); + } + commit(types.RECEIVE_LABELS_SUCCESS, labels); return labels; + }) + .catch((e) => { + commit(types.RECEIVE_LABELS_FAILURE); + throw e; }); }, @@ -225,6 +257,10 @@ export default { }); }, + toggleListCollapsed: ({ commit }, { listId, collapsed }) => { + commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed }); + }, + removeList: ({ state, commit }, listId) => { const listsBackup = { ...state.boardLists }; @@ -253,8 +289,8 @@ export default { }); }, - fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { - commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext }); + fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => { + commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext }); const { fullPath, boardId, boardType, filterParams } = state; @@ -279,28 +315,32 @@ export default { }) .then(({ data }) => { const { lists } = data[boardType]?.board; - const listIssues = formatListIssues(lists); + const listItems = formatListIssues(lists); const listPageInfo = formatListsPageInfo(lists); - commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId }); + commit(types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, { listItems, listPageInfo, listId }); }) - .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId)); + .catch(() => commit(types.RECEIVE_ITEMS_FOR_LIST_FAILURE, listId)); }, resetIssues: ({ commit }) => { commit(types.RESET_ISSUES); }, + moveItem: ({ dispatch }) => { + dispatch('moveIssue'); + }, + moveIssue: ( { state, commit }, - { issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId }, + { itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId }, ) => { - const originalIssue = state.issues[issueId]; - const fromList = state.issuesByListId[fromListId]; - const originalIndex = fromList.indexOf(Number(issueId)); + const originalIssue = state.boardItems[itemId]; + const fromList = state.boardItemsByListId[fromListId]; + const originalIndex = fromList.indexOf(Number(itemId)); commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }); const { boardId } = state; - const [fullProjectPath] = issuePath.split(/[#]/); + const [fullProjectPath] = itemPath.split(/[#]/); gqlClient .mutate({ @@ -308,7 +348,7 @@ export default { variables: { projectPath: fullProjectPath, boardId: fullBoardId(boardId), - iid: issueIid, + iid: itemIid, fromListId: getIdFromGraphQLId(fromListId), toListId: getIdFromGraphQLId(toListId), moveBeforeId, @@ -317,7 +357,7 @@ export default { }) .then(({ data }) => { if (data?.issueMoveList?.errors.length) { - commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }); + throw new Error(); } else { const issue = data.issueMoveList?.issue; commit(types.MOVE_ISSUE_SUCCESS, { issue }); @@ -532,10 +572,17 @@ export default { commit(types.SET_SELECTED_PROJECT, project); }, - toggleBoardItemMultiSelection: ({ commit, state }, boardItem) => { + toggleBoardItemMultiSelection: ({ commit, state, dispatch, getters }, boardItem) => { const { selectedBoardItems } = state; const index = selectedBoardItems.indexOf(boardItem); + // If user already selected an item (activeIssue) without using mult-select, + // include that item in the selection and unset state.ActiveId to hide the sidebar. + if (getters.activeIssue) { + commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue); + dispatch('unsetActiveId'); + } + if (index === -1) { commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem); } else { @@ -547,6 +594,20 @@ export default { commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible); }, + resetBoardItemMultiSelection: ({ commit }) => { + commit(types.RESET_BOARD_ITEM_SELECTION); + }, + + toggleBoardItem: ({ state, dispatch }, { boardItem, sidebarType = ISSUABLE }) => { + dispatch('resetBoardItemMultiSelection'); + + if (boardItem.id === state.activeId) { + dispatch('unsetActiveId'); + } else { + dispatch('setActiveId', { id: boardItem.id, sidebarType }); + } + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index fbff736c7e1..092f81ad279 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -575,7 +575,7 @@ const boardsStore = { }, saveList(list) { - const entity = list.label || list.assignee || list.milestone; + const entity = list.label || list.assignee || list.milestone || list.iteration; let entityType = ''; if (list.label) { entityType = 'label_id'; @@ -583,6 +583,8 @@ const boardsStore = { entityType = 'assignee_id'; } else if (IS_EE && list.milestone) { entityType = 'milestone_id'; + } else if (IS_EE && list.iteration) { + entityType = 'iteration_id'; } return this.createList(entity.id, entityType) diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index cab97088bc6..caa518f91ce 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,20 +1,22 @@ import { find } from 'lodash'; -import { inactiveId } from '../constants'; +import { BoardType, inactiveId } from '../constants'; export default { + isGroupBoard: (state) => state.boardType === BoardType.group, + isProjectBoard: (state) => state.boardType === BoardType.project, isSidebarOpen: (state) => state.activeId !== inactiveId, isSwimlanesOn: () => false, - getIssueById: (state) => (id) => { - return state.issues[id] || {}; + getBoardItemById: (state) => (id) => { + return state.boardItems[id] || {}; }, - getIssuesByList: (state, getters) => (listId) => { - const listIssueIds = state.issuesByListId[listId] || []; - return listIssueIds.map((id) => getters.getIssueById(id)); + getBoardItemsByList: (state, getters) => (listId) => { + const listItemsIds = state.boardItemsByListId[listId] || []; + return listItemsIds.map((id) => getters.getBoardItemById(id)); }, activeIssue: (state) => { - return state.issues[state.activeId] || {}; + return state.boardItems[state.activeId] || {}; }, groupPathForActiveIssue: (_, getters) => { @@ -27,6 +29,10 @@ export default { return referencePath.slice(0, referencePath.indexOf('#')); }, + activeGroupProjects: (state) => { + return state.groupProjects.filter((p) => !p.archived); + }, + getListByLabelId: (state) => (labelId) => { if (!labelId) { return null; @@ -38,6 +44,10 @@ export default { return find(state.boardLists, (l) => l.title === title); }, + isEpicBoard: () => { + return false; + }, + shouldUseGraphQL: () => { return gon?.features?.graphqlBoardLists; }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index a89e961ae2d..e7c034fb087 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -2,7 +2,9 @@ export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA'; export const SET_FILTERS = 'SET_FILTERS'; export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS'; export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE'; +export const RECEIVE_LABELS_REQUEST = 'RECEIVE_LABELS_REQUEST'; export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS'; +export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE'; export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE'; export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE'; @@ -12,11 +14,12 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR'; export const MOVE_LIST = 'MOVE_LIST'; export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; +export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED'; export const REMOVE_LIST = 'REMOVE_LIST'; export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; -export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST'; -export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE'; -export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS'; +export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; +export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; +export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; export const CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_FAILURE'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; @@ -45,3 +48,4 @@ export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTIO export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE'; export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS'; export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS'; +export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 79c98c3d90c..75b60366b6a 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -2,7 +2,8 @@ import { pull, union } from 'lodash'; import Vue from 'vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__ } from '~/locale'; -import { formatIssue, moveIssueListHelper } from '../boards_util'; +import { formatIssue, moveItemListHelper } from '../boards_util'; +import { issuableTypes } from '../constants'; import * as mutationTypes from './mutation_types'; const notImplemented = () => { @@ -10,34 +11,42 @@ const notImplemented = () => { throw new Error('Not implemented!'); }; -export const removeIssueFromList = ({ state, listId, issueId }) => { - Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); +const updateListItemsCount = ({ state, listId, value }) => { const list = state.boardLists[listId]; - Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount - 1 }); + if (state.issuableType === issuableTypes.epic) { + Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value }); + } else { + Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + value }); + } +}; + +export const removeItemFromList = ({ state, listId, itemId }) => { + Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId)); + updateListItemsCount({ state, listId, value: -1 }); }; -export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { - const listIssues = state.issuesByListId[listId]; +export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }) => { + const listIssues = state.boardItemsByListId[listId]; let newIndex = atIndex || 0; if (moveBeforeId) { newIndex = listIssues.indexOf(moveBeforeId) + 1; } else if (moveAfterId) { newIndex = listIssues.indexOf(moveAfterId); } - listIssues.splice(newIndex, 0, issueId); - Vue.set(state.issuesByListId, listId, listIssues); - const list = state.boardLists[listId]; - Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 }); + listIssues.splice(newIndex, 0, itemId); + Vue.set(state.boardItemsByListId, listId, listIssues); + updateListItemsCount({ state, listId, value: 1 }); }; export default { [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { - const { boardType, disabled, boardId, fullPath, boardConfig } = data; + const { boardType, disabled, boardId, fullPath, boardConfig, issuableType } = data; state.boardId = boardId; state.fullPath = fullPath; state.boardType = boardType; state.disabled = disabled; state.boardConfig = boardConfig; + state.issuableType = issuableType; }, [mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => { @@ -59,12 +68,25 @@ export default { state.filterParams = filterParams; }, - [mutationTypes.CREATE_LIST_FAILURE]: (state) => { - state.error = s__('Boards|An error occurred while creating the list. Please try again.'); + [mutationTypes.CREATE_LIST_FAILURE]: ( + state, + error = s__('Boards|An error occurred while creating the list. Please try again.'), + ) => { + state.error = error; + }, + + [mutationTypes.RECEIVE_LABELS_REQUEST]: (state) => { + state.labelsLoading = true; }, [mutationTypes.RECEIVE_LABELS_SUCCESS]: (state, labels) => { state.labels = labels; + state.labelsLoading = false; + }, + + [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => { + state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.'); + state.labelsLoading = false; }, [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => { @@ -94,6 +116,10 @@ export default { Vue.set(state, 'boardLists', backupList); }, + [mutationTypes.TOGGLE_LIST_COLLAPSED]: (state, { listId, collapsed }) => { + Vue.set(state.boardLists[listId], 'collapsed', collapsed); + }, + [mutationTypes.REMOVE_LIST]: (state, listId) => { Vue.delete(state.boardLists, listId); }, @@ -103,26 +129,23 @@ export default { state.boardLists = listsBackup; }, - [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => { + [mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => { Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); }, - [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: ( - state, - { listIssues, listPageInfo, listId }, - ) => { - const { listData, issues } = listIssues; - Vue.set(state, 'issues', { ...state.issues, ...issues }); + [mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => { + const { listData, boardItems } = listItems; + Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems }); Vue.set( - state.issuesByListId, + state.boardItemsByListId, listId, - union(state.issuesByListId[listId] || [], listData[listId]), + union(state.boardItemsByListId[listId] || [], listData[listId]), ); Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]); Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); }, - [mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => { + [mutationTypes.RECEIVE_ITEMS_FOR_LIST_FAILURE]: (state, listId) => { state.error = s__( 'Boards|An error occurred while fetching the board issues. Please reload the page.', ); @@ -130,18 +153,18 @@ export default { }, [mutationTypes.RESET_ISSUES]: (state) => { - Object.keys(state.issuesByListId).forEach((listId) => { - Vue.set(state.issuesByListId, listId, []); + Object.keys(state.boardItemsByListId).forEach((listId) => { + Vue.set(state.boardItemsByListId, listId, []); }); }, [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => { - if (!state.issues[issueId]) { + if (!state.boardItems[issueId]) { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('No issue found.'); } - Vue.set(state.issues[issueId], prop, value); + Vue.set(state.boardItems[issueId], prop, value); }, [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { @@ -167,16 +190,16 @@ export default { const fromList = state.boardLists[fromListId]; const toList = state.boardLists[toListId]; - const issue = moveIssueListHelper(originalIssue, fromList, toList); - Vue.set(state.issues, issue.id, issue); + const issue = moveItemListHelper(originalIssue, fromList, toList); + Vue.set(state.boardItems, issue.id, issue); - removeIssueFromList({ state, listId: fromListId, issueId: issue.id }); - addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); + removeItemFromList({ state, listId: fromListId, itemId: issue.id }); + addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId }); }, [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => { const issueId = getIdFromGraphQLId(issue.id); - Vue.set(state.issues, issueId, formatIssue({ ...issue, id: issueId })); + Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId })); }, [mutationTypes.MOVE_ISSUE_FAILURE]: ( @@ -184,12 +207,12 @@ export default { { originalIssue, fromListId, toListId, originalIndex }, ) => { state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); - Vue.set(state.issues, originalIssue.id, originalIssue); - removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id }); - addIssueToList({ + Vue.set(state.boardItems, originalIssue.id, originalIssue); + removeItemFromList({ state, listId: toListId, itemId: originalIssue.id }); + addItemToList({ state, listId: fromListId, - issueId: originalIssue.id, + itemId: originalIssue.id, atIndex: originalIndex, }); }, @@ -211,23 +234,23 @@ export default { }, [mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => { - addIssueToList({ + addItemToList({ state, listId: list.id, - issueId: issue.id, + itemId: issue.id, atIndex: position, }); - Vue.set(state.issues, issue.id, issue); + Vue.set(state.boardItems, issue.id, issue); }, [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => { state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); - removeIssueFromList({ state, listId: list.id, issueId }); + removeItemFromList({ state, listId: list.id, itemId: issueId }); }, [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => { - removeIssueFromList({ state, listId: list.id, issueId: issue.id }); - Vue.delete(state.issues, issue.id); + removeItemFromList({ state, listId: list.id, itemId: issue.id }); + Vue.delete(state.boardItems, issue.id); }, [mutationTypes.SET_CURRENT_PAGE]: () => { @@ -272,7 +295,7 @@ export default { }, [mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => { - state.addColumnFormVisible = visible; + Vue.set(state.addColumnForm, 'visible', visible); }, [mutationTypes.ADD_LIST_TO_HIGHLIGHTED_LISTS]: (state, listId) => { @@ -282,4 +305,8 @@ export default { [mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => { state.highlightedLists = state.highlightedLists.filter((id) => id !== listId); }, + + [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => { + state.selectedBoardItems = []; + }, }; diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 91544d6c9c5..19ba2a5df83 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,19 +1,22 @@ -import { inactiveId } from '~/boards/constants'; +import { inactiveId, ListType } from '~/boards/constants'; export default () => ({ boardType: null, + issuableType: null, + fullPath: null, disabled: false, isShowingLabels: true, activeId: inactiveId, sidebarType: '', boardLists: {}, listsFlags: {}, - issuesByListId: {}, + boardItemsByListId: {}, isSettingAssignees: false, pageInfoByListId: {}, - issues: {}, + boardItems: {}, filterParams: {}, boardConfig: {}, + labelsLoading: false, labels: [], highlightedLists: [], selectedBoardItems: [], @@ -25,7 +28,10 @@ export default () => ({ }, selectedProject: {}, error: undefined, - addColumnFormVisible: false, + addColumnForm: { + visible: false, + columnType: ListType.label, + }, // TODO: remove after ce/ee split of board_content.vue isShowingEpicsSwimlanes: false, }); |