diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index fec1bcf6ba1299bd3e2d64f634312240907eda4e..e93c5fbb8db525365baecf2fc22fc07d879b6482 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -92,7 +92,9 @@ export default { computed: { issuesDrawerEnabled() { - return Boolean(this.glFeatures.issuesListDrawer); + return Boolean( + this.isIssueBoard ? this.glFeatures.issuesListDrawer : this.glFeatures.epicsListDrawer, + ); }, listQueryVariables() { return { diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index f89cb85339bba5227e27847ad6dd4f1c7acff181..e0bd73983dc87bc4f4ce36ce43a8f3ba33944f2a 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -181,6 +181,9 @@ export default { hasActions() { return !this.disabled && this.list.listType !== ListType.closed; }, + workItemDrawerEnabled() { + return this.isEpicBoard ? this.glFeatures.epicsListDrawer : this.glFeatures.issuesListDrawer; + }, }, methods: { setError, @@ -263,8 +266,8 @@ export default { :title="item.title" :class="{ '!gl-text-gray-400': isLoading, - 'js-no-trigger': !glFeatures.issuesListDrawer, - 'js-no-trigger-title': glFeatures.issuesListDrawer, + 'js-no-trigger': !workItemDrawerEnabled, + 'js-no-trigger-title': workItemDrawerEnabled, }" class="gl-text-primary hover:gl-text-gray-900" data-testid="board-card-title-link" 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..8786d48ee6a0a5c135a9878005b66a5b501fa7e8 100644 --- a/app/assets/javascripts/work_items/components/work_item_drawer.vue +++ b/app/assets/javascripts/work_items/components/work_item_drawer.vue @@ -108,7 +108,7 @@ export default { const regex = new RegExp(`groups\/${escapedFullPath}\/-\/(work_items|epics)\/\\d+`); const isWorkItemPath = regex.test(workItem.webUrl); - if (isWorkItemPath || this.issueAsWorkItem) { + if (this.$router && (isWorkItemPath || this.issueAsWorkItem)) { this.$router.push({ name: 'workItem', params: { diff --git a/app/assets/javascripts/work_items/pages/work_items_list_app.vue b/app/assets/javascripts/work_items/pages/work_items_list_app.vue index e9f219e221d8a1f92b13403d12caae2868127cd6..a35606b21d9f266d93c1f3743e96a030dba0e60a 100644 --- a/app/assets/javascripts/work_items/pages/work_items_list_app.vue +++ b/app/assets/javascripts/work_items/pages/work_items_list_app.vue @@ -159,10 +159,7 @@ export default { if (data?.[this.namespace]) { if (this.isGroup) { - const rootBreadcrumbName = - this.workItemType === WORK_ITEM_TYPE_ENUM_EPIC - ? __('Epics') - : s__('WorkItem|Work items'); + const rootBreadcrumbName = this.isEpicsList ? __('Epics') : s__('WorkItem|Work items'); document.title = `${rootBreadcrumbName} · ${data.group.name} · GitLab`; } else { document.title = `Issues · ${data.project.name} · GitLab`; @@ -217,7 +214,10 @@ export default { }); }, workItemDrawerEnabled() { - return this.glFeatures?.issuesListDrawer; + return this.isEpicsList ? this.glFeatures.epicsListDrawer : this.glFeatures.issuesListDrawer; + }, + isEpicsList() { + return this.workItemType === WORK_ITEM_TYPE_ENUM_EPIC; }, hasSearch() { return Boolean(this.searchQuery); @@ -239,7 +239,7 @@ export default { search: this.searchQuery, ...this.apiFilterParams, ...this.pageParams, - excludeProjects: this.workItemType === WORK_ITEM_TYPE_ENUM_EPIC, + excludeProjects: this.isEpicsList, includeDescendants: !this.apiFilterParams.fullPath, types: this.apiFilterParams.types || this.workItemType || this.defaultWorkItemTypes, isGroup: this.isGroup, @@ -408,7 +408,11 @@ export default { }; }, activeWorkItemType() { - return this.workItemType || this.activeItem?.workItemType; + const activeWorkItemTypeName = + typeof this.activeItem?.workItemType === 'object' + ? this.activeItem?.workItemType?.name + : this.activeItem?.workItemType; + return this.workItemType || activeWorkItemTypeName; }, }, watch: { diff --git a/doc/user/group/epics/epic_boards.md b/doc/user/group/epics/epic_boards.md index 26c5f3fd3300c0ccb8e1c4b16435790707bf8670..680e2ed83f53e0c65f33a370420d7a9cf8886c0a 100644 --- a/doc/user/group/epics/epic_boards.md +++ b/doc/user/group/epics/epic_boards.md @@ -127,7 +127,7 @@ To create an epic from a list in epic board: ### Edit an epic - + If your administrator enabled the [epic drawer](manage_epics.md#open-epics-in-a-drawer), when you select an epic card from the epic board, the epic opens in a drawer. diff --git a/doc/user/group/epics/manage_epics.md b/doc/user/group/epics/manage_epics.md index 969b50eb23d6867e92aeb9201e35bafa734b5276..7d0e76b0607566498a1b09aac3ff7ba370e092dd 100644 --- a/doc/user/group/epics/manage_epics.md +++ b/doc/user/group/epics/manage_epics.md @@ -130,6 +130,7 @@ DETAILS: **Offering:** Self-managed > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/464063) in GitLab 17.4 [with a flag](../../../administration/feature_flags.md) named `issues_list_drawer`. Disabled by default. +> - Feature flag name was [changed] to `epics_list_drawer` in GitLab 17.6. FLAG: The availability of this feature is controlled by a feature flag. diff --git a/ee/app/controllers/groups/epic_boards_controller.rb b/ee/app/controllers/groups/epic_boards_controller.rb index 3fed72882e929f4445b8c66d2945e3685c099e6b..e1d46e6943a2a6c871779bf94e9111ea8aee700d 100644 --- a/ee/app/controllers/groups/epic_boards_controller.rb +++ b/ee/app/controllers/groups/epic_boards_controller.rb @@ -8,7 +8,7 @@ class Groups::EpicBoardsController < Groups::ApplicationController before_action do push_force_frontend_feature_flag(:work_item_epics, group.work_item_epics_enabled?) - push_frontend_feature_flag(:issues_list_drawer, group) + push_frontend_feature_flag(:epics_list_drawer, group) end track_event :index, :show, name: 'g_project_management_users_viewing_epic_boards' diff --git a/ee/app/controllers/groups/epics_controller.rb b/ee/app/controllers/groups/epics_controller.rb index afcaf292b99e2d548c600f99a8704a7bb6c8d4f0..96ae4b4cb747bdf393e37be6ff8f2399feddfe7d 100644 --- a/ee/app/controllers/groups/epics_controller.rb +++ b/ee/app/controllers/groups/epics_controller.rb @@ -25,7 +25,7 @@ class Groups::EpicsController < Groups::ApplicationController push_force_frontend_feature_flag(:glql_integration, @group&.glql_integration_feature_flag_enabled?) push_frontend_feature_flag(:work_item_epics_list, @group) push_force_frontend_feature_flag(:work_items_alpha, group.work_items_alpha_feature_flag_enabled?) - push_frontend_feature_flag(:issues_list_drawer, @group) + push_frontend_feature_flag(:epics_list_drawer, @group) push_frontend_feature_flag(:bulk_update_work_items_mutation, @group) end diff --git a/ee/config/feature_flags/wip/epics_list_drawer.yml b/ee/config/feature_flags/wip/epics_list_drawer.yml new file mode 100644 index 0000000000000000000000000000000000000000..c3ec86d6540aea5a709ff28058d191916845f00d --- /dev/null +++ b/ee/config/feature_flags/wip/epics_list_drawer.yml @@ -0,0 +1,9 @@ +--- +name: epics_list_drawer +feature_issue_url: +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/170066 +rollout_issue_url: +milestone: '17.6' +group: group::product planning +type: wip +default_enabled: false diff --git a/ee/spec/features/epic_boards/epic_boards_sidebar_spec.rb b/ee/spec/features/epic_boards/epic_boards_sidebar_spec.rb index ff9051d4213ca46c30de734f5ed17a601ac5dbde..e002873f0703befe12b51b4c6d43e5c9257e1e0e 100644 --- a/ee/spec/features/epic_boards/epic_boards_sidebar_spec.rb +++ b/ee/spec/features/epic_boards/epic_boards_sidebar_spec.rb @@ -24,7 +24,7 @@ context 'when work item drawer is disabled' do before do stub_feature_flags(work_item_epics: false) - stub_feature_flags(issues_list_drawer: false) + stub_feature_flags(epics_list_drawer: false) sign_in(user) visit group_epic_boards_path(group) diff --git a/ee/spec/frontend/boards/components/board_app_spec.js b/ee/spec/frontend/boards/components/board_app_spec.js index 3e8c2c7af1d69648717c6c8d0900cfdfe403e3f9..97b78d11a762e36807359df1eabf193155502c0e 100644 --- a/ee/spec/frontend/boards/components/board_app_spec.js +++ b/ee/spec/frontend/boards/components/board_app_spec.js @@ -5,12 +5,15 @@ import VueApollo from 'vue-apollo'; import { issueBoardListsQueryResponse } from 'jest/boards/mock_data'; import createMockApollo from 'helpers/mock_apollo_helper'; import BoardApp from '~/boards/components/board_app.vue'; +import BoardContent from '~/boards/components/board_content.vue'; import epicBoardListsQuery from 'ee_component/boards/graphql/epic_board_lists.query.graphql'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; import { rawIssue, epicBoardListsQueryResponse } from '../mock_data'; describe('BoardApp', () => { + let wrapper; + const boardListQueryHandler = jest.fn().mockResolvedValue(issueBoardListsQueryResponse); const epicBoardListQueryHandler = jest.fn().mockResolvedValue(epicBoardListsQueryResponse); const mockApollo = createMockApollo([ @@ -18,6 +21,8 @@ describe('BoardApp', () => { [epicBoardListsQuery, epicBoardListQueryHandler], ]); + const findBoardContent = () => wrapper.findComponent(BoardContent); + Vue.use(VueApollo); const createComponent = ({ issue = rawIssue, provide = {} } = {}) => { @@ -28,7 +33,7 @@ describe('BoardApp', () => { }, }); - shallowMount(BoardApp, { + wrapper = shallowMount(BoardApp, { apolloProvider: mockApollo, provide: { fullPath: 'gitlab-org', @@ -58,4 +63,38 @@ describe('BoardApp', () => { expect(notCalledHandler).not.toHaveBeenCalled(); }, ); + + describe('when on epic board', () => { + describe('when `epicsListDrawer` feature is disabled', () => { + beforeEach(() => { + createComponent({ + provide: { + isIssueBoard: false, + issuableType: 'epic', + glFeatures: { issuesListDrawer: true, epicsListDrawer: false }, + }, + }); + }); + + it('passes `useWorkItemDrawer` as false', () => { + expect(findBoardContent().props('useWorkItemDrawer')).toBe(false); + }); + }); + + describe('when issues when `issuesListDrawer` feature is enabled', () => { + beforeEach(() => { + createComponent({ + provide: { + isIssueBoard: false, + issuableType: 'epic', + glFeatures: { issuesListDrawer: false, epicsListDrawer: true }, + }, + }); + }); + + it('passes `useWorkItemDrawer` as true', () => { + expect(findBoardContent().props('useWorkItemDrawer')).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js index 4db572d1f58fb2eef96dcb37326e9d11d64a4fa5..3fa4cb5b82471f9cf289898afdd07bae787581ea 100644 --- a/spec/frontend/boards/components/board_app_spec.js +++ b/spec/frontend/boards/components/board_app_spec.js @@ -29,6 +29,7 @@ describe('BoardApp', () => { issue = rawIssue, handler = boardListQueryHandler, workItemDrawerEnabled = true, + isIssueBoard = true, } = {}) => { mockApollo = createMockApollo([[boardListsQuery, handler]]); mockApollo.clients.defaultClient.cache.writeQuery({ @@ -44,12 +45,13 @@ describe('BoardApp', () => { fullPath: 'gitlab-org', initialBoardId: 'gid://gitlab/Board/1', initialFilterParams: {}, - issuableType: 'issue', - boardType: 'project', - isIssueBoard: true, + issuableType: isIssueBoard ? 'issue' : 'epic', + boardType: isIssueBoard ? 'project' : 'group', + isIssueBoard, isGroupBoard: false, glFeatures: { issuesListDrawer: workItemDrawerEnabled, + epicsListDrawer: !workItemDrawerEnabled, }, }, }); @@ -98,4 +100,26 @@ describe('BoardApp', () => { expect(cacheUpdates.setError).toHaveBeenCalled(); }); + + describe('when on issue board', () => { + describe('when `issuesListDrawer` feature is disabled', () => { + beforeEach(() => { + createComponent({ workItemDrawerEnabled: false }); + }); + + it('passes `useWorkItemDrawer` as false', () => { + expect(findBoardContent().props('useWorkItemDrawer')).toBe(false); + }); + }); + + describe('when `issuesListDrawer` feature is enabled', () => { + beforeEach(() => { + createComponent({ workItemDrawerEnabled: true }); + }); + + it('passes `useWorkItemDrawer` as true', () => { + expect(findBoardContent().props('useWorkItemDrawer')).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/work_items/list/components/work_items_list_app_spec.js b/spec/frontend/work_items/list/components/work_items_list_app_spec.js index 326e04fd7f97f53deb315d35d7bfa9097caabd9f..dbd87c5e42cd8f43498a2c90325fcbb2bb4aa072 100644 --- a/spec/frontend/work_items/list/components/work_items_list_app_spec.js +++ b/spec/frontend/work_items/list/components/work_items_list_app_spec.js @@ -41,7 +41,11 @@ import { sortOptions, urlSortParams } from '~/work_items/pages/list/constants'; import getWorkItemStateCountsQuery from '~/work_items/graphql/list/get_work_item_state_counts.query.graphql'; import getWorkItemsQuery from '~/work_items/graphql/list/get_work_items.query.graphql'; import WorkItemDrawer from '~/work_items/components/work_item_drawer.vue'; -import { STATE_CLOSED, DETAIL_VIEW_QUERY_PARAM_NAME } from '~/work_items/constants'; +import { + STATE_CLOSED, + DETAIL_VIEW_QUERY_PARAM_NAME, + WORK_ITEM_TYPE_ENUM_EPIC, +} from '~/work_items/constants'; import { createRouter } from '~/work_items/router'; import { groupWorkItemsQueryResponse, @@ -461,92 +465,134 @@ describeSkipVue3(skipReason, () => { }); describe('work item drawer', () => { - describe('when issues_list_drawer feature is disabled', () => { - it('is not rendered when feature is disabled', async () => { - mountComponent({ - provide: { - glFeatures: { - issuesListDrawer: false, + describe('when rendering issues list', () => { + describe('when issues_list_drawer feature is disabled', () => { + it('is not rendered when feature is disabled', async () => { + mountComponent({ + provide: { + glFeatures: { + issuesListDrawer: false, + epicsListDrawer: true, + }, }, - }, - }); - await waitForPromises(); + }); + await waitForPromises(); - expect(findDrawer().exists()).toBe(false); + expect(findDrawer().exists()).toBe(false); + }); }); - }); - describe('when issues_list_drawer feature is enabled', () => { - beforeEach(async () => { - mountComponent({ - provide: { - glFeatures: { - issuesListDrawer: true, + describe('when issues_list_drawer feature is enabled', () => { + beforeEach(async () => { + mountComponent({ + provide: { + glFeatures: { + issuesListDrawer: true, + epicsListDrawer: false, + }, }, - }, + }); + await waitForPromises(); }); - await waitForPromises(); - }); - it('is rendered when feature is enabled', () => { - expect(findDrawer().exists()).toBe(true); - }); + it('is rendered when feature is enabled', () => { + expect(findDrawer().exists()).toBe(true); + }); - describe('selecting issues', () => { - const issue = groupWorkItemsQueryResponse.data.group.workItems.nodes[0]; - const payload = { - iid: issue.iid, - webUrl: issue.webUrl, - fullPath: issue.namespace.fullPath, - }; + describe('selecting issues', () => { + const issue = groupWorkItemsQueryResponse.data.group.workItems.nodes[0]; + const payload = { + iid: issue.iid, + webUrl: issue.webUrl, + fullPath: issue.namespace.fullPath, + }; - beforeEach(async () => { - findIssuableList().vm.$emit('select-issuable', payload); + beforeEach(async () => { + findIssuableList().vm.$emit('select-issuable', payload); - await nextTick(); - }); + await nextTick(); + }); - it('opens drawer when work item is selected', () => { - expect(findDrawer().props('open')).toBe(true); - expect(findDrawer().props('activeItem')).toEqual(payload); - }); + it('opens drawer when work item is selected', () => { + expect(findDrawer().props('open')).toBe(true); + expect(findDrawer().props('activeItem')).toEqual(payload); + }); - const checkThatDrawerPropsAreEmpty = () => { - expect(findDrawer().props('activeItem')).toBeNull(); - expect(findDrawer().props('open')).toBe(false); - }; + const checkThatDrawerPropsAreEmpty = () => { + expect(findDrawer().props('activeItem')).toBeNull(); + expect(findDrawer().props('open')).toBe(false); + }; - it('resets the selected item when the drawer is closed', async () => { - findDrawer().vm.$emit('close'); + it('resets the selected item when the drawer is closed', async () => { + findDrawer().vm.$emit('close'); - await nextTick(); + await nextTick(); - checkThatDrawerPropsAreEmpty(); - }); + checkThatDrawerPropsAreEmpty(); + }); - it('refetches and resets when work item is deleted', async () => { - expect(defaultQueryHandler).toHaveBeenCalledTimes(1); + it('refetches and resets when work item is deleted', async () => { + expect(defaultQueryHandler).toHaveBeenCalledTimes(1); - findDrawer().vm.$emit('workItemDeleted'); + findDrawer().vm.$emit('workItemDeleted'); - await nextTick(); + await nextTick(); - checkThatDrawerPropsAreEmpty(); + checkThatDrawerPropsAreEmpty(); - expect(defaultQueryHandler).toHaveBeenCalledTimes(2); - }); + expect(defaultQueryHandler).toHaveBeenCalledTimes(2); + }); + + it('refetches when the selected work item is closed', async () => { + expect(defaultQueryHandler).toHaveBeenCalledTimes(1); - it('refetches when the selected work item is closed', async () => { - expect(defaultQueryHandler).toHaveBeenCalledTimes(1); + // component displays open work items by default + findDrawer().vm.$emit('work-item-updated', { + state: STATE_CLOSED, + }); - // component displays open work items by default - findDrawer().vm.$emit('work-item-updated', { - state: STATE_CLOSED, + await nextTick(); + + expect(defaultQueryHandler).toHaveBeenCalledTimes(2); }); + }); + }); + }); - await nextTick(); + describe('when rendering epics list', () => { + describe('when epics_list_drawer feature is disabled', () => { + it('is not rendered when feature is disabled', async () => { + mountComponent({ + provide: { + glFeatures: { + issuesListDrawer: true, + epicsListDrawer: false, + }, + workItemType: WORK_ITEM_TYPE_ENUM_EPIC, + }, + }); + await waitForPromises(); + + expect(findDrawer().exists()).toBe(false); + }); + }); + + describe('when issues_list_drawer feature is enabled', () => { + beforeEach(async () => { + mountComponent({ + provide: { + glFeatures: { + issuesListDrawer: false, + epicsListDrawer: true, + }, + workItemType: WORK_ITEM_TYPE_ENUM_EPIC, + }, + }); + await waitForPromises(); + }); - expect(defaultQueryHandler).toHaveBeenCalledTimes(2); + it('is rendered when feature is enabled', () => { + expect(findDrawer().exists()).toBe(true); }); }); });