From 96fc0d7774119a1870b5438d4fff6a01ec396980 Mon Sep 17 00:00:00 2001 From: Rajan Mistry Date: Thu, 10 Aug 2023 15:35:05 +0530 Subject: [PATCH 1/4] Extract token selector input and create a shared component Token input in work item links form can be reused in work item relationship widget. Extract the token selector input and create a shared component so that it can be reused in both widgets. Changelog: other --- .../shared/work_item_token_input.vue | 146 ++++++++++++++++++ .../work_item_links/work_item_links_form.vue | 93 ++--------- .../shared/work_item_token_input_spec.js | 81 ++++++++++ .../work_item_links_form_spec.js | 52 ++++--- spec/frontend/work_items/mock_data.js | 21 +++ 5 files changed, 292 insertions(+), 101 deletions(-) create mode 100644 app/assets/javascripts/work_items/components/shared/work_item_token_input.vue create mode 100644 spec/frontend/work_items/components/shared/work_item_token_input_spec.js diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue new file mode 100644 index 00000000000000..cd3f5f9938a92c --- /dev/null +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -0,0 +1,146 @@ + + diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 4960189fb488a7..6811bc99045622 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -3,19 +3,15 @@ import { GlAlert, GlFormGroup, GlForm, - GlTokenSelector, GlButton, GlFormInput, GlFormCheckbox, GlTooltip, } from '@gitlab/ui'; -import { debounce } from 'lodash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; +import WorkItemTokenInput from '../shared/work_item_token_input.vue'; import { addHierarchyChild } from '../../graphql/cache_utils'; import projectWorkItemTypesQuery from '../../graphql/project_work_item_types.query.graphql'; -import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; import { @@ -23,7 +19,6 @@ import { WORK_ITEMS_TYPE_MAP, WORK_ITEM_TYPE_ENUM_TASK, I18N_WORK_ITEM_CREATE_BUTTON_LABEL, - I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, I18N_WORK_ITEM_ADD_BUTTON_LABEL, I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, @@ -35,12 +30,12 @@ export default { components: { GlAlert, GlForm, - GlTokenSelector, GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlTooltip, + WorkItemTokenInput, }, inject: ['fullPath', 'hasIterationsFeature'], props: { @@ -101,35 +96,14 @@ export default { return data.workspace?.workItemTypes?.nodes; }, }, - availableWorkItems: { - query: projectWorkItemsQuery, - variables() { - return { - fullPath: this.fullPath, - searchTerm: this.search?.title || this.search, - types: [this.childrenType], - in: this.search ? 'TITLE' : undefined, - }; - }, - skip() { - return !this.searchStarted; - }, - update(data) { - return data.workspace.workItems.nodes.filter( - (wi) => !this.childrenIds.includes(wi.id) && this.issuableGid !== wi.id, - ); - }, - }, }, data() { return { workItemTypes: [], - availableWorkItems: [], - search: '', - searchStarted: false, + workItemsToAdd: [], error: null, + search: '', childToCreateTitle: null, - workItemsToAdd: [], confidential: this.parentConfidential, }; }, @@ -216,15 +190,6 @@ export default { } return this.workItemsToAdd.length === 0 || !this.areWorkItemsToAddValid; }, - isLoading() { - return this.$apollo.queries.availableWorkItems.loading; - }, - addInputPlaceholder() { - return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); - }, - tokenSelectorContainerClass() { - return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : ''; - }, invalidWorkItemsToAdd() { return this.parentConfidential ? this.workItemsToAdd.filter((workItem) => !workItem.confidential) @@ -249,11 +214,7 @@ export default { ); }, }, - created() { - this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - }, methods: { - getIdFromGraphQLId, getConfidentialityTooltipTarget() { // We want tooltip to be anchored to `input` within checkbox component // but `$el.querySelector('input')` doesn't work. 🤷‍♂️ @@ -317,19 +278,8 @@ export default { this.childToCreateTitle = null; }); }, - setSearchKey(value) { - this.search = value; - }, - handleFocus() { - this.searchStarted = true; - }, - handleMouseOver() { - this.timeout = setTimeout(() => { - this.searchStarted = true; - }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - }, - handleMouseOut() { - clearTimeout(this.timeout); + updateWorkItemsList(workItems) { + this.workItemsToAdd = workItems; }, }, i18n: { @@ -385,30 +335,17 @@ export default { >{{ confidentialityCheckboxTooltip }}
- - - - + :is-create-form="isCreateForm" + :full-path="fullPath" + :parent-work-item-id="issuableGid" + :children-type="childrenType" + :children-ids="childrenIds" + :are-work-items-to-add-valid="areWorkItemsToAddValid" + @onChange="updateWorkItemsList" + />
{ + let wrapper; + + const availableWorkItemsResolver = jest.fn().mockResolvedValue(availableWorkItemsResponse); + const searchedWorkItemResolver = jest.fn().mockResolvedValue(searchedWorkItemsResponse); + + const createComponent = async ({ + workItemsToAdd = [], + parentConfidential = false, + childrenType = WORK_ITEM_TYPE_ENUM_TASK, + areWorkItemsToAddValid = true, + workItemsResolver = searchedWorkItemResolver, + } = {}) => { + wrapper = shallowMountExtended(WorkItemTokenInput, { + apolloProvider: createMockApollo([[projectWorkItemsQuery, workItemsResolver]]), + propsData: { + value: workItemsToAdd, + childrenType, + childrenIds: [], + fullPath: 'test-project-path', + parentWorkItemId: 'gid://gitlab/WorkItem/1', + parentConfidential, + areWorkItemsToAddValid, + }, + }); + + await waitForPromises(); + }; + + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + + it('searches for available work items on focus', async () => { + createComponent({ workItemsResolver: availableWorkItemsResolver }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(availableWorkItemsResolver).toHaveBeenCalledWith({ + fullPath: 'test-project-path', + searchTerm: '', + types: [WORK_ITEM_TYPE_ENUM_TASK], + in: undefined, + }); + expect(findTokenSelector().props('dropdownItems')).toHaveLength(3); + }); + + it('searches for available work items when typing in input', async () => { + createComponent({ workItemsResolver: searchedWorkItemResolver }); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', 'Task 2'); + await waitForPromises(); + + expect(searchedWorkItemResolver).toHaveBeenCalledWith({ + fullPath: 'test-project-path', + searchTerm: 'Task 2', + types: [WORK_ITEM_TYPE_ENUM_TASK], + in: 'TITLE', + }); + expect(findTokenSelector().props('dropdownItems')).toHaveLength(1); + }); + + it('renders red border around token selector input when work item is not valid', () => { + createComponent({ + areWorkItemsToAddValid: false, + }); + + expect(findTokenSelector().props('containerClass')).toBe('gl-inset-border-1-red-500!'); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index 8caacc2dc972c0..106364cdcbc1de 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -1,11 +1,12 @@ import Vue from 'vue'; -import { GlForm, GlFormInput, GlFormCheckbox, GlTooltip, GlTokenSelector } from '@gitlab/ui'; +import { GlForm, GlFormInput, GlFormCheckbox, GlTooltip } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import { sprintf, s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; +import WorkItemTokenInput from '~/work_items/components/shared/work_item_token_input.vue'; import { FORM_TYPES, WORK_ITEM_TYPE_ENUM_TASK, @@ -70,10 +71,12 @@ describe('WorkItemLinksForm', () => { }; const findForm = () => wrapper.findComponent(GlForm); - const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findWorkItemTokenInput = () => wrapper.findComponent(WorkItemTokenInput); const findInput = () => wrapper.findComponent(GlFormInput); const findConfidentialCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findTooltip = () => wrapper.findComponent(GlTooltip); const findAddChildButton = () => wrapper.findByTestId('add-child-button'); + const findValidationElement = () => wrapper.findByTestId('work-items-invalid'); describe('creating a new work item', () => { beforeEach(async () => { @@ -84,7 +87,7 @@ describe('WorkItemLinksForm', () => { expect(findForm().exists()).toBe(true); expect(findInput().exists()).toBe(true); expect(findAddChildButton().text()).toBe('Create task'); - expect(findTokenSelector().exists()).toBe(false); + expect(findWorkItemTokenInput().exists()).toBe(false); }); it('creates child task in non confidential parent', async () => { @@ -137,7 +140,7 @@ describe('WorkItemLinksForm', () => { const confidentialCheckbox = findConfidentialCheckbox(); expect(confidentialCheckbox.exists()).toBe(true); - expect(wrapper.findComponent(GlTooltip).exists()).toBe(false); + expect(findTooltip().exists()).toBe(false); expect(confidentialCheckbox.text()).toBe( sprintf(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, { workItemType: WORK_ITEM_TYPE_ENUM_TASK.toLocaleLowerCase(), @@ -149,12 +152,11 @@ describe('WorkItemLinksForm', () => { createComponent({ parentConfidential: true }); const confidentialCheckbox = findConfidentialCheckbox(); - const confidentialTooltip = wrapper.findComponent(GlTooltip); expect(confidentialCheckbox.attributes('disabled')).toBeDefined(); expect(confidentialCheckbox.attributes('checked')).toBe('true'); - expect(confidentialTooltip.exists()).toBe(true); - expect(confidentialTooltip.text()).toBe( + expect(findTooltip().exists()).toBe(true); + expect(findTooltip().text()).toBe( sprintf(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, { workItemType: WORK_ITEM_TYPE_ENUM_TASK.toLocaleLowerCase(), parentWorkItemType: WORK_ITEM_TYPE_VALUE_ISSUE.toLocaleLowerCase(), @@ -165,14 +167,11 @@ describe('WorkItemLinksForm', () => { }); describe('adding an existing work item', () => { - const selectAvailableWorkItemTokens = async () => { - findTokenSelector().vm.$emit( - 'input', + const selectAvailableWorkItemTokens = () => { + findWorkItemTokenInput().vm.$emit( + 'onChange', availableWorkItemsResponse.data.workspace.workItems.nodes, ); - findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); - - await waitForPromises(); }; beforeEach(async () => { @@ -181,24 +180,31 @@ describe('WorkItemLinksForm', () => { it('renders add form', () => { expect(findForm().exists()).toBe(true); - expect(findTokenSelector().exists()).toBe(true); + expect(findWorkItemTokenInput().exists()).toBe(true); expect(findAddChildButton().text()).toBe('Add task'); expect(findInput().exists()).toBe(false); expect(findConfidentialCheckbox().exists()).toBe(false); }); - it('searches for available work items as prop when typing in input', async () => { - findTokenSelector().vm.$emit('focus'); - findTokenSelector().vm.$emit('text-input', 'Task'); - await waitForPromises(); - - expect(availableWorkItemsResolver).toHaveBeenCalled(); + it('renders work item token input with default props', () => { + expect(findWorkItemTokenInput().props()).toMatchObject({ + value: [], + fullPath: 'project/path', + childrenType: WORK_ITEM_TYPE_ENUM_TASK, + childrenIds: [], + parentWorkItemId: 'gid://gitlab/WorkItem/1', + areWorkItemsToAddValid: true, + }); }); it('selects and adds children', async () => { await selectAvailableWorkItemTokens(); expect(findAddChildButton().text()).toBe('Add tasks'); + expect(findWorkItemTokenInput().props('areWorkItemsToAddValid')).toBe(true); + expect(findWorkItemTokenInput().props('value')).toBe( + availableWorkItemsResponse.data.workspace.workItems.nodes, + ); findForm().vm.$emit('submit', { preventDefault: jest.fn(), }); @@ -211,9 +217,9 @@ describe('WorkItemLinksForm', () => { await selectAvailableWorkItemTokens(); - const validationEl = wrapper.findByTestId('work-items-invalid'); - expect(validationEl.exists()).toBe(true); - expect(validationEl.text().trim()).toBe( + expect(findWorkItemTokenInput().props('areWorkItemsToAddValid')).toBe(false); + expect(findValidationElement().exists()).toBe(true); + expect(findValidationElement().text().trim()).toBe( sprintf( s__( 'WorkItem|%{invalidWorkItemsList} cannot be added: Cannot assign a non-confidential %{childWorkItemType} to a confidential parent %{parentWorkItemType}. Make the selected %{childWorkItemType} confidential and try again.', diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 05e83c0df3da3e..ea4115f178f5a1 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1471,6 +1471,27 @@ export const availableWorkItemsResponse = { }, }; +export const searchedWorkItemsResponse = { + data: { + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/2', + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/459', + title: 'Task 2', + state: 'OPEN', + createdAt: '2022-08-03T12:41:54Z', + confidential: false, + __typename: 'WorkItem', + }, + ], + }, + }, + }, +}; + export const projectMembersResponseWithCurrentUser = { data: { workspace: { -- GitLab From 6a7cb5a9004252caa9ebfe9396fd75ee90ab06ce Mon Sep 17 00:00:00 2001 From: Rajan Mistry Date: Wed, 23 Aug 2023 13:29:01 +0530 Subject: [PATCH 2/4] Use injection for fullpath and computed property for work items list --- .../shared/work_item_token_input.vue | 22 ++++++++----------- .../work_item_links/work_item_links_form.vue | 4 ---- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue index cd3f5f9938a92c..b3a27640718c0d 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -16,16 +16,13 @@ export default { components: { GlTokenSelector, }, + inject: ['fullPath'], props: { value: { type: Array, required: false, default: () => [], }, - fullPath: { - type: String, - required: true, - }, childrenType: { type: String, required: false, @@ -70,12 +67,19 @@ export default { data() { return { availableWorkItems: [], - workItemsToAdd: this.value, search: '', searchStarted: false, }; }, computed: { + workItemsToAdd: { + get() { + return this.value; + }, + set(workItemsToAdd) { + this.$emit('input', workItemsToAdd); + }, + }, isLoading() { return this.$apollo.queries.availableWorkItems.loading; }, @@ -89,14 +93,6 @@ export default { return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : ''; }, }, - watch: { - workItemsToAdd() { - this.$emit('onChange', this.workItemsToAdd); - }, - value() { - this.workItemsToAdd = this.value; - }, - }, created() { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 6811bc99045622..b8e33126b8acd7 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -278,9 +278,6 @@ export default { this.childToCreateTitle = null; }); }, - updateWorkItemsList(workItems) { - this.workItemsToAdd = workItems; - }, }, i18n: { inputLabel: __('Title'), @@ -344,7 +341,6 @@ export default { :children-type="childrenType" :children-ids="childrenIds" :are-work-items-to-add-valid="areWorkItemsToAddValid" - @onChange="updateWorkItemsList" />
Date: Wed, 23 Aug 2023 14:04:49 +0530 Subject: [PATCH 3/4] Fix unit test case for the event change --- .../components/work_item_links/work_item_links_form.vue | 1 - .../components/shared/work_item_token_input_spec.js | 4 +++- .../components/work_item_links/work_item_links_form_spec.js | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index b8e33126b8acd7..140d9c6d8dd9d6 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -336,7 +336,6 @@ export default { v-if="!isCreateForm" v-model="workItemsToAdd" :is-create-form="isCreateForm" - :full-path="fullPath" :parent-work-item-id="issuableGid" :children-type="childrenType" :children-ids="childrenIds" diff --git a/spec/frontend/work_items/components/shared/work_item_token_input_spec.js b/spec/frontend/work_items/components/shared/work_item_token_input_spec.js index 075b69415cfa95..2a1508f364a890 100644 --- a/spec/frontend/work_items/components/shared/work_item_token_input_spec.js +++ b/spec/frontend/work_items/components/shared/work_item_token_input_spec.js @@ -30,11 +30,13 @@ describe('WorkItemTokenInput', () => { value: workItemsToAdd, childrenType, childrenIds: [], - fullPath: 'test-project-path', parentWorkItemId: 'gid://gitlab/WorkItem/1', parentConfidential, areWorkItemsToAddValid, }, + provide: { + fullPath: 'test-project-path', + }, }); await waitForPromises(); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index 106364cdcbc1de..939555a870e0ff 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -169,7 +169,7 @@ describe('WorkItemLinksForm', () => { describe('adding an existing work item', () => { const selectAvailableWorkItemTokens = () => { findWorkItemTokenInput().vm.$emit( - 'onChange', + 'input', availableWorkItemsResponse.data.workspace.workItems.nodes, ); }; @@ -189,7 +189,6 @@ describe('WorkItemLinksForm', () => { it('renders work item token input with default props', () => { expect(findWorkItemTokenInput().props()).toMatchObject({ value: [], - fullPath: 'project/path', childrenType: WORK_ITEM_TYPE_ENUM_TASK, childrenIds: [], parentWorkItemId: 'gid://gitlab/WorkItem/1', -- GitLab From 273b414a3d8bf3e6f821772d27145a9ed798949d Mon Sep 17 00:00:00 2001 From: Rajan Mistry Date: Wed, 23 Aug 2023 19:29:00 +0530 Subject: [PATCH 4/4] Revert the fullpath changes --- .../work_items/components/shared/work_item_token_input.vue | 5 ++++- .../components/work_item_links/work_item_links_form.vue | 1 + .../components/shared/work_item_token_input_spec.js | 4 +--- .../components/work_item_links/work_item_links_form_spec.js | 1 + 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue index b3a27640718c0d..7b38e83803325f 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -16,13 +16,16 @@ export default { components: { GlTokenSelector, }, - inject: ['fullPath'], props: { value: { type: Array, required: false, default: () => [], }, + fullPath: { + type: String, + required: true, + }, childrenType: { type: String, required: false, diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 140d9c6d8dd9d6..a58752a861a412 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -340,6 +340,7 @@ export default { :children-type="childrenType" :children-ids="childrenIds" :are-work-items-to-add-valid="areWorkItemsToAddValid" + :full-path="fullPath" />
{ value: workItemsToAdd, childrenType, childrenIds: [], + fullPath: 'test-project-path', parentWorkItemId: 'gid://gitlab/WorkItem/1', parentConfidential, areWorkItemsToAddValid, }, - provide: { - fullPath: 'test-project-path', - }, }); await waitForPromises(); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index 939555a870e0ff..aaab22fd18dbf0 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -189,6 +189,7 @@ describe('WorkItemLinksForm', () => { it('renders work item token input with default props', () => { expect(findWorkItemTokenInput().props()).toMatchObject({ value: [], + fullPath: 'project/path', childrenType: WORK_ITEM_TYPE_ENUM_TASK, childrenIds: [], parentWorkItemId: 'gid://gitlab/WorkItem/1', -- GitLab