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 @@
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+ {{ s__('WorkItem|New type') }}
+
+
+
+
+
+
+
+
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 @@
-
+
+
+
+ {{
+ s__(
+ 'WorkItem|Work item types are used to track different kinds of work. Each work item type can have different lifecycles and fields.',
+ )
+ }}
+
+ {{ s__('WorkItem|How do I use configure work item types?') }}
+
+
+
+
+
+
+
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');
+ });
+ });
+});