diff --git a/app/assets/javascripts/work_items/components/work_item_drawer.vue b/app/assets/javascripts/work_items/components/work_item_drawer.vue index 995395a469ab9e3c1b44b7e55ecaf148ce7c090f..36c0d72c4edd874f7a8f181fb74f8dc4d9715a03 100644 --- a/app/assets/javascripts/work_items/components/work_item_drawer.vue +++ b/app/assets/javascripts/work_items/components/work_item_drawer.vue @@ -169,6 +169,8 @@ export default { '.pika-single', '.atwho-container', '.tippy-content .gl-new-dropdown-panel', + '#blocked-by-issues-modal', + '#open-children-warning-modal', ], }; </script> diff --git a/app/assets/javascripts/work_items/components/work_item_state_toggle.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue index 4a5afb230dad9810c9127a3073d47fa93188b06a..3b3841ba3faea51d492dceb92a5726cf97e1c82f 100644 --- a/app/assets/javascripts/work_items/components/work_item_state_toggle.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue @@ -13,10 +13,11 @@ import { LINKED_CATEGORIES_MAP, i18n, } from '../constants'; -import { findLinkedItemsWidget } from '../utils'; +import { findHierarchyWidgets, findLinkedItemsWidget } from '../utils'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import workItemLinkedItemsQuery from '../graphql/work_item_linked_items.query.graphql'; +import workItemOpenChildCountQuery from '../graphql/open_child_count.query.graphql'; export default { components: { @@ -63,6 +64,7 @@ export default { return { updateInProgress: false, blockerItems: [], + openChildItemsCount: 0, }; }, apollo: { @@ -101,7 +103,7 @@ export default { update({ workspace }) { if (!workspace?.workItem) return []; - const linkedWorkItems = findLinkedItemsWidget(workspace.workItem).linkedItems?.nodes || []; + const linkedWorkItems = findLinkedItemsWidget(workspace.workItem)?.linkedItems?.nodes || []; return linkedWorkItems.filter((item) => { return item.linkType === LINKED_CATEGORIES_MAP.IS_BLOCKED_BY; @@ -113,6 +115,32 @@ export default { Sentry.captureException(new Error(msg)); }, }, + openChildItemsCount: { + query: workItemOpenChildCountQuery, + variables() { + return { + fullPath: this.fullPath, + iid: this.workItemIid, + }; + }, + skip() { + return !this.workItemIid; + }, + update({ namespace }) { + if (!namespace?.workItem) return 0; + + /** @type {Array<{countsByState: { opened : number }}> } */ + const countsByType = findHierarchyWidgets(namespace.workItem.widgets)?.rolledUpCountsByType; + + if (!countsByType) { + return 0; + } + + const total = countsByType.reduce((acc, curr) => acc + curr.countsByState.opened, 0); + + return total; + }, + }, }, computed: { isWorkItemOpen() { @@ -146,24 +174,46 @@ export default { isBlocked() { return this.blockerItems.length > 0; }, + hasOpenChildren() { + return this.openChildItemsCount > 0; + }, action() { - if (this.isBlocked && this.isWorkItemOpen) { - return () => this.$refs.blockedByIssuesModal.show(); + if (this.isWorkItemOpen) { + if (this.isBlocked) { + return () => this.$refs.blockedByIssuesModal.show(); + } + if (this.hasOpenChildren) { + return () => this.$refs.openChildrenWarningModal.show(); + } } return this.updateWorkItem; }, - modalTitle() { + blockedByModalTitle() { return sprintfWorkItem( s__('WorkItem|Are you sure you want to close this blocked %{workItemType}?'), this.workItemType, ); }, - modalBody() { + blockedByModalBody() { return sprintfWorkItem( s__('WorkItem|This %{workItemType} is currently blocked by the following items:'), this.workItemType, ); }, + openChildrenModalTitle() { + return sprintfWorkItem( + s__('WorkItem|Are you sure you want to close this %{workItemType}?'), + this.workItemType, + ); + }, + openChildrenModalBody() { + return sprintfWorkItem( + s__( + 'WorkItem|This %{workItemType} has open child items. If you close this %{workItemType}, they will remain open.', + ), + this.workItemType, + ); + }, modalActionCancel() { return { text: __('Cancel'), @@ -235,17 +285,30 @@ export default { <gl-modal ref="blockedByIssuesModal" modal-id="blocked-by-issues-modal" + data-testid="blocked-by-issues-modal" :action-cancel="modalActionCancel" :action-primary="modalActionPrimary" - :title="modalTitle" + :title="blockedByModalTitle" @primary="updateWorkItem" > - <p>{{ modalBody }}</p> + <p>{{ blockedByModalBody }}</p> <ul> <li v-for="issue in blockerItems" :key="issue.workItem.iid"> <gl-link :href="issue.workItem.webUrl">#{{ issue.workItem.iid }}</gl-link> </li> </ul> </gl-modal> + + <gl-modal + ref="openChildrenWarningModal" + modal-id="open-children-warning-modal" + data-testid="open-children-warning-modal" + :action-cancel="modalActionCancel" + :action-primary="modalActionPrimary" + :title="openChildrenModalTitle" + @primary="updateWorkItem" + > + <p>{{ openChildrenModalBody }}</p> + </gl-modal> </span> </template> diff --git a/app/assets/javascripts/work_items/graphql/open_child_count.query.graphql b/app/assets/javascripts/work_items/graphql/open_child_count.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2cd6a335938e0d716d39f8462d472bf9913d371a --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/open_child_count.query.graphql @@ -0,0 +1,25 @@ +query openChildItemCount($fullPath: ID!, $iid: String!) { + namespace(fullPath: $fullPath) { + id + workItem(iid: $iid) { + id + widgets { + ... on WorkItemWidgetHierarchy { + type + rolledUpCountsByType { + countsByState { + opened + all + closed + } + workItemType { + id + name + iconName + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_hierarchy.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_hierarchy.fragment.graphql index 6a3fbc217d050490a06f331bfb14f81e3a2cf7ee..7c8538a3ea0c4106387d60ab72f985f9db3ff8c8 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_hierarchy.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_hierarchy.fragment.graphql @@ -32,6 +32,7 @@ fragment WorkItemHierarchy on WorkItem { } rolledUpCountsByType { countsByState { + opened all closed } 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 fc94c898cf34491bc8bf795024dd3624b74fac71..8e6b90895e3c31f61e5e7c851a169cf8a53fb136 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 @@ -52,6 +52,7 @@ fragment WorkItemWidgets on WorkItemWidget { hasParent rolledUpCountsByType { countsByState { + opened all closed } 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 cfcc20db9c4033b4736096f00349e20020b05202..3415b5b6f2558c7dcea113e79875377a5ffd1640 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 @@ -68,6 +68,7 @@ fragment WorkItemWidgets on WorkItemWidget { hasParent rolledUpCountsByType { countsByState { + opened all closed } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e749fd007c6df2c39992d22709eb99924e9c909c..c04859bdae37897f79db986839fad8e32c1faa0b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -62193,6 +62193,9 @@ msgstr "" msgid "WorkItem|Are you sure you want to cancel editing?" msgstr "" +msgid "WorkItem|Are you sure you want to close this %{workItemType}?" +msgstr "" + msgid "WorkItem|Are you sure you want to close this blocked %{workItemType}?" msgstr "" @@ -62574,6 +62577,9 @@ msgstr "" msgid "WorkItem|The current task" msgstr "" +msgid "WorkItem|This %{workItemType} has open child items. If you close this %{workItemType}, they will remain open." +msgstr "" + msgid "WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access" msgstr "" diff --git a/spec/frontend/work_items/components/work_item_state_toggle_spec.js b/spec/frontend/work_items/components/work_item_state_toggle_spec.js index 72130ddd061bdfc100d331970dc60ebac8cf9810..b56360a0ac2f11e0268bfeaadb9384bc9477bcd5 100644 --- a/spec/frontend/work_items/components/work_item_state_toggle_spec.js +++ b/spec/frontend/work_items/components/work_item_state_toggle_spec.js @@ -1,10 +1,10 @@ -import { GlButton, GlModal, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import WorkItemStateToggle from '~/work_items/components/work_item_state_toggle.vue'; import { STATE_OPEN, @@ -16,11 +16,15 @@ import { import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import workItemLinkedItemsQuery from '~/work_items/graphql/work_item_linked_items.query.graphql'; +import workItemOpenChildCountQuery from '~/work_items/graphql/open_child_count.query.graphql'; import { updateWorkItemMutationResponse, mockBlockedByLinkedItem, workItemByIidResponseFactory, workItemBlockedByLinkedItemsResponse, + workItemNoBlockedByLinkedItemsResponse, + mockOpenChildrenCount, + mockNoOpenChildrenCount, } from '../mock_data'; describe('Work Item State toggle button component', () => { @@ -34,27 +38,32 @@ describe('Work Item State toggle button component', () => { const querySuccessHander = jest.fn().mockResolvedValue(workItemQueryResponse); const workItemBlockedByItemsSuccessHandler = jest .fn() - .mockResolvedValue(workItemBlockedByLinkedItemsResponse); + .mockResolvedValue(workItemNoBlockedByLinkedItemsResponse); + const openChildCountSuccessHandler = jest.fn().mockResolvedValue(mockNoOpenChildrenCount); const findStateToggleButton = () => wrapper.findComponent(GlButton); - const findModal = () => wrapper.findComponent(GlModal); - const findModalLinkAt = (index) => findModal().findAllComponents(GlLink).at(index); + const findBlockedByModal = () => wrapper.findByTestId('blocked-by-issues-modal'); + const findBlockedByModalLinkAt = (index) => + findBlockedByModal().findAllComponents(GlLink).at(index); + const findOpenChildrenModal = () => wrapper.findByTestId('open-children-warning-modal'); const { id, iid } = workItemQueryResponse.data.workspace.workItem; const createComponent = ({ mutationHandler = mutationSuccessHandler, workItemLinkedItemsHandler = workItemBlockedByItemsSuccessHandler, + workItemOpenChildCountHandler = openChildCountSuccessHandler, canUpdate = true, workItemState = STATE_OPEN, workItemType = 'Task', hasComment = false, } = {}) => { - wrapper = shallowMount(WorkItemStateToggle, { + wrapper = shallowMountExtended(WorkItemStateToggle, { apolloProvider: createMockApollo([ [updateWorkItemMutation, mutationHandler], [workItemByIidQuery, querySuccessHander], [workItemLinkedItemsQuery, workItemLinkedItemsHandler], + [workItemOpenChildCountQuery, workItemOpenChildCountHandler], ]), propsData: { workItemId: id, @@ -173,18 +182,22 @@ describe('Work Item State toggle button component', () => { const blockers = mockBlockedByLinkedItem.linkedItems.nodes; beforeEach(async () => { - createComponent(); + createComponent({ + workItemLinkedItemsHandler: jest + .fn() + .mockResolvedValue(workItemBlockedByLinkedItemsResponse), + }); await waitForPromises(); }); it('has title text', () => { - expect(findModal().attributes('title')).toBe( + expect(findBlockedByModal().attributes('title')).toBe( 'Are you sure you want to close this blocked task?', ); }); it('has body text', () => { - expect(findModal().text()).toContain( + expect(findBlockedByModal().text()).toContain( 'This task is currently blocked by the following items:', ); }); @@ -195,12 +208,36 @@ describe('Work Item State toggle button component', () => { ${'second'} | ${1} `('$ordinal blocked-by issue link', ({ index }) => { it('has link text', () => { - expect(findModalLinkAt(index).text()).toBe(`#${blockers[index].workItem.iid}`); + expect(findBlockedByModalLinkAt(index).text()).toBe(`#${blockers[index].workItem.iid}`); }); it('has url', () => { - expect(findModalLinkAt(index).attributes('href')).toBe(blockers[index].workItem.webUrl); + expect(findBlockedByModalLinkAt(index).attributes('href')).toBe( + blockers[index].workItem.webUrl, + ); + }); + }); + }); + + describe('with open child items', () => { + beforeEach(async () => { + createComponent({ + workItemOpenChildCountHandler: jest.fn().mockResolvedValue(mockOpenChildrenCount), + workItemType: 'Epic', }); + await waitForPromises(); + }); + + it('has title text', () => { + expect(findOpenChildrenModal().attributes('title')).toBe( + 'Are you sure you want to close this epic?', + ); + }); + + it('has body text', () => { + expect(findOpenChildrenModal().text()).toContain( + 'This epic has open child items. If you close this epic, they will remain open.', + ); }); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index a43e90ade340c32934237adb47ccc0caf0cb76be..e8eb4e5b821bd19fcb7f09ae17976c151b3f97c1 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -699,6 +699,13 @@ export const mockBlockedByLinkedItem = { __typename: 'WorkItemWidgetLinkedItems', }; +export const mockNoLinkedItems = { + type: WIDGET_TYPE_LINKED_ITEMS, + linkedItems: { + nodes: [], + }, +}; + export const mockLinkedItems = { type: WIDGET_TYPE_LINKED_ITEMS, linkedItems: { @@ -886,6 +893,20 @@ export const workItemSingleLinkedItemResponse = { }, }; +export const workItemNoBlockedByLinkedItemsResponse = { + data: { + workspace: { + __typename: 'Namespace', + id: 'gid://gitlab/Group/1', + workItem: { + id: 'gid://gitlab/WorkItem/2', + widgets: [mockNoLinkedItems], + __typename: 'WorkItem', + }, + }, + }, +}; + export const workItemBlockedByLinkedItemsResponse = { data: { workspace: { @@ -2295,6 +2316,7 @@ export const mockDepthLimitReachedByType = [ export const mockRolledUpCountsByType = [ { countsByState: { + opened: 0, all: 3, closed: 0, __typename: 'WorkItemStateCountsType', @@ -2309,6 +2331,7 @@ export const mockRolledUpCountsByType = [ }, { countsByState: { + opened: 0, all: 5, closed: 2, __typename: 'WorkItemStateCountsType', @@ -2323,6 +2346,7 @@ export const mockRolledUpCountsByType = [ }, { countsByState: { + opened: 0, all: 2, closed: 1, __typename: 'WorkItemStateCountsType', @@ -2358,6 +2382,138 @@ export const mockHierarchyWidget = { __typename: 'WorkItemWidgetHierarchy', }; +export const mockOpenChildrenCount = { + data: { + namespace: { + id: 'gid://gitlab/Group/33', + workItem: { + id: 'gid://gitlab/WorkItem/843', + widgets: [ + { + type: 'HIERARCHY', + rolledUpCountsByType: [ + { + countsByState: { + opened: 0, + all: 0, + closed: 0, + __typename: 'WorkItemStateCountsType', + }, + workItemType: { + id: 'gid://gitlab/WorkItems::Type/8', + name: 'Epic', + iconName: 'issue-type-epic', + __typename: 'WorkItemType', + }, + __typename: 'WorkItemTypeCountsByState', + }, + { + countsByState: { + opened: 1, + all: 1, + closed: 0, + __typename: 'WorkItemStateCountsType', + }, + workItemType: { + id: 'gid://gitlab/WorkItems::Type/1', + name: 'Issue', + iconName: 'issue-type-issue', + __typename: 'WorkItemType', + }, + __typename: 'WorkItemTypeCountsByState', + }, + { + countsByState: { + opened: 0, + all: 0, + closed: 0, + __typename: 'WorkItemStateCountsType', + }, + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', + __typename: 'WorkItemType', + }, + __typename: 'WorkItemTypeCountsByState', + }, + ], + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', + }, + __typename: 'Namespace', + }, + }, +}; + +export const mockNoOpenChildrenCount = { + data: { + namespace: { + id: 'gid://gitlab/Group/33', + workItem: { + id: 'gid://gitlab/WorkItem/843', + widgets: [ + { + type: 'HIERARCHY', + rolledUpCountsByType: [ + { + countsByState: { + opened: 0, + all: 0, + closed: 0, + __typename: 'WorkItemStateCountsType', + }, + workItemType: { + id: 'gid://gitlab/WorkItems::Type/8', + name: 'Epic', + iconName: 'issue-type-epic', + __typename: 'WorkItemType', + }, + __typename: 'WorkItemTypeCountsByState', + }, + { + countsByState: { + opened: 0, + all: 0, + closed: 0, + __typename: 'WorkItemStateCountsType', + }, + workItemType: { + id: 'gid://gitlab/WorkItems::Type/1', + name: 'Issue', + iconName: 'issue-type-issue', + __typename: 'WorkItemType', + }, + __typename: 'WorkItemTypeCountsByState', + }, + { + countsByState: { + opened: 0, + all: 0, + closed: 0, + __typename: 'WorkItemStateCountsType', + }, + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', + __typename: 'WorkItemType', + }, + __typename: 'WorkItemTypeCountsByState', + }, + ], + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', + }, + __typename: 'Namespace', + }, + }, +}; + export const workItemHierarchyTreeResponse = { data: { workItem: { diff --git a/spec/support/shared_examples/features/work_items_shared_examples.rb b/spec/support/shared_examples/features/work_items_shared_examples.rb index d8e53e28135d72d1ffa178b1cec08e9ada7ac0a7..8712eefe2c72a16e60860542de874d375d53dd23 100644 --- a/spec/support/shared_examples/features/work_items_shared_examples.rb +++ b/spec/support/shared_examples/features/work_items_shared_examples.rb @@ -422,6 +422,8 @@ def notification_button click_button(class: 'gl-toggle') + wait_for_requests + expect(page).to have_button(class: 'gl-toggle is-checked') end end