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 0000000000000000000000000000000000000000..7b38e83803325f99648fdb81a63b9c27ffad926f --- /dev/null +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -0,0 +1,145 @@ + + 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 4960189fb488a748b859935b8b36568e12c3f9a9..a58752a861a4124c54ab92ff8b3bff19914aba15 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,20 +278,6 @@ 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); - }, }, i18n: { inputLabel: __('Title'), @@ -385,30 +332,16 @@ export default { >{{ confidentialityCheckboxTooltip }}
- - - - + :is-create-form="isCreateForm" + :parent-work-item-id="issuableGid" + :children-type="childrenType" + :children-ids="childrenIds" + :are-work-items-to-add-valid="areWorkItemsToAddValid" + :full-path="fullPath" + />
{ + 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 8caacc2dc972c043c01204f0dd556abd0182ed32..aaab22fd18dbf007f8ec0ddeaf63065aadb4268a 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( + const selectAvailableWorkItemTokens = () => { + findWorkItemTokenInput().vm.$emit( 'input', 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 05e83c0df3da3e4bc4bb0c4fa4dacddf460c86a6..ea4115f178f5a1e99f775573c5b43e8ca6b66338 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: {