diff --git a/app/assets/javascripts/work_items/components/work_item_types_list.vue b/app/assets/javascripts/work_items/components/work_item_types_list.vue new file mode 100644 index 0000000000000000000000000000000000000000..6397ce351cf8f12d768a8ef46a6f088823501d80 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_types_list.vue @@ -0,0 +1,122 @@ + + + diff --git a/ee/app/assets/javascripts/groups/settings/work_items/configurable_types/configurable_types_settings.vue b/ee/app/assets/javascripts/groups/settings/work_items/configurable_types/configurable_types_settings.vue index d2ca33925404732ef25641a9d8d25c5c7f8996f1..838118acc999aa31ead27ae59244913c1d9c6778 100644 --- a/ee/app/assets/javascripts/groups/settings/work_items/configurable_types/configurable_types_settings.vue +++ b/ee/app/assets/javascripts/groups/settings/work_items/configurable_types/configurable_types_settings.vue @@ -1,9 +1,54 @@ diff --git a/ee/app/assets/javascripts/groups/settings/work_items/work_item_settings_home.vue b/ee/app/assets/javascripts/groups/settings/work_items/work_item_settings_home.vue index 8fbebda2aee10b6617ddf18c438c1865c00a272c..d8063969dc7e6f5af9d6bf10a987cd2f1b9a596a 100644 --- a/ee/app/assets/javascripts/groups/settings/work_items/work_item_settings_home.vue +++ b/ee/app/assets/javascripts/groups/settings/work_items/work_item_settings_home.vue @@ -8,6 +8,7 @@ import ConfigurableTypesSettings from './configurable_types/configurable_types_s const STATUS_SECTION_ID = 'js-custom-status-settings'; const CUSTOM_FIELD_SECTION_ID = 'js-custom-fields-settings'; +const WORK_ITEM_TYPES_SECTION_ID = 'js-work-item-types-settings'; export default { components: { @@ -25,11 +26,13 @@ export default { }, STATUS_SECTION_ID, CUSTOM_FIELD_SECTION_ID, + WORK_ITEM_TYPES_SECTION_ID, data() { return { sectionsExpandedState: { [STATUS_SECTION_ID]: false, [CUSTOM_FIELD_SECTION_ID]: false, + [WORK_ITEM_TYPES_SECTION_ID]: false, }, searchRoot: null, }; @@ -107,7 +110,13 @@ export default {

- + { + let wrapper; + + const defaultProps = { + fullPath: 'test-group', + id: 'work-item-types-settings', + expanded: false, + }; + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(ConfigurableTypesSettings, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + const findWorkItemTypesList = () => wrapper.findComponent(WorkItemTypesList); + const findHelpPageLink = () => wrapper.findComponent(HelpPageLink); + const findDescription = () => wrapper.find('p'); + + describe('default rendering', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders SettingsBlock component', () => { + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('passes correct props to SettingsBlock', () => { + expect(findSettingsBlock().props()).toEqual({ + id: defaultProps.id, + title: 'Work item types', + expanded: defaultProps.expanded, + }); + }); + + it('passes fullPath prop to WorkItemTypesList', () => { + expect(findWorkItemTypesList().props('fullPath')).toBe(defaultProps.fullPath); + }); + + it('renders description text', () => { + expect(findDescription().exists()).toBe(true); + expect(findDescription().text()).toContain( + 'Work item types are used to track different kinds of work. Each work item type can have different lifecycles and fields.', + ); + }); + + it('renders help page link', () => { + expect(findHelpPageLink().exists()).toBe(true); + expect(findHelpPageLink().props('href')).toBe('user/work_items/_index.md'); + expect(findHelpPageLink().text()).toBe('How do I use configure work item types?'); + }); + }); + + describe('props handling', () => { + it('renders with expanded prop as true', () => { + createWrapper({ expanded: true }); + + expect(findSettingsBlock().props('expanded')).toBe(true); + }); + + it('renders with expanded prop as false', () => { + createWrapper({ expanded: false }); + + expect(findSettingsBlock().props('expanded')).toBe(false); + }); + + it('renders with different fullPath', () => { + const customPath = 'my-group/sub-group/project'; + createWrapper({ fullPath: customPath }); + + expect(findWorkItemTypesList().props('fullPath')).toBe(customPath); + }); + + it('renders with different id', () => { + const customId = 'custom-id-123'; + createWrapper({ id: customId }); + + expect(findSettingsBlock().props('id')).toBe(customId); + }); + }); + + describe('event handling', () => { + beforeEach(() => { + createWrapper(); + }); + + it('emits toggle-expand event when SettingsBlock emits toggle-expand', () => { + findSettingsBlock().vm.$emit('toggle-expand', true); + + expect(wrapper.emitted('toggle-expand')).toHaveLength(1); + expect(wrapper.emitted('toggle-expand')[0]).toEqual([true]); + }); + + it('emits toggle-expand event with false value', () => { + findSettingsBlock().vm.$emit('toggle-expand', false); + + expect(wrapper.emitted('toggle-expand')).toHaveLength(1); + expect(wrapper.emitted('toggle-expand')[0]).toEqual([false]); + }); + + it('emits multiple toggle-expand events', () => { + findSettingsBlock().vm.$emit('toggle-expand', true); + findSettingsBlock().vm.$emit('toggle-expand', false); + findSettingsBlock().vm.$emit('toggle-expand', true); + + expect(wrapper.emitted('toggle-expand')).toHaveLength(3); + }); + }); +}); diff --git a/ee/spec/frontend/groups/settings/work_items/work_item_settings_home_spec.js b/ee/spec/frontend/groups/settings/work_items/work_item_settings_home_spec.js index 38c77104dd181b2c748dc36b1f7e706908a0c5bf..c79554ccfcd3a5d132a178a3135624f81f101a13 100644 --- a/ee/spec/frontend/groups/settings/work_items/work_item_settings_home_spec.js +++ b/ee/spec/frontend/groups/settings/work_items/work_item_settings_home_spec.js @@ -2,6 +2,7 @@ import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import CustomFieldsList from 'ee/groups/settings/work_items/custom_fields/custom_fields_list.vue'; import CustomStatusSettings from 'ee/groups/settings/work_items/custom_status/custom_status_settings.vue'; +import ConfigurableTypesSettings from 'ee/groups/settings/work_items/configurable_types/configurable_types_settings.vue'; import SearchSettings from '~/search_settings/components/search_settings.vue'; import WorkItemSettingsHome from 'ee/groups/settings/work_items/work_item_settings_home.vue'; @@ -9,7 +10,10 @@ describe('WorkItemSettingsHome', () => { let wrapper; const fullPath = 'group/project'; - const createComponent = ({ mocks = {}, glFeatures = {} } = {}) => { + const createComponent = ({ + mocks = {}, + glFeatures = { workItemConfigurableTypes: true }, + } = {}) => { wrapper = shallowMount(WorkItemSettingsHome, { propsData: { fullPath, @@ -26,9 +30,25 @@ describe('WorkItemSettingsHome', () => { const findCustomFieldsList = () => wrapper.findComponent(CustomFieldsList); const findCustomStatusSettings = () => wrapper.findComponent(CustomStatusSettings); + const findConfigurableTypesSettings = () => wrapper.findComponent(ConfigurableTypesSettings); const findSearchSettings = () => wrapper.findComponent(SearchSettings); const findPageTitle = () => wrapper.find('h1'); + it('renders ConfigurableTypesSettings component with correct props when FF is switched on', () => { + createComponent(); + + expect(findConfigurableTypesSettings().exists()).toBe(true); + expect(findConfigurableTypesSettings().props('fullPath')).toBe(fullPath); + }); + + it('does not render ConfigurableTypesSettings when `workItemTypesConfigurableTypes` FF is off', () => { + createComponent({ + glFeatures: { workItemConfigurableTypes: false }, + }); + + expect(findConfigurableTypesSettings().exists()).toBe(false); + }); + it('always renders CustomFieldsList component with correct props', () => { createComponent(); @@ -92,6 +112,17 @@ describe('WorkItemSettingsHome', () => { }); }); + it('navigates to hash when ConfigurableTypesSettings toggle-expand emits true', async () => { + createComponent({ mocks: { $router: mockRouter } }); + + await findConfigurableTypesSettings().vm.$emit('toggle-expand', true); + + expect(mockRouter.push).toHaveBeenCalledWith({ + name: 'workItemSettingsHome', + hash: '#js-work-item-types-settings', + }); + }); + it('clears hash when toggling to false without existing hash', async () => { createComponent({ mocks: { $router: mockRouter, $route: { hash: '' } } }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bb90f781b70423f2ae34317074dccd70f2d545fe..2a6b9c47df0ea8f5caf8ab0185106d211fc08182 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -77064,6 +77064,9 @@ msgstr "" msgid "WorkItem|Edit custom field %{fieldName}" msgstr "" +msgid "WorkItem|Edit name and icon" +msgstr "" + msgid "WorkItem|Edit status" msgstr "" @@ -77124,6 +77127,9 @@ msgstr "" msgid "WorkItem|Failed to fetch namespace metadata." msgstr "" +msgid "WorkItem|Failed to fetch work item types." +msgstr "" + msgid "WorkItem|Failed to load custom fields." msgstr "" @@ -77166,6 +77172,9 @@ msgstr "" msgid "WorkItem|History only" msgstr "" +msgid "WorkItem|How do I use configure work item types?" +msgstr "" + msgid "WorkItem|How do I use custom fields?" msgstr "" @@ -77347,6 +77356,9 @@ msgstr "" msgid "WorkItem|New task" msgstr "" +msgid "WorkItem|New type" +msgstr "" + msgid "WorkItem|No assignees" msgstr "" @@ -77860,6 +77872,9 @@ msgstr "" msgid "WorkItem|Type lifecycle updated." msgstr "" +msgid "WorkItem|Types" +msgstr "" + msgid "WorkItem|Unable to fetch destination projects." msgstr "" @@ -77917,6 +77932,12 @@ msgstr "" msgid "WorkItem|Work item not found" msgstr "" +msgid "WorkItem|Work item types" +msgstr "" + +msgid "WorkItem|Work item types are used to track different kinds of work. Each work item type can have different lifecycles and fields." +msgstr "" + msgid "WorkItem|Work items" msgstr "" diff --git a/spec/frontend/work_items/components/work_item_types_list_spec.js b/spec/frontend/work_items/components/work_item_types_list_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e269749bc823c55abcda0fc4153114d0ada1876c --- /dev/null +++ b/spec/frontend/work_items/components/work_item_types_list_spec.js @@ -0,0 +1,185 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert, GlButton, GlDisclosureDropdown, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemTypesList from '~/work_items/components/work_item_types_list.vue'; +import CrudComponent from '~/vue_shared/components/crud_component.vue'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; +import namespaceWorkItemTypesQuery from '~/work_items/graphql/namespace_work_item_types.query.graphql'; +import { namespaceWorkItemTypesQueryResponse } from 'ee_else_ce_jest/work_items/mock_data'; + +Vue.use(VueApollo); + +describe('WorkItemTypesList', () => { + let wrapper; + let mockApollo; + + const mockEmptyResponse = { + data: { + workspace: { + workItemTypes: { + nodes: [], + __typename: 'WorkItemTypeConnection', + }, + __typename: 'Namespace', + }, + }, + }; + + const getMockWorkItemTypes = () => + namespaceWorkItemTypesQueryResponse.data.workspace.workItemTypes.nodes; + const mockWorkItemTypes = getMockWorkItemTypes(); + const namespaceQueryHandler = jest.fn().mockResolvedValue(namespaceWorkItemTypesQueryResponse); + const mockEmptyResponseHandler = jest.fn().mockResolvedValue(mockEmptyResponse); + + const createWrapper = ({ queryHandler = namespaceQueryHandler, props = {} } = {}) => { + mockApollo = createMockApollo([[namespaceWorkItemTypesQuery, queryHandler]]); + + wrapper = shallowMountExtended(WorkItemTypesList, { + apolloProvider: mockApollo, + propsData: { + fullPath: 'test-group', + ...props, + }, + stubs: { + CrudComponent, + }, + }); + }; + + const findCrudComponent = () => wrapper.findComponent(CrudComponent); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findWorkItemTypesTable = () => wrapper.findByTestId('work-item-types-table'); + const findWorkItemTypeRows = () => wrapper.findAll('[data-testid^="work-item-type-row"]'); + const findWorkItemTypeRow = (id) => wrapper.findByTestId(`work-item-type-row-${id}`); + const findNewTypeButton = () => wrapper.findComponent(GlButton); + const findDropdownForType = (id) => findWorkItemTypeRow(id).findComponent(GlDisclosureDropdown); + const findErrorAlert = () => wrapper.findComponent(GlAlert); + + describe('default rendering', () => { + beforeEach(async () => { + createWrapper(); + await waitForPromises(); + }); + + it('renders the component with CrudComponent', () => { + expect(findCrudComponent().exists()).toBe(true); + }); + + it('renders with correct title and count', () => { + expect(findCrudComponent().props('title')).toBe('Types'); + expect(findCrudComponent().props('count')).toBe(mockWorkItemTypes.length); + }); + + it('renders the work item types table', () => { + expect(findWorkItemTypesTable().exists()).toBe(true); + }); + + it('renders WorkItemTypeIcon for each type', () => { + const icons = wrapper.findAllComponents(WorkItemTypeIcon); + + expect(icons).toHaveLength(mockWorkItemTypes.length); + icons.wrappers.forEach((icon, index) => { + expect(icon.props()).toMatchObject({ + workItemType: mockWorkItemTypes[index].name, + }); + }); + }); + + it('renders New type button', () => { + expect(findNewTypeButton().exists()).toBe(true); + expect(findNewTypeButton().text()).toContain('New type'); + }); + + it('renders dropdown for each work item type', () => { + const dropdowns = wrapper.findAllComponents(GlDisclosureDropdown); + + expect(dropdowns).toHaveLength(mockWorkItemTypes.length); + }); + + it('renders dropdowns with correct items', () => { + mockWorkItemTypes.forEach((mockWorkItemType) => { + const dropdown = findDropdownForType(mockWorkItemType.id); + expect(dropdown.props('items')).toHaveLength(2); + expect(dropdown.props('items')[0].text).toContain('Edit name and icon'); + expect(dropdown.props('items')[1].text).toContain('Delete'); + }); + }); + + it('renders dropdown with correct toggle attributes', () => { + const dropdown = findDropdownForType(mockWorkItemTypes[0].id); + + expect(dropdown.props('toggleId')).toBe(`work-item-type-actions-${mockWorkItemTypes[0].id}`); + expect(dropdown.props('icon')).toBe('ellipsis_v'); + expect(dropdown.props('noCaret')).toBe(true); + }); + }); + + describe('loading state', () => { + beforeEach(() => { + createWrapper(); + }); + + it('shows loading state when query is loading', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findWorkItemTypesTable().exists()).toBe(false); + }); + + it('hides loading state after query resolves', async () => { + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + expect(findWorkItemTypesTable().exists()).toBe(true); + }); + }); + + describe('empty state', () => { + beforeEach(async () => { + createWrapper({ queryHandler: mockEmptyResponseHandler }); + await waitForPromises(); + }); + + it('renders table even when no work item types exist', () => { + expect(findWorkItemTypesTable().exists()).toBe(true); + }); + + it('displays zero count', () => { + expect(findCrudComponent().props('count')).toBe(0); + }); + + it('does not render any work item type rows', () => { + expect(findWorkItemTypeRows()).toHaveLength(0); + }); + + it('still renders New type button', () => { + expect(findNewTypeButton().exists()).toBe(true); + }); + }); + + describe('query behavior', () => { + it('passes correct fullPath to query', async () => { + createWrapper({ props: { fullPath: 'my-group/sub-group' } }); + + await waitForPromises(); + + expect(namespaceQueryHandler).toHaveBeenCalledWith({ + fullPath: 'my-group/sub-group', + onlyAvailable: false, + }); + }); + + it('error handling', async () => { + const errorQueryHandler = jest.fn().mockRejectedValue('Network error'); + createWrapper({ queryHandler: errorQueryHandler }); + + await waitForPromises(); + + expect(findErrorAlert().exists()).toBe(true); + expect(findErrorAlert().text()).toContain('Failed to fetch work item types'); + }); + }); +});