From 6a7f12a15505d24899ca67b99a4575091f613961 Mon Sep 17 00:00:00 2001 From: Rajan Mistry <rmistry@gitlab.com> Date: Mon, 19 Feb 2024 04:37:54 +0000 Subject: [PATCH] Work items labels widget with edit button Add edit pattern for labels widget --- .../work_item_attributes_wrapper.vue | 34 +- ...labels.vue => work_item_labels_inline.vue} | 0 .../components/work_item_labels_with_edit.vue | 267 ++++++++++++ ...pec.js => work_item_labels_inline_spec.js} | 6 +- .../work_item_labels_with_edit_spec.js | 56 +++ locale/gitlab.pot | 8 + .../projects/work_items/work_item_spec.rb | 18 +- .../work_item_attributes_wrapper_spec.js | 26 +- ...pec.js => work_item_labels_inline_spec.js} | 11 +- .../work_item_labels_with_edit_spec.js | 395 ++++++++++++++++++ spec/frontend/work_items/mock_data.js | 32 ++ .../features/work_items_shared_examples.rb | 190 ++++++--- 12 files changed, 956 insertions(+), 87 deletions(-) rename app/assets/javascripts/work_items/components/{work_item_labels.vue => work_item_labels_inline.vue} (100%) create mode 100644 app/assets/javascripts/work_items/components/work_item_labels_with_edit.vue rename ee/spec/frontend/work_items/components/{work_item_labels_spec.js => work_item_labels_inline_spec.js} (88%) create mode 100644 ee/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js rename spec/frontend/work_items/components/{work_item_labels_spec.js => work_item_labels_inline_spec.js} (97%) create mode 100644 spec/frontend/work_items/components/work_item_labels_with_edit_spec.js diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index 662edda13f18e..d5c7cd4c05ce7 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -21,7 +21,8 @@ import WorkItemAssigneesInline from './work_item_assignees_inline.vue'; import WorkItemAssigneesWithEdit from './work_item_assignees_with_edit.vue'; import WorkItemDueDateInline from './work_item_due_date_inline.vue'; import WorkItemDueDateWithEdit from './work_item_due_date_with_edit.vue'; -import WorkItemLabels from './work_item_labels.vue'; +import WorkItemLabelsInline from './work_item_labels_inline.vue'; +import WorkItemLabelsWithEdit from './work_item_labels_with_edit.vue'; import WorkItemMilestoneInline from './work_item_milestone_inline.vue'; import WorkItemMilestoneWithEdit from './work_item_milestone_with_edit.vue'; import WorkItemParentInline from './work_item_parent_inline.vue'; @@ -30,7 +31,8 @@ import WorkItemParent from './work_item_parent_with_edit.vue'; export default { components: { Participants, - WorkItemLabels, + WorkItemLabelsInline, + WorkItemLabelsWithEdit, WorkItemMilestoneInline, WorkItemMilestoneWithEdit, WorkItemAssigneesInline, @@ -155,14 +157,26 @@ export default { @error="$emit('error', $event)" /> </template> - <work-item-labels - v-if="workItemLabels" - :can-update="canUpdate" - :full-path="fullPath" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - @error="$emit('error', $event)" - /> + <template v-if="workItemLabels"> + <work-item-labels-with-edit + v-if="glFeatures.workItemsMvc2" + class="gl-mb-5" + :can-update="canUpdate" + :full-path="fullPath" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + <work-item-labels-inline + v-else + :can-update="canUpdate" + :full-path="fullPath" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + @error="$emit('error', $event)" + /> + </template> <template v-if="workItemWeight"> <work-item-weight v-if="glFeatures.workItemsMvc2" diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels_inline.vue similarity index 100% rename from app/assets/javascripts/work_items/components/work_item_labels.vue rename to app/assets/javascripts/work_items/components/work_item_labels_inline.vue diff --git a/app/assets/javascripts/work_items/components/work_item_labels_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_labels_with_edit.vue new file mode 100644 index 0000000000000..5533f743f736c --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_labels_with_edit.vue @@ -0,0 +1,267 @@ +<script> +import { GlLabel } from '@gitlab/ui'; +import { difference } from 'lodash'; +import { __, n__ } from '~/locale'; +import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue'; +import groupLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql'; +import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import Tracking from '~/tracking'; +import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql'; +import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS, TRACKING_CATEGORY_SHOW } from '../constants'; +import { isLabelsWidget } from '../utils'; + +export default { + components: { + WorkItemSidebarDropdownWidgetWithEdit, + GlLabel, + }, + mixins: [Tracking.mixin()], + inject: { + issuesListPath: { + type: String, + }, + isGroup: { + type: Boolean, + }, + }, + props: { + fullPath: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + workItemIid: { + type: String, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + searchTerm: '', + searchStarted: false, + updateInProgress: false, + removeLabelIds: [], + addLabelIds: [], + }; + }, + computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_label', + property: `type_${this.workItemType}`, + }; + }, + areLabelsSelected() { + return this.addLabelIds.length > 0 || this.itemValues.length > 0; + }, + selectedLabelCount() { + return this.addLabelIds.length + this.itemValues.length - this.removeLabelIds.length; + }, + dropDownLabelText() { + return n__('%d label', '%d labels', this.selectedLabelCount); + }, + dropdownText() { + return this.areLabelsSelected ? `${this.dropDownLabelText}` : __('No labels'); + }, + isLoadingLabels() { + return this.$apollo.queries.searchLabels.loading; + }, + labelsList() { + return this.searchLabels?.map(({ id, title, color }) => ({ + value: id, + text: title, + color, + })); + }, + labelsWidget() { + return this.workItem?.widgets?.find(isLabelsWidget); + }, + localLabels() { + return this.labelsWidget?.labels?.nodes || []; + }, + itemValues() { + return this.localLabels.map(({ id }) => id); + }, + allowsScopedLabels() { + return this.labelsWidget?.allowsScopedLabels; + }, + }, + apollo: { + workItem: { + query() { + return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery; + }, + variables() { + return { + fullPath: this.fullPath, + iid: this.workItemIid, + }; + }, + update(data) { + return data.workspace?.workItems?.nodes[0] || {}; + }, + skip() { + return !this.workItemIid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + searchLabels: { + query() { + return this.isGroup ? groupLabelsQuery : projectLabelsQuery; + }, + variables() { + return { + fullPath: this.fullPath, + searchTerm: this.searchTerm, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace?.labels?.nodes; + }, + error() { + this.$emit('error', I18N_WORK_ITEM_ERROR_FETCHING_LABELS); + }, + }, + }, + methods: { + onDropdownShown() { + this.searchTerm = ''; + this.searchStarted = true; + }, + search(searchTerm) { + this.searchTerm = searchTerm; + this.searchStarted = true; + }, + removeLabel({ id }) { + this.removeLabelIds.push(id); + this.updateLabels(); + }, + updateLabel(labels) { + this.removeLabelIds = difference(this.itemValues, labels); + this.addLabelIds = difference(labels, this.itemValues); + }, + async updateLabels(labels) { + this.searchTerm = ''; + this.updateInProgress = true; + + if (labels && labels.length === 0) { + this.removeLabelIds = this.itemValues; + this.addLabelIds = []; + } + + try { + const { + data: { + workItemUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + labelsWidget: { + addLabelIds: this.addLabelIds, + removeLabelIds: this.removeLabelIds, + }, + }, + }, + }); + + if (errors.length > 0) { + this.throwUpdateError(); + return; + } + this.addLabelIds = []; + this.removeLabelIds = []; + + this.track('updated_labels'); + } catch { + this.throwUpdateError(); + } finally { + this.updateInProgress = false; + } + }, + scopedLabel(label) { + return this.allowsScopedLabels && isScopedLabel(label); + }, + isSelected(id) { + return this.itemValues.includes(id) || this.addLabelIds.includes(id); + }, + throwUpdateError() { + this.$emit('error', i18n.updateError); + this.addLabelIds = []; + this.removeLabelIds = []; + }, + labelFilterUrl(label) { + return `${this.issuesListPath}?label_name[]=${encodeURIComponent(label.title)}`; + }, + }, +}; +</script> + +<template> + <work-item-sidebar-dropdown-widget-with-edit + :dropdown-label="__('Labels')" + :can-update="canUpdate" + dropdown-name="label" + :loading="isLoadingLabels" + :list-items="labelsList" + :item-value="itemValues" + :update-in-progress="updateInProgress" + :toggle-dropdown-text="dropdownText" + :header-text="__('Select label')" + :reset-button-label="__('Clear')" + :multi-select="true" + data-testid="work-item-labels-with-edit" + @dropdownShown="onDropdownShown" + @searchStarted="search" + @updateValue="updateLabels" + @updateSelected="updateLabel" + > + <template #list-item="{ item }"> + <span> + <span + :style="{ background: item.color }" + :class="{ 'gl-border gl-border-white': isSelected(item.value) }" + class="gl-display-inline-block gl-rounded-base gl-mr-1 gl-w-5 gl-h-3 gl-vertical-align-middle gl-mt-n1" + ></span> + {{ item.text }} + </span> + </template> + <template #readonly> + <gl-label + v-for="label in localLabels" + :key="label.id" + class="gl-mr-2 gl-mb-2" + :title="label.title" + :description="label.description" + :background-color="label.color" + :scoped="scopedLabel(label)" + :show-close-button="canUpdate" + :target="labelFilterUrl(label)" + @close="removeLabel(label)" + /> + </template> + </work-item-sidebar-dropdown-widget-with-edit> +</template> diff --git a/ee/spec/frontend/work_items/components/work_item_labels_spec.js b/ee/spec/frontend/work_items/components/work_item_labels_inline_spec.js similarity index 88% rename from ee/spec/frontend/work_items/components/work_item_labels_spec.js rename to ee/spec/frontend/work_items/components/work_item_labels_inline_spec.js index e01ef1bf60c95..c4547f3130c1f 100644 --- a/ee/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/ee/spec/frontend/work_items/components/work_item_labels_inline_spec.js @@ -5,12 +5,12 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; -import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import WorkItemLabelsInline from '~/work_items/components/work_item_labels_inline.vue'; import { workItemByIidResponseFactory } from 'jest/work_items/mock_data'; Vue.use(VueApollo); -describe('WorkItemLabels component', () => { +describe('WorkItemLabelsInline component', () => { let wrapper; const findScopedLabel = () => @@ -20,7 +20,7 @@ describe('WorkItemLabels component', () => { canUpdate = true, workItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()), } = {}) => { - wrapper = mount(WorkItemLabels, { + wrapper = mount(WorkItemLabelsInline, { apolloProvider: createMockApollo([[workItemByIidQuery, workItemQueryHandler]]), provide: { isGroup: false, diff --git a/ee/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js b/ee/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js new file mode 100644 index 0000000000000..308d25269ee48 --- /dev/null +++ b/ee/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js @@ -0,0 +1,56 @@ +import { GlLabel } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import WorkItemLabelsWithEdit from '~/work_items/components/work_item_labels_with_edit.vue'; +import { workItemByIidResponseFactory } from 'jest/work_items/mock_data'; + +Vue.use(VueApollo); + +const workItemId = 'gid://gitlab/WorkItem/1'; + +describe('WorkItemLabelsWithEdit component', () => { + let wrapper; + + const createComponent = ({ + canUpdate = true, + isGroup = false, + workItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()), + workItemIid = '1', + fullPath = 'test-project-path', + issuesListPath = 'test-project-path/issues', + } = {}) => { + wrapper = shallowMountExtended(WorkItemLabelsWithEdit, { + apolloProvider: createMockApollo([[workItemByIidQuery, workItemQueryHandler]]), + provide: { + isGroup, + issuesListPath, + }, + propsData: { + fullPath, + workItemId, + workItemIid, + canUpdate, + workItemType: 'epic', + }, + }); + }; + + const findScopedLabel = () => + wrapper.findAllComponents(GlLabel).filter((label) => label.props('scoped')); + + describe('allows scoped labels', () => { + it.each([true, false])('= %s', async (allowsScopedLabels) => { + const workItemQueryHandler = jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ allowsScopedLabels })); + createComponent({ workItemQueryHandler }); + await waitForPromises(); + + expect(findScopedLabel().exists()).toBe(allowsScopedLabels); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 92b0537814c29..a1efcc3ffaa33 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -322,6 +322,11 @@ msgid_plural "%d jobs" msgstr[0] "" msgstr[1] "" +msgid "%d label" +msgid_plural "%d labels" +msgstr[0] "" +msgstr[1] "" + msgid "%d layer" msgid_plural "%d layers" msgstr[0] "" @@ -32822,6 +32827,9 @@ msgstr "" msgid "No label" msgstr "" +msgid "No labels" +msgstr "" + msgid "No labels found" msgstr "" diff --git a/spec/features/projects/work_items/work_item_spec.rb b/spec/features/projects/work_items/work_item_spec.rb index b23d7375e725b..cb5214b81574f 100644 --- a/spec/features/projects/work_items/work_item_spec.rb +++ b/spec/features/projects/work_items/work_item_spec.rb @@ -166,11 +166,17 @@ wait_for_all_requests end - it 'assignees input field is disabled' do + it 'disabled the assignees input field' do within('[data-testid="work-item-assignees-input"]') do expect(page).to have_field(type: 'text', disabled: true) end end + + it 'disables the labels input field' do + within('[data-testid="work-item-labels-input"]') do + expect(page).to have_field(type: 'text', disabled: true) + end + end end context 'when work_items_mvc_2 is enabled' do @@ -181,16 +187,16 @@ wait_for_all_requests end - it 'assignees edit button is not visible' do + it 'hides the assignees edit button' do within('[data-testid="work-item-assignees-with-edit"]') do expect(page).not_to have_button('Edit') end end - end - it 'labels input field is disabled' do - within('[data-testid="work-item-labels-input"]') do - expect(page).to have_field(type: 'text', disabled: true) + it 'hides the labels edit button' do + within('[data-testid="work-item-labels-with-edit"]') do + expect(page).not_to have_button('Edit') + end end end end diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js index 5dfe27c41817b..c6c88a5d5fd81 100644 --- a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js +++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js @@ -4,7 +4,8 @@ import Participants from '~/sidebar/components/participants/participants.vue'; import WorkItemAssigneesWithEdit from '~/work_items/components/work_item_assignees_with_edit.vue'; import WorkItemDueDateInline from '~/work_items/components/work_item_due_date_inline.vue'; import WorkItemDueDateWithEdit from '~/work_items/components/work_item_due_date_with_edit.vue'; -import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import WorkItemLabelsInline from '~/work_items/components/work_item_labels_inline.vue'; +import WorkItemLabelsWithEdit from '~/work_items/components/work_item_labels_with_edit.vue'; import WorkItemMilestoneInline from '~/work_items/components/work_item_milestone_inline.vue'; import WorkItemMilestoneWithEdit from '~/work_items/components/work_item_milestone_with_edit.vue'; import WorkItemParentInline from '~/work_items/components/work_item_parent_inline.vue'; @@ -27,7 +28,8 @@ describe('WorkItemAttributesWrapper component', () => { const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssigneesWithEdit); const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDateWithEdit); const findWorkItemDueDateInline = () => wrapper.findComponent(WorkItemDueDateInline); - const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); + const findWorkItemLabelsInline = () => wrapper.findComponent(WorkItemLabelsInline); + const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabelsWithEdit); const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestoneWithEdit); const findWorkItemMilestoneInline = () => wrapper.findComponent(WorkItemMilestoneInline); const findWorkItemParentInline = () => wrapper.findComponent(WorkItemParentInline); @@ -89,6 +91,26 @@ describe('WorkItemAttributesWrapper component', () => { expect(findWorkItemLabels().exists()).toBe(exists); }); + + it.each` + description | labelsWidgetInlinePresent | labelsWidgetWithEditPresent | workItemsMvc2FlagEnabled + ${'renders WorkItemLabels when workItemsMvc2 enabled'} | ${false} | ${true} | ${true} + ${'renders WorkItemLabelsInline when workItemsMvc2 disabled'} | ${true} | ${false} | ${false} + `( + '$description', + async ({ + labelsWidgetInlinePresent, + labelsWidgetWithEditPresent, + workItemsMvc2FlagEnabled, + }) => { + createComponent({ workItemsMvc2: workItemsMvc2FlagEnabled }); + + await waitForPromises(); + + expect(findWorkItemLabels().exists()).toBe(labelsWidgetWithEditPresent); + expect(findWorkItemLabelsInline().exists()).toBe(labelsWidgetInlinePresent); + }, + ); }); describe('dates widget', () => { diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_inline_spec.js similarity index 97% rename from spec/frontend/work_items/components/work_item_labels_spec.js rename to spec/frontend/work_items/components/work_item_labels_inline_spec.js index d7bebac6dbdf0..047698e3a73e1 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_inline_spec.js @@ -10,7 +10,7 @@ import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; -import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import WorkItemLabelsInline from '~/work_items/components/work_item_labels_inline.vue'; import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS } from '~/work_items/constants'; import { groupWorkItemByIidResponseFactory, @@ -18,13 +18,14 @@ import { mockLabels, workItemByIidResponseFactory, updateWorkItemMutationResponse, + groupLabelsResponse, } from '../mock_data'; Vue.use(VueApollo); const workItemId = 'gid://gitlab/WorkItem/1'; -describe('WorkItemLabels component', () => { +describe('WorkItemLabelsInline component', () => { let wrapper; const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); @@ -39,7 +40,7 @@ describe('WorkItemLabels component', () => { .fn() .mockResolvedValue(groupWorkItemByIidResponseFactory({ labels: null })); const projectLabelsQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); - const groupLabelsQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); + const groupLabelsQueryHandler = jest.fn().mockResolvedValue(groupLabelsResponse); const successUpdateWorkItemMutationHandler = jest .fn() .mockResolvedValue(updateWorkItemMutationResponse); @@ -53,7 +54,7 @@ describe('WorkItemLabels component', () => { updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, workItemIid = '1', } = {}) => { - wrapper = mountExtended(WorkItemLabels, { + wrapper = mountExtended(WorkItemLabelsInline, { apolloProvider: createMockApollo([ [workItemByIidQuery, workItemQueryHandler], [groupWorkItemByIidQuery, groupWorkItemQuerySuccess], @@ -152,7 +153,7 @@ describe('WorkItemLabels component', () => { await waitForPromises(); expect(findSkeletonLoader().exists()).toBe(false); - expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + expect(findTokenSelector().props('dropdownItems')).toHaveLength(3); }); it.each([true, false])( diff --git a/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js b/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js new file mode 100644 index 0000000000000..25bbac20b393c --- /dev/null +++ b/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js @@ -0,0 +1,395 @@ +import { GlLabel } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { + TRACKING_CATEGORY_SHOW, + I18N_WORK_ITEM_ERROR_FETCHING_LABELS, +} from '~/work_items/constants'; +import groupLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql'; +import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import WorkItemLabelsWithEdit from '~/work_items/components/work_item_labels_with_edit.vue'; +import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue'; +import { + groupWorkItemByIidResponseFactory, + projectLabelsResponse, + groupLabelsResponse, + getProjectLabelsResponse, + mockLabels, + workItemByIidResponseFactory, + updateWorkItemMutationResponseFactory, + updateWorkItemMutationErrorResponse, +} from '../mock_data'; + +Vue.use(VueApollo); + +const workItemId = 'gid://gitlab/WorkItem/1'; + +describe('WorkItemLabelsWithEdit component', () => { + let wrapper; + + const label1Id = mockLabels[0].id; + const label2Id = mockLabels[1].id; + const label3Id = mockLabels[2].id; + + const workItemQuerySuccess = jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ labels: null })); + const workItemQueryWithLabelsHandler = jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ labels: mockLabels })); + const workItemQueryWithFewLabelsHandler = jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ labels: [mockLabels[0], mockLabels[1]] })); + const groupWorkItemQuerySuccess = jest + .fn() + .mockResolvedValue(groupWorkItemByIidResponseFactory({ labels: null })); + const projectLabelsQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); + const groupLabelsQueryHandler = jest.fn().mockResolvedValue(groupLabelsResponse); + const errorHandler = jest.fn().mockRejectedValue('Error'); + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponseFactory({ labels: [mockLabels[0]] })); + const successRemoveLabelWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponseFactory({ labels: [mockLabels[0]] })); + const successRemoveAllLabelWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponseFactory({ labels: [] })); + const successAddRemoveLabelWorkItemMutationHandler = jest.fn().mockResolvedValue( + updateWorkItemMutationResponseFactory({ + labels: [mockLabels[0], mockLabels[2]], + }), + ); + + const createComponent = ({ + canUpdate = true, + isGroup = false, + workItemQueryHandler = workItemQuerySuccess, + searchQueryHandler = projectLabelsQueryHandler, + updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, + workItemIid = '1', + } = {}) => { + wrapper = shallowMountExtended(WorkItemLabelsWithEdit, { + apolloProvider: createMockApollo([ + [workItemByIidQuery, workItemQueryHandler], + [groupWorkItemByIidQuery, groupWorkItemQuerySuccess], + [projectLabelsQuery, searchQueryHandler], + [groupLabelsQuery, groupLabelsQueryHandler], + [updateWorkItemMutation, updateWorkItemMutationHandler], + ]), + provide: { + isGroup, + issuesListPath: 'test-project-path/issues', + }, + propsData: { + workItemId, + workItemIid, + canUpdate, + fullPath: 'test-project-path', + workItemType: 'Task', + }, + }); + }; + + const findWorkItemSidebarDropdownWidget = () => + wrapper.findComponent(WorkItemSidebarDropdownWidgetWithEdit); + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findRegularLabel = () => findAllLabels().at(0); + const findLabelWithDescription = () => findAllLabels().at(2); + + const showDropdown = () => { + findWorkItemSidebarDropdownWidget().vm.$emit('dropdownShown'); + }; + + const updateLabels = (labels) => { + findWorkItemSidebarDropdownWidget().vm.$emit('updateSelected', labels); + findWorkItemSidebarDropdownWidget().vm.$emit('updateValue', labels); + }; + + const getMutationInput = (addLabelIds, removeLabelIds) => { + return { + input: { + id: workItemId, + labelsWidget: { + addLabelIds, + removeLabelIds, + }, + }, + }; + }; + + const expectDropdownCountToBe = (count, toggleDropdownText) => { + expect(findWorkItemSidebarDropdownWidget().props('itemValue')).toHaveLength(count); + expect(findWorkItemSidebarDropdownWidget().props('toggleDropdownText')).toBe( + toggleDropdownText, + ); + }; + + it('renders the work item sidebar dropdown widget with default props', () => { + createComponent(); + + expect(findWorkItemSidebarDropdownWidget().props()).toMatchObject({ + dropdownLabel: 'Labels', + canUpdate: true, + dropdownName: 'label', + updateInProgress: false, + toggleDropdownText: 'No labels', + headerText: 'Select label', + resetButtonLabel: 'Clear', + multiSelect: true, + itemValue: [], + }); + expect(findAllLabels()).toHaveLength(0); + }); + + it('renders the labels when they are already present', async () => { + createComponent({ + workItemQueryHandler: workItemQueryWithLabelsHandler, + }); + + await waitForPromises(); + + expect(workItemQueryWithLabelsHandler).toHaveBeenCalled(); + expect(groupWorkItemQuerySuccess).not.toHaveBeenCalled(); + + expect(findWorkItemSidebarDropdownWidget().props('itemValue')).toStrictEqual([ + label1Id, + label2Id, + label3Id, + ]); + expect(findAllLabels()).toHaveLength(3); + expect(findRegularLabel().props()).toMatchObject({ + backgroundColor: '#f00', + title: 'Label 1', + target: 'test-project-path/issues?label_name[]=Label%201', + scoped: false, + showCloseButton: true, + }); + expect(findLabelWithDescription().props('description')).toBe('Label 3 description'); + }); + + it('renders the labels without close button when canUpdate is false', async () => { + createComponent({ + workItemQueryHandler: workItemQueryWithLabelsHandler, + canUpdate: false, + }); + + await waitForPromises(); + + expect(findWorkItemSidebarDropdownWidget().props('canUpdate')).toBe(false); + expect(findRegularLabel().props('showCloseButton')).toBe(false); + }); + + it.each` + expectedAssertion | searchTerm | handler | result + ${'when dropdown is shown'} | ${''} | ${projectLabelsQueryHandler} | ${3} + ${'when correct input is entered'} | ${'Label 1'} | ${jest.fn().mockResolvedValue(getProjectLabelsResponse([mockLabels[0]]))} | ${1} + ${'and shows no matching results when incorrect input is entered'} | ${'Label 2'} | ${jest.fn().mockResolvedValue(getProjectLabelsResponse([]))} | ${0} + `('calls search label query $expectedAssertion', async ({ searchTerm, result, handler }) => { + createComponent({ + searchQueryHandler: handler, + }); + + showDropdown(); + await findWorkItemSidebarDropdownWidget().vm.$emit('searchStarted', searchTerm); + + expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findWorkItemSidebarDropdownWidget().props('listItems')).toHaveLength(result); + expect(handler).toHaveBeenCalledWith({ + fullPath: 'test-project-path', + searchTerm, + }); + expect(groupLabelsQueryHandler).not.toHaveBeenCalled(); + expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(false); + }); + + it('emits error event if search query fails', async () => { + createComponent({ searchQueryHandler: errorHandler }); + showDropdown(); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[I18N_WORK_ITEM_ERROR_FETCHING_LABELS]]); + }); + + it('update labels when labels are added', async () => { + createComponent({ + workItemQueryHandler: workItemQuerySuccess, + updateWorkItemMutationHandler: successUpdateWorkItemMutationHandler, + }); + + await waitForPromises(); + + showDropdown(); + + expectDropdownCountToBe(0, 'No labels'); + + updateLabels([label1Id]); + + await waitForPromises(); + + expectDropdownCountToBe(1, '1 label'); + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith( + getMutationInput([label1Id], []), + ); + }); + + it('update labels when labels are removed', async () => { + createComponent({ + workItemQueryHandler: workItemQueryWithLabelsHandler, + updateWorkItemMutationHandler: successRemoveLabelWorkItemMutationHandler, + }); + + await waitForPromises(); + + showDropdown(); + + expectDropdownCountToBe(3, '3 labels'); + + updateLabels([label1Id]); + + await waitForPromises(); + + expectDropdownCountToBe(1, '1 label'); + expect(successRemoveLabelWorkItemMutationHandler).toHaveBeenCalledWith( + getMutationInput([], [label2Id, label3Id]), + ); + }); + + it('update labels when labels are added or removed at same time', async () => { + createComponent({ + workItemQueryHandler: workItemQueryWithFewLabelsHandler, + updateWorkItemMutationHandler: successAddRemoveLabelWorkItemMutationHandler, + }); + + await waitForPromises(); + + showDropdown(); + + expectDropdownCountToBe(2, '2 labels'); + + updateLabels([label1Id, label3Id]); + + await waitForPromises(); + + expectDropdownCountToBe(2, '2 labels'); + expect(successAddRemoveLabelWorkItemMutationHandler).toHaveBeenCalledWith( + getMutationInput([label3Id], [label2Id]), + ); + }); + + it('clears all labels when updateValue has no labels', async () => { + createComponent({ + workItemQueryHandler: workItemQueryWithLabelsHandler, + updateWorkItemMutationHandler: successRemoveAllLabelWorkItemMutationHandler, + }); + + await waitForPromises(); + + showDropdown(); + + expectDropdownCountToBe(3, '3 labels'); + + findWorkItemSidebarDropdownWidget().vm.$emit('updateValue', []); + + await waitForPromises(); + + expectDropdownCountToBe(0, 'No labels'); + expect(successRemoveAllLabelWorkItemMutationHandler).toHaveBeenCalledWith( + getMutationInput([], [label1Id, label2Id, label3Id]), + ); + }); + + describe('tracking', () => { + let trackingSpy; + + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + trackingSpy = null; + }); + + it('tracks editing the labels on dropdown widget updateValue', async () => { + showDropdown(); + updateLabels([label1Id]); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_labels', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_label', + property: 'type_Task', + }); + }); + }); + + it.each` + errorType | expectedErrorMessage | failureHandler + ${'graphql error'} | ${'Something went wrong while updating the work item. Please try again.'} | ${jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse)} + ${'network error'} | ${'Something went wrong while updating the work item. Please try again.'} | ${jest.fn().mockRejectedValue(new Error())} + `( + 'emits an error when there is a $errorType', + async ({ expectedErrorMessage, failureHandler }) => { + createComponent({ + updateWorkItemMutationHandler: failureHandler, + }); + + updateLabels([label1Id]); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[expectedErrorMessage]]); + }, + ); + + it('skips calling the work item query when missing workItemIid', async () => { + createComponent({ workItemIid: '' }); + + await waitForPromises(); + + expect(workItemQuerySuccess).not.toHaveBeenCalled(); + }); + + it('skips calling the group work item query when missing workItemIid', async () => { + createComponent({ isGroup: true, workItemIid: '' }); + + await waitForPromises(); + + expect(groupWorkItemQuerySuccess).not.toHaveBeenCalled(); + }); + + describe('when group context', () => { + beforeEach(async () => { + createComponent({ isGroup: true }); + + await waitForPromises(); + }); + + it('skips calling the project work item query', () => { + expect(workItemQuerySuccess).not.toHaveBeenCalled(); + }); + + it('calls the group work item query', () => { + expect(groupWorkItemQuerySuccess).toHaveBeenCalled(); + }); + + it('calls the group labels query on search', async () => { + showDropdown(); + await waitForPromises(); + + expect(groupLabelsQueryHandler).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 0962bed9a4cab..84f540835b534 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -36,6 +36,14 @@ export const mockLabels = [ color: '#b00', textColor: '#00b', }, + { + __typename: 'Label', + id: 'gid://gitlab/Label/3', + title: 'Label 3', + description: 'Label 3 description', + color: '#fff', + textColor: '#000', + }, ]; export const mockMilestone = { @@ -2101,6 +2109,30 @@ export const projectLabelsResponse = { }, }; +export const groupLabelsResponse = { + data: { + workspace: { + id: '1', + __typename: 'Group', + labels: { + nodes: mockLabels, + }, + }, + }, +}; + +export const getProjectLabelsResponse = (labels) => ({ + data: { + workspace: { + id: '1', + __typename: 'Project', + labels: { + nodes: labels, + }, + }, + }, +}); + export const mockIterationWidgetResponse = { description: 'Iteration description', dueDate: '2022-07-19', diff --git a/spec/support/shared_examples/features/work_items_shared_examples.rb b/spec/support/shared_examples/features/work_items_shared_examples.rb index 4f36d8a046c74..fdd6dd163f41b 100644 --- a/spec/support/shared_examples/features/work_items_shared_examples.rb +++ b/spec/support/shared_examples/features/work_items_shared_examples.rb @@ -294,91 +294,159 @@ def click_reply_and_enter_slash let(:label_title_selector) { '[data-testid="labels-title"]' } let(:labels_input_selector) { '[data-testid="work-item-labels-input"]' } - it 'successfully assigns a label' do - find(labels_input_selector).fill_in(with: label.title) - wait_for_requests - # submit and simulate blur to save - send_keys(:enter) - find(label_title_selector).click - wait_for_requests + context 'when work_items_mvc_2 is disabled' do + include_context 'with work_items_mvc_2', false - expect(work_item.labels).to include(label) - end + it 'successfully assigns a label' do + find(labels_input_selector).fill_in(with: label.title) + wait_for_requests + # submit and simulate blur to save + send_keys(:enter) + find(label_title_selector).click + wait_for_requests - it 'successfully assigns multiple labels' do - label2 = create(:label, project: project, title: "testing-label-2") + expect(work_item.labels).to include(label) + end - find(labels_input_selector).fill_in(with: label.title) - wait_for_requests - send_keys(:enter) + it 'successfully assigns multiple labels' do + label2 = create(:label, project: project, title: "testing-label-2") - find(labels_input_selector).fill_in(with: label2.title) - wait_for_requests - send_keys(:enter) + find(labels_input_selector).fill_in(with: label.title) + wait_for_requests + send_keys(:enter) - find(label_title_selector).click - wait_for_requests + find(labels_input_selector).fill_in(with: label2.title) + wait_for_requests + send_keys(:enter) - expect(work_item.labels).to include(label) - expect(work_item.labels).to include(label2) - end + find(label_title_selector).click + wait_for_requests - it 'removes all labels on clear all button click' do - find(labels_input_selector).fill_in(with: label.title) - wait_for_requests - send_keys(:enter) - find(label_title_selector).click - wait_for_requests + expect(work_item.labels).to include(label) + expect(work_item.labels).to include(label2) + end - expect(work_item.labels).to include(label) + it 'removes all labels on clear all button click' do + find(labels_input_selector).fill_in(with: label.title) + wait_for_requests + send_keys(:enter) + find(label_title_selector).click + wait_for_requests - within(labels_input_selector) do - find('input').click - click_button 'Clear all' + expect(work_item.labels).to include(label) + + within(labels_input_selector) do + find('input').click + click_button 'Clear all' + end + find(label_title_selector).click + wait_for_requests + + expect(work_item.labels).not_to include(label) end - find(label_title_selector).click - wait_for_requests - expect(work_item.labels).not_to include(label) - end + it 'removes label on clicking badge cross button' do + find(labels_input_selector).fill_in(with: label.title) + wait_for_requests + send_keys(:enter) + find(label_title_selector).click + wait_for_requests - it 'removes label on clicking badge cross button' do - find(labels_input_selector).fill_in(with: label.title) - wait_for_requests - send_keys(:enter) - find(label_title_selector).click - wait_for_requests + expect(page).to have_text(label.title) - expect(page).to have_text(label.title) + within(labels_input_selector) do + click_button 'Remove label' + end + find(label_title_selector).click + wait_for_requests - within(labels_input_selector) do - click_button 'Remove label' + expect(work_item.labels).not_to include(label) end - find(label_title_selector).click - wait_for_requests - expect(work_item.labels).not_to include(label) + it 'updates the labels in real-time' do + Capybara::Session.new(:other_session) + + using_session :other_session do + visit work_items_path + expect(page).not_to have_text(label.title) + end + + find(labels_input_selector).fill_in(with: label.title) + wait_for_requests + send_keys(:enter) + find(label_title_selector).click + wait_for_requests + + expect(page).to have_text(label.title) + + using_session :other_session do + wait_for_requests + expect(page).to have_text(label.title) + end + end end - it 'updates the labels in real-time' do - Capybara::Session.new(:other_session) + context 'when work_items_mvc_2 is enabled' do + let(:work_item_labels_selector) { '[data-testid="work-item-labels-with-edit"]' } + + include_context 'with work_items_mvc_2', true + + it 'successfully applies the label by searching' do + expect(work_item.reload.labels).not_to include(label) - using_session :other_session do - visit work_items_path - expect(page).not_to have_text(label.title) + find_and_click_edit(work_item_labels_selector) + + select_listbox_item(label.title) + + find("body").click + wait_for_all_requests + + expect(work_item.reload.labels).to include(label) + within(work_item_labels_selector) do + expect(page).to have_link(label.title) + end end - find(labels_input_selector).fill_in(with: label.title) - wait_for_requests - send_keys(:enter) - find(label_title_selector).click - wait_for_requests + it 'successfully removes all users on clear all button click' do + expect(work_item.reload.labels).not_to include(label) - expect(page).to have_text(label.title) + find_and_click_edit(work_item_labels_selector) - using_session :other_session do + select_listbox_item(label.title) + + find("body").click wait_for_requests - expect(page).to have_text(label.title) + + expect(work_item.reload.labels).to include(label) + + find_and_click_edit(work_item_labels_selector) + + find_and_click_clear(work_item_labels_selector) + wait_for_all_requests + + expect(work_item.reload.labels).not_to include(label) + end + + it 'updates the assignee in real-time' do + Capybara::Session.new(:other_session) + + using_session :other_session do + visit work_items_path + expect(work_item.reload.labels).not_to include(label) + end + + find_and_click_edit(work_item_labels_selector) + + select_listbox_item(label.title) + + find("body").click + wait_for_all_requests + + expect(work_item.reload.labels).to include(label) + + using_session :other_session do + expect(work_item.reload.labels).to include(label) + end end end end -- GitLab