diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 176f84f6c1a2fef20d450226887575bbde9db2b0..6c8af8dc74ccac63dc8b5a7f085baf531e278822 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -22,6 +22,8 @@ export default function initWorkItemLinks() { return; } + const { projectPath, wiHasIssueWeightsFeature } = workItemLinksRoot.dataset; + // eslint-disable-next-line no-new new Vue({ el: workItemLinksRoot, @@ -31,7 +33,9 @@ export default function initWorkItemLinks() { workItemLinks: WorkItemLinks, }, provide: { - projectPath: workItemLinksRoot.dataset.projectPath, + projectPath, + fullPath: projectPath, + hasIssueWeightsFeature: wiHasIssueWeightsFeature, }, render: (createElement) => createElement('work-item-links', { diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 8aa1c862055712b33dce88cfc893d3f83755716f..fd568c1dfce4ab0970b04eac13564e6e88bdb03a 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -1,8 +1,11 @@ <script> import { GlButton, GlBadge, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { produce } from 'immer'; import { s__ } from '~/locale'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { isMetaKey } from '~/lib/utils/common_utils'; +import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { STATE_OPEN, WIDGET_ICONS, @@ -10,6 +13,8 @@ import { WIDGET_TYPE_HIERARCHY, } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; +import updateWorkItem from '../../graphql/update_work_item.mutation.graphql'; +import WorkItemDetailModal from '../work_item_detail_modal.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; import WorkItemLinksMenu from './work_item_links_menu.vue'; @@ -21,7 +26,9 @@ export default { GlLoadingIcon, WorkItemLinksForm, WorkItemLinksMenu, + WorkItemDetailModal, }, + inject: ['projectPath'], props: { workItemId: { type: String, @@ -64,6 +71,8 @@ export default { children: [], canUpdate: false, confidential: false, + activeChildId: null, + activeToast: null, }; }, computed: { @@ -106,7 +115,87 @@ export default { this.isShownAddForm = false; }, addChild(child) { - this.children = [child, ...this.children]; + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(child, child.id, client); + }, + openChild(childItemId, e) { + if (isMetaKey(e)) { + return; + } + e.preventDefault(); + this.activeChildId = childItemId; + this.$refs.modal.show(); + this.updateWorkItemIdUrlQuery(childItemId); + }, + closeModal() { + this.activeChildId = null; + this.updateWorkItemIdUrlQuery(undefined); + }, + handleWorkItemDeleted(childId) { + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(null, childId, client); + this.activeToast = this.$toast.show(s__('WorkItem|Task deleted')); + }, + updateWorkItemIdUrlQuery(childItemId) { + updateHistory({ + url: setUrlParams({ work_item_id: getIdFromGraphQLId(childItemId) }), + replace: true, + }); + }, + childPath(childItemId) { + return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`; + }, + toggleChildFromCache(workItem, childId, store) { + const sourceData = store.readQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.issuableGid }, + }); + + const newData = produce(sourceData, (draftState) => { + const widgetHierarchy = draftState.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + + const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); + + if (index >= 0) { + widgetHierarchy.children.nodes.splice(index, 1); + } else { + widgetHierarchy.children.nodes.push(workItem); + } + }); + + store.writeQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.issuableGid }, + data: newData, + }); + }, + async updateWorkItemMutation(workItem, childId, parentId) { + return this.$apollo.mutate({ + mutation: updateWorkItem, + variables: { input: { id: childId, hierarchyWidget: { parentId } } }, + update: this.toggleChildFromCache.bind(this, workItem, childId), + }); + }, + async undoChildRemoval(workItem, childId) { + const { data } = await this.updateWorkItemMutation(workItem, childId, this.issuableGid); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast?.hide(); + } + }, + async removeChild(childId) { + const { data } = await this.updateWorkItemMutation(null, childId, null); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId), + }, + }); + } }, }, i18n: { @@ -175,9 +264,17 @@ export default { class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" data-testid="links-child" > - <div> + <div class="gl-overflow-hidden"> <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" /> - <span class="gl-word-break-all">{{ child.title }}</span> + <gl-button + :href="childPath(child.id)" + category="tertiary" + variant="link" + class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" + @click="openChild(child.id, $event)" + > + {{ child.title }} + </gl-button> </div> <div class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0 gl-display-inline-flex gl-align-items-center" @@ -192,9 +289,16 @@ export default { :work-item-id="child.id" :parent-work-item-id="issuableGid" data-testid="links-menu" + @removeChild="removeChild(child.id)" /> </div> </div> + <work-item-detail-modal + ref="modal" + :work-item-id="activeChildId" + @close="closeModal" + @workItemDeleted="handleWorkItemDeleted(activeChildId)" + /> </template> </div> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue index 0c27793e7babbd7cd80e459d0277a1f5d841ef57..1aa4a433a58b4f30707bc25a9d755efe97cc8b3b 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue @@ -1,10 +1,5 @@ <script> import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { produce } from 'immer'; -import { s__ } from '~/locale'; -import updateWorkItem from '../../graphql/update_work_item.mutation.graphql'; -import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; -import { WIDGET_TYPE_HIERARCHY } from '../../constants'; export default { components: { @@ -12,80 +7,6 @@ export default { GlDropdown, GlIcon, }, - props: { - workItemId: { - type: String, - required: true, - }, - parentWorkItemId: { - type: String, - required: true, - }, - }, - data() { - return { - activeToast: null, - }; - }, - methods: { - toggleChildFromCache(data, store) { - const sourceData = store.readQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.parentWorkItemId }, - }); - - const newData = produce(sourceData, (draftState) => { - const widgetHierarchy = draftState.workItem.widgets.find( - (widget) => widget.type === WIDGET_TYPE_HIERARCHY, - ); - - const index = widgetHierarchy.children.nodes.findIndex( - (child) => child.id === this.workItemId, - ); - - if (index >= 0) { - widgetHierarchy.children.nodes.splice(index, 1); - } else { - widgetHierarchy.children.nodes.push(data.workItemUpdate.workItem); - } - }); - - store.writeQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.parentWorkItemId }, - data: newData, - }); - }, - async addChild(data) { - const { data: resp } = await this.$apollo.mutate({ - mutation: updateWorkItem, - variables: { - input: { id: this.workItemId, hierarchyWidget: { parentId: this.parentWorkItemId } }, - }, - update: this.toggleChildFromCache.bind(this, data), - }); - - if (resp.workItemUpdate.errors.length === 0) { - this.activeToast?.hide(); - } - }, - async removeChild() { - const { data } = await this.$apollo.mutate({ - mutation: updateWorkItem, - variables: { input: { id: this.workItemId, hierarchyWidget: { parentId: null } } }, - update: this.toggleChildFromCache.bind(this, null), - }); - - if (data.workItemUpdate.errors.length === 0) { - this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { - action: { - text: s__('WorkItem|Undo'), - onClick: this.addChild.bind(this, data), - }, - }); - } - }, - }, }; </script> @@ -95,7 +16,7 @@ export default { <template #button-content> <gl-icon name="ellipsis_v" :size="14" /> </template> - <gl-dropdown-item @click="removeChild"> + <gl-dropdown-item @click="$emit('removeChild')"> {{ s__('WorkItem|Remove') }} </gl-dropdown-item> </gl-dropdown> diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index 827976551e0522b5a5e7e6e489c8a9298cc908d0..c30c907baeb45e6bab06384c3d4882eae78dc6db 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -6,6 +6,7 @@ query workItemQuery($id: WorkItemID!) { } title userPermissions { + deleteWorkItem updateWorkItem } confidential diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml index 5d4787843508eec549b72b6f3733a872560db1d9..df2ffdd30eec16d66210e2d88a69441a622d2fef 100644 --- a/app/views/projects/issues/_work_item_links.html.haml +++ b/app/views/projects/issues/_work_item_links.html.haml @@ -1,2 +1,2 @@ - if Feature.enabled?(:work_items_hierarchy, @project) - .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path } } + .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path, wi: work_items_index_data(@project) } } diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js index 69316b7f0ca991f7b44ea4470761d3fdb59562a8..287ec022d3f1d5e8334a56a39f38afb58213338a 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js @@ -1,75 +1,24 @@ -import Vue from 'vue'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { cloneDeep } from 'lodash'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; + import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; -import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; -import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; -import { workItemHierarchyResponse, changeWorkItemParentMutationResponse } from '../../mock_data'; - -Vue.use(VueApollo); - -const PARENT_ID = 'gid://gitlab/WorkItem/1'; -const WORK_ITEM_ID = 'gid://gitlab/WorkItem/3'; describe('WorkItemLinksMenu', () => { let wrapper; - let mockApollo; - - const $toast = { - show: jest.fn(), - }; - - const createComponent = async ({ - data = {}, - mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse), - } = {}) => { - mockApollo = createMockApollo([ - [getWorkItemLinksQuery, jest.fn().mockResolvedValue(workItemHierarchyResponse)], - [changeWorkItemParentMutation, mutationHandler], - ]); - - mockApollo.clients.defaultClient.cache.writeQuery({ - query: getWorkItemLinksQuery, - variables: { - id: PARENT_ID, - }, - data: workItemHierarchyResponse.data, - }); - wrapper = shallowMountExtended(WorkItemLinksMenu, { - data() { - return { - ...data, - }; - }, - propsData: { - workItemId: WORK_ITEM_ID, - parentWorkItemId: PARENT_ID, - }, - apolloProvider: mockApollo, - mocks: { - $toast, - }, - }); - - await waitForPromises(); + const createComponent = () => { + wrapper = shallowMountExtended(WorkItemLinksMenu); }; const findDropdown = () => wrapper.find(GlDropdown); const findRemoveDropdownItem = () => wrapper.find(GlDropdownItem); beforeEach(async () => { - await createComponent(); + createComponent(); }); afterEach(() => { wrapper.destroy(); - mockApollo = null; }); it('renders dropdown and dropdown items', () => { @@ -77,69 +26,9 @@ describe('WorkItemLinksMenu', () => { expect(findRemoveDropdownItem().exists()).toBe(true); }); - it('calls correct mutation with correct variables', async () => { - const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse); - - createComponent({ mutationHandler }); - - findRemoveDropdownItem().vm.$emit('click'); - - await waitForPromises(); - - expect(mutationHandler).toHaveBeenCalledWith({ - input: { - id: WORK_ITEM_ID, - hierarchyWidget: { - parentId: null, - }, - }, - }); - }); - - it('shows toast when mutation succeeds', async () => { - const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse); - - createComponent({ mutationHandler }); - - findRemoveDropdownItem().vm.$emit('click'); - - await waitForPromises(); - - expect($toast.show).toHaveBeenCalledWith('Child removed', { - action: { onClick: expect.anything(), text: 'Undo' }, - }); - }); - - it('updates the cache when mutation succeeds', async () => { - const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse); - - createComponent({ mutationHandler }); - - mockApollo.clients.defaultClient.cache.readQuery = jest.fn( - () => workItemHierarchyResponse.data, - ); - - mockApollo.clients.defaultClient.cache.writeQuery = jest.fn(); - + it('emits removeChild event on click Remove', () => { findRemoveDropdownItem().vm.$emit('click'); - await waitForPromises(); - - // Remove the work item from parent's children - const resp = cloneDeep(workItemHierarchyResponse); - const index = resp.data.workItem.widgets - .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) - .children.nodes.findIndex((child) => child.id === WORK_ITEM_ID); - resp.data.workItem.widgets - .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) - .children.nodes.splice(index, 1); - - expect(mockApollo.clients.defaultClient.cache.writeQuery).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.anything(), - variables: { id: PARENT_ID }, - data: resp.data, - }), - ); + expect(wrapper.emitted('removeChild')).toHaveLength(1); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 3b780bcc5bcf98fc5fc9ea32fad60aab354de83c..a89815d05ab670f76ecc8eb1e0c212b7190b27bc 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -1,28 +1,70 @@ import Vue, { nextTick } from 'vue'; import { GlBadge } from '@gitlab/ui'; +import { cloneDeep } from 'lodash'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; +import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; import { workItemHierarchyResponse, workItemHierarchyEmptyResponse, workItemHierarchyNoUpdatePermissionResponse, + changeWorkItemParentMutationResponse, } from '../../mock_data'; Vue.use(VueApollo); describe('WorkItemLinks', () => { let wrapper; + let mockApollo; + + const PARENT_ID = 'gid://gitlab/WorkItem/1'; + const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; + + const $toast = { + show: jest.fn(), + }; + + const mutationChangeParentHandler = jest + .fn() + .mockResolvedValue(changeWorkItemParentMutationResponse); + + const createComponent = async ({ + data = {}, + response = workItemHierarchyResponse, + mutationHandler = mutationChangeParentHandler, + } = {}) => { + mockApollo = createMockApollo([ + [getWorkItemLinksQuery, jest.fn().mockResolvedValue(response)], + [changeWorkItemParentMutation, mutationHandler], + ]); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getWorkItemLinksQuery, + variables: { + id: PARENT_ID, + }, + data: response.data, + }); - const createComponent = async ({ response = workItemHierarchyResponse } = {}) => { wrapper = shallowMountExtended(WorkItemLinks, { - apolloProvider: createMockApollo([ - [getWorkItemLinksQuery, jest.fn().mockResolvedValue(response)], - ]), + data() { + return { + ...data, + }; + }, + provide: { + projectPath: 'project/path', + }, propsData: { issuableId: 1 }, + apolloProvider: mockApollo, + mocks: { + $toast, + }, }); await waitForPromises(); @@ -41,6 +83,7 @@ describe('WorkItemLinks', () => { afterEach(() => { wrapper.destroy(); + mockApollo = null; }); it('is expanded by default', () => { @@ -103,4 +146,63 @@ describe('WorkItemLinks', () => { expect(findFirstLinksMenu().exists()).toBe(false); }); }); + + describe('remove child', () => { + beforeEach(async () => { + await createComponent({ mutationHandler: mutationChangeParentHandler }); + mockApollo.clients.defaultClient.cache.readQuery = jest.fn( + () => workItemHierarchyResponse.data, + ); + + mockApollo.clients.defaultClient.cache.writeQuery = jest.fn(); + }); + + it('calls correct mutation with correct variables', async () => { + findFirstLinksMenu().vm.$emit('removeChild'); + + await waitForPromises(); + + expect(mutationChangeParentHandler).toHaveBeenCalledWith({ + input: { + id: WORK_ITEM_ID, + hierarchyWidget: { + parentId: null, + }, + }, + }); + }); + + it('shows toast when mutation succeeds', async () => { + findFirstLinksMenu().vm.$emit('removeChild'); + + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Child removed', { + action: { onClick: expect.anything(), text: 'Undo' }, + }); + }); + + it('updates the cache when mutation succeeds', async () => { + findFirstLinksMenu().vm.$emit('removeChild'); + + await waitForPromises(); + + // Remove the work item from parent's children + const resp = cloneDeep(workItemHierarchyResponse); + const index = resp.data.workItem.widgets + .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) + .children.nodes.findIndex((child) => child.id === WORK_ITEM_ID); + resp.data.workItem.widgets + .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) + .children.nodes.splice(index, 1); + + expect(mockApollo.clients.defaultClient.cache.writeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.anything(), + variables: { id: PARENT_ID }, + data: resp.data, + }), + ); + }); + }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 050d3c5672742374d777b45ca39b7fe1cc5603ba..c7105533ea7db5981c416eb0e12e3f960b43c490 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -335,6 +335,7 @@ export const workItemHierarchyEmptyResponse = { }, title: 'New title', userPermissions: { + deleteWorkItem: false, updateWorkItem: false, }, confidential: false, @@ -368,6 +369,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { }, title: 'New title', userPermissions: { + deleteWorkItem: false, updateWorkItem: false, }, confidential: false, @@ -412,6 +414,7 @@ export const workItemHierarchyResponse = { }, title: 'New title', userPermissions: { + deleteWorkItem: true, updateWorkItem: true, }, confidential: false,