diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 18:18:33 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 18:18:33 +0000 |
commit | f64a639bcfa1fc2bc89ca7db268f594306edfd7c (patch) | |
tree | a2c3c2ebcc3b45e596949db485d6ed18ffaacfa1 /app/assets/javascripts/boards/components | |
parent | bfbc3e0d6583ea1a91f627528bedc3d65ba4b10f (diff) | |
download | gitlab-ce-f64a639bcfa1fc2bc89ca7db268f594306edfd7c.tar.gz |
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc40
Diffstat (limited to 'app/assets/javascripts/boards/components')
31 files changed, 790 insertions, 409 deletions
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue 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> |