From 6f65f199034936fbf8385593dfd27a06fcca42bd Mon Sep 17 00:00:00 2001 From: Deepika Guliani <dguliani@gitlab.com> Date: Wed, 18 Dec 2024 11:09:33 +0000 Subject: [PATCH] Show Related MR's in dev widget with deduplication Changelog: added --- .../javascripts/graphql_shared/constants.js | 1 + .../work_item_development.vue | 51 +++++- ...ork_item_development_relationship_list.vue | 49 ++--- .../graphql/merge_request.fragment.graphql | 39 ++++ .../work_item_development.fragment.graphql | 26 +++ .../work_item_development.query.graphql | 16 ++ ...work_item_development.subscription.graphql | 16 ++ .../work_item_widgets.fragment.graphql | 51 ------ .../work_item_development.fragment.graphql | 35 ++++ .../work_item_widgets.fragment.graphql | 61 ------- .../work_item_development_spec.js | 168 +++++++++--------- .../work_items/work_item_detail_spec.rb | 1 + ...item_development_relationship_list_spec.js | 22 ++- .../work_item_development_spec.js | 136 ++++++-------- spec/frontend/work_items/mock_data.js | 67 ++++++- .../work_items/work_items_shared_examples.rb | 2 + 16 files changed, 422 insertions(+), 319 deletions(-) create mode 100644 app/assets/javascripts/work_items/graphql/merge_request.fragment.graphql create mode 100644 app/assets/javascripts/work_items/graphql/work_item_development.fragment.graphql create mode 100644 app/assets/javascripts/work_items/graphql/work_item_development.query.graphql create mode 100644 app/assets/javascripts/work_items/graphql/work_item_development.subscription.graphql create mode 100644 ee/app/assets/javascripts/work_items/graphql/work_item_development.fragment.graphql diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 681a8f371b2aa..6e8f75426835c 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -34,6 +34,7 @@ export const TYPENAME_TODO = 'Todo'; export const TYPENAME_USER = 'User'; export const TYPENAME_VULNERABILITY = 'Vulnerability'; export const TYPENAME_WORK_ITEM = 'WorkItem'; +export const TYPENAME_WORK_ITEM_RELATED_BRANCH = 'WorkItemRelatedBranch'; export const TYPE_ORGANIZATION = 'Organizations::Organization'; export const TYPE_USERS_SAVED_REPLY = 'Users::SavedReply'; export const TYPE_WORKSPACE = 'RemoteDevelopment::Workspace'; diff --git a/app/assets/javascripts/work_items/components/work_item_development/work_item_development.vue b/app/assets/javascripts/work_items/components/work_item_development/work_item_development.vue index c484d6e73a395..6465f6391f393 100644 --- a/app/assets/javascripts/work_items/components/work_item_development/work_item_development.vue +++ b/app/assets/javascripts/work_items/components/work_item_development/work_item_development.vue @@ -6,6 +6,8 @@ import { s__, __ } from '~/locale'; import { findWidget } from '~/issues/list/utils'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import workItemDevelopmentQuery from '~/work_items/graphql/work_item_development.query.graphql'; +import workItemDevelopmentUpdatedSubscription from '~/work_items/graphql/work_item_development.subscription.graphql'; import { sprintfWorkItem, WIDGET_TYPE_DEVELOPMENT, @@ -78,6 +80,7 @@ export default { return { error: undefined, workItem: {}, + workItemDevelopment: {}, showCreateBranchAndMrModal: false, showBranchFlow: true, showMergeRequestFlow: false, @@ -95,16 +98,18 @@ export default { workItemTypeName() { return this.workItem?.workItemType?.name; }, - workItemDevelopment() { - return findWidget(WIDGET_TYPE_DEVELOPMENT, this.workItem); - }, isLoading() { - return this.$apollo.queries.workItem.loading; + return ( + this.$apollo.queries.workItem.loading || this.$apollo.queries.workItemDevelopment.loading + ); }, willAutoCloseByMergeRequest() { return this.workItemDevelopment?.willAutoCloseByMergeRequest; }, - linkedMergeRequests() { + relatedMergeRequests() { + return this.workItemDevelopment?.relatedMergeRequests?.nodes || []; + }, + closingMergeRequests() { return this.workItemDevelopment?.closingMergeRequests?.nodes || []; }, featureFlags() { @@ -119,18 +124,19 @@ export default { isRelatedDevelopmentListEmpty() { return ( !this.error && - this.linkedMergeRequests.length === 0 && + this.relatedMergeRequests.length === 0 && + this.closingMergeRequests.length === 0 && this.featureFlags.length === 0 && this.relatedBranches.length === 0 ); }, showAutoCloseInformation() { return ( - this.linkedMergeRequests.length > 0 && this.willAutoCloseByMergeRequest && !this.isLoading + this.closingMergeRequests.length > 0 && this.willAutoCloseByMergeRequest && !this.isLoading ); }, openStateText() { - return this.linkedMergeRequests.length > 1 + return this.closingMergeRequests.length > 1 ? sprintfWorkItem(this.$options.i18n.openStateText, this.workItemTypeName) : sprintfWorkItem( this.$options.i18n.openStateWithOneMergeRequestText, @@ -204,6 +210,35 @@ export default { this.error = e.message || this.$options.i18n.fetchError; }, }, + workItemDevelopment: { + query: workItemDevelopmentQuery, + variables() { + return { + id: this.workItemId, + }; + }, + update(data) { + return findWidget(WIDGET_TYPE_DEVELOPMENT, data?.workItem) || {}; + }, + skip() { + return !this.workItemIid; + }, + error(e) { + this.$emit('error', this.$options.i18n.fetchError); + this.error = e.message || this.$options.i18n.fetchError; + }, + subscribeToMore: { + document: workItemDevelopmentUpdatedSubscription, + variables() { + return { + id: this.workItem.id, + }; + }, + skip() { + return !this.workItem?.id; + }, + }, + }, }, methods: { openModal(createBranch = true, createMergeRequest = false) { diff --git a/app/assets/javascripts/work_items/components/work_item_development/work_item_development_relationship_list.vue b/app/assets/javascripts/work_items/components/work_item_development/work_item_development_relationship_list.vue index e792034830678..2a2119e3be8c5 100644 --- a/app/assets/javascripts/work_items/components/work_item_development/work_item_development_relationship_list.vue +++ b/app/assets/javascripts/work_items/components/work_item_development/work_item_development_relationship_list.vue @@ -1,8 +1,11 @@ <script> import { GlButton } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import { renderGFM } from '~/behaviors/markdown/render_gfm'; -import { TYPENAME_FEATURE_FLAG } from '~/graphql_shared/constants'; +import { unionBy, uniqueId, map } from 'lodash'; +import { + TYPENAME_FEATURE_FLAG, + TYPENAME_MERGE_REQUEST, + TYPENAME_WORK_ITEM_RELATED_BRANCH, +} from '~/graphql_shared/constants'; import WorkItemDevelopmentMrItem from './work_item_development_mr_item.vue'; import WorkItemDevelopmentBranchItem from './work_item_development_branch_item.vue'; @@ -31,10 +34,20 @@ export default { list() { return [...this.sortedFeatureFlags, ...this.mergeRequests, ...this.relatedBranches]; }, + mergeRequests() { + return unionBy( + map(this.closingMergeRequests, 'mergeRequest'), + this.relatedMergeRequests, + 'id', + ); + }, relatedBranches() { return this.workItemDevWidget.relatedBranches?.nodes || []; }, - mergeRequests() { + relatedMergeRequests() { + return this.workItemDevWidget.relatedMergeRequests?.nodes || []; + }, + closingMergeRequests() { return this.workItemDevWidget.closingMergeRequests?.nodes || []; }, featureFlags() { @@ -48,10 +61,6 @@ export default { return [...enabledFlags, ...disabledFlags]; }, }, - mounted() { - // render the popovers of the merge requests - renderGFM(this.$refs['list-body']); - }, methods: { itemComponent(item) { let component; @@ -68,7 +77,8 @@ export default { return component; }, isMergeRequest(item) { - return item.fromMrDescription !== undefined; + // eslint-disable-next-line no-underscore-dangle + return item.__typename === TYPENAME_MERGE_REQUEST; }, isFeatureFlag(item) { // eslint-disable-next-line no-underscore-dangle @@ -76,31 +86,28 @@ export default { }, isBranch(item) { // eslint-disable-next-line no-underscore-dangle - return item.__typename === 'WorkItemRelatedBranch'; - }, - async toggleShowLess() { - this.showLess = !this.showLess; - await this.$nextTick(); - renderGFM(this.$refs['list-body']); + return item.__typename === TYPENAME_WORK_ITEM_RELATED_BRANCH; }, itemId(item) { - return item?.id || item?.mergeRequest?.id || uniqueId('branch-id-'); - }, - itemObject(item) { - return this.isMergeRequest(item) ? item.mergeRequest : item; + return item?.id || uniqueId('branch-id-'); }, }, }; </script> <template> <div> - <ul ref="list-body" class="gl-m-0 gl-list-none gl-p-0" data-testid="work-item-dev-items-list"> + <ul + ref="list-body" + class="gl-m-0 gl-list-none gl-p-0" + data-testid="work-item-dev-items-list" + :data-list-length="list.length" + > <li v-for="item in list" :key="itemId(item)" class="gl-border-b gl-py-4 first:!gl-pt-0 last:gl-border-none last:!gl-pb-0" > - <component :is="itemComponent(item)" :item-content="itemObject(item)" :is-modal="isModal" /> + <component :is="itemComponent(item)" :item-content="item" :is-modal="isModal" /> </li> </ul> </div> diff --git a/app/assets/javascripts/work_items/graphql/merge_request.fragment.graphql b/app/assets/javascripts/work_items/graphql/merge_request.fragment.graphql new file mode 100644 index 0000000000000..ce132e5f378de --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/merge_request.fragment.graphql @@ -0,0 +1,39 @@ +#import "~/work_items/graphql/milestone.fragment.graphql" + +fragment MergeRequestFragment on MergeRequest { + id + iid + title + webUrl + state + sourceBranch + reference + headPipeline { + id + detailedStatus { + id + icon + text + detailsPath + } + } + milestone { + ...MilestoneFragment + } + project { + id + name + namespace { + path + } + } + assignees { + nodes { + webUrl + id + name + webPath + avatarUrl + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_development.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_development.fragment.graphql new file mode 100644 index 0000000000000..267e83b7338ee --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_development.fragment.graphql @@ -0,0 +1,26 @@ +#import "~/work_items/graphql/merge_request.fragment.graphql" + +fragment WorkItemDevelopmentFragment on WorkItemWidgetDevelopment { + type + willAutoCloseByMergeRequest + relatedBranches { + nodes { + name + comparePath + } + } + relatedMergeRequests { + nodes { + ...MergeRequestFragment + } + } + closingMergeRequests { + nodes { + id + fromMrDescription + mergeRequest { + ...MergeRequestFragment + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_development.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_development.query.graphql new file mode 100644 index 0000000000000..14943d1251a94 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_development.query.graphql @@ -0,0 +1,16 @@ +#import "ee_else_ce/work_items/graphql/work_item_development.fragment.graphql" + +query workItemDevelopment($id: WorkItemID!) { + workItem(id: $id) { + id + iid + namespace { + id + } + widgets { + ... on WorkItemWidgetDevelopment { + ...WorkItemDevelopmentFragment + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_development.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_development.subscription.graphql new file mode 100644 index 0000000000000..2a8454bad3f30 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_development.subscription.graphql @@ -0,0 +1,16 @@ +#import "ee_else_ce/work_items/graphql/work_item_development.fragment.graphql" + +subscription workItemDevelopmentUpdated($id: WorkItemID!) { + workItemUpdated(workItemId: $id) { + id + iid + namespace { + id + } + widgets { + ... on WorkItemWidgetDevelopment { + ...WorkItemDevelopmentFragment + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 5899a3094dcfe..523947fee0f17 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -93,57 +93,6 @@ fragment WorkItemWidgets on WorkItemWidget { } } } - ... on WorkItemWidgetDevelopment { - type - willAutoCloseByMergeRequest - relatedBranches { - nodes { - name - comparePath - } - } - closingMergeRequests { - nodes { - fromMrDescription - mergeRequest { - iid - id - title - webUrl - state - sourceBranch - reference - headPipeline { - detailedStatus { - id - icon - text - detailsPath - } - } - milestone { - ...MilestoneFragment - } - project { - id - name - namespace { - path - } - } - assignees { - nodes { - webUrl - id - name - webPath - avatarUrl - } - } - } - } - } - } ... on WorkItemWidgetCrmContacts { contacts { nodes { diff --git a/ee/app/assets/javascripts/work_items/graphql/work_item_development.fragment.graphql b/ee/app/assets/javascripts/work_items/graphql/work_item_development.fragment.graphql new file mode 100644 index 0000000000000..c9410eb244d56 --- /dev/null +++ b/ee/app/assets/javascripts/work_items/graphql/work_item_development.fragment.graphql @@ -0,0 +1,35 @@ +#import "~/work_items/graphql/merge_request.fragment.graphql" + +fragment WorkItemDevelopmentFragment on WorkItemWidgetDevelopment { + type + willAutoCloseByMergeRequest + featureFlags { + nodes { + id + active + name + path + reference + } + } + relatedBranches { + nodes { + name + comparePath + } + } + relatedMergeRequests { + nodes { + ...MergeRequestFragment + } + } + closingMergeRequests { + nodes { + id + fromMrDescription + mergeRequest { + ...MergeRequestFragment + } + } + } +} diff --git a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index cfc5e388ee9ea..153dc31a9674e 100644 --- a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -135,67 +135,6 @@ fragment WorkItemWidgets on WorkItemWidget { textColor } - ... on WorkItemWidgetDevelopment { - type - willAutoCloseByMergeRequest - relatedBranches { - nodes { - name - comparePath - } - } - featureFlags { - nodes { - id - active - name - path - reference - } - } - closingMergeRequests { - nodes { - fromMrDescription - mergeRequest { - iid - id - title - webUrl - state - sourceBranch - reference - headPipeline { - detailedStatus { - id - icon - text - detailsPath - } - } - milestone { - ...MilestoneFragment - } - project { - id - name - namespace { - path - } - } - assignees { - nodes { - webUrl - id - name - webPath - avatarUrl - } - } - } - } - } - } - ... on WorkItemWidgetLinkedItems { linkedItems { nodes { diff --git a/ee/spec/frontend/work_items/components/work_item_development/work_item_development_spec.js b/ee/spec/frontend/work_items/components/work_item_development/work_item_development_spec.js index 5d5e1918872f2..861abf025e59b 100644 --- a/ee/spec/frontend/work_items/components/work_item_development/work_item_development_spec.js +++ b/ee/spec/frontend/work_items/components/work_item_development/work_item_development_spec.js @@ -1,16 +1,17 @@ import Vue from 'vue'; -import MockAdapter from 'axios-mock-adapter'; import VueApollo from 'vue-apollo'; -import axios from 'axios'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { map } from 'lodash'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective } from 'helpers/vue_mock_directive'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import workItemDevelopmentQuery from '~/work_items/graphql/work_item_development.query.graphql'; +import workItemDevelopmentUpdatedSubscription from '~/work_items/graphql/work_item_development.subscription.graphql'; import waitForPromises from 'helpers/wait_for_promises'; import { - workItemResponseFactory, + workItemByIidResponseFactory, + workItemDevelopmentResponse, workItemDevelopmentFragmentResponse, workItemDevelopmentMRNodes, workItemDevelopmentFeatureFlagNodes, @@ -29,110 +30,100 @@ describe('WorkItemDevelopment EE', () => { let wrapper; let mockApollo; - let mock; - const workItemWithMRListOnly = workItemResponseFactory({ - developmentWidgetPresent: true, + const workItemSuccessQueryHandler = jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ canUpdate: true })); + + const devWidgetWithMRListOnly = workItemDevelopmentResponse({ developmentItems: workItemDevelopmentFragmentResponse({ mrNodes: workItemDevelopmentMRNodes, willAutoCloseByMergeRequest: true, featureFlagNodes: [], branchNodes: [], + relatedMergeRequests: [], }), }); - const workItemWithFlagListOnly = workItemResponseFactory({ - developmentWidgetPresent: true, + const devWidgetWithFlagListOnly = workItemDevelopmentResponse({ developmentItems: workItemDevelopmentFragmentResponse({ mrNodes: [], willAutoCloseByMergeRequest: false, featureFlagNodes: workItemDevelopmentFeatureFlagNodes, branchNodes: [], + relatedMergeRequests: [], }), - canUpdate: true, }); - const workItemWithBranchListOnly = workItemResponseFactory({ - developmentWidgetPresent: true, + const devWidgetWithBranchListOnly = workItemDevelopmentResponse({ developmentItems: workItemDevelopmentFragmentResponse({ mrNodes: [], willAutoCloseByMergeRequest: false, featureFlagNodes: [], branchNodes: workItemRelatedBranchNodes, + relatedMergeRequests: [], + }), + }); + + const devWidgetWithRelatedMRListOnly = workItemDevelopmentResponse({ + developmentItems: workItemDevelopmentFragmentResponse({ + mrNodes: [], + willAutoCloseByMergeRequest: false, + featureFlagNodes: [], + branchNodes: [], + relatedMergeRequests: map(workItemDevelopmentMRNodes, 'mergeRequest'), }), - canUpdate: true, }); - const workItemWithNoDevItems = workItemResponseFactory({ - developmentWidgetPresent: true, + const devWidgetWithNoDevItems = workItemDevelopmentResponse({ developmentItems: workItemDevelopmentFragmentResponse({ mrNodes: [], willAutoCloseByMergeRequest: false, featureFlagNodes: [], branchNodes: [], + relatedMergeRequests: [], }), - canUpdate: true, }); - const workItemWithAllDevItems = workItemResponseFactory({ - developmentWidgetPresent: true, + const devWidgetWithWithAllDevItems = workItemDevelopmentResponse({ developmentItems: workItemDevelopmentFragmentResponse({ mrNodes: workItemDevelopmentMRNodes, willAutoCloseByMergeRequest: true, featureFlagNodes: workItemDevelopmentFeatureFlagNodes, branchNodes: workItemRelatedBranchNodes, + relatedMergeRequests: map(workItemDevelopmentMRNodes, 'mergeRequest'), }), }); - const successQueryHandler = jest.fn().mockResolvedValue({ - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: workItemWithFlagListOnly.data.workItem, - }, - }, - }); + const devWidgetsuccessQueryHandler = jest.fn().mockResolvedValue(devWidgetWithMRListOnly); - const successQueryHandlerWithOnlyMRList = jest.fn().mockResolvedValue({ - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: workItemWithMRListOnly.data.workItem, - }, - }, - }); + const devWidgetSuccessQueryHandlerWithOnlyMRList = jest + .fn() + .mockResolvedValue(devWidgetWithMRListOnly); - const successQueryHandlerWithOnlyBranchList = jest.fn().mockResolvedValue({ - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: workItemWithBranchListOnly.data.workItem, - }, - }, - }); + const devWidgetSuccessQueryHandlerWithFlagListOnly = jest + .fn() + .mockResolvedValue(devWidgetWithFlagListOnly); - const successQueryHandlerWithNoDevItem = jest.fn().mockResolvedValue({ - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: workItemWithNoDevItems.data.workItem, - }, - }, - }); + const devWidgetSuccessQueryHandlerWithOnlyRelatedMRList = jest + .fn() + .mockResolvedValue(devWidgetWithRelatedMRListOnly); - const successQueryHandlerWithAllDevItemsList = jest.fn().mockResolvedValue({ - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: workItemWithAllDevItems.data.workItem, - }, - }, - }); + const devWidgetSuccessQueryHandlerWithOnlyBranchList = jest + .fn() + .mockResolvedValue(devWidgetWithBranchListOnly); + + const devWidgetSuccessQueryHandlerWithNoDevItem = jest + .fn() + .mockResolvedValue(devWidgetWithNoDevItems); + + const devWidgetSuccessQueryHandlerWithAllDevItemsList = jest + .fn() + .mockResolvedValue(devWidgetWithWithAllDevItems); + + const workItemDevelopmentUpdatedSubscriptionHandler = jest + .fn() + .mockResolvedValue({ data: { workItemUpdated: null } }); const createComponent = ({ mountFn = mountExtended, @@ -140,10 +131,15 @@ describe('WorkItemDevelopment EE', () => { workItemIid = '1', workItemFullPath = 'full-path', workItemType = 'Issue', - workItemQueryHandler = successQueryHandler, + workItemQueryHandler = workItemSuccessQueryHandler, workItemsAlphaEnabled = true, + workItemDevelopmentQueryHandler = devWidgetsuccessQueryHandler, } = {}) => { - mockApollo = createMockApollo([[workItemByIidQuery, workItemQueryHandler]]); + mockApollo = createMockApollo([ + [workItemByIidQuery, workItemQueryHandler], + [workItemDevelopmentQuery, workItemDevelopmentQueryHandler], + [workItemDevelopmentUpdatedSubscription, workItemDevelopmentUpdatedSubscriptionHandler], + ]); wrapper = mountFn(WorkItemDevelopment, { apolloProvider: mockApollo, @@ -172,23 +168,9 @@ describe('WorkItemDevelopment EE', () => { const findCreateMRButton = () => wrapper.findByTestId('create-mr-button'); const findCreateBranchButton = () => wrapper.findByTestId('create-branch-button'); - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onGet('/full-path/-/issues/1/can_create_branch').reply(HTTP_STATUS_OK, { - can_create_branch: true, - suggested_branch_name: 'suggested_branch_name', - }); - return createComponent(); - }); - - afterEach(() => { - mock.restore(); - }); - describe('when the list of MRs is empty but there is a Feature Flag list', () => { it(`hides 'Create MR' and 'Create branch' buttons when flag enabled`, async () => { createComponent({ - workItemQueryHandler: successQueryHandler, workItemsAlphaEnabled: true, }); await waitForPromises(); @@ -199,7 +181,6 @@ describe('WorkItemDevelopment EE', () => { it(`hides 'Create MR' and 'Create branch' buttons when flag disabled`, async () => { createComponent({ - workItemQueryHandler: successQueryHandler, workItemsAlphaEnabled: false, }); await waitForPromises(); @@ -212,7 +193,7 @@ describe('WorkItemDevelopment EE', () => { describe('when the list of Feature Flag is empty but there is a MR list', () => { it(`hides 'Create MR' and 'Create branch' buttons when flag enabled`, async () => { createComponent({ - workItemQueryHandler: successQueryHandlerWithOnlyMRList, + workItemDevelopmentQueryHandler: devWidgetSuccessQueryHandlerWithOnlyMRList, workItemsAlphaEnabled: true, }); await waitForPromises(); @@ -223,7 +204,7 @@ describe('WorkItemDevelopment EE', () => { it(`hides 'Create MR' and 'Create branch' buttons when flag disabled`, async () => { createComponent({ - workItemQueryHandler: successQueryHandlerWithOnlyMRList, + workItemDevelopmentQueryHandler: devWidgetSuccessQueryHandlerWithOnlyMRList, workItemsAlphaEnabled: false, }); await waitForPromises(); @@ -236,7 +217,7 @@ describe('WorkItemDevelopment EE', () => { describe('when both the list of Feature flags and MRs are empty', () => { it(`hides 'Create MR' and 'Create branch' buttons when flag disabled`, async () => { createComponent({ - workItemQueryHandler: successQueryHandlerWithNoDevItem, + workItemDevelopmentQueryHandler: devWidgetSuccessQueryHandlerWithFlagListOnly, workItemsAlphaEnabled: false, }); await waitForPromises(); @@ -249,7 +230,7 @@ describe('WorkItemDevelopment EE', () => { describe('when both the list of Feature flags and MRs exist', () => { it(`hides 'Create MR' and 'Create branch' buttons when flag disabled`, async () => { createComponent({ - workItemQueryHandler: successQueryHandlerWithAllDevItemsList, + workItemDevelopmentQueryHandler: devWidgetSuccessQueryHandlerWithAllDevItemsList, workItemsAlphaEnabled: false, }); await waitForPromises(); @@ -259,17 +240,28 @@ describe('WorkItemDevelopment EE', () => { }); }); + it('should not show the widget when any of the dev item is not available', async () => { + createComponent({ + mountFn: shallowMountExtended, + workItemDevelopmentQueryHandler: devWidgetSuccessQueryHandlerWithNoDevItem, + }); + await waitForPromises(); + + expect(findRelationshipList().exists()).toBe(false); + }); + it.each` description | successQueryResolveHandler - ${'feature flags'} | ${successQueryHandler} - ${'MRs'} | ${successQueryHandlerWithOnlyMRList} - ${'branches'} | ${successQueryHandlerWithOnlyBranchList} + ${'feature flags'} | ${devWidgetSuccessQueryHandlerWithFlagListOnly} + ${'MRs'} | ${devWidgetSuccessQueryHandlerWithOnlyMRList} + ${'branches'} | ${devWidgetSuccessQueryHandlerWithOnlyBranchList} + ${'related MRs'} | ${devWidgetSuccessQueryHandlerWithOnlyRelatedMRList} `( 'should show the relationship list when there is only a list of $description', async ({ successQueryResolveHandler }) => { createComponent({ mountFn: shallowMountExtended, - workItemQueryHandler: successQueryResolveHandler, + workItemDevelopmentQueryHandler: successQueryResolveHandler, }); await waitForPromises(); diff --git a/spec/features/work_items/work_item_detail_spec.rb b/spec/features/work_items/work_item_detail_spec.rb index 557783e1292bd..bb7477ac55979 100644 --- a/spec/features/work_items/work_item_detail_spec.rb +++ b/spec/features/work_items/work_item_detail_spec.rb @@ -106,6 +106,7 @@ context 'for user not signed in' do before do visit work_items_path + wait_for_all_requests end it 'todos action is not displayed' do diff --git a/spec/frontend/work_items/components/work_item_development/work_item_development_relationship_list_spec.js b/spec/frontend/work_items/components/work_item_development/work_item_development_relationship_list_spec.js index b8fa90510c2d5..c4d5f8b9e1bb2 100644 --- a/spec/frontend/work_items/components/work_item_development/work_item_development_relationship_list_spec.js +++ b/spec/frontend/work_items/components/work_item_development/work_item_development_relationship_list_spec.js @@ -1,5 +1,9 @@ +import { map } from 'lodash'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { workItemDevelopmentFragmentResponse } from 'jest/work_items/mock_data'; +import { + workItemDevelopmentFragmentResponse, + workItemDevelopmentMRNodes, +} from 'jest/work_items/mock_data'; import WorkItemDevelopmentRelationshipList from '~/work_items/components/work_item_development/work_item_development_relationship_list.vue'; describe('WorkItemDevelopmentRelationshipList', () => { @@ -21,4 +25,20 @@ describe('WorkItemDevelopmentRelationshipList', () => { expect(findDevList().exists()).toBe(true); }); }); + + it('deduplicates the closingMRs and relatedMRs on frontend', () => { + createComponent({ + workItemDevWidget: workItemDevelopmentFragmentResponse({ + mrNodes: workItemDevelopmentMRNodes, + willAutoCloseByMergeRequest: false, + relatedMergeRequests: map(workItemDevelopmentMRNodes, 'mergeRequest'), + branchNodes: [], + featureFlagNodes: [], + }), + }); + + expect(Number(findDevList().attributes('data-list-length'))).toEqual( + workItemDevelopmentMRNodes.length, + ); + }); }); diff --git a/spec/frontend/work_items/components/work_item_development/work_item_development_spec.js b/spec/frontend/work_items/components/work_item_development/work_item_development_spec.js index 8247a088fc8de..42a8682cae885 100644 --- a/spec/frontend/work_items/components/work_item_development/work_item_development_spec.js +++ b/spec/frontend/work_items/components/work_item_development/work_item_development_spec.js @@ -9,13 +9,16 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective } from 'helpers/vue_mock_directive'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import workItemDevelopmentQuery from '~/work_items/graphql/work_item_development.query.graphql'; +import workItemDevelopmentUpdatedSubscription from '~/work_items/graphql/work_item_development.subscription.graphql'; import waitForPromises from 'helpers/wait_for_promises'; import { STATE_CLOSED, STATE_OPEN } from '~/work_items/constants'; import { - workItemResponseFactory, + workItemByIidResponseFactory, workItemDevelopmentFragmentResponse, workItemDevelopmentMRNodes, + workItemDevelopmentResponse, } from 'jest/work_items/mock_data'; import WorkItemDevelopment from '~/work_items/components/work_item_development/work_item_development.vue'; @@ -30,100 +33,50 @@ describe('WorkItemDevelopment CE', () => { let wrapper; let mockApollo; - const workItem = workItemResponseFactory({ developmentWidgetPresent: true, canUpdate: true }); - const workItemWithOneMR = workItemResponseFactory({ - developmentWidgetPresent: true, + const workItemSucessQueryHandler = ({ state = STATE_OPEN } = {}) => { + return jest.fn().mockResolvedValue(workItemByIidResponseFactory({ canUpdate: true, state })); + }; + + const devWidgetWithOneMR = workItemDevelopmentResponse({ developmentItems: workItemDevelopmentFragmentResponse({ mrNodes: [workItemDevelopmentMRNodes[0]], willAutoCloseByMergeRequest: true, featureFlagNodes: null, branchNodes: [], + relatedMergeRequests: [], }), }); - const workItemWithMRList = workItemResponseFactory({ - developmentWidgetPresent: true, + + const devWidgetWithMoreThanOneMR = workItemDevelopmentResponse({ developmentItems: workItemDevelopmentFragmentResponse({ mrNodes: workItemDevelopmentMRNodes, willAutoCloseByMergeRequest: true, featureFlagNodes: null, branchNodes: [], + relatedMergeRequests: [], }), }); - const projectWorkItemResponseWithMRList = { - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: workItem.data.workItem, - }, - }, - }; - - const closedWorkItemWithAutoCloseFlagEnabled = { - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: { - ...workItemWithMRList.data.workItem, - state: STATE_CLOSED, - }, - }, - }, - }; - - const openWorkItemWithAutoCloseFlagEnabledAndOneMR = { - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: workItemWithOneMR.data.workItem, - }, - }, - }; - - const openWorkItemWithAutoCloseFlagEnabledAndMRList = { - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: workItemWithMRList.data.workItem, - }, - }, - }; - - const successQueryHandler = jest.fn().mockResolvedValue(projectWorkItemResponseWithMRList); - - const workItemWithAutoCloseFlagEnabled = workItemResponseFactory({ - developmentWidgetPresent: true, + const devWidgetWithAutoCloseDisabled = workItemDevelopmentResponse({ developmentItems: workItemDevelopmentFragmentResponse({ mrNodes: workItemDevelopmentMRNodes, - willAutoCloseByMergeRequest: true, + willAutoCloseByMergeRequest: false, featureFlagNodes: null, branchNodes: [], + relatedMergeRequests: [], }), }); - const successQueryHandlerWorkItemWithAutoCloseFlagEnabled = jest.fn().mockResolvedValue({ - data: { - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItem: workItemWithAutoCloseFlagEnabled.data.workItem, - }, - }, - }); - - const successQueryHandlerWithOneMR = jest + const devWidgetSuccessHandlerWithAutoCloseDisabled = jest .fn() - .mockResolvedValue(openWorkItemWithAutoCloseFlagEnabledAndOneMR); - const successQueryHandlerWithMRList = jest + .mockResolvedValue(devWidgetWithAutoCloseDisabled); + const devWidgetSuccessQueryHandlerWithOneMR = jest.fn().mockResolvedValue(devWidgetWithOneMR); + const devWidgeSuccessQueryHandlerWithMRList = jest .fn() - .mockResolvedValue(openWorkItemWithAutoCloseFlagEnabledAndMRList); - const successQueryHandlerWithClosedWorkItem = jest + .mockResolvedValue(devWidgetWithMoreThanOneMR); + const workItemDevelopmentUpdatedSubscriptionHandler = jest .fn() - .mockResolvedValue(closedWorkItemWithAutoCloseFlagEnabled); + .mockResolvedValue({ data: { workItemUpdated: null } }); const createComponent = ({ mountFn = shallowMountExtended, @@ -131,10 +84,15 @@ describe('WorkItemDevelopment CE', () => { workItemIid = '1', workItemFullPath = 'full-path', workItemType = 'Issue', - workItemQueryHandler = successQueryHandler, + workItemQueryHandler = workItemSucessQueryHandler(), workItemsAlphaEnabled = true, + workItemDevelopmentQueryHandler = devWidgetSuccessQueryHandlerWithOneMR, } = {}) => { - mockApollo = createMockApollo([[workItemByIidQuery, workItemQueryHandler]]); + mockApollo = createMockApollo([ + [workItemByIidQuery, workItemQueryHandler], + [workItemDevelopmentQuery, workItemDevelopmentQueryHandler], + [workItemDevelopmentUpdatedSubscription, workItemDevelopmentUpdatedSubscriptionHandler], + ]); wrapper = mountFn(WorkItemDevelopment, { apolloProvider: mockApollo, @@ -171,6 +129,8 @@ describe('WorkItemDevelopment CE', () => { wrapper.findComponent(WorkItemCreateBranchMergeRequestModal); const findDropdownGroups = () => findCreateOptionsDropdown().findAllComponents(GlDisclosureDropdownGroup); + const findWorkItemCreateMergeRequestModal = () => + wrapper.findComponent(WorkItemCreateBranchMergeRequestModal); describe('Default', () => { it('should show the widget label', async () => { @@ -186,6 +146,19 @@ describe('WorkItemDevelopment CE', () => { expect(findAddButton().exists()).toBe(true); }); + + it('does not render the modal when the queries are still loading', () => { + createComponent({ workItemsAlphaEnabled: true, mountFn: mountExtended }); + + expect(findWorkItemCreateMergeRequestModal().exists()).toBe(false); + }); + + it('renders the modal when the queries are have loaded', async () => { + createComponent({ workItemsAlphaEnabled: true, mountFn: mountExtended }); + await waitForPromises(); + + expect(findWorkItemCreateMergeRequestModal().exists()).toBe(true); + }); }); describe('when the response is successful', () => { @@ -201,7 +174,9 @@ describe('WorkItemDevelopment CE', () => { }); it('when auto close flag is disabled, should not show the "i" indicator', async () => { - createComponent(); + createComponent({ + workItemDevelopmentQueryHandler: devWidgetSuccessHandlerWithAutoCloseDisabled, + }); await waitForPromises(); expect(findMoreInformation().exists()).toBe(false); @@ -209,7 +184,7 @@ describe('WorkItemDevelopment CE', () => { it('when auto close flag is enabled, should show the "i" indicator', async () => { createComponent({ - workItemQueryHandler: successQueryHandlerWorkItemWithAutoCloseFlagEnabled, + workItemDevelopmentQueryHandler: devWidgetSuccessQueryHandlerWithOneMR, }); await waitForPromises(); @@ -218,15 +193,16 @@ describe('WorkItemDevelopment CE', () => { }); it.each` - queryHandler | message | workItemState | linkedMRsNumber - ${successQueryHandlerWithOneMR} | ${'This task will be closed when the following is merged.'} | ${STATE_OPEN} | ${1} - ${successQueryHandlerWithMRList} | ${'This task will be closed when any of the following is merged.'} | ${STATE_OPEN} | ${workItemDevelopmentMRNodes.length} - ${successQueryHandlerWithClosedWorkItem} | ${'The task was closed automatically when a branch was merged.'} | ${STATE_CLOSED} | ${workItemDevelopmentMRNodes.length} + developmentWidgetQueryHandler | message | workItemState | linkedMRsNumber + ${devWidgetSuccessQueryHandlerWithOneMR} | ${'This task will be closed when the following is merged.'} | ${STATE_OPEN} | ${1} + ${devWidgeSuccessQueryHandlerWithMRList} | ${'This task will be closed when any of the following is merged.'} | ${STATE_OPEN} | ${workItemDevelopmentMRNodes.length} + ${devWidgeSuccessQueryHandlerWithMRList} | ${'The task was closed automatically when a branch was merged.'} | ${STATE_CLOSED} | ${workItemDevelopmentMRNodes.length} `( 'when the workItemState is `$workItemState` and number of linked MRs is `$linkedMRsNumber` shows message `$message`', - async ({ queryHandler, message }) => { + async ({ developmentWidgetQueryHandler, message, workItemState }) => { createComponent({ - workItemQueryHandler: queryHandler, + workItemQueryHandler: workItemSucessQueryHandler({ state: workItemState }), + workItemDevelopmentQueryHandler: developmentWidgetQueryHandler, }); await waitForPromises(); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 8ebe0ef301ef1..b811bba467923 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1,3 +1,4 @@ +import { map } from 'lodash'; import { EMOJI_THUMBS_UP, EMOJI_THUMBS_DOWN } from '~/emoji/constants'; import { WIDGET_TYPE_LINKED_ITEMS, NEW_WORK_ITEM_IID, STATE_CLOSED } from '~/work_items/constants'; @@ -938,6 +939,7 @@ export const workItemBlockedByLinkedItemsResponse = { export const workItemDevelopmentMRNodes = [ { + id: 'gid://gitlab/MergeRequestsClosingIssues/61', fromMrDescription: true, mergeRequest: { iid: '13', @@ -977,6 +979,7 @@ export const workItemDevelopmentMRNodes = [ __typename: 'WorkItemClosingMergeRequest', }, { + id: 'gid://gitlab/MergeRequestsClosingIssues/62', fromMrDescription: true, mergeRequest: { iid: '15', @@ -1006,6 +1009,7 @@ export const workItemDevelopmentMRNodes = [ __typename: 'WorkItemClosingMergeRequest', }, { + id: 'gid://gitlab/MergeRequestsClosingIssues/63', fromMrDescription: true, mergeRequest: { iid: '14', @@ -1035,6 +1039,7 @@ export const workItemDevelopmentMRNodes = [ __typename: 'WorkItemClosingMergeRequest', }, { + id: 'gid://gitlab/MergeRequestsClosingIssues/64', fromMrDescription: true, mergeRequest: { iid: '12', @@ -1074,6 +1079,7 @@ export const workItemDevelopmentMRNodes = [ __typename: 'WorkItemClosingMergeRequest', }, { + id: 'gid://gitlab/MergeRequestsClosingIssues/65', fromMrDescription: true, mergeRequest: { iid: '11', @@ -1186,10 +1192,15 @@ export const workItemDevelopmentFragmentResponse = ({ willAutoCloseByMergeRequest = false, featureFlagNodes = workItemDevelopmentFeatureFlagNodes, branchNodes = workItemRelatedBranchNodes, + relatedMergeRequests = map(workItemDevelopmentMRNodes, 'mergeRequest'), } = {}) => { return { type: 'DEVELOPMENT', willAutoCloseByMergeRequest, + relatedMergeRequests: { + nodes: relatedMergeRequests, + __typename: 'MergeRequestConnection', + }, featureFlags: { nodes: featureFlagNodes, __typename: 'FeatureFlagConnection', @@ -1206,6 +1217,53 @@ export const workItemDevelopmentFragmentResponse = ({ }; }; +export const workItemDevelopmentResponse = ({ + iid = '1', + id = 'gid://gitlab/WorkItem/1', + developmentItems, +} = {}) => ({ + data: { + workItem: { + __typename: 'WorkItem', + id, + iid, + namespace: { + __typename: 'Project', + id: '1', + }, + widgets: [ + { + __typename: 'WorkItemWidgetIteration', + }, + { + __typename: 'WorkItemWidgetWeight', + }, + { + __typename: 'WorkItemWidgetAssignees', + }, + { + __typename: 'WorkItemWidgetLabels', + }, + { + __typename: 'WorkItemWidgetDescription', + }, + { + __typename: 'WorkItemWidgetHierarchy', + }, + { + __typename: 'WorkItemWidgetStartAndDueDate', + }, + { + __typename: 'WorkItemWidgetMilestone', + }, + { + ...developmentItems, + }, + ], + }, + }, +}); + export const workItemResponseFactory = ({ iid = '1', id = 'gid://gitlab/WorkItem/1', @@ -1231,7 +1289,6 @@ export const workItemResponseFactory = ({ healthStatusWidgetPresent = true, notesWidgetPresent = true, designWidgetPresent = true, - developmentWidgetPresent = true, confidential = false, discussionLocked = false, canInviteMembers = false, @@ -1256,7 +1313,6 @@ export const workItemResponseFactory = ({ awardEmoji = mockAwardsWidget, state = 'OPEN', linkedItems = mockEmptyLinkedItems, - developmentItems = workItemDevelopmentFragmentResponse(), color = '#1068bf', editableWeightWidget = true, hasParent = false, @@ -1570,13 +1626,6 @@ export const workItemResponseFactory = ({ type: 'DESIGNS', } : { type: 'MOCK TYPE' }, - developmentWidgetPresent - ? { - ...developmentItems, - } - : { - type: 'MOCK TYPE', - }, crmContactsWidgetPresent ? { __typename: 'WorkItemWidgetCrmContacts', diff --git a/spec/support/shared_examples/features/work_items/work_items_shared_examples.rb b/spec/support/shared_examples/features/work_items/work_items_shared_examples.rb index d1095cf26f95a..7fbdb52919ee8 100644 --- a/spec/support/shared_examples/features/work_items/work_items_shared_examples.rb +++ b/spec/support/shared_examples/features/work_items/work_items_shared_examples.rb @@ -170,6 +170,7 @@ def click_reply_and_enter_slash click_button 'assign yourself' expect(page).to have_link(user.name) + wait_for_requests using_session :other_session do expect(page).to have_link(user.name) end @@ -216,6 +217,7 @@ def click_reply_and_enter_slash end expect(page).to have_css '.gl-label', text: label2.title + wait_for_requests using_session :other_session do expect(page).to have_css '.gl-label', text: label2.title end -- GitLab