diff options
Diffstat (limited to 'app/assets/javascripts/boards')
52 files changed, 925 insertions, 1312 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 2cd25f58770..a8b870f9b8e 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,4 +1,4 @@ -import { sortBy } from 'lodash'; +import { sortBy, cloneDeep } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ListType, NOT_FILTER } from './constants'; @@ -113,6 +113,37 @@ export function formatIssueInput(issueInput, boardConfig) { }; } +export function shouldCloneCard(fromListType, toListType) { + const involvesClosed = fromListType === ListType.closed || toListType === ListType.closed; + const involvesBacklog = fromListType === ListType.backlog || toListType === ListType.backlog; + + if (involvesClosed || involvesBacklog) { + return false; + } + + if (fromListType !== toListType) { + return true; + } + + return false; +} + +export function getMoveData(state, params) { + const { boardItems, boardItemsByListId, boardLists } = state; + const { itemId, fromListId, toListId } = params; + const fromListType = boardLists[fromListId].listType; + const toListType = boardLists[toListId].listType; + + return { + reordering: fromListId === toListId, + shouldClone: shouldCloneCard(fromListType, toListType), + itemNotInToList: !boardItemsByListId[toListId].includes(itemId), + originalIssue: cloneDeep(boardItems[itemId]), + originalIndex: boardItemsByListId[fromListId].indexOf(itemId), + ...params, + }; +} + export function moveItemListHelper(item, fromList, toList) { const updatedItem = item; if ( diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue index 3c7c792b787..d4b559add6e 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -1,23 +1,16 @@ <script> -import { - GlFormRadio, - GlFormRadioGroup, - GlLabel, - GlTooltipDirective as GlTooltip, -} from '@gitlab/ui'; +import { GlFormRadio, GlFormRadioGroup, 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, @@ -26,17 +19,12 @@ export default { data() { return { selectedId: null, + selectedLabel: 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); }, @@ -89,8 +77,13 @@ export default { this.fetchLabels(searchTerm); }, - showScopedLabels(label) { - return this.scopedLabelsAvailable && isScopedLabel(label); + setSelectedItem(selectedId) { + const label = this.labels.find(({ id }) => id === selectedId); + if (!selectedId || !label) { + this.selectedLabel = null; + } else { + this.selectedLabel = { ...label }; + } }, }, }; @@ -99,38 +92,39 @@ export default { <template> <board-add-new-column-form :loading="labelsLoading" - :form-description="__('A label list displays issues with the selected label.')" - :search-label="__('Select label')" + :none-selected="__('Select a 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 #selected> + <template v-if="selectedLabel"> + <span + class="dropdown-label-box gl-top-0 gl-flex-shrink-0" + :style="{ + backgroundColor: selectedLabel.color, + }" + ></span> + <div class="gl-text-truncate">{{ selectedLabel.title }}</div> + </template> </template> - <template slot="items"> + <template #items> <gl-form-radio-group v-if="labels.length > 0" v-model="selectedId" class="gl-overflow-y-auto gl-px-5 gl-pt-3" + @change="setSelectedItem" > <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" + class="gl-display-flex gl-mb-5 gl-font-weight-normal gl-overflow-break-word" > - <gl-form-radio :value="label.id" class="gl-mb-0" /> + <gl-form-radio :value="label.id" /> <span - class="dropdown-label-box gl-top-0" + class="dropdown-label-box gl-top-0 gl-flex-shrink-0" :style="{ backgroundColor: label.color, }" diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue index d85343a5390..70ba90bb1d4 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -1,5 +1,12 @@ <script> -import { GlButton, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlFormGroup, + GlIcon, + GlSearchBoxByType, + GlSkeletonLoader, +} from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __ } from '~/locale'; @@ -8,13 +15,16 @@ export default { add: __('Add to board'), cancel: __('Cancel'), newList: __('New list'), - noneSelected: __('None'), noResults: __('No matching results'), + scope: __('Scope'), + scopeDescription: __('Issues must match this scope to appear in this list.'), selected: __('Selected'), }, components: { GlButton, + GlDropdown, GlFormGroup, + GlIcon, GlSearchBoxByType, GlSkeletonLoader, }, @@ -23,11 +33,12 @@ export default { type: Boolean, required: true, }, - formDescription: { + searchLabel: { type: String, - required: true, + required: false, + default: null, }, - searchLabel: { + noneSelected: { type: String, required: true, }, @@ -46,8 +57,23 @@ export default { searchValue: '', }; }, + watch: { + selectedId(val) { + if (val) { + this.$refs.dropdown.hide(true); + } + }, + }, methods: { ...mapActions(['setAddColumnFormVisibility']), + setFocus() { + this.$refs.searchBox.focusInput(); + }, + onHide() { + this.searchValue = ''; + this.$emit('filter-items', ''); + this.$emit('hide'); + }, }, }; </script> @@ -62,51 +88,64 @@ export default { 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" + class="gl-font-size-h2 gl-px-5 gl-py-4 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> + <div + class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-y-auto gl-align-items-flex-start" + > + <div class="gl-px-5"> + <h3 class="gl-font-lg gl-mt-5 gl-mb-2"> + {{ $options.i18n.scope }} + </h3> + <p class="gl-mb-3">{{ $options.i18n.scopeDescription }}</p> + </div> - <p class="gl-px-5">{{ formDescription }}</p> + <slot name="select-list-type"></slot> - <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-px-5 lg-mb-3 gl-max-w-full" :label="searchLabel"> + <gl-dropdown + ref="dropdown" + class="gl-mb-3 gl-max-w-full" + toggle-class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate" + boundary="viewport" + @shown="setFocus" + @hide="onHide" + > + <template #button-content> + <slot name="selected"> + <div>{{ noneSelected }}</div> + </slot> + <gl-icon class="dropdown-chevron gl-flex-shrink-0" name="chevron-down" /> + </template> - <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> + <template #header> + <gl-search-box-by-type + ref="searchBox" + v-model="searchValue" + debounce="250" + class="gl-mt-0!" + :placeholder="searchPlaceholder" + @input="$emit('filter-items', $event)" + /> + </template> - <div v-if="loading" class="gl-px-5"> - <gl-skeleton-loader :width="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> + <div v-if="loading" class="gl-px-5"> + <gl-skeleton-loader :width="400" :height="172"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="330" height="20" x="10" y="85" rx="4" /> + </gl-skeleton-loader> + </div> - <slot v-else name="items"> - <p class="gl-mx-5">{{ $options.i18n.noResults }}</p> - </slot> + <slot v-else name="items"> + <p class="gl-mx-5">{{ $options.i18n.noResults }}</p> + </slot> + </gl-dropdown> + </gl-form-group> </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" 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 7c08e33be7e..85f001d9d61 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,9 +13,9 @@ export default { </script> <template> - <span class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list"> + <div 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> - </span> + </div> </template> diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue new file mode 100644 index 00000000000..0f92e714752 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue @@ -0,0 +1,192 @@ +<script> +import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; +import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; +import { IssueType } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { truncate } from '~/lib/utils/text_utility'; +import { __, n__, s__, sprintf } from '~/locale'; + +export default { + i18n: { + issuableType: { + [issuableTypes.issue]: __('issue'), + }, + }, + graphQLIdType: { + [issuableTypes.issue]: IssueType, + }, + referenceFormatter: { + [issuableTypes.issue]: (r) => r.split('/')[1], + }, + defaultDisplayLimit: 3, + textTruncateWidth: 80, + components: { + GlIcon, + GlPopover, + GlLink, + GlLoadingIcon, + }, + blockingIssuablesQueries, + props: { + item: { + type: Object, + required: true, + }, + uniqueId: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: true, + validator(value) { + return [issuableTypes.issue].includes(value); + }, + }, + }, + apollo: { + blockingIssuables: { + skip() { + return this.skip; + }, + query() { + return blockingIssuablesQueries[this.issuableType].query; + }, + variables() { + return { + id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id), + }; + }, + update(data) { + this.skip = true; + + return data?.issuable?.blockingIssuables?.nodes || []; + }, + error(error) { + const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), { + issuableType: this.issuableTypeText, + }); + this.$emit('blocking-issuables-error', { error, message }); + }, + }, + }, + data() { + return { + skip: true, + blockingIssuables: [], + }; + }, + computed: { + displayedIssuables() { + const { defaultDisplayLimit, referenceFormatter } = this.$options; + return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => { + return { + ...i, + title: truncate(i.title, this.$options.textTruncateWidth), + reference: referenceFormatter[this.issuableType](i.reference), + }; + }); + }, + loading() { + return this.$apollo.queries.blockingIssuables.loading; + }, + issuableTypeText() { + return this.$options.i18n.issuableType[this.issuableType]; + }, + blockedLabel() { + return sprintf( + n__( + 'Boards|Blocked by %{blockedByCount} %{issuableType}', + 'Boards|Blocked by %{blockedByCount} %{issuableType}s', + this.item.blockedByCount, + ), + { + blockedByCount: this.item.blockedByCount, + issuableType: this.issuableTypeText, + }, + ); + }, + glIconId() { + return `blocked-icon-${this.uniqueId}`; + }, + hasMoreIssuables() { + return this.item.blockedByCount > this.$options.defaultDisplayLimit; + }, + displayedIssuablesCount() { + return this.hasMoreIssuables + ? this.item.blockedByCount - this.$options.defaultDisplayLimit + : this.item.blockedByCount; + }, + moreIssuablesText() { + return sprintf( + n__( + 'Boards|+ %{displayedIssuablesCount} more %{issuableType}', + 'Boards|+ %{displayedIssuablesCount} more %{issuableType}s', + this.displayedIssuablesCount, + ), + { + displayedIssuablesCount: this.displayedIssuablesCount, + issuableType: this.issuableTypeText, + }, + ); + }, + viewAllIssuablesText() { + return sprintf(s__('Boards|View all blocking %{issuableType}s'), { + issuableType: this.issuableTypeText, + }); + }, + loadingMessage() { + return sprintf(s__('Boards|Retrieving blocking %{issuableType}s'), { + issuableType: this.issuableTypeText, + }); + }, + }, + methods: { + handleMouseEnter() { + this.skip = false; + }, + }, +}; +</script> +<template> + <div class="gl-display-inline"> + <gl-icon + :id="glIconId" + ref="icon" + name="issue-block" + class="issue-blocked-icon gl-mr-2 gl-cursor-pointer" + data-testid="issue-blocked-icon" + @mouseenter="handleMouseEnter" + /> + <gl-popover :target="glIconId" placement="top"> + <template #title + ><span data-testid="popover-title">{{ blockedLabel }}</span></template + > + <template v-if="loading"> + <gl-loading-icon /> + <p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p> + </template> + <template v-else> + <ul class="gl-list-style-none gl-p-0"> + <li v-for="issuable in displayedIssuables" :key="issuable.id"> + <gl-link :href="issuable.webUrl" class="gl-text-blue-500! gl-font-sm">{{ + issuable.reference + }}</gl-link> + <p class="gl-mb-3 gl-display-block!" data-testid="issuable-title"> + {{ issuable.title }} + </p> + </li> + </ul> + <div v-if="hasMoreIssuables" class="gl-mt-4"> + <p class="gl-mb-3" data-testid="hidden-blocking-count">{{ moreIssuablesText }}</p> + <gl-link + data-testid="view-all-issues" + :href="`${item.webUrl}#related-issues`" + class="gl-text-blue-500! gl-font-sm" + >{{ viewAllIssuablesText }}</gl-link + > + </div> + </template> + </gl-popover> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index d4d6b17a589..9ff2cdd76d0 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -10,6 +10,7 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import { ListType } from '../constants'; import eventHub from '../eventhub'; +import BoardBlockedIcon from './board_blocked_icon.vue'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; @@ -22,6 +23,7 @@ export default { IssueDueDate, IssueTimeEstimate, IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), + BoardBlockedIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -52,7 +54,7 @@ export default { }; }, computed: { - ...mapState(['isShowingLabels']), + ...mapState(['isShowingLabels', 'issuableType']), ...mapGetters(['isEpicBoard']), cappedAssignees() { // e.g. maxRender is 4, @@ -114,7 +116,7 @@ export default { }, }, methods: { - ...mapActions(['performSearch']), + ...mapActions(['performSearch', 'setError']), isIndexLessThanlimit(index) { return index < this.limitBeforeCounter; }, @@ -164,14 +166,12 @@ export default { <div> <div class="gl-display-flex" dir="auto"> <h4 class="board-card-title gl-mb-0 gl-mt-0"> - <gl-icon + <board-blocked-icon v-if="item.blocked" - v-gl-tooltip - name="issue-block" - :title="blockedLabel" - class="issue-blocked-icon gl-mr-2" - :aria-label="blockedLabel" - data-testid="issue-blocked-icon" + :item="item" + :unique-id="`${item.id}${list.id}`" + :issuable-type="issuableType" + @blocking-issuables-error="setError" /> <gl-icon v-if="item.confidential" @@ -181,13 +181,9 @@ export default { class="confidential-icon gl-mr-2" :aria-label="__('Confidential')" /> - <a - :href="item.path || item.webUrl || ''" - :title="item.title" - class="js-no-trigger" - @mousemove.stop - >{{ item.title }}</a - > + <a :href="item.path || item.webUrl || ''" :title="item.title" @mousemove.stop>{{ + item.title + }}</a> </h4> </div> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> diff --git a/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue b/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue new file mode 100644 index 00000000000..15bff1226a6 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue @@ -0,0 +1,26 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + name: 'BoardCardLoading', + components: { + GlSkeletonLoader, + }, +}; +</script> + +<template> + <div + class="board-card-skeleton gl-mb-3 gl-bg-white gl-rounded-base gl-p-5 gl-border-1 gl-border-solid gl-border-gray-50" + > + <div class="board-card-skeleton-inner"> + <gl-skeleton-loader :width="340" :height="100"> + <rect width="340" height="16" rx="4" /> + <rect y="30" width="118" height="16" rx="8" /> + <rect x="122" y="30" width="130" height="16" rx="8" /> + <rect y="62" width="38" height="16" rx="4" /> + <circle cx="320" cy="68" r="16" /> + </gl-skeleton-loader> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index e9c4237d759..a4b1e6adacf 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -17,21 +17,20 @@ export default { gon.features?.graphqlBoardLists || gon.features?.epicBoards ? BoardColumn : BoardColumnDeprecated, - BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), + BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'), + EpicBoardContentSidebar: () => + import('ee_component/boards/components/epic_board_content_sidebar.vue'), EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, }, mixins: [glFeatureFlagMixin()], + inject: ['canAdminList'], props: { lists: { type: Array, required: false, default: () => [], }, - canAdminList: { - type: Boolean, - required: true, - }, disabled: { type: Boolean, required: true, @@ -69,7 +68,7 @@ export default { }, }, methods: { - ...mapActions(['moveList']), + ...mapActions(['moveList', 'unsetError']), afterFormEnters() { const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); @@ -99,8 +98,8 @@ export default { </script> <template> - <div> - <gl-alert v-if="error" variant="danger" :dismissible="false"> + <div v-cloak data-qa-selector="boards_list"> + <gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="unsetError"> {{ error }} </gl-alert> <component @@ -127,13 +126,23 @@ export default { </component> <epics-swimlanes - v-else + v-else-if="boardListsToUse.length" ref="swimlanes" :lists="boardListsToUse" :can-admin-list="canAdminList" :disabled="disabled" /> - <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" /> + <board-content-sidebar + v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" + class="boards-sidebar" + data-testid="issue-boards-sidebar" + /> + + <epic-board-content-sidebar + v-else-if="isEpicBoard" + class="boards-sidebar" + data-testid="epic-boards-sidebar" + /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue new file mode 100644 index 00000000000..46359cc2bca --- /dev/null +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -0,0 +1,96 @@ +<script> +import { GlDrawer } from '@gitlab/ui'; +import { mapState, mapActions, mapGetters } from 'vuex'; +import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; +import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; +import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; +import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; +import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; +import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; +import { ISSUABLE } from '~/boards/constants'; +import { contentTop } from '~/lib/utils/common_utils'; +import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + headerHeight: `${contentTop()}px`, + components: { + GlDrawer, + BoardSidebarTitle, + SidebarAssigneesWidget, + BoardSidebarTimeTracker, + BoardSidebarLabelsSelect, + BoardSidebarDueDate, + BoardSidebarSubscription, + BoardSidebarMilestoneSelect, + BoardSidebarEpicSelect: () => + import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'), + BoardSidebarWeightInput: () => + import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'), + SidebarIterationWidget: () => + import('ee_component/sidebar/components/sidebar_iteration_widget.vue'), + }, + mixins: [glFeatureFlagsMixin()], + computed: { + ...mapGetters([ + 'isSidebarOpen', + 'activeBoardItem', + 'groupPathForActiveIssue', + 'projectPathForActiveIssue', + ]), + ...mapState(['sidebarType', 'issuableType']), + isIssuableSidebar() { + return this.sidebarType === ISSUABLE; + }, + showSidebar() { + return this.isIssuableSidebar && this.isSidebarOpen; + }, + fullPath() { + return this.activeBoardItem?.referencePath?.split('#')[0] || ''; + }, + }, + methods: { + ...mapActions(['toggleBoardItem', 'setAssignees']), + handleClose() { + this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); + }, + }, +}; +</script> + +<template> + <gl-drawer + v-if="showSidebar" + :open="isSidebarOpen" + :header-height="$options.headerHeight" + @close="handleClose" + > + <template #header>{{ __('Issue details') }}</template> + <template #default> + <board-sidebar-title /> + <sidebar-assignees-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :initial-assignees="activeBoardItem.assignees" + class="assignee" + @assignees-updated="setAssignees" + /> + <board-sidebar-epic-select class="epic" /> + <div> + <board-sidebar-milestone-select /> + <sidebar-iteration-widget + :iid="activeBoardItem.iid" + :workspace-path="projectPathForActiveIssue" + :iterations-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + /> + </div> + <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> + <board-sidebar-due-date /> + <board-sidebar-labels-select class="labels" /> + <board-sidebar-weight-input v-if="glFeatures.issueWeights" class="weight" /> + <board-sidebar-subscription class="subscriptions" /> + </template> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/boards/components/board_extra_actions.vue b/app/assets/javascripts/boards/components/board_extra_actions.vue deleted file mode 100644 index b802ccc7882..00000000000 --- a/app/assets/javascripts/boards/components/board_extra_actions.vue +++ /dev/null @@ -1,57 +0,0 @@ -<script> -import { GlTooltip, GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - name: 'BoardExtraActions', - components: { - GlTooltip, - GlButton, - }, - props: { - canAdminList: { - type: Boolean, - required: true, - }, - disabled: { - type: Boolean, - required: true, - }, - openModal: { - type: Function, - required: true, - }, - }, - computed: { - tooltipTitle() { - if (this.disabled) { - return __('Please add a list to your board first'); - } - - return ''; - }, - }, -}; -</script> - -<template> - <div class="board-extra-actions"> - <span ref="addIssuesButtonTooltip" class="gl-ml-3"> - <gl-button - v-if="canAdminList" - type="button" - data-placement="bottom" - data-track-event="click_button" - data-track-label="board_add_issues" - :disabled="disabled" - :aria-disabled="disabled" - @click="openModal" - > - {{ __('Add issues') }} - </gl-button> - </span> - <gl-tooltip v-if="disabled" :target="() => $refs.addIssuesButtonTooltip" placement="bottom"> - {{ tooltipTitle }} - </gl-tooltip> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index d8504dcfb0f..78da4137d69 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -107,7 +107,7 @@ export default { }; }, computed: { - ...mapGetters(['isEpicBoard', 'isGroupBoard', 'isProjectBoard']), + ...mapGetters(['isIssueBoard', 'isGroupBoard', 'isProjectBoard']), isNewForm() { return this.currentPage === formType.new; }, @@ -127,7 +127,7 @@ export default { if (this.isDeleteForm) { return 'danger'; } - return 'info'; + return 'confirm'; }, title() { if (this.readonly) { @@ -163,6 +163,9 @@ export default { currentMutation() { return this.board.id ? updateBoardMutation : createBoardMutation; }, + deleteMutation() { + return destroyBoardMutation; + }, baseMutationVariables() { const { board } = this; const variables = { @@ -182,7 +185,7 @@ export default { groupPath: this.isGroupBoard ? this.fullPath : undefined, }; }, - boardScopeMutationVariables() { + issueBoardScopeMutationVariables() { /* eslint-disable @gitlab/require-i18n-strings */ return { weight: this.board.weight, @@ -193,13 +196,18 @@ export default { 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 */ }, + boardScopeMutationVariables() { + return { + labelIds: this.board.labels.map(fullLabelId), + ...(this.isIssueBoard && this.issueBoardScopeMutationVariables), + }; + }, mutationVariables() { return { ...this.baseMutationVariables, @@ -239,17 +247,20 @@ export default { return this.boardUpdateResponse(response.data); }, + async deleteBoard() { + await this.$apollo.mutate({ + mutation: this.deleteMutation, + variables: { + id: fullBoardId(this.board.id), + }, + }); + }, async submit() { if (this.board.name.length === 0) return; this.isLoading = true; if (this.isDeleteForm) { try { - await this.$apollo.mutate({ - mutation: destroyBoardMutation, - variables: { - id: fullBoardId(this.board.id), - }, - }); + await this.deleteBoard(); visitUrl(this.rootPath); } catch { Flash(this.$options.i18n.deleteErrorMessage); @@ -324,7 +335,7 @@ export default { /> <board-scope - v-if="scopedIssueBoardFeatureEnabled && !isEpicBoard" + v-if="scopedIssueBoardFeatureEnabled" :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 ae8434be312..94e29f3ad86 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -190,7 +190,7 @@ export default { } this.moveItem({ - itemId, + itemId: Number(itemId), itemIid, itemPath, fromListId: from.dataset.listId, diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index d59fbcc1b31..0534e027c86 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -134,9 +134,10 @@ export default { e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list'); const toBoardType = containerEl.dataset.boardType; const cloneActions = { - label: ['milestone', 'assignee'], - assignee: ['milestone', 'label'], - milestone: ['label', 'assignee'], + label: ['milestone', 'assignee', 'iteration'], + assignee: ['milestone', 'label', 'iteration'], + milestone: ['label', 'assignee', 'iteration'], + iteration: ['label', 'assignee', 'milestone'], }; if (toBoardType) { diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 6ccaec4a633..ca66ad6934a 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -328,6 +328,7 @@ export default { <div class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" + data-testid="issue-count-badge" :class="{ 'gl-display-none!': list.collapsed && isSwimlanesHeader, 'gl-p-0': list.collapsed, diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index a81c28733cd..144cae15ab3 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -2,23 +2,23 @@ import { GlButton } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; +import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; import { __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../eventhub'; import ProjectSelect from './project_select.vue'; export default { name: 'BoardNewIssue', i18n: { - submit: __('Submit issue'), + submit: __('Create issue'), cancel: __('Cancel'), }, components: { ProjectSelect, GlButton, }, - mixins: [glFeatureFlagMixin()], - inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], + mixins: [BoardNewIssueMixin], + inject: ['groupId'], props: { list: { type: Object, @@ -53,14 +53,11 @@ export default { submit(e) { e.preventDefault(); + const { title } = this; const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; const milestone = getMilestone(this.list); - const weight = this.weightFeatureAvailable ? this.boardWeight : undefined; - - const { title } = this; - eventHub.$emit(`scroll-board-list-${this.list.id}`); return this.addListNewIssue({ @@ -70,7 +67,7 @@ export default { assigneeIds: assignees?.map((a) => a?.id), milestoneId: milestone?.id, projectPath: this.selectedProject.fullPath, - weight: weight >= 0 ? weight : null, + ...this.extraIssueInput(), }, list: this.list, }).then(() => { 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 16f23dfff0e..1218941065f 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue @@ -121,7 +121,7 @@ export default { variant="success" category="primary" type="submit" - >{{ __('Submit issue') }}</gl-button + >{{ __('Create issue') }}</gl-button > <gl-button ref="cancelButton" diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 7cfedad0aed..997655c346a 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -22,13 +22,7 @@ export default { import('ee_component/boards/components/board_settings_list_types.vue'), }, mixins: [glFeatureFlagMixin()], - props: { - canAdminList: { - type: Boolean, - required: false, - default: false, - }, - }, + inject: ['canAdminList'], data() { return { ListType, diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index 7ec99e51f5b..fdb60d0ae6a 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -15,7 +15,8 @@ export default { props: { boardsStore: { type: Object, - required: true, + required: false, + default: null, }, canAdminList: { type: Boolean, @@ -26,11 +27,6 @@ export default { required: true, }, }, - data() { - return { - state: this.boardsStore.state, - }; - }, computed: { buttonText() { return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope'); @@ -42,7 +38,9 @@ export default { methods: { showPage() { eventHub.$emit('showBoardModal', formType.edit); - return this.boardsStore.showPage(formType.edit); + if (this.boardsStore) { + this.boardsStore.showPage(formType.edit); + } }, }, }; diff --git a/app/assets/javascripts/boards/components/filtered_search.vue b/app/assets/javascripts/boards/components/filtered_search.vue deleted file mode 100644 index 8505ea39a6b..00000000000 --- a/app/assets/javascripts/boards/components/filtered_search.vue +++ /dev/null @@ -1,54 +0,0 @@ -<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/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue deleted file mode 100644 index 486b012e3d2..00000000000 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ /dev/null @@ -1,84 +0,0 @@ -<script> -import { GlButton, GlSprintf } from '@gitlab/ui'; -import { __ } from '~/locale'; -import modalMixin from '../../mixins/modal_mixins'; -import ModalStore from '../../stores/modal_store'; - -export default { - components: { - GlButton, - GlSprintf, - }, - mixins: [modalMixin], - props: { - newIssuePath: { - type: String, - required: true, - }, - emptyStateSvg: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - contents() { - const obj = { - title: __("You haven't added any issues to your project yet"), - content: __( - 'An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable.', - ), - }; - - if (this.activeTab === 'selected') { - obj.title = __("You haven't selected any issues yet"); - obj.content = __( - 'Go back to %{tagStart}Open issues%{tagEnd} and select some issues to add to your board.', - ); - } - - return obj; - }, - }, -}; -</script> - -<template> - <section class="empty-state d-flex mt-0 h-100"> - <div class="row w-100 my-auto mx-0"> - <div class="col-12 col-md-6 order-md-last"> - <aside class="svg-content d-none d-md-block"><img :src="emptyStateSvg" /></aside> - </div> - <div class="col-12 col-md-6 order-md-first"> - <div class="text-content"> - <h4>{{ contents.title }}</h4> - <p> - <gl-sprintf :message="contents.content"> - <template #tag="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <gl-button - v-if="activeTab === 'all'" - :href="newIssuePath" - category="secondary" - variant="success" - > - {{ __('New issue') }} - </gl-button> - <gl-button - v-if="activeTab === 'selected'" - category="primary" - variant="default" - @click="changeTab('all')" - > - {{ __('Open issues') }} - </gl-button> - </div> - </div> - </div> - </section> -</template> diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js deleted file mode 100644 index 2fb38a549f3..00000000000 --- a/app/assets/javascripts/boards/components/modal/filters.js +++ /dev/null @@ -1,27 +0,0 @@ -import FilteredSearchContainer from '../../../filtered_search/container'; -import FilteredSearchBoards from '../../filtered_search_boards'; - -export default { - name: 'modal-filters', - props: { - store: { - type: Object, - required: true, - }, - }, - mounted() { - FilteredSearchContainer.container = this.$el; - - this.filteredSearch = new FilteredSearchBoards(this.store); - this.filteredSearch.setup(); - this.filteredSearch.removeTokens(); - this.filteredSearch.handleInputPlaceholder(); - this.filteredSearch.toggleClearSearchButton(); - }, - destroyed() { - this.filteredSearch.cleanup(); - FilteredSearchContainer.container = document; - this.store.path = ''; - }, - template: '#js-board-modal-filter', -}; diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue deleted file mode 100644 index 05e1219bc70..00000000000 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer'; -import { deprecatedCreateFlash as Flash } from '../../../flash'; -import { __, n__ } from '../../../locale'; -import modalMixin from '../../mixins/modal_mixins'; -import boardsStore from '../../stores/boards_store'; -import ModalStore from '../../stores/modal_store'; -import ListsDropdown from './lists_dropdown.vue'; - -export default { - components: { - ListsDropdown, - GlButton, - }, - mixins: [modalMixin, footerEEMixin], - data() { - return { - modal: ModalStore.store, - state: boardsStore.state, - }; - }, - computed: { - submitDisabled() { - return !ModalStore.selectedCount(); - }, - submitText() { - const count = ModalStore.selectedCount(); - if (!count) return __('Add issues'); - return n__(`Add %d issue`, `Add %d issues`, count); - }, - }, - methods: { - buildUpdateRequest(list) { - return { - add_label_ids: [list.label.id], - }; - }, - addIssues() { - const firstListIndex = 1; - const list = this.modal.selectedList || this.state.lists[firstListIndex]; - const selectedIssues = ModalStore.getSelectedIssues(); - const issueIds = selectedIssues.map((issue) => issue.id); - const req = this.buildUpdateRequest(list); - - // Post the data to the backend - boardsStore.bulkUpdate(issueIds, req).catch(() => { - Flash(__('Failed to update issues, please try again.')); - - selectedIssues.forEach((issue) => { - list.removeIssue(issue); - list.issuesSize -= 1; - }); - }); - - // Add the issues on the frontend - selectedIssues.forEach((issue) => { - list.addIssue(issue); - list.issuesSize += 1; - }); - - this.toggleModal(false); - }, - }, -}; -</script> -<template> - <footer class="form-actions add-issues-footer"> - <div class="float-left"> - <gl-button :disabled="submitDisabled" category="primary" variant="success" @click="addIssues"> - {{ submitText }} - </gl-button> - <span class="inline add-issues-footer-to-list">{{ __('to list') }}</span> - <lists-dropdown /> - </div> - <gl-button class="float-right" @click="toggleModal(false)"> - {{ __('Cancel') }} - </gl-button> - </footer> -</template> diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue deleted file mode 100644 index c3a71e7177a..00000000000 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; -import modalMixin from '../../mixins/modal_mixins'; -import ModalStore from '../../stores/modal_store'; -import ModalFilters from './filters'; -import ModalTabs from './tabs.vue'; - -export default { - components: { - ModalTabs, - ModalFilters, - GlButton, - }, - mixins: [modalMixin], - props: { - projectId: { - type: Number, - required: true, - }, - labelPath: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - selectAllText() { - if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { - return __('Select all'); - } - - return __('Deselect all'); - }, - showSearch() { - return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; - }, - }, - methods: { - toggleAll() { - this.$refs.selectAllBtn.$el.blur(); - - ModalStore.toggleAll(); - }, - }, -}; -</script> -<template> - <div> - <header class="add-issues-header border-top-0 form-actions"> - <h2 class="m-0"> - Add issues - <gl-button - category="tertiary" - icon="close" - class="close" - data-dismiss="modal" - :aria-label="__('Close')" - @click="toggleModal(false)" - /> - </h2> - </header> - <modal-tabs v-if="!loading && issuesCount > 0" /> - <div v-if="showSearch" class="d-flex gl-mb-3"> - <modal-filters :store="filter" /> - <gl-button - ref="selectAllBtn" - category="secondary" - variant="success" - class="gl-ml-3" - @click="toggleAll" - > - {{ selectAllText }} - </gl-button> - </div> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue deleted file mode 100644 index 5af90c1ee66..00000000000 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ /dev/null @@ -1,151 +0,0 @@ -<script> -/* global ListIssue */ -import { GlLoadingIcon } from '@gitlab/ui'; -import boardsStore from '~/boards/stores/boards_store'; -import { urlParamsToObject } from '~/lib/utils/common_utils'; -import ModalStore from '../../stores/modal_store'; -import EmptyState from './empty_state.vue'; -import ModalFooter from './footer.vue'; -import ModalHeader from './header.vue'; -import ModalList from './list.vue'; - -export default { - components: { - EmptyState, - ModalHeader, - ModalList, - ModalFooter, - GlLoadingIcon, - }, - props: { - newIssuePath: { - type: String, - required: true, - }, - emptyStateSvg: { - type: String, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - labelPath: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - showList() { - if (this.activeTab === 'selected') { - return this.selectedIssues.length > 0; - } - - return this.issuesCount > 0; - }, - showEmptyState() { - if (!this.loading && this.issuesCount === 0) { - return true; - } - - return this.activeTab === 'selected' && this.selectedIssues.length === 0; - }, - }, - watch: { - page() { - this.loadIssues(); - }, - showAddIssuesModal() { - if (this.showAddIssuesModal && !this.issues.length) { - this.loading = true; - const loadingDone = () => { - this.loading = false; - }; - - this.loadIssues().then(loadingDone).catch(loadingDone); - } else if (!this.showAddIssuesModal) { - this.issues = []; - this.selectedIssues = []; - this.issuesCount = false; - } - }, - filter: { - handler() { - if (this.$el.tagName) { - this.page = 1; - this.filterLoading = true; - const loadingDone = () => { - this.filterLoading = false; - }; - - this.loadIssues(true).then(loadingDone).catch(loadingDone); - } - }, - deep: true, - }, - }, - created() { - this.page = 1; - }, - methods: { - loadIssues(clearIssues = false) { - if (!this.showAddIssuesModal) return false; - - return boardsStore - .getBacklog({ - ...urlParamsToObject(this.filter.path), - page: this.page, - per: this.perPage, - }) - .then((res) => res.data) - .then((data) => { - if (clearIssues) { - this.issues = []; - } - - data.issues.forEach((issueObj) => { - const issue = new ListIssue(issueObj); - const foundSelectedIssue = ModalStore.findSelectedIssue(issue); - issue.selected = Boolean(foundSelectedIssue); - - this.issues.push(issue); - }); - - this.loadingNewPage = false; - - if (!this.issuesCount) { - this.issuesCount = data.size; - } - }) - .catch(() => { - // TODO: handle request error - }); - }, - }, -}; -</script> -<template> - <div - v-if="showAddIssuesModal" - class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100" - > - <div class="add-issues-container d-flex flex-column m-auto rounded"> - <modal-header :project-id="projectId" :label-path="labelPath" /> - <modal-list v-if="!loading && showList && !filterLoading" :empty-state-svg="emptyStateSvg" /> - <empty-state - v-if="showEmptyState" - :new-issue-path="newIssuePath" - :empty-state-svg="emptyStateSvg" - /> - <section v-if="loading || filterLoading" class="add-issues-list d-flex h-100 text-center"> - <div class="add-issues-list-loading w-100 align-self-center"> - <gl-loading-icon size="md" /> - </div> - </section> - <modal-footer /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue deleted file mode 100644 index e66cae0ce18..00000000000 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ /dev/null @@ -1,141 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import ModalStore from '../../stores/modal_store'; -import BoardCardInner from '../board_card_inner.vue'; - -export default { - components: { - BoardCardInner, - GlIcon, - }, - props: { - emptyStateSvg: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - loopIssues() { - if (this.activeTab === 'all') { - return this.issues; - } - - return this.selectedIssues; - }, - groupedIssues() { - const groups = []; - this.loopIssues.forEach((issue, i) => { - const index = i % this.columns; - - if (!groups[index]) { - groups.push([]); - } - - groups[index].push(issue); - }); - - return groups; - }, - }, - watch: { - activeTab() { - if (this.activeTab === 'all') { - ModalStore.purgeUnselectedIssues(); - } - }, - }, - mounted() { - this.scrollHandlerWrapper = this.scrollHandler.bind(this); - this.setColumnCountWrapper = this.setColumnCount.bind(this); - this.setColumnCount(); - - this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); - window.addEventListener('resize', this.setColumnCountWrapper); - }, - beforeDestroy() { - this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); - window.removeEventListener('resize', this.setColumnCountWrapper); - }, - methods: { - scrollHandler() { - const currentPage = Math.floor(this.issues.length / this.perPage); - - if ( - this.scrollTop() > this.scrollHeight() - 100 && - !this.loadingNewPage && - currentPage === this.page - ) { - this.loadingNewPage = true; - this.page += 1; - } - }, - toggleIssue(e, issue) { - if (e.target.tagName !== 'A') { - ModalStore.toggleIssue(issue); - } - }, - listHeight() { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight() { - return this.$refs.list.scrollHeight; - }, - scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); - }, - showIssue(issue) { - if (this.activeTab === 'all') return true; - - const index = ModalStore.selectedIssueIndex(issue); - - return index !== -1; - }, - setColumnCount() { - const breakpoint = bp.getBreakpointSize(); - - if (breakpoint === 'xl' || breakpoint === 'lg') { - this.columns = 3; - } else if (breakpoint === 'md') { - this.columns = 2; - } else { - this.columns = 1; - } - }, - }, -}; -</script> -<template> - <section ref="list" class="add-issues-list add-issues-list-columns d-flex h-100"> - <div - v-if="issuesCount > 0 && issues.length === 0" - class="empty-state add-issues-empty-state-filter text-center" - > - <div class="svg-content"><img :src="emptyStateSvg" /></div> - <div class="text-content"> - <h4>{{ __('There are no issues to show.') }}</h4> - </div> - </div> - <div v-for="(group, index) in groupedIssues" :key="index" class="add-issues-list-column"> - <div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent"> - <div - :class="{ 'is-active': issue.selected }" - class="board-card position-relative p-3 rounded" - @click="toggleIssue($event, issue)" - > - <board-card-inner :item="issue" /> - <gl-icon - v-if="issue.selected" - :aria-label="'Issue #' + issue.id + ' selected'" - name="mobile-issue-close" - aria-checked="true" - class="issue-card-selected text-center" - /> - </div> - </div> - </div> - </section> -</template> diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue deleted file mode 100644 index 2065568d275..00000000000 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue +++ /dev/null @@ -1,49 +0,0 @@ -<script> -import { GlLink, GlIcon } from '@gitlab/ui'; -import boardsStore from '../../stores/boards_store'; -import ModalStore from '../../stores/modal_store'; - -export default { - components: { - GlLink, - GlIcon, - }, - data() { - return { - modal: ModalStore.store, - state: boardsStore.state, - }; - }, - computed: { - selected() { - return this.modal.selectedList || this.state.lists[1]; - }, - }, - destroyed() { - this.modal.selectedList = null; - }, -}; -</script> -<template> - <div class="dropdown inline"> - <button class="dropdown-menu-toggle" type="button" data-toggle="dropdown" aria-expanded="false"> - <span :style="{ backgroundColor: selected.label.color }" class="dropdown-label-box"> </span> - {{ selected.title }} <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon" /> - </button> - <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> - <ul> - <li v-for="(list, i) in state.lists" v-if="list.type == 'label'" :key="i"> - <gl-link - :class="{ 'is-active': list.id == selected.id }" - href="#" - role="button" - @click.prevent="modal.selectedList = list" - > - <span :style="{ backgroundColor: list.label.color }" class="dropdown-label-box"> </span> - {{ list.title }} - </gl-link> - </li> - </ul> - </div> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue deleted file mode 100644 index 0b717f516db..00000000000 --- a/app/assets/javascripts/boards/components/modal/tabs.vue +++ /dev/null @@ -1,42 +0,0 @@ -<script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; -import modalMixin from '../../mixins/modal_mixins'; -import ModalStore from '../../stores/modal_store'; - -export default { - components: { - GlTabs, - GlTab, - GlBadge, - }, - mixins: [modalMixin], - data() { - return ModalStore.store; - }, - computed: { - selectedCount() { - return ModalStore.selectedCount(); - }, - }, - destroyed() { - this.activeTab = 'all'; - }, -}; -</script> -<template> - <gl-tabs class="gl-mt-3"> - <gl-tab @click.prevent="changeTab('all')"> - <template slot="title"> - <span>Open issues</span> - <gl-badge size="sm" class="gl-tab-counter-badge">{{ issuesCount }}</gl-badge> - </template> - </gl-tab> - <gl-tab @click.prevent="changeTab('selected')"> - <template slot="title"> - <span>Selected issues</span> - <gl-badge size="sm" class="gl-tab-counter-badge">{{ selectedCount }}</gl-badge> - </template> - </gl-tab> - </gl-tabs> -</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue index 61863bbe2a9..352a25ef6d9 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -98,14 +98,14 @@ export default { <gl-button v-if="canUpdate" variant="link" - class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle" + class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle edit-link" data-testid="edit-button" @click="toggle" > {{ __('Edit') }} </gl-button> </header> - <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content"> + <div v-show="!edit" class="gl-text-gray-500 value" data-testid="collapsed-content"> <slot name="collapsed">{{ __('None') }}</slot> </div> <div v-show="edit" data-testid="expanded-content"> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue index 6d928337396..13e1e232676 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -18,16 +18,16 @@ export default { }; }, computed: { - ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), + ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']), hasDueDate() { - return this.activeIssue.dueDate != null; + return this.activeBoardItem.dueDate != null; }, parsedDueDate() { if (!this.hasDueDate) { return null; } - return parsePikadayDate(this.activeIssue.dueDate); + return parsePikadayDate(this.activeBoardItem.dueDate); }, formattedDueDate() { if (!this.hasDueDate) { @@ -69,6 +69,7 @@ export default { <board-editable-item ref="sidebarItem" class="board-sidebar-due-date" + data-testid="sidebar-due-date" :title="$options.i18n.dueDate" :loading="loading" @open="openDatePicker" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 55b1596ee18..f78be83cd82 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -21,9 +21,9 @@ export default { }; }, computed: { - ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), + ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']), selectedLabels() { - const { labels = [] } = this.activeIssue; + const { labels = [] } = this.activeBoardItem; return labels.map((label) => ({ ...label, @@ -31,7 +31,7 @@ export default { })); }, issueLabels() { - const { labels = [] } = this.activeIssue; + const { labels = [] } = this.activeBoardItem; return labels.map((label) => ({ ...label, @@ -40,7 +40,7 @@ export default { }, }, methods: { - ...mapActions(['setActiveIssueLabels']), + ...mapActions(['setActiveBoardItemLabels']), async setLabels(payload) { this.loading = true; this.$refs.sidebarItem.collapse(); @@ -52,7 +52,7 @@ export default { .map((label) => label.id); const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; - await this.setActiveIssueLabels(input); + await this.setActiveBoardItemLabels(input); } catch (e) { createFlash({ message: __('An error occurred while updating labels.') }); } finally { @@ -65,7 +65,7 @@ export default { try { const removeLabelIds = [getIdFromGraphQLId(id)]; const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue }; - await this.setActiveIssueLabels(input); + await this.setActiveBoardItemLabels(input); } catch (e) { createFlash({ message: __('An error occurred when removing the label.') }); } finally { diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue index 829f1c72806..ad225c7bf5c 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue @@ -56,20 +56,20 @@ export default { }, }, computed: { - ...mapGetters(['activeIssue']), + ...mapGetters(['activeBoardItem']), hasMilestone() { - return this.activeIssue.milestone !== null; + return this.activeBoardItem.milestone !== null; }, groupFullPath() { - const { referencePath = '' } = this.activeIssue; + const { referencePath = '' } = this.activeBoardItem; return referencePath.slice(0, referencePath.indexOf('/')); }, projectPath() { - const { referencePath = '' } = this.activeIssue; + const { referencePath = '' } = this.activeBoardItem; return referencePath.slice(0, referencePath.indexOf('#')); }, dropdownText() { - return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone; + return this.activeBoardItem.milestone?.title ?? this.$options.i18n.noMilestone; }, }, methods: { @@ -113,11 +113,12 @@ export default { ref="sidebarItem" :title="$options.i18n.milestone" :loading="loading" - @open="handleOpen()" + data-testid="sidebar-milestones" + @open="handleOpen" @close="handleClose" > <template v-if="hasMilestone" #collapsed> - <strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong> + <strong class="gl-text-gray-900">{{ activeBoardItem.milestone.title }}</strong> </template> <gl-dropdown ref="dropdown" @@ -130,7 +131,7 @@ export default { <gl-dropdown-item data-testid="no-milestone-item" :is-check-item="true" - :is-checked="!activeIssue.milestone" + :is-checked="!activeBoardItem.milestone" @click="setMilestone(null)" > {{ $options.i18n.noMilestone }} @@ -142,7 +143,7 @@ export default { v-for="milestone in milestones" :key="milestone.id" :is-check-item="true" - :is-checked="activeIssue.milestone && milestone.id === activeIssue.milestone.id" + :is-checked="activeBoardItem.milestone && milestone.id === activeBoardItem.milestone.id" data-testid="milestone-item" @click="setMilestone(milestone.id)" > 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 f01c8e8fa20..376985f7cb6 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue @@ -21,27 +21,31 @@ export default { components: { GlToggle, }, + inject: ['emailsDisabled'], data() { return { loading: false, }; }, computed: { - ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), + ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue', 'isEpicBoard']), + isEmailsDisabled() { + return this.isEpicBoard ? this.emailsDisabled : this.activeBoardItem.emailsDisabled; + }, notificationText() { - return this.activeIssue.emailsDisabled + return this.isEmailsDisabled ? this.$options.i18n.header.subscribeDisabledDescription : this.$options.i18n.header.title; }, }, methods: { - ...mapActions(['setActiveIssueSubscribed']), + ...mapActions(['setActiveItemSubscribed']), async handleToggleSubscription() { this.loading = true; try { - await this.setActiveIssueSubscribed({ - subscribed: !this.activeIssue.subscribed, + await this.setActiveItemSubscribed({ + subscribed: !this.activeBoardItem.subscribed, projectPath: this.projectPathForActiveIssue, }); } catch (error) { @@ -61,8 +65,8 @@ export default { > <span data-testid="notification-header-text"> {{ notificationText }} </span> <gl-toggle - v-if="!activeIssue.emailsDisabled" - :value="activeIssue.subscribed" + v-if="!isEmailsDisabled" + :value="activeBoardItem.subscribed" :is-loading="loading" :label="$options.i18n.header.title" label-position="hidden" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue new file mode 100644 index 00000000000..96d444980a8 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue @@ -0,0 +1,25 @@ +<script> +import { mapGetters } from 'vuex'; +import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; + +export default { + components: { + IssuableTimeTracker, + }, + inject: ['timeTrackingLimitToHours'], + computed: { + ...mapGetters(['activeBoardItem']), + }, +}; +</script> + +<template> + <issuable-time-tracker + :time-estimate="activeBoardItem.timeEstimate" + :time-spent="activeBoardItem.totalTimeSpent" + :human-time-estimate="activeBoardItem.humanTimeEstimate" + :human-time-spent="activeBoardItem.humanTotalTimeSpent" + :limit-to-hours="timeTrackingLimitToHours" + :show-collapsed="false" + /> +</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index 95864bd62a7..b8d3107c377 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -27,12 +27,12 @@ export default { }; }, computed: { - ...mapGetters({ issue: 'activeIssue' }), + ...mapGetters({ item: 'activeBoardItem' }), pendingChangesStorageKey() { - return this.getPendingChangesKey(this.issue); + return this.getPendingChangesKey(this.item); }, projectPath() { - const referencePath = this.issue.referencePath || ''; + const referencePath = this.item.referencePath || ''; return referencePath.slice(0, referencePath.indexOf('#')); }, validationState() { @@ -40,29 +40,29 @@ export default { }, }, watch: { - issue: { - handler(updatedIssue, formerIssue) { - if (formerIssue?.title !== this.title) { - localStorage.setItem(this.getPendingChangesKey(formerIssue), this.title); + item: { + handler(updatedItem, formerItem) { + if (formerItem?.title !== this.title) { + localStorage.setItem(this.getPendingChangesKey(formerItem), this.title); } - this.title = updatedIssue.title; + this.title = updatedItem.title; this.setPendingState(); }, immediate: true, }, }, methods: { - ...mapActions(['setActiveIssueTitle']), - getPendingChangesKey(issue) { - if (!issue) { + ...mapActions(['setActiveItemTitle']), + getPendingChangesKey(item) { + if (!item) { return ''; } return joinPaths( window.location.pathname.slice(1), - String(issue.id), - 'issue-title-pending-changes', + String(item.id), + 'item-title-pending-changes', ); }, async setPendingState() { @@ -78,7 +78,7 @@ export default { } }, cancel() { - this.title = this.issue.title; + this.title = this.item.title; this.$refs.sidebarItem.collapse(); this.showChangesAlert = false; localStorage.removeItem(this.pendingChangesStorageKey); @@ -86,24 +86,24 @@ export default { async setTitle() { this.$refs.sidebarItem.collapse(); - if (!this.title || this.title === this.issue.title) { + if (!this.title || this.title === this.item.title) { return; } try { this.loading = true; - await this.setActiveIssueTitle({ title: this.title, projectPath: this.projectPath }); + await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath }); localStorage.removeItem(this.pendingChangesStorageKey); this.showChangesAlert = false; } catch (e) { - this.title = this.issue.title; + this.title = this.item.title; createFlash({ message: this.$options.i18n.updateTitleError }); } finally { this.loading = false; } }, handleOffClick() { - if (this.title !== this.issue.title) { + if (this.title !== this.item.title) { this.showChangesAlert = true; localStorage.setItem(this.pendingChangesStorageKey, this.title); } else { @@ -112,11 +112,11 @@ export default { }, }, i18n: { - issueTitlePlaceholder: __('Issue title'), + titlePlaceholder: __('Title'), submitButton: __('Save changes'), cancelButton: __('Cancel'), - updateTitleError: __('An error occurred when updating the issue title'), - invalidFeedback: __('An issue title is required'), + updateTitleError: __('An error occurred when updating the title'), + invalidFeedback: __('A title is required'), reviewYourChanges: __('Changes to the title have not been saved'), }, }; @@ -131,10 +131,10 @@ export default { @off-click="handleOffClick" > <template #title> - <span class="gl-font-weight-bold" data-testid="issue-title">{{ issue.title }}</span> + <span class="gl-font-weight-bold" data-testid="item-title">{{ item.title }}</span> </template> <template #collapsed> - <span class="gl-text-gray-800">{{ issue.referencePath }}</span> + <span class="gl-text-gray-800">{{ item.referencePath }}</span> </template> <gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false"> {{ $options.i18n.reviewYourChanges }} @@ -144,7 +144,7 @@ export default { <gl-form-input v-model="title" v-autofocusonshow - :placeholder="$options.i18n.issueTitlePlaceholder" + :placeholder="$options.i18n.titlePlaceholder" :state="validationState" /> </gl-form-group> diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue index 74805f8a681..49f5e7d20a9 100644 --- a/app/assets/javascripts/boards/components/toggle_focus.vue +++ b/app/assets/javascripts/boards/components/toggle_focus.vue @@ -38,14 +38,16 @@ export default { </script> <template> - <div class="board-extra-actions gl-ml-3 gl-display-flex gl-align-items-center"> + <div class="gl-ml-3 gl-display-none gl-md-display-flex gl-align-items-center"> <gl-button ref="toggleFocusModeButton" v-gl-tooltip + category="tertiary" :icon="isFullscreen ? 'minimize' : 'maximize'" class="js-focus-mode-btn" data-qa-selector="focus_mode_button" :title="$options.i18n.toggleFocusMode" + :aria-label="$options.i18n.toggleFocusMode" @click="toggleFocusMode" /> </div> diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js index 7f327c5764d..41938d8e284 100644 --- a/app/assets/javascripts/boards/config_toggle.js +++ b/app/assets/javascripts/boards/config_toggle.js @@ -2,14 +2,15 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import ConfigToggle from './components/config_toggle.vue'; -export default (boardsStore) => { +export default (boardsStore = undefined) => { const el = document.querySelector('.js-board-config'); if (!el) { return; } - gl.boardConfigToggle = new Vue({ + // eslint-disable-next-line no-new + new Vue({ el, render(h) { return h(ConfigToggle, { diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 65ebfe7be6c..4ebd30fe67b 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,4 +1,9 @@ import { __ } from '~/locale'; +import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; +import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; +import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql'; +import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; +import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; export const issuableTypes = { issue: 'issue', @@ -45,3 +50,27 @@ export default { BoardType, ListType, }; + +export const blockingIssuablesQueries = { + [issuableTypes.issue]: { + query: boardBlockingIssuesQuery, + }, +}; + +export const titleQueries = { + [issuableTypes.issue]: { + mutation: issueSetTitleMutation, + }, + [issuableTypes.epic]: { + mutation: updateEpicTitleMutation, + }, +}; + +export const subscriptionQueries = { + [issuableTypes.issue]: { + mutation: issueSetSubscriptionMutation, + }, + [issuableTypes.epic]: { + mutation: updateEpicSubscriptionMutation, + }, +}; diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js index b6b34556663..62a0d930ec0 100644 --- a/app/assets/javascripts/boards/ee_functions.js +++ b/app/assets/javascripts/boards/ee_functions.js @@ -2,4 +2,3 @@ export const setWeightFetchingState = () => {}; export const setEpicFetchingState = () => {}; export const getMilestoneTitle = () => ({}); -export const getBoardsModalData = () => ({}); diff --git a/app/assets/javascripts/boards/filtered_search.js b/app/assets/javascripts/boards/filtered_search.js deleted file mode 100644 index 182a2cf3724..00000000000 --- a/app/assets/javascripts/boards/filtered_search.js +++ /dev/null @@ -1,25 +0,0 @@ -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_blocking_issues.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql new file mode 100644 index 00000000000..4dc245660a4 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql @@ -0,0 +1,16 @@ +query BoardBlockingIssues($id: IssueID!) { + issuable: issue(id: $id) { + __typename + id + blockingIssuables: blockedByIssues { + __typename + nodes { + id + iid + title + reference(full: true) + webUrl + } + } + } +} diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql index 1395bef39ed..7ecf9261214 100644 --- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql @@ -7,6 +7,10 @@ fragment IssueNode on Issue { referencePath: reference(full: true) dueDate timeEstimate + totalTimeSpent + humanTimeEstimate + humanTotalTimeSpent + emailsDisabled confidential webUrl subscribed diff --git a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql index 1f383245ac2..bfb87758e17 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql @@ -1,5 +1,5 @@ mutation issueSetSubscription($input: IssueSetSubscriptionInput!) { - issueSetSubscription(input: $input) { + updateIssuableSubscription: issueSetSubscription(input: $input) { issue { subscribed } diff --git a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql index 62e6c1352a6..6ad12d982e0 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql @@ -1,5 +1,5 @@ mutation issueSetTitle($input: UpdateIssueInput!) { - updateIssue(input: $input) { + updateIssuableTitle: updateIssue(input: $input) { issue { title } diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index ceca5b0a451..e3f9d2f24c2 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -10,26 +10,21 @@ import { setWeightFetchingState, setEpicFetchingState, getMilestoneTitle, - getBoardsModalData, } from 'ee_else_ce/boards/ee_functions'; import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes'; import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; import BoardContent from '~/boards/components/board_content.vue'; -import BoardExtraActions from '~/boards/components/board_extra_actions.vue'; import './models/label'; import './models/assignee'; 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'; import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; -import ModalStore from '~/boards/stores/modal_store'; import toggleFocusMode from '~/boards/toggle_focus'; import { deprecatedCreateFlash as Flash } from '~/flash'; import createDefaultClient from '~/lib/graphql'; @@ -72,21 +67,12 @@ 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, components: { BoardContent, BoardSidebar, - BoardAddIssuesModal, BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'), }, provide: { @@ -95,6 +81,7 @@ export default () => { rootPath: $boardApp.dataset.rootPath, currentUserId: gon.current_user_id || null, canUpdate: parseBoolean($boardApp.dataset.canUpdate), + canAdminList: parseBoolean($boardApp.dataset.canAdminList), labelsFetchPath: $boardApp.dataset.labelsFetchPath, labelsManagePath: $boardApp.dataset.labelsManagePath, labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath, @@ -107,6 +94,8 @@ export default () => { milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable), assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable), iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable), + issuableType: issuableTypes.issue, + emailsDisabled: parseBoolean($boardApp.dataset.emailsDisabled), }, store, apolloProvider, @@ -174,15 +163,9 @@ export default () => { eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { - if (!gon.features?.boardsFilteredSearch) { - this.filterManager = new FilteredSearchBoards( - boardsStore.filter, - true, - boardsStore.cantEdit, - ); + this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); - this.filterManager.setup(); - } + this.filterManager.setup(); this.performSearch(); @@ -323,49 +306,7 @@ export default () => { boardConfigToggle(boardsStore); - const issueBoardsModal = document.getElementById('js-add-issues-btn'); - - if (issueBoardsModal && gon.features.addIssuesButton) { - // eslint-disable-next-line no-new - new Vue({ - el: issueBoardsModal, - mixins: [modalMixin], - data() { - return { - modal: ModalStore.store, - store: boardsStore.state, - ...getBoardsModalData(), - canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), - }; - }, - computed: { - disabled() { - if (!this.store) { - return true; - } - return !this.store.lists.filter((list) => !list.preset).length; - }, - }, - methods: { - openModal() { - if (!this.disabled) { - this.toggleModal(true); - } - }, - }, - render(createElement) { - return createElement(BoardExtraActions, { - props: { - canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), - openModal: this.openModal, - disabled: this.disabled, - }, - }); - }, - }); - } - - toggleFocusMode(ModalStore, boardsStore); + toggleFocusMode(); toggleLabels(); if (gon.licensed_features?.swimlanes) { diff --git a/app/assets/javascripts/boards/mixins/board_new_issue.js b/app/assets/javascripts/boards/mixins/board_new_issue.js new file mode 100644 index 00000000000..d4b74544735 --- /dev/null +++ b/app/assets/javascripts/boards/mixins/board_new_issue.js @@ -0,0 +1,6 @@ +export default { + // EE-only + methods: { + extraIssueInput: () => {}, + }, +}; diff --git a/app/assets/javascripts/boards/mixins/modal_footer.js b/app/assets/javascripts/boards/mixins/modal_footer.js deleted file mode 100644 index ff8b4c56321..00000000000 --- a/app/assets/javascripts/boards/mixins/modal_footer.js +++ /dev/null @@ -1 +0,0 @@ -export default {}; diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js deleted file mode 100644 index 6c97e1629bf..00000000000 --- a/app/assets/javascripts/boards/mixins/modal_mixins.js +++ /dev/null @@ -1,12 +0,0 @@ -import ModalStore from '../stores/modal_store'; - -export default { - methods: { - toggleModal(toggle) { - ModalStore.store.showAddIssuesModal = toggle; - }, - changeTab(tab) { - ModalStore.store.activeTab = tab; - }, - }, -}; diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 19b31ee7291..8005414962c 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,16 +1,21 @@ +import * as Sentry from '@sentry/browser'; 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 issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import { BoardType, ListType, inactiveId, flashAnimationDuration, ISSUABLE, + titleQueries, + subscriptionQueries, } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; import { formatBoardLists, formatListIssues, @@ -20,18 +25,17 @@ import { formatIssueInput, updateListPosition, transformNotFilters, + moveItemListHelper, + getMoveData, } from '../boards_util'; import boardLabelsQuery from '../graphql/board_labels.query.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'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; -import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql'; import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql'; import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql'; -import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql'; -import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; import * as types from './mutation_types'; @@ -68,6 +72,7 @@ export default { 'milestoneTitle', 'releaseTag', 'search', + 'myReactionEmoji', ]); filterParams.not = transformNotFilters(filters); commit(types.SET_FILTERS, filterParams); @@ -326,63 +331,155 @@ export default { commit(types.RESET_ISSUES); }, - moveItem: ({ dispatch }) => { - dispatch('moveIssue'); + moveItem: ({ dispatch }, payload) => { + dispatch('moveIssue', payload); }, - moveIssue: ( - { state, commit }, - { itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId }, + moveIssue: ({ dispatch, state }, params) => { + const moveData = getMoveData(state, params); + + dispatch('moveIssueCard', moveData); + dispatch('updateMovedIssue', moveData); + dispatch('updateIssueOrder', { moveData }); + }, + + moveIssueCard: ({ commit }, moveData) => { + const { + reordering, + shouldClone, + itemNotInToList, + originalIndex, + itemId, + fromListId, + toListId, + moveBeforeId, + moveAfterId, + } = moveData; + + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); + + if (reordering) { + commit(types.ADD_BOARD_ITEM_TO_LIST, { + itemId, + listId: toListId, + moveBeforeId, + moveAfterId, + }); + + return; + } + + if (itemNotInToList) { + commit(types.ADD_BOARD_ITEM_TO_LIST, { + itemId, + listId: toListId, + moveBeforeId, + moveAfterId, + }); + } + + if (shouldClone) { + commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex }); + } + }, + + updateMovedIssue: ( + { commit, state: { boardItems, boardLists } }, + { itemId, fromListId, toListId }, ) => { - 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 updatedIssue = moveItemListHelper( + boardItems[itemId], + boardLists[fromListId], + boardLists[toListId], + ); - const { boardId } = state; - const [fullProjectPath] = itemPath.split(/[#]/); + commit(types.UPDATE_BOARD_ITEM, updatedIssue); + }, - gqlClient - .mutate({ + undoMoveIssueCard: ({ commit }, moveData) => { + const { + reordering, + shouldClone, + itemNotInToList, + itemId, + fromListId, + toListId, + originalIssue, + originalIndex, + } = moveData; + + commit(types.UPDATE_BOARD_ITEM, originalIssue); + + if (reordering) { + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); + commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex }); + return; + } + + if (shouldClone) { + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); + } + if (itemNotInToList) { + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: toListId }); + } + + commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex }); + }, + + updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => { + try { + const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData; + const { + boardId, + boardItems: { + [itemId]: { iid, referencePath }, + }, + } = state; + + const { data } = await gqlClient.mutate({ mutation: issueMoveListMutation, variables: { - projectPath: fullProjectPath, + iid, + projectPath: referencePath.split(/[#]/)[0], boardId: fullBoardId(boardId), - iid: itemIid, fromListId: getIdFromGraphQLId(fromListId), toListId: getIdFromGraphQLId(toListId), moveBeforeId, moveAfterId, + // 'mutationVariables' allows EE code to pass in extra parameters. + ...mutationVariables, }, - }) - .then(({ data }) => { - if (data?.issueMoveList?.errors.length) { - throw new Error(); - } else { - const issue = data.issueMoveList?.issue; - commit(types.MOVE_ISSUE_SUCCESS, { issue }); - } - }) - .catch(() => - commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }), + }); + + if (data?.issueMoveList?.errors.length || !data.issueMoveList) { + throw new Error('issueMoveList empty'); + } + + commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue }); + } catch { + commit( + types.SET_ERROR, + s__('Boards|An error occurred while moving the issue. Please try again.'), ); + dispatch('undoMoveIssueCard', moveData); + } }, setAssignees: ({ commit, getters }, assigneeUsernames) => { - commit('UPDATE_ISSUE_BY_ID', { - issueId: getters.activeIssue.id, + commit('UPDATE_BOARD_ITEM_BY_ID', { + itemId: getters.activeBoardItem.id, prop: 'assignees', value: assigneeUsernames, }); }, setActiveIssueMilestone: async ({ commit, getters }, input) => { - const { activeIssue } = getters; + const { activeBoardItem } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetMilestoneMutation, variables: { input: { - iid: String(activeIssue.iid), + iid: String(activeBoardItem.iid), milestoneId: getIdFromGraphQLId(input.milestoneId), projectPath: input.projectPath, }, @@ -393,65 +490,71 @@ export default { throw new Error(data.updateIssue.errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'milestone', value: data.updateIssue.issue.milestone, }); }, - createNewIssue: ({ commit, state }, issueInput) => { - const { boardConfig } = state; + addListItem: ({ commit }, { list, item, position }) => { + commit(types.ADD_BOARD_ITEM_TO_LIST, { listId: list.id, itemId: item.id, atIndex: position }); + commit(types.UPDATE_BOARD_ITEM, item); + }, + + removeListItem: ({ commit }, { listId, itemId }) => { + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { listId, itemId }); + commit(types.REMOVE_BOARD_ITEM, itemId); + }, + addListNewIssue: ( + { state: { boardConfig, boardType, fullPath }, dispatch, commit }, + { issueInput, list, placeholderId = `tmp-${new Date().getTime()}` }, + ) => { const input = formatIssueInput(issueInput, boardConfig); - const { boardType, fullPath } = state; if (boardType === BoardType.project) { input.projectPath = fullPath; } - return gqlClient + const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId }); + dispatch('addListItem', { list, item: placeholderIssue, position: 0 }); + + gqlClient .mutate({ mutation: issueCreateMutation, variables: { input }, }) .then(({ data }) => { if (data.createIssue.errors.length) { - commit(types.CREATE_ISSUE_FAILURE); - } else { - return data.createIssue?.issue; + throw new Error(); } - return null; - }) - .catch(() => commit(types.CREATE_ISSUE_FAILURE)); - }, - addListIssue: ({ commit }, { list, issue, position }) => { - commit(types.ADD_ISSUE_TO_LIST, { list, issue, position }); + const rawIssue = data.createIssue?.issue; + const formattedIssue = formatIssue({ ...rawIssue, id: getIdFromGraphQLId(rawIssue.id) }); + dispatch('removeListItem', { listId: list.id, itemId: placeholderId }); + dispatch('addListItem', { list, item: formattedIssue, position: 0 }); + }) + .catch(() => { + dispatch('removeListItem', { listId: list.id, itemId: placeholderId }); + commit( + types.SET_ERROR, + s__('Boards|An error occurred while creating the issue. Please try again.'), + ); + }); }, - addListNewIssue: ({ commit, dispatch }, { issueInput, list }) => { - const issue = formatIssue({ ...issueInput, id: 'tmp' }); - commit(types.ADD_ISSUE_TO_LIST, { list, issue, position: 0 }); - - dispatch('createNewIssue', issueInput) - .then((res) => { - commit(types.ADD_ISSUE_TO_LIST, { - list, - issue: formatIssue({ ...res, id: getIdFromGraphQLId(res.id) }), - }); - commit(types.REMOVE_ISSUE_FROM_LIST, { list, issue }); - }) - .catch(() => commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issueId: issueInput.id })); + setActiveBoardItemLabels: ({ dispatch }, params) => { + dispatch('setActiveIssueLabels', params); }, setActiveIssueLabels: async ({ commit, getters }, input) => { - const { activeIssue } = getters; + const { activeBoardItem } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetLabelsMutation, variables: { input: { - iid: String(activeIssue.iid), + iid: String(activeBoardItem.iid), addLabelIds: input.addLabelIds ?? [], removeLabelIds: input.removeLabelIds ?? [], projectPath: input.projectPath, @@ -463,20 +566,20 @@ export default { throw new Error(data.updateIssue.errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'labels', value: data.updateIssue.issue.labels.nodes, }); }, setActiveIssueDueDate: async ({ commit, getters }, input) => { - const { activeIssue } = getters; + const { activeBoardItem } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetDueDateMutation, variables: { input: { - iid: String(activeIssue.iid), + iid: String(activeBoardItem.iid), projectPath: input.projectPath, dueDate: input.dueDate, }, @@ -487,57 +590,66 @@ export default { throw new Error(data.updateIssue.errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'dueDate', value: data.updateIssue.issue.dueDate, }); }, - setActiveIssueSubscribed: async ({ commit, getters }, input) => { + setActiveItemSubscribed: async ({ commit, getters, state }, input) => { + const { activeBoardItem, isEpicBoard } = getters; + const { fullPath, issuableType } = state; + const workspacePath = isEpicBoard + ? { groupPath: fullPath } + : { projectPath: input.projectPath }; const { data } = await gqlClient.mutate({ - mutation: issueSetSubscriptionMutation, + mutation: subscriptionQueries[issuableType].mutation, variables: { input: { - iid: String(getters.activeIssue.iid), - projectPath: input.projectPath, + ...workspacePath, + iid: String(activeBoardItem.iid), subscribedState: input.subscribed, }, }, }); - if (data.issueSetSubscription?.errors?.length > 0) { - throw new Error(data.issueSetSubscription.errors); + if (data.updateIssuableSubscription?.errors?.length > 0) { + throw new Error(data.updateIssuableSubscription[issuableType].errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: getters.activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'subscribed', - value: data.issueSetSubscription.issue.subscribed, + value: data.updateIssuableSubscription[issuableType].subscribed, }); }, - setActiveIssueTitle: async ({ commit, getters }, input) => { - const { activeIssue } = getters; + setActiveItemTitle: async ({ commit, getters, state }, input) => { + const { activeBoardItem, isEpicBoard } = getters; + const { fullPath, issuableType } = state; + const workspacePath = isEpicBoard + ? { groupPath: fullPath } + : { projectPath: input.projectPath }; const { data } = await gqlClient.mutate({ - mutation: issueSetTitleMutation, + mutation: titleQueries[issuableType].mutation, variables: { input: { - iid: String(activeIssue.iid), - projectPath: input.projectPath, + ...workspacePath, + iid: String(activeBoardItem.iid), title: input.title, }, }, }); - if (data.updateIssue?.errors?.length > 0) { - throw new Error(data.updateIssue.errors); + if (data.updateIssuableTitle?.errors?.length > 0) { + throw new Error(data.updateIssuableTitle.errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'title', - value: data.updateIssue.issue.title, + value: data.updateIssuableTitle[issuableType].title, }); }, @@ -576,10 +688,10 @@ export default { const { selectedBoardItems } = state; const index = selectedBoardItems.indexOf(boardItem); - // If user already selected an item (activeIssue) without using mult-select, + // If user already selected an item (activeBoardItem) 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); + if (getters.activeBoardItem) { + commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeBoardItem); dispatch('unsetActiveId'); } @@ -608,6 +720,18 @@ export default { } }, + setError: ({ commit }, { message, error, captureError = false }) => { + commit(types.SET_ERROR, message); + + if (captureError) { + Sentry.captureException(error); + } + }, + + unsetError: ({ commit }) => { + commit(types.SET_ERROR, undefined); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index caa518f91ce..0589851c658 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,5 +1,5 @@ import { find } from 'lodash'; -import { BoardType, inactiveId } from '../constants'; +import { BoardType, inactiveId, issuableTypes } from '../constants'; export default { isGroupBoard: (state) => state.boardType === BoardType.group, @@ -15,17 +15,17 @@ export default { return listItemsIds.map((id) => getters.getBoardItemById(id)); }, - activeIssue: (state) => { + activeBoardItem: (state) => { return state.boardItems[state.activeId] || {}; }, groupPathForActiveIssue: (_, getters) => { - const { referencePath = '' } = getters.activeIssue; + const { referencePath = '' } = getters.activeBoardItem; return referencePath.slice(0, referencePath.indexOf('/')); }, projectPathForActiveIssue: (_, getters) => { - const { referencePath = '' } = getters.activeIssue; + const { referencePath = '' } = getters.activeBoardItem; return referencePath.slice(0, referencePath.indexOf('#')); }, @@ -44,6 +44,10 @@ export default { return find(state.boardLists, (l) => l.title === title); }, + isIssueBoard: (state) => { + return state.issuableType === issuableTypes.issue; + }, + isEpicBoard: () => { return false; }, diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js deleted file mode 100644 index 8a8fa61361c..00000000000 --- a/app/assets/javascripts/boards/stores/modal_store.js +++ /dev/null @@ -1,95 +0,0 @@ -class ModalStore { - constructor() { - this.store = { - columns: 3, - issues: [], - issuesCount: false, - selectedIssues: [], - showAddIssuesModal: false, - activeTab: 'all', - selectedList: null, - searchTerm: '', - loading: false, - loadingNewPage: false, - filterLoading: false, - page: 1, - perPage: 50, - filter: { - path: '', - }, - }; - } - - selectedCount() { - return this.getSelectedIssues().length; - } - - toggleIssue(issueObj) { - const issue = issueObj; - const { selected } = issue; - - issue.selected = !selected; - - if (!selected) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } - } - - toggleAll() { - const select = this.selectedCount() !== this.store.issues.length; - - this.store.issues.forEach((issue) => { - const issueUpdate = issue; - - if (issueUpdate.selected !== select) { - issueUpdate.selected = select; - - if (select) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } - } - }); - } - - getSelectedIssues() { - return this.store.selectedIssues.filter((issue) => issue.selected); - } - - addSelectedIssue(issue) { - const index = this.selectedIssueIndex(issue); - - if (index === -1) { - this.store.selectedIssues.push(issue); - } - } - - removeSelectedIssue(issue, forcePurge = false) { - if (this.store.activeTab === 'all' || forcePurge) { - this.store.selectedIssues = this.store.selectedIssues.filter( - (fIssue) => fIssue.id !== issue.id, - ); - } - } - - purgeUnselectedIssues() { - this.store.selectedIssues.forEach((issue) => { - if (!issue.selected) { - this.removeSelectedIssue(issue, true); - } - }); - } - - selectedIssueIndex(issue) { - return this.store.selectedIssues.indexOf(issue); - } - - findSelectedIssue(issue) { - return this.store.selectedIssues.filter((filteredIssue) => filteredIssue.id === issue.id)[0]; - } -} - -export default new ModalStore(); diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index e7c034fb087..22b9905ee62 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -20,23 +20,21 @@ export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; 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'; export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; -export const MOVE_ISSUE = 'MOVE_ISSUE'; -export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS'; -export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE'; +export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM'; +export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM'; export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE'; +export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS'; export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS'; export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR'; -export const ADD_ISSUE_TO_LIST = 'ADD_ISSUE_TO_LIST'; -export const ADD_ISSUE_TO_LIST_FAILURE = 'ADD_ISSUE_TO_LIST_FAILURE'; -export const REMOVE_ISSUE_FROM_LIST = 'REMOVE_ISSUE_FROM_LIST'; +export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST'; +export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST'; export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; -export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID'; +export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID'; export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; export const RESET_ISSUES = 'RESET_ISSUES'; export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; @@ -49,3 +47,4 @@ 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'; +export const SET_ERROR = 'SET_ERROR'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 75b60366b6a..561c21b78c1 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -2,7 +2,7 @@ import { pull, union } from 'lodash'; import Vue from 'vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__ } from '~/locale'; -import { formatIssue, moveItemListHelper } from '../boards_util'; +import { formatIssue } from '../boards_util'; import { issuableTypes } from '../constants'; import * as mutationTypes from './mutation_types'; @@ -158,13 +158,13 @@ export default { }); }, - [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => { - if (!state.boardItems[issueId]) { + [mutationTypes.UPDATE_BOARD_ITEM_BY_ID]: (state, { itemId, prop, value }) => { + if (!state.boardItems[itemId]) { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('No issue found.'); } - Vue.set(state.boardItems[issueId], prop, value); + Vue.set(state.boardItems[itemId], prop, value); }, [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { @@ -183,40 +183,11 @@ export default { notImplemented(); }, - [mutationTypes.MOVE_ISSUE]: ( - state, - { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }, - ) => { - const fromList = state.boardLists[fromListId]; - const toList = state.boardLists[toListId]; - - const issue = moveItemListHelper(originalIssue, fromList, toList); - Vue.set(state.boardItems, issue.id, issue); - - removeItemFromList({ state, listId: fromListId, itemId: issue.id }); - addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId }); - }, - - [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => { + [mutationTypes.MUTATE_ISSUE_SUCCESS]: (state, { issue }) => { const issueId = getIdFromGraphQLId(issue.id); Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId })); }, - [mutationTypes.MOVE_ISSUE_FAILURE]: ( - state, - { originalIssue, fromListId, toListId, originalIndex }, - ) => { - state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); - Vue.set(state.boardItems, originalIssue.id, originalIssue); - removeItemFromList({ state, listId: toListId, itemId: originalIssue.id }); - addItemToList({ - state, - listId: fromListId, - itemId: originalIssue.id, - atIndex: originalIndex, - }); - }, - [mutationTypes.REQUEST_UPDATE_ISSUE]: () => { notImplemented(); }, @@ -229,28 +200,23 @@ export default { notImplemented(); }, - [mutationTypes.CREATE_ISSUE_FAILURE]: (state) => { - state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); + [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: ( + state, + { itemId, listId, moveBeforeId, moveAfterId, atIndex }, + ) => { + addItemToList({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }); }, - [mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => { - addItemToList({ - state, - listId: list.id, - itemId: issue.id, - atIndex: position, - }); - Vue.set(state.boardItems, issue.id, issue); + [mutationTypes.REMOVE_BOARD_ITEM_FROM_LIST]: (state, { itemId, listId }) => { + removeItemFromList({ state, listId, itemId }); }, - [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => { - state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); - removeItemFromList({ state, listId: list.id, itemId: issueId }); + [mutationTypes.UPDATE_BOARD_ITEM]: (state, item) => { + Vue.set(state.boardItems, item.id, item); }, - [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => { - removeItemFromList({ state, listId: list.id, itemId: issue.id }); - Vue.delete(state.boardItems, issue.id); + [mutationTypes.REMOVE_BOARD_ITEM]: (state, itemId) => { + Vue.delete(state.boardItems, itemId); }, [mutationTypes.SET_CURRENT_PAGE]: () => { @@ -309,4 +275,8 @@ export default { [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => { state.selectedBoardItems = []; }, + + [mutationTypes.SET_ERROR]: (state, error) => { + state.error = error; + }, }; |