diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index ba02f21bd6eadb9c2569a6938bdbba60484e3b74..8fd72cfceb5c829756f282ff822f9d8fbf6f3542 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -1,9 +1,9 @@ <script> import { GlDrawer } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; +import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; 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 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'; @@ -23,11 +23,9 @@ export default { BoardSidebarLabelsSelect, BoardSidebarDueDate, SidebarSubscriptionsWidget, - BoardSidebarMilestoneSelect, + SidebarDropdownWidget, BoardSidebarWeightInput: () => import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'), - SidebarDropdownWidget: () => - import('ee_component/sidebar/components/sidebar_dropdown_widget.vue'), }, inject: { multipleAssigneesFeatureAvailable: { @@ -97,7 +95,14 @@ export default { data-testid="sidebar-epic" /> <div> - <board-sidebar-milestone-select /> + <sidebar-dropdown-widget + :iid="activeBoardItem.iid" + issuable-attribute="milestone" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="projectPathForActiveIssue" + :issuable-type="issuableType" + data-testid="sidebar-milestones" + /> <sidebar-dropdown-widget v-if="iterationFeatureAvailable" :iid="activeBoardItem.iid" diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue new file mode 100644 index 0000000000000000000000000000000000000000..277e1400bf213703e54ea0effa8a9c41f6d87677 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -0,0 +1,344 @@ +<script> +import { + GlLink, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlDropdownDivider, + GlLoadingIcon, + GlIcon, + GlTooltipDirective, +} from '@gitlab/ui'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; +import { __, s__, sprintf } from '~/locale'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { + IssuableAttributeState, + IssuableAttributeType, + issuableAttributesQueries, + noAttributeId, +} from '../constants'; + +export default { + noAttributeId, + IssuableAttributeState, + issuableAttributesQueries, + i18n: { + [IssuableAttributeType.Milestone]: __('Milestone'), + none: __('None'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + SidebarEditableItem, + GlLink, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlDropdownDivider, + GlSearchBoxByType, + GlIcon, + GlLoadingIcon, + }, + inject: { + isClassicSidebar: { + default: false, + }, + }, + props: { + issuableAttribute: { + type: String, + required: true, + validator(value) { + return [IssuableAttributeType.Milestone].includes(value); + }, + }, + workspacePath: { + required: true, + type: String, + }, + iid: { + required: true, + type: String, + }, + attrWorkspacePath: { + required: true, + type: String, + }, + issuableType: { + type: String, + required: true, + validator(value) { + return value === IssuableType.Issue; + }, + }, + }, + apollo: { + currentAttribute: { + query() { + const { current } = this.issuableAttributeQuery; + const { query } = current[this.issuableType]; + + return query; + }, + variables() { + return { + fullPath: this.workspacePath, + iid: this.iid, + }; + }, + update(data) { + return data?.workspace?.issuable.attribute; + }, + error(error) { + createFlash({ + message: this.i18n.currentFetchError, + captureError: true, + error, + }); + }, + }, + attributesList: { + query() { + const { list } = this.issuableAttributeQuery; + const { query } = list[this.issuableType]; + + return query; + }, + skip() { + return !this.editing; + }, + debounce: 250, + variables() { + return { + fullPath: this.attrWorkspacePath, + title: this.searchTerm, + state: this.$options.IssuableAttributeState[this.issuableAttribute], + }; + }, + update(data) { + if (data?.workspace) { + return data?.workspace?.attributes.nodes; + } + return []; + }, + error(error) { + createFlash({ message: this.i18n.listFetchError, captureError: true, error }); + }, + }, + }, + data() { + return { + searchTerm: '', + editing: false, + updating: false, + selectedTitle: null, + currentAttribute: null, + attributesList: [], + tracking: { + label: 'right_sidebar', + event: 'click_edit_button', + property: this.issuableAttribute, + }, + }; + }, + computed: { + issuableAttributeQuery() { + return this.$options.issuableAttributesQueries[this.issuableAttribute]; + }, + attributeTitle() { + return this.currentAttribute?.title || this.i18n.noAttribute; + }, + attributeUrl() { + return this.currentAttribute?.webUrl; + }, + dropdownText() { + return this.currentAttribute + ? this.currentAttribute?.title + : this.$options.i18n[this.issuableAttribute]; + }, + loading() { + return this.$apollo.queries.currentAttribute.loading; + }, + emptyPropsList() { + return this.attributesList.length === 0; + }, + attributeTypeTitle() { + return this.$options.i18n[this.issuableAttribute]; + }, + i18n() { + return { + noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { + issuableAttribute: this.issuableAttribute, + }), + assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), { + issuableAttribute: this.issuableAttribute, + }), + noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), { + issuableAttribute: this.issuableAttribute, + }), + updateError: sprintf( + s__( + 'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.', + ), + { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, + ), + listFetchError: sprintf( + s__( + 'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.', + ), + { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, + ), + currentFetchError: sprintf( + s__( + 'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.', + ), + { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, + ), + }; + }, + }, + methods: { + updateAttribute(attributeId) { + if (this.currentAttribute === null && attributeId === null) return; + if (attributeId === this.currentAttribute?.id) return; + + this.updating = true; + + const selectedAttribute = + Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId); + this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none; + + const { current } = this.issuableAttributeQuery; + const { mutation } = current[this.issuableType]; + + this.$apollo + .mutate({ + mutation, + variables: { + fullPath: this.workspacePath, + attributeId: + this.issuableAttribute === IssuableAttributeType.Milestone + ? getIdFromGraphQLId(attributeId) + : attributeId, + iid: this.iid, + }, + }) + .then(({ data }) => { + if (data.issuableSetAttribute?.errors?.length) { + createFlash({ + message: data.issuableSetAttribute.errors[0], + captureError: true, + error: data.issuableSetAttribute.errors[0], + }); + } else { + this.$emit('attribute-updated', data); + } + }) + .catch((error) => { + createFlash({ message: this.i18n.updateError, captureError: true, error }); + }) + .finally(() => { + this.updating = false; + this.searchTerm = ''; + this.selectedTitle = null; + }); + }, + isAttributeChecked(attributeId = undefined) { + return ( + attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId) + ); + }, + showDropdown() { + this.$refs.newDropdown.show(); + }, + handleOpen() { + this.editing = true; + this.showDropdown(); + }, + handleClose() { + this.editing = false; + }, + setFocus() { + this.$refs.search.focusInput(); + }, + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="attributeTypeTitle" + :data-testid="`${issuableAttribute}-edit`" + :tracking="tracking" + :loading="updating || loading" + @open="handleOpen" + @close="handleClose" + > + <template #collapsed> + <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon"> + <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" /> + <span class="collapse-truncated-title">{{ attributeTitle }}</span> + </div> + <div + :data-testid="`select-${issuableAttribute}`" + :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'" + > + <span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span> + <span v-else-if="!currentAttribute" class="gl-text-gray-500"> + {{ $options.i18n.none }} + </span> + <gl-link v-else class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl"> + {{ attributeTitle }} + </gl-link> + </div> + </template> + <template #default> + <gl-dropdown + ref="newDropdown" + lazy + :header-text="i18n.assignAttribute" + :text="dropdownText" + :loading="loading" + class="gl-w-full" + @shown="setFocus" + > + <gl-search-box-by-type ref="search" v-model="searchTerm" /> + <gl-dropdown-item + :data-testid="`no-${issuableAttribute}-item`" + :is-check-item="true" + :is-checked="isAttributeChecked($options.noAttributeId)" + @click="updateAttribute($options.noAttributeId)" + > + {{ i18n.noAttribute }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-loading-icon + v-if="$apollo.queries.attributesList.loading" + class="gl-py-4" + data-testid="loading-icon-dropdown" + /> + <template v-else> + <gl-dropdown-text v-if="emptyPropsList"> + {{ i18n.noAttributesFound }} + </gl-dropdown-text> + <gl-dropdown-item + v-for="attrItem in attributesList" + :key="attrItem.id" + :is-check-item="true" + :is-checked="isAttributeChecked(attrItem.id)" + :data-testid="`${issuableAttribute}-items`" + @click="updateAttribute(attrItem.id)" + > + {{ attrItem.title }} + </gl-dropdown-item> + </template> + </gl-dropdown> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 95b0de38a8ed5d86991c456e3f53280db7a44c78..6b16c47ba85d4aee9d0a0f7ebf14f4f274c1f5e6 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -29,6 +29,9 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; +import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql'; +import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; +import projectMilestonesQuery from './queries/project_milestones.query.graphql'; export const ASSIGNEES_DEBOUNCE_DELAY = 250; @@ -143,3 +146,33 @@ export const timelogQueries = { query: getMrTimelogsQuery, }, }; + +export const noAttributeId = null; + +export const issuableMilestoneQueries = { + [IssuableType.Issue]: { + query: projectIssueMilestoneQuery, + mutation: projectIssueMilestoneMutation, + }, +}; + +export const milestonesQueries = { + [IssuableType.Issue]: { + query: projectMilestonesQuery, + }, +}; + +export const IssuableAttributeType = { + Milestone: 'milestone', +}; + +export const IssuableAttributeState = { + [IssuableAttributeType.Milestone]: 'active', +}; + +export const issuableAttributesQueries = { + [IssuableAttributeType.Milestone]: { + current: issuableMilestoneQueries, + list: milestonesQueries, + }, +}; diff --git a/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..8db5359dac03e78b584728dbaa58c3765c982ebb --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql @@ -0,0 +1,5 @@ +fragment MilestoneFragment on Milestone { + id + title + webUrl: webPath +} diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..d88ad8b10874ab367ede8725b7dc836c17fcc28a --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql @@ -0,0 +1,17 @@ +mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attributeId: ID) { + issuableSetAttribute: updateIssue( + input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId } + ) { + __typename + errors + issuable: issue { + __typename + id + attribute: milestone { + title + id + state + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2bc42a0b0113c3803630a97fd10a9f0b93aa8017 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql @@ -0,0 +1,14 @@ +#import "./milestone.fragment.graphql" + +query projectIssueMilestone($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + attribute: milestone { + ...MilestoneFragment + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..1237640c46887a28c75b8bacc7e1754263535914 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql @@ -0,0 +1,13 @@ +#import "./milestone.fragment.graphql" + +query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) { + workspace: project(fullPath: $fullPath) { + __typename + attributes: milestones(searchTitle: $title, state: $state) { + nodes { + ...MilestoneFragment + state + } + } + } +} diff --git a/ee/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/ee/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index dee1f67bf7f652bd9a5ac28a3099a48761cd750e..97722f444576986690d9b41d4b9fe67281266098 100644 --- a/ee/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/ee/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -1,339 +1,37 @@ <script> -import { - GlLink, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlDropdownDivider, - GlLoadingIcon, - GlIcon, - GlTooltipDirective, -} from '@gitlab/ui'; -import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; -import { __, s__, sprintf } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +// This is a false violation of @gitlab/no-runtime-template-compiler, since it +// extends a valid Vue single file component. +/* eslint-disable @gitlab/no-runtime-template-compiler */ +import { __ } from '~/locale'; +import SidebarDropdownWidgetFoss from '~/sidebar/components/sidebar_dropdown_widget.vue'; import { IssuableAttributeState, IssuableAttributeType, issuableAttributesQueries, - noAttributeId, } from '../constants'; export default { - noAttributeId, + extends: SidebarDropdownWidgetFoss, + IssuableAttributeState, + issuableAttributesQueries, i18n: { + [IssuableAttributeType.Milestone]: __('Milestone'), [IssuableAttributeType.Iteration]: __('Iteration'), [IssuableAttributeType.Epic]: __('Epic'), none: __('None'), }, - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { - SidebarEditableItem, - GlLink, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlDropdownDivider, - GlSearchBoxByType, - GlIcon, - GlLoadingIcon, - }, - inject: { - isClassicSidebar: { - default: false, - }, - }, props: { issuableAttribute: { type: String, required: true, validator(value) { - return [IssuableAttributeType.Iteration, IssuableAttributeType.Epic].includes(value); - }, - }, - workspacePath: { - required: true, - type: String, - }, - iid: { - required: true, - type: String, - }, - attrWorkspacePath: { - required: true, - type: String, - }, - issuableType: { - type: String, - required: true, - validator(value) { - return value === IssuableType.Issue; - }, - }, - }, - apollo: { - currentAttribute: { - query() { - const { current } = this.issuableAttributeQuery; - const { query } = current[this.issuableType]; - - return query; - }, - variables() { - return { - fullPath: this.workspacePath, - iid: this.iid, - }; - }, - update(data) { - return data?.workspace?.issuable.attribute; - }, - error(error) { - createFlash({ - message: this.i18n.currentFetchError, - captureError: true, - error, - }); - }, - }, - attributesList: { - query() { - const { list } = this.issuableAttributeQuery; - const { query } = list[this.issuableType]; - - return query; - }, - skip() { - return !this.editing; - }, - debounce: 250, - variables() { - return { - fullPath: this.attrWorkspacePath, - title: this.searchTerm, - state: IssuableAttributeState[this.issuableAttribute], - }; - }, - update(data) { - if (data?.workspace) { - return data?.workspace?.attributes.nodes; - } - return []; - }, - error(error) { - createFlash({ message: this.i18n.listFetchError, captureError: true, error }); + return [ + IssuableAttributeType.Milestone, + IssuableAttributeType.Iteration, + IssuableAttributeType.Epic, + ].includes(value); }, }, }, - data() { - return { - searchTerm: '', - editing: false, - updating: false, - selectedTitle: null, - currentAttribute: null, - attributesList: [], - tracking: { - label: 'right_sidebar', - event: 'click_edit_button', - property: this.issuableAttribute, - }, - }; - }, - computed: { - issuableAttributeQuery() { - return issuableAttributesQueries[this.issuableAttribute]; - }, - attributeTitle() { - return this.currentAttribute?.title || this.i18n.noAttribute; - }, - attributeUrl() { - return this.currentAttribute?.webUrl; - }, - dropdownText() { - return this.currentAttribute - ? this.currentAttribute?.title - : this.$options.i18n[this.issuableAttribute]; - }, - loading() { - return this.$apollo.queries.currentAttribute.loading; - }, - emptyPropsList() { - return this.attributesList.length === 0; - }, - attributeTypeTitle() { - return this.$options.i18n[this.issuableAttribute]; - }, - i18n() { - return { - noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { - issuableAttribute: this.issuableAttribute, - }), - assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), { - issuableAttribute: this.issuableAttribute, - }), - noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), { - issuableAttribute: this.issuableAttribute, - }), - updateError: sprintf( - s__( - 'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.', - ), - { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, - ), - listFetchError: sprintf( - s__( - 'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.', - ), - { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, - ), - currentFetchError: sprintf( - s__( - 'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.', - ), - { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, - ), - }; - }, - }, - methods: { - updateAttribute(attributeId) { - if (this.currentAttribute === null && attributeId === null) return; - if (attributeId === this.currentAttribute?.id) return; - - this.updating = true; - - const selectedAttribute = - Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId); - this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none; - - const { current } = this.issuableAttributeQuery; - const { mutation } = current[this.issuableType]; - - this.$apollo - .mutate({ - mutation, - variables: { - fullPath: this.workspacePath, - attributeId, - iid: this.iid, - }, - }) - .then(({ data }) => { - if (data.issuableSetAttribute?.errors?.length) { - createFlash({ - message: data.issuableSetAttribute.errors[0], - captureError: true, - error: data.issuableSetAttribute.errors[0], - }); - } else { - this.$emit('attribute-updated', data); - } - }) - .catch((error) => { - createFlash({ message: this.i18n.updateError, captureError: true, error }); - }) - .finally(() => { - this.updating = false; - this.searchTerm = ''; - this.selectedTitle = null; - }); - }, - isAttributeChecked(attributeId = undefined) { - return ( - attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId) - ); - }, - showDropdown() { - this.$refs.newDropdown.show(); - }, - handleOpen() { - this.editing = true; - this.showDropdown(); - }, - handleClose() { - this.editing = false; - }, - setFocus() { - this.$refs.search.focusInput(); - }, - }, }; </script> - -<template> - <sidebar-editable-item - ref="editable" - :title="attributeTypeTitle" - :data-testid="`${issuableAttribute}-edit`" - :tracking="tracking" - :loading="updating || loading" - @open="handleOpen" - @close="handleClose" - > - <template #collapsed> - <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon"> - <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" /> - <span class="collapse-truncated-title">{{ attributeTitle }}</span> - </div> - <div - :data-testid="`select-${issuableAttribute}`" - :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'" - > - <span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span> - <span v-else-if="!currentAttribute" class="gl-text-gray-500"> - {{ $options.i18n.none }} - </span> - <gl-link v-else class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl"> - {{ attributeTitle }} - </gl-link> - </div> - </template> - <template #default> - <gl-dropdown - ref="newDropdown" - lazy - :header-text="i18n.assignAttribute" - :text="dropdownText" - :loading="loading" - class="gl-w-full" - @shown="setFocus" - > - <gl-search-box-by-type ref="search" v-model="searchTerm" /> - <gl-dropdown-item - :data-testid="`no-${issuableAttribute}-item`" - :is-check-item="true" - :is-checked="isAttributeChecked($options.noAttributeId)" - @click="updateAttribute($options.noAttributeId)" - > - {{ i18n.noAttribute }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-loading-icon - v-if="$apollo.queries.attributesList.loading" - class="gl-py-4" - data-testid="loading-icon-dropdown" - /> - <template v-else> - <gl-dropdown-text v-if="emptyPropsList"> - {{ i18n.noAttributesFound }} - </gl-dropdown-text> - <gl-dropdown-item - v-for="attrItem in attributesList" - :key="attrItem.id" - :is-check-item="true" - :is-checked="isAttributeChecked(attrItem.id)" - :data-testid="`${issuableAttribute}-items`" - @click="updateAttribute(attrItem.id)" - > - {{ attrItem.title }} - </gl-dropdown-item> - </template> - </gl-dropdown> - </template> - </sidebar-editable-item> -</template> diff --git a/ee/app/assets/javascripts/sidebar/constants.js b/ee/app/assets/javascripts/sidebar/constants.js index 023ae8d8b512f13815283a62e73e51df89f3d50b..10ecac7806f7a0255cc8e4afa4f0aced537df9b7 100644 --- a/ee/app/assets/javascripts/sidebar/constants.js +++ b/ee/app/assets/javascripts/sidebar/constants.js @@ -1,5 +1,10 @@ import { IssuableType } from '~/issue_show/constants'; import { s__, __ } from '~/locale'; +import { + IssuableAttributeType as IssuableAttributeTypeFoss, + IssuableAttributeState as IssuableAttributeStateFoss, + issuableAttributesQueries as issuableAttributesQueriesFoss, +} from '~/sidebar/constants'; import groupEpicsQuery from './queries/group_epics.query.graphql'; import groupIterationsQuery from './queries/group_iterations.query.graphql'; import projectIssueEpicMutation from './queries/project_issue_epic.mutation.graphql'; @@ -95,16 +100,19 @@ const epicsQueries = { }; export const IssuableAttributeType = { + ...IssuableAttributeTypeFoss, Iteration: 'iteration', Epic: 'epic', }; export const IssuableAttributeState = { + ...IssuableAttributeStateFoss, [IssuableAttributeType.Iteration]: 'opened', [IssuableAttributeType.Epic]: 'opened', }; export const issuableAttributesQueries = { + ...issuableAttributesQueriesFoss, [IssuableAttributeType.Iteration]: { current: issuableIterationQueries, list: iterationsQueries, diff --git a/ee/spec/frontend/boards/components/__snapshots__/board_content_sidebar_spec.js.snap b/ee/spec/frontend/boards/components/__snapshots__/board_content_sidebar_spec.js.snap index 7611a41bfd4d39625d20e2b417bc182f11385453..4a34a8266eef040de1f7286719cec0c4a3462cf0 100644 --- a/ee/spec/frontend/boards/components/__snapshots__/board_content_sidebar_spec.js.snap +++ b/ee/spec/frontend/boards/components/__snapshots__/board_content_sidebar_spec.js.snap @@ -13,26 +13,33 @@ exports[`ee/BoardContentSidebar matches the snapshot 1`] = ` /> <sidebardropdownwidget-stub - attr-workspace-path="gitlab-org" + attrworkspacepath="gitlab-org" data-testid="sidebar-epic" iid="27" - issuable-attribute="epic" - issuable-type="issue" - workspace-path="gitlab-org/gitlab-test" + issuableattribute="epic" + issuabletype="issue" + workspacepath="gitlab-org/gitlab-test" /> <div> - <boardsidebarmilestoneselect-stub /> + <sidebardropdownwidget-stub + attrworkspacepath="gitlab-org/gitlab-test" + data-testid="sidebar-milestones" + iid="27" + issuableattribute="milestone" + issuabletype="issue" + workspacepath="gitlab-org/gitlab-test" + /> <sidebardropdownwidget-stub - attr-workspace-path="gitlab-org" + attrworkspacepath="gitlab-org" class="gl-mt-5" data-qa-selector="iteration_container" data-testid="iteration-edit" iid="27" - issuable-attribute="iteration" - issuable-type="issue" - workspace-path="gitlab-org/gitlab-test" + issuableattribute="iteration" + issuabletype="issue" + workspacepath="gitlab-org/gitlab-test" /> </div> diff --git a/ee/spec/frontend/boards/components/board_content_sidebar_spec.js b/ee/spec/frontend/boards/components/board_content_sidebar_spec.js index 79c32286b5dc6c757dbdbc7b3bfdee20738705ed..706e23e1ce63741f0c7017676a23bc9e1f8f1830 100644 --- a/ee/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/ee/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -61,7 +61,6 @@ describe('ee/BoardContentSidebar', () => { SidebarConfidentialityWidget: true, BoardSidebarDueDate: true, SidebarSubscriptionsWidget: true, - BoardSidebarMilestoneSelect: true, BoardSidebarWeightInput: true, SidebarDropdownWidget: true, }, diff --git a/spec/features/boards/sidebar_milestones_spec.rb b/spec/features/boards/sidebar_milestones_spec.rb index 54182781a308effe763191967edd472faf5e43e4..be7435263b16eaede54e4d308bba5812df1555d0 100644 --- a/spec/features/boards/sidebar_milestones_spec.rb +++ b/spec/features/boards/sidebar_milestones_spec.rb @@ -38,7 +38,7 @@ wait_for_requests - page.within('.value') do + page.within('[data-testid="select-milestone"]') do expect(page).to have_content(milestone.title) end end @@ -56,7 +56,7 @@ wait_for_requests - page.within('.value') do + page.within('[data-testid="select-milestone"]') do expect(page).not_to have_content(milestone.title) end end diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 01c99a02db2c9fb3e247969016149ac797afa275..e97bdba5fea13ff9f7993b79b0f7768486330bef 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -1,11 +1,11 @@ import { GlDrawer } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; +import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import { stubComponent } from 'helpers/stub_component'; import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; 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 BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; @@ -68,6 +68,9 @@ describe('BoardContentSidebar', () => { iterations: { loading: false, }, + attributesList: { + loading: false, + }, }, }, }, @@ -84,38 +87,41 @@ describe('BoardContentSidebar', () => { }); it('confirms we render GlDrawer', () => { - expect(wrapper.find(GlDrawer).exists()).toBe(true); + expect(wrapper.findComponent(GlDrawer).exists()).toBe(true); }); it('does not render GlDrawer when isSidebarOpen is false', () => { createStore({ mockGetters: { isSidebarOpen: () => false } }); createComponent(); - expect(wrapper.find(GlDrawer).exists()).toBe(false); + expect(wrapper.findComponent(GlDrawer).exists()).toBe(false); }); it('applies an open attribute', () => { - expect(wrapper.find(GlDrawer).props('open')).toBe(true); + expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true); }); it('renders BoardSidebarLabelsSelect', () => { - expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true); + expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true); }); it('renders BoardSidebarTitle', () => { - expect(wrapper.find(BoardSidebarTitle).exists()).toBe(true); + expect(wrapper.findComponent(BoardSidebarTitle).exists()).toBe(true); }); it('renders BoardSidebarDueDate', () => { - expect(wrapper.find(BoardSidebarDueDate).exists()).toBe(true); + expect(wrapper.findComponent(BoardSidebarDueDate).exists()).toBe(true); }); it('renders BoardSidebarSubscription', () => { - expect(wrapper.find(SidebarSubscriptionsWidget).exists()).toBe(true); + expect(wrapper.findComponent(SidebarSubscriptionsWidget).exists()).toBe(true); }); - it('renders BoardSidebarMilestoneSelect', () => { - expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true); + it('renders SidebarDropdownWidget for milestones', () => { + expect(wrapper.findComponent(SidebarDropdownWidget).exists()).toBe(true); + expect(wrapper.findComponent(SidebarDropdownWidget).props('issuableAttribute')).toEqual( + 'milestone', + ); }); describe('when we emit close', () => { @@ -128,7 +134,7 @@ describe('BoardContentSidebar', () => { }); it('calls toggleBoardItem with correct parameters', async () => { - wrapper.find(GlDrawer).vm.$emit('close'); + wrapper.findComponent(GlDrawer).vm.$emit('close'); expect(toggleBoardItem).toHaveBeenCalledTimes(1); expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8d58854b0130f04de8056c24e5ffd57e2395298a --- /dev/null +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -0,0 +1,503 @@ +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLink, + GlSearchBoxByType, + GlFormInput, + GlLoadingIcon, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; +import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { IssuableAttributeType } from '~/sidebar/constants'; +import projectIssueMilestoneMutation from '~/sidebar/queries/project_issue_milestone.mutation.graphql'; +import projectIssueMilestoneQuery from '~/sidebar/queries/project_issue_milestone.query.graphql'; +import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; + +import { + mockIssue, + mockProjectMilestonesResponse, + noCurrentMilestoneResponse, + mockMilestoneMutationResponse, + mockMilestone2, + emptyProjectMilestonesResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('SidebarDropdownWidget', () => { + let wrapper; + let mockApollo; + + const promiseData = { issuableSetAttribute: { issue: { attribute: { id: '123' } } } }; + const firstErrorMsg = 'first error'; + const promiseWithErrors = { + ...promiseData, + issuableSetAttribute: { ...promiseData.issuableSetAttribute, errors: [firstErrorMsg] }, + }; + + const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData }); + const mutationError = () => + jest.fn().mockRejectedValue('Failed to set milestone on this issue. Please try again.'); + const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors }); + + const findGlLink = () => wrapper.findComponent(GlLink); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownText = () => wrapper.findComponent(GlDropdownText); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemWithText = (text) => + findAllDropdownItems().wrappers.find((x) => x.text() === text); + + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findEditButton = () => findSidebarEditableItem().find('[data-testid="edit-button"]'); + const findEditableLoadingIcon = () => findSidebarEditableItem().findComponent(GlLoadingIcon); + const findAttributeItems = () => wrapper.findByTestId('milestone-items'); + const findSelectedAttribute = () => wrapper.findByTestId('select-milestone'); + const findNoAttributeItem = () => wrapper.findByTestId('no-milestone-item'); + const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown'); + + const waitForDropdown = async () => { + // BDropdown first changes its `visible` property + // in a requestAnimationFrame callback. + // It then emits `shown` event in a watcher for `visible` + // Hence we need both of these: + await waitForPromises(); + await wrapper.vm.$nextTick(); + }; + + const waitForApollo = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + // Used with createComponentWithApollo which uses 'mount' + const clickEdit = async () => { + await findEditButton().trigger('click'); + + await waitForDropdown(); + + // We should wait for attributes list to be fetched. + await waitForApollo(); + }; + + // Used with createComponent which shallow mounts components + const toggleDropdown = async () => { + wrapper.vm.$refs.editable.expand(); + + await waitForDropdown(); + }; + + const createComponentWithApollo = async ({ + requestHandlers = [], + projectMilestonesSpy = jest.fn().mockResolvedValue(mockProjectMilestonesResponse), + currentMilestoneSpy = jest.fn().mockResolvedValue(noCurrentMilestoneResponse), + } = {}) => { + localVue.use(VueApollo); + mockApollo = createMockApollo([ + [projectMilestonesQuery, projectMilestonesSpy], + [projectIssueMilestoneQuery, currentMilestoneSpy], + ...requestHandlers, + ]); + + wrapper = extendedWrapper( + mount(SidebarDropdownWidget, { + localVue, + provide: { canUpdate: true }, + apolloProvider: mockApollo, + propsData: { + workspacePath: mockIssue.projectPath, + attrWorkspacePath: mockIssue.projectPath, + iid: mockIssue.iid, + issuableType: IssuableType.Issue, + issuableAttribute: IssuableAttributeType.Milestone, + }, + attachTo: document.body, + }), + ); + + await waitForApollo(); + }; + + const createComponent = ({ data = {}, mutationPromise = mutationSuccess, queries = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(SidebarDropdownWidget, { + provide: { canUpdate: true }, + data() { + return data; + }, + propsData: { + workspacePath: '', + attrWorkspacePath: '', + iid: '', + issuableType: IssuableType.Issue, + issuableAttribute: IssuableAttributeType.Milestone, + }, + mocks: { + $apollo: { + mutate: mutationPromise(), + queries: { + currentAttribute: { loading: false }, + attributesList: { loading: false }, + ...queries, + }, + }, + }, + stubs: { + SidebarEditableItem, + GlSearchBoxByType, + GlDropdown, + }, + }), + ); + + // We need to mock out `showDropdown` which + // invokes `show` method of BDropdown used inside GlDropdown. + jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not editing', () => { + beforeEach(() => { + createComponent({ + data: { + currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl' }, + }, + stubs: { + GlDropdown, + SidebarEditableItem, + }, + }); + }); + + it('shows the current attribute', () => { + expect(findSelectedAttribute().text()).toBe('title'); + }); + + it('links to the current attribute', () => { + expect(findGlLink().attributes().href).toBe('webUrl'); + }); + + it('does not show a loading spinner next to the heading', () => { + expect(findEditableLoadingIcon().exists()).toBe(false); + }); + + it('shows a loading spinner while fetching the current attribute', () => { + createComponent({ + queries: { + currentAttribute: { loading: true }, + }, + }); + + expect(findEditableLoadingIcon().exists()).toBe(true); + }); + + it('shows the loading spinner and the title of the selected attribute while updating', () => { + createComponent({ + data: { + updating: true, + selectedTitle: 'Some milestone title', + }, + queries: { + currentAttribute: { loading: false }, + }, + }); + + expect(findEditableLoadingIcon().exists()).toBe(true); + expect(findSelectedAttribute().text()).toBe('Some milestone title'); + }); + + describe('when current attribute does not exist', () => { + it('renders "None" as the selected attribute title', () => { + createComponent(); + + expect(findSelectedAttribute().text()).toBe('None'); + }); + }); + }); + + describe('when a user can edit', () => { + describe('when user is editing', () => { + describe('when rendering the dropdown', () => { + it('shows a loading spinner while fetching a list of attributes', async () => { + createComponent({ + queries: { + attributesList: { loading: true }, + }, + }); + + await toggleDropdown(); + + expect(findLoadingIconDropdown().exists()).toBe(true); + }); + + describe('GlDropdownItem with the right title and id', () => { + const id = 'id'; + const title = 'title'; + + beforeEach(async () => { + createComponent({ + data: { attributesList: [{ id, title }], currentAttribute: { id, title } }, + }); + + await toggleDropdown(); + }); + + it('does not show a loading spinner', () => { + expect(findLoadingIconDropdown().exists()).toBe(false); + }); + + it('renders title $title', () => { + expect(findDropdownItemWithText(title).exists()).toBe(true); + }); + + it('checks the correct dropdown item', () => { + expect( + findAllDropdownItems() + .filter((w) => w.props('isChecked') === true) + .at(0) + .text(), + ).toBe(title); + }); + }); + + describe('when no data is assigned', () => { + beforeEach(async () => { + createComponent(); + + await toggleDropdown(); + }); + + it('finds GlDropdownItem with "No milestone"', () => { + expect(findNoAttributeItem().text()).toBe('No milestone'); + }); + + it('"No milestone" is checked', () => { + expect(findNoAttributeItem().props('isChecked')).toBe(true); + }); + + it('does not render any dropdown item', () => { + expect(findAttributeItems().exists()).toBe(false); + }); + }); + + describe('when clicking on dropdown item', () => { + describe('when currentAttribute is equal to attribute id', () => { + it('does not call setIssueAttribute mutation', async () => { + createComponent({ + data: { + attributesList: [{ id: 'id', title: 'title' }], + currentAttribute: { id: 'id', title: 'title' }, + }, + }); + + await toggleDropdown(); + + findDropdownItemWithText('title').vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0); + }); + }); + + describe('when currentAttribute is not equal to attribute id', () => { + describe('when error', () => { + const bootstrapComponent = (mutationResp) => { + createComponent({ + data: { + attributesList: [ + { id: '123', title: '123' }, + { id: 'id', title: 'title' }, + ], + currentAttribute: '123', + }, + mutationPromise: mutationResp, + }); + }; + + describe.each` + description | mutationResp | expectedMsg + ${'top-level error'} | ${mutationError} | ${'Failed to set milestone on this issue. Please try again.'} + ${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg} + `(`$description`, ({ mutationResp, expectedMsg }) => { + beforeEach(async () => { + bootstrapComponent(mutationResp); + + await toggleDropdown(); + + findDropdownItemWithText('title').vm.$emit('click'); + }); + + it(`calls createFlash with "${expectedMsg}"`, async () => { + await wrapper.vm.$nextTick(); + expect(createFlash).toHaveBeenCalledWith({ + message: expectedMsg, + captureError: true, + error: expectedMsg, + }); + }); + }); + }); + }); + }); + }); + + describe('when a user is searching', () => { + describe('when search result is not found', () => { + it('renders "No milestone found"', async () => { + createComponent(); + + await toggleDropdown(); + + findSearchBox().vm.$emit('input', 'non existing milestones'); + + await wrapper.vm.$nextTick(); + + expect(findDropdownText().text()).toBe('No milestone found'); + }); + }); + }); + }); + }); + + describe('with mock apollo', () => { + let error; + + beforeEach(() => { + jest.spyOn(Sentry, 'captureException'); + error = new Error('mayday'); + }); + + describe("when issuable type is 'issue'", () => { + describe('when dropdown is expanded and user can edit', () => { + let milestoneMutationSpy; + beforeEach(async () => { + milestoneMutationSpy = jest.fn().mockResolvedValue(mockMilestoneMutationResponse); + + await createComponentWithApollo({ + requestHandlers: [[projectIssueMilestoneMutation, milestoneMutationSpy]], + }); + + await clickEdit(); + }); + + it('renders the dropdown on clicking edit', async () => { + expect(findDropdown().isVisible()).toBe(true); + }); + + it('focuses on the input when dropdown is shown', async () => { + expect(document.activeElement).toEqual(wrapper.findComponent(GlFormInput).element); + }); + + describe('when currentAttribute is not equal to attribute id', () => { + describe('when update is successful', () => { + beforeEach(() => { + findDropdownItemWithText(mockMilestone2.title).vm.$emit('click'); + }); + + it('calls setIssueAttribute mutation', () => { + expect(milestoneMutationSpy).toHaveBeenCalledWith({ + iid: mockIssue.iid, + attributeId: getIdFromGraphQLId(mockMilestone2.id), + fullPath: mockIssue.projectPath, + }); + }); + + it('sets the value returned from the mutation to currentAttribute', async () => { + expect(findSelectedAttribute().text()).toBe(mockMilestone2.title); + }); + }); + }); + + describe('milestones', () => { + let projectMilestonesSpy; + + it('should call createFlash if milestones query fails', async () => { + await createComponentWithApollo({ + projectMilestonesSpy: jest.fn().mockRejectedValue(error), + }); + + await clickEdit(); + + expect(createFlash).toHaveBeenCalledWith({ + message: wrapper.vm.i18n.listFetchError, + captureError: true, + error: expect.any(Error), + }); + }); + + it('only fetches attributes when dropdown is opened', async () => { + projectMilestonesSpy = jest.fn().mockResolvedValueOnce(emptyProjectMilestonesResponse); + await createComponentWithApollo({ projectMilestonesSpy }); + + expect(projectMilestonesSpy).not.toHaveBeenCalled(); + + await clickEdit(); + + expect(projectMilestonesSpy).toHaveBeenNthCalledWith(1, { + fullPath: mockIssue.projectPath, + title: '', + state: 'active', + }); + }); + + describe('when a user is searching', () => { + const mockSearchTerm = 'foobar'; + + beforeEach(async () => { + projectMilestonesSpy = jest + .fn() + .mockResolvedValueOnce(emptyProjectMilestonesResponse); + await createComponentWithApollo({ projectMilestonesSpy }); + + await clickEdit(); + }); + + it('sends a projectMilestones query with the entered search term "foo"', async () => { + findSearchBox().vm.$emit('input', mockSearchTerm); + await wrapper.vm.$nextTick(); + + // Account for debouncing + jest.runAllTimers(); + + expect(projectMilestonesSpy).toHaveBeenNthCalledWith(2, { + fullPath: mockIssue.projectPath, + title: mockSearchTerm, + state: 'active', + }); + }); + }); + }); + }); + + describe('currentAttributes', () => { + it('should call createFlash if currentAttributes query fails', async () => { + await createComponentWithApollo({ + currentMilestoneSpy: jest.fn().mockRejectedValue(error), + }); + + expect(createFlash).toHaveBeenCalledWith({ + message: wrapper.vm.i18n.currentFetchError, + captureError: true, + error: expect.any(Error), + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index b052038661a6dbabd4fb109335ad74084402b9ed..2c0a213df6da9645b7e4d94d71a6ac6b9576a2e2 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -513,4 +513,83 @@ export const participantsQueryResponse = { }, }; +export const mockGroupPath = 'gitlab-org'; +export const mockProjectPath = `${mockGroupPath}/some-project`; + +export const mockIssue = { + projectPath: mockProjectPath, + iid: '1', + groupPath: mockGroupPath, +}; + +export const mockIssueId = 'gid://gitlab/Issue/1'; + +export const mockMilestone1 = { + __typename: 'Milestone', + id: 'gid://gitlab/Milestone/1', + title: 'Foobar Milestone', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1', + state: 'active', +}; + +export const mockMilestone2 = { + __typename: 'Milestone', + id: 'gid://gitlab/Milestone/2', + title: 'Awesome Milestone', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2', + state: 'active', +}; + +export const mockProjectMilestonesResponse = { + data: { + workspace: { + attributes: { + nodes: [mockMilestone1, mockMilestone2], + }, + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, +}; + +export const noCurrentMilestoneResponse = { + data: { + workspace: { + issuable: { id: mockIssueId, attribute: null, __typename: 'Issue' }, + __typename: 'Project', + }, + }, +}; + +export const mockMilestoneMutationResponse = { + data: { + issuableSetAttribute: { + errors: [], + issuable: { + id: 'gid://gitlab/Issue/1', + attribute: { + id: 'gid://gitlab/Milestone/2', + title: 'Awesome Milestone', + state: 'active', + __typename: 'Milestone', + }, + __typename: 'Issue', + }, + __typename: 'UpdateIssuePayload', + }, + }, +}; + +export const emptyProjectMilestonesResponse = { + data: { + workspace: { + attributes: { + nodes: [], + }, + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, +}; + export default mockData;