diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 4eacad6c435124bcc203347d5f18ce7f764e8862..027c31081207bbe89e82d971cc3fd6547ac3ffde 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -10,6 +10,7 @@ import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import { DETAIL_VIEW_QUERY_PARAM_NAME } from '~/work_items/constants'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import relatedIssuableMixin from '../mixins/related_issuable_mixin'; import IssueAssignees from './issue_assignees.vue'; @@ -97,15 +98,15 @@ export default { } event.preventDefault(); this.$refs.modal.show(); - this.updateWorkItemIidUrlQuery(this.iid); + this.updateQueryParam(this.idKey); } }, handleWorkItemDeleted(workItemId) { this.$emit('relatedIssueRemoveRequest', workItemId); }, - updateWorkItemIidUrlQuery(iid) { + updateQueryParam(id) { updateHistory({ - url: setUrlParams({ work_item_iid: iid }), + url: setUrlParams({ [DETAIL_VIEW_QUERY_PARAM_NAME]: id }), replace: true, }); }, @@ -251,7 +252,7 @@ export default { ref="modal" :work-item-id="workItemId" :work-item-iid="workItemIid" - @close="updateWorkItemIidUrlQuery" + @close="updateQueryParam" @workItemDeleted="handleWorkItemDeleted" @openReportAbuse="openReportAbuseDrawer" /> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index b6618265ee802051b2a26d891cba81865f8a0967..9fceead6d5e6498cfd0f343642b90930e2791c91 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -6,11 +6,13 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { s__ } from '~/locale'; import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { WORKSPACE_PROJECT } from '~/issues/constants'; import { i18n, + DETAIL_VIEW_QUERY_PARAM_NAME, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_NOTIFICATIONS, WIDGET_TYPE_CURRENT_USER_TODOS, @@ -30,6 +32,7 @@ import { import workItemUpdatedSubscription from '../graphql/work_item_updated.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import workItemByIdQuery from '../graphql/work_item_by_id.query.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import getAllowedWorkItemChildTypes from '../graphql/work_item_allowed_children.query.graphql'; import { findHierarchyWidgetDefinition } from '../utils'; @@ -89,6 +92,11 @@ export default { required: false, default: false, }, + workItemId: { + type: String, + required: false, + default: null, + }, workItemIid: { type: String, required: false, @@ -106,12 +114,18 @@ export default { }, }, data() { + let modalWorkItemId = getParameterByName(DETAIL_VIEW_QUERY_PARAM_NAME); + + if (modalWorkItemId) { + modalWorkItemId = convertToGraphQLId(TYPENAME_WORK_ITEM, modalWorkItemId); + } + return { error: undefined, updateError: undefined, workItem: {}, updateInProgress: false, - modalWorkItemId: undefined, + modalWorkItemId, modalWorkItemIid: getParameterByName('work_item_iid'), modalWorkItemNamespaceFullPath: '', isReportModalOpen: false, @@ -125,17 +139,30 @@ export default { }, apollo: { workItem: { - query: workItemByIidQuery, + query() { + if (this.workItemId) { + return workItemByIdQuery; + } + return workItemByIidQuery; + }, variables() { + if (this.workItemId) { + return { + id: this.workItemId, + }; + } return { fullPath: this.workItemFullPath, iid: this.workItemIid, }; }, skip() { - return !this.workItemIid; + return !this.workItemIid && !this.workItemId; }, update(data) { + if (this.workItemId) { + return data.workItem ?? {}; + } return data.workspace.workItem ?? {}; }, error() { @@ -339,10 +366,10 @@ export default { }, }, mounted() { - if (this.modalWorkItemIid) { + if (this.modalWorkItemId) { this.openInModal({ event: undefined, - modalWorkItem: { iid: this.modalWorkItemIid }, + modalWorkItem: { id: this.modalWorkItemId }, }); } }, @@ -397,7 +424,9 @@ export default { }, updateUrl(modalWorkItem) { updateHistory({ - url: setUrlParams({ work_item_iid: modalWorkItem?.iid }), + url: setUrlParams({ + [DETAIL_VIEW_QUERY_PARAM_NAME]: getIdFromGraphQLId(modalWorkItem?.id), + }), replace: true, }); }, diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index 7230e96a2c424e8fae4b3bf25fcfcc56c47e6aa4..afabf4691b1cfe662d0c8c4458e1ffa61526df3b 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -124,6 +124,7 @@ export default { child.iid === iid) ?? {}; + const id = getParameterByName(DETAIL_VIEW_QUERY_PARAM_NAME); + this.activeChild = + this.children.find( + (child) => getIdFromGraphQLId(child.id) === getIdFromGraphQLId(id) || child.iid === iid, + ) ?? {}; await this.$nextTick(); if (!isEmpty(this.activeChild)) { this.$refs.modal.show(); return; } - this.updateWorkItemIdUrlQuery(); + this.updateQueryParam(); }, }, parentIssue: { @@ -190,10 +195,10 @@ export default { event.preventDefault(); this.activeChild = child; this.$refs.modal.show(); - this.updateWorkItemIdUrlQuery(child); + this.updateQueryParam(child.id); }, async closeModal() { - this.updateWorkItemIdUrlQuery(); + this.updateQueryParam(); }, handleWorkItemDeleted(child) { const { defaultClient: cache } = this.$apollo.provider.clients; @@ -205,8 +210,11 @@ export default { }); this.$toast.show(s__('WorkItem|Task deleted')); }, - updateWorkItemIdUrlQuery({ iid } = {}) { - updateHistory({ url: setUrlParams({ work_item_iid: iid }), replace: true }); + updateQueryParam(id) { + updateHistory({ + url: setUrlParams({ [DETAIL_VIEW_QUERY_PARAM_NAME]: getIdFromGraphQLId(id) }), + replace: true, + }); }, toggleReportAbuseModal(isOpen, reply = {}) { this.isReportModalOpen = isOpen; diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 75c820b8e77a89328cf526ba624dd8b329c3212e..e78b06abe1a87bd3a8fdee2676c16265e7023df9 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -354,6 +354,7 @@ export const FEATURE_NAME = 'work_item_epic_feedback'; export const CLEAR_VALUE = 'CLEAR_VALUE'; +export const DETAIL_VIEW_QUERY_PARAM_NAME = 'show'; export const ROUTES = { index: 'workItemList', workItem: 'workItem', diff --git a/app/assets/javascripts/work_items/graphql/work_item_by_id.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_by_id.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..500a04e91239fa1411c8062c5cbbb5277015db94 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_by_id.query.graphql @@ -0,0 +1,7 @@ +#import "./work_item.fragment.graphql" + +query workItemById($id: WorkItemID!) { + workItem(id: $id) { + ...WorkItem + } +} diff --git a/spec/frontend/issuable/components/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js index bffa708436f67f81f707560ac089eb57e390be36..3055e20f0d3e5778083f84140a748d5e95f2017f 100644 --- a/spec/frontend/issuable/components/related_issuable_item_spec.js +++ b/spec/frontend/issuable/components/related_issuable_item_spec.js @@ -25,7 +25,7 @@ describe('RelatedIssuableItem', () => { let showModalSpy; const defaultProps = { - idKey: 1, + idKey: 10, iid: 1, displayReference: 'gitlab-org/gitlab-test#1', pathIdSeparator: '#', @@ -243,7 +243,7 @@ describe('RelatedIssuableItem', () => { }); describe('work item modal', () => { - const workItemId = 'gid://gitlab/WorkItem/1'; + const workItemId = 'gid://gitlab/WorkItem/10'; it('renders', () => { mountComponent(); @@ -276,7 +276,7 @@ describe('RelatedIssuableItem', () => { it('updates the url params with the work item id', () => { expect(updateHistory).toHaveBeenCalledWith({ - url: `${TEST_HOST}/?work_item_iid=1`, + url: `${TEST_HOST}/?show=10`, replace: true, }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index 499a630b323eb114b6ac1391238cbe68a4fb91d4..7fc3063e5dfb5e0492e0ba1e0d0e1fb58b5c7867 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -60,6 +60,7 @@ describe('WorkItemDetailModal component', () => { expect(findWorkItemDetail().props()).toEqual({ isModal: true, + workItemId, workItemIid: '1', modalWorkItemFullPath: '', isDrawer: false, diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 285c03899b02ceb028cab1eee5301ae9fc50e942..91d8d7392eb7d40416731ae6dadd3d62e660eca8 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -24,6 +24,7 @@ import WorkItemAbuseModal from '~/work_items/components/work_item_abuse_modal.vu import WorkItemTodos from '~/work_items/components/work_item_todos.vue'; import DesignWidget from '~/work_items/components/design_management/design_management_widget.vue'; import { i18n } from '~/work_items/constants'; +import workItemByIdQuery from '~/work_items/graphql/work_item_by_id.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import workItemUpdatedSubscription from '~/work_items/graphql/work_item_updated.subscription.graphql'; @@ -32,6 +33,7 @@ import getAllowedWorkItemChildTypes from '~/work_items/graphql/work_item_allowed import { mockParent, workItemByIidResponseFactory, + workItemQueryResponse, objectiveType, epicType, mockWorkItemCommentNote, @@ -46,7 +48,10 @@ describe('WorkItemDetail component', () => { Vue.use(VueApollo); - const workItemQueryResponse = workItemByIidResponseFactory({ canUpdate: true, canDelete: true }); + const workItemByIidQueryResponse = workItemByIidResponseFactory({ + canUpdate: true, + canDelete: true, + }); const workItemQueryResponseWithNoPermissions = workItemByIidResponseFactory({ canUpdate: false, canDelete: false, @@ -56,12 +61,13 @@ describe('WorkItemDetail component', () => { canUpdate: true, canDelete: true, }); - const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const workItemByIdQueryHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const successHandler = jest.fn().mockResolvedValue(workItemByIidQueryResponse); const successHandlerWithNoPermissions = jest .fn() .mockResolvedValue(workItemQueryResponseWithNoPermissions); const showModalHandler = jest.fn(); - const { id } = workItemQueryResponse.data.workspace.workItem; + const { id } = workItemByIidQueryResponse.data.workspace.workItem; const workItemUpdatedSubscriptionHandler = jest .fn() .mockResolvedValue({ data: { workItemUpdated: null } }); @@ -97,8 +103,10 @@ describe('WorkItemDetail component', () => { isModal = false, isDrawer = false, updateInProgress = false, + workItemId = '', workItemIid = '1', handler = successHandler, + workItemByIdHandler = workItemByIdQueryHandler, mutationHandler, error = undefined, workItemsAlphaEnabled = false, @@ -110,12 +118,14 @@ describe('WorkItemDetail component', () => { wrapper = shallowMountExtended(WorkItemDetail, { apolloProvider: createMockApollo([ [workItemByIidQuery, handler], + [workItemByIdQuery, workItemByIdHandler], [updateWorkItemMutation, mutationHandler], [workItemUpdatedSubscription, workItemUpdatedSubscriptionHandler], [getAllowedWorkItemChildTypes, allowedChildrenTypesHandler], ]), isLoggedIn: isLoggedIn(), propsData: { + workItemId, isModal, workItemIid, isDrawer, @@ -469,8 +479,28 @@ describe('WorkItemDetail component', () => { expect(successHandler).toHaveBeenCalledWith({ fullPath: 'group/project', iid: '1' }); }); - it('skips calling the work item query when there is no workItemIid', async () => { - createComponent({ workItemIid: null }); + it('calls the work item query by workItemId', async () => { + const workItemId = workItemQueryResponse.data.workItem.id; + createComponent({ workItemId }); + await waitForPromises(); + + expect(workItemByIdQueryHandler).toHaveBeenCalledWith({ id: workItemId }); + expect(successHandler).not.toHaveBeenCalled(); + }); + + it('shows work item modal if "show" query param set', async () => { + const workItemId = workItemQueryResponse.data.workItem.id; + setWindowLocation(`?show=${workItemId}`); + + createComponent(); + await waitForPromises(); + + expect(findModal().exists()).toBe(true); + expect(findModal().props('workItemId')).toBe(workItemId); + }); + + it('skips calling the work item query when there is no workItemIid and no workItemId', async () => { + createComponent({ workItemIid: null, workItemId: null }); await waitForPromises(); expect(successHandler).not.toHaveBeenCalled(); @@ -674,7 +704,7 @@ describe('WorkItemDetail component', () => { createComponent(); await waitForPromises(); - const { confidential } = workItemQueryResponse.data.workspace.workItem; + const { confidential } = workItemByIidQueryResponse.data.workspace.workItem; expect(findNotesWidget().exists()).toBe(true); expect(findNotesWidget().props('isWorkItemConfidential')).toBe(confidential); 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 2154e7fee93ad1b8c03a86a615f16cd15bcc86c6..f3c8f17de734448d132e00d14b273d6c94269751 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 @@ -228,6 +228,15 @@ describe('WorkItemLinks', () => { expect(findWorkItemDetailModal().props('workItemIid')).toBe('37'); }); + it('opens the modal if work item id URL parameter is found in child items', async () => { + setWindowLocation('?show=31'); + await createComponent(); + + expect(showModal).toHaveBeenCalled(); + expect(findWorkItemDetailModal().props('workItemId')).toBe('gid://gitlab/WorkItem/31'); + expect(findWorkItemDetailModal().props('workItemIid')).toBe('37'); + }); + describe('abuse category selector', () => { beforeEach(async () => { setWindowLocation('?work_item_id=2'); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 3a2f30edf7e0bef374f83eaa875546a5a835b50b..fe758e12d0896706f273b515899950882d4dc6d5 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1961,7 +1961,7 @@ export const workItemHierarchyTreeEmptyResponse = { export const mockHierarchyChildren = [ { - id: 'gid://gitlab/WorkItem/37', + id: 'gid://gitlab/WorkItem/31', iid: '37', workItemType: { id: 'gid://gitlab/WorkItems::Type/2411', diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 0848aea4eb1c6d7b130a42fd8dbb425fe6857a12..d5869f75063052196b2211c1110bc2b6ef91d648 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -49,6 +49,7 @@ describe('Work items root component', () => { expect(findWorkItemDetail().props()).toEqual({ isModal: false, + workItemId: null, workItemIid: '1', modalWorkItemFullPath: '', isDrawer: false,