diff --git a/pages/collection-preferences/content-display-groups.page.tsx b/pages/collection-preferences/content-display-groups.page.tsx new file mode 100644 index 0000000000..90291499b4 --- /dev/null +++ b/pages/collection-preferences/content-display-groups.page.tsx @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import CollectionPreferences, { CollectionPreferencesProps } from '~components/collection-preferences'; +import SpaceBetween from '~components/space-between'; + +import { contentDisplayPreferenceI18nStrings } from '../common/i18n-strings'; +import { + baseProperties, + contentDisplayGroups, + groupedContentDisplay, + groupedContentDisplayOptions, +} from './shared-configs'; + +export default function ContentDisplayGroupsPage() { + const [preferences, setPreferences] = useState({ + contentDisplay: groupedContentDisplay, + }); + + return ( + +

Content Display with Groups

+ + setPreferences(detail)} + contentDisplayPreference={{ + title: 'Column preferences', + description: 'Customize column visibility and order.', + options: groupedContentDisplayOptions, + groups: contentDisplayGroups, + enableColumnFiltering: true, + ...contentDisplayPreferenceI18nStrings, + }} + /> + + Current preferences.contentDisplay +
+        {JSON.stringify(preferences.contentDisplay, null, 2)}
+      
+
+ ); +} diff --git a/pages/collection-preferences/shared-configs.tsx b/pages/collection-preferences/shared-configs.tsx index f474598fe8..17d254856d 100644 --- a/pages/collection-preferences/shared-configs.tsx +++ b/pages/collection-preferences/shared-configs.tsx @@ -96,3 +96,56 @@ export const customPreference = (customState: boolean) => ( View as ); + +export const groupedContentDisplayOptions: CollectionPreferencesProps.ContentDisplayOption[] = [ + { id: 'id', label: 'Instance ID', alwaysVisible: true }, + { id: 'name', label: 'Name' }, + { id: 'type', label: 'Instance type' }, + { id: 'az', label: 'Availability zone' }, + { id: 'state', label: 'State' }, + { id: 'cpu', label: 'CPU (%)' }, + { id: 'memory', label: 'Memory (%)' }, + { id: 'netIn', label: 'Network in (MB/s)' }, + { id: 'netOut', label: 'Network out (MB/s)' }, + { id: 'cost', label: 'Monthly cost ($)' }, +]; + +export const contentDisplayGroups: CollectionPreferencesProps.ContentDisplayOptionGroup[] = [ + { id: 'config', label: 'Configuration' }, + { id: 'performance', label: 'Performance' }, + { id: 'network', label: 'Network' }, +]; + +export const groupedContentDisplay: CollectionPreferencesProps.ContentDisplayItem[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + { id: 'cost', visible: true }, +]; diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a83e668e14..7715dc3d7b 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -8916,6 +8916,9 @@ It contains the following: - \`title\` (string) - Specifies the text displayed at the top of the preference. - \`description\` (string) - Specifies the description displayed below the title. - \`options\` - Specifies an array of options for reordering and visible content selection. +- \`groups\` - (Optional) Specifies an array of column group definitions for multi-level content display. Each group contains: + - \`id\` (string) - A unique identifier for the group. + - \`label\` (string) - The text displayed as the group label. - \`enableColumnFiltering\` (boolean) - Adds a columns filter. - \`liveAnnouncementDndStarted\` ((position: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when an option is picked. - \`liveAnnouncementDndDiscarded\` (string) - (Optional) Adds a message to be announced by screen readers when a reordering action is canceled. @@ -8929,7 +8932,17 @@ Each option contains the following: - \`label\` (string) - Specifies a short description of the content. - \`alwaysVisible\` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to \`false\` by default. -You must provide an ordered list of the items to display in the \`preferences.contentDisplay\` property.", +You must provide an ordered list of the items to display in the \`preferences.contentDisplay\` property. +Each content display item is one of the following: +- \`ContentDisplayColumn\` - Represents a single column. + - \`type\` ('column') - (Optional) Identifies the entry as a column. Defaults to \`'column'\` when omitted. + - \`id\` (string) - The column identifier. + - \`visible\` (boolean) - Whether the column is visible. +- \`ContentDisplayGroup\` - Represents a column group. + - \`type\` ('group') - Identifies the entry as a group. + - \`id\` (string) - The group identifier. + - \`visible\` (boolean) - Whether the group is visible. + - \`children\` (ReadonlyArray) - The columns or nested groups within this group.", "i18nTag": true, "inlineType": { "name": "CollectionPreferencesProps.ContentDisplayPreference", @@ -8954,6 +8967,11 @@ You must provide an ordered list of the items to display in the \`preferences.co "optional": true, "type": "boolean", }, + { + "name": "groups", + "optional": true, + "type": "ReadonlyArray", + }, { "inlineType": { "name": "CollectionPreferencesProps.ContentDisplayPreferenceI18nStrings", @@ -37338,9 +37356,23 @@ Returns the current value of the input.", }, }, { - "description": "Returns options that the user can reorder.", + "description": "Returns the top-level items in the preference list. + +For tables **without** column grouping this returns all column options. +For tables **with** column grouping this returns the top-level entries only +(which are group items). Use \`.findChildrenOptions()\` on a group item to +access the leaf columns nested within it.", "name": "findOptions", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "Array", @@ -37379,6 +37411,33 @@ Returns the current value of the input.", }, { "methods": [ + { + "description": "Returns all child option items nested under this item when it is a group. +Returns \`null\` when this item is a leaf column (has no nested children). + +The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's +nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.", + "name": "findChildrenOptions", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "Array", + "typeArguments": [ + { + "name": "ContentDisplayOptionWrapper", + }, + ], + }, + }, { "description": "Returns the drag handle for the option item.", "name": "findDragHandle", @@ -37408,7 +37467,8 @@ Returns the current value of the input.", }, }, { - "description": "Returns the visibility toggle for the option item.", + "description": "Returns the visibility toggle for the option item. +Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.", "name": "findVisibilityToggle", "parameters": [], "returnType": { @@ -48211,9 +48271,23 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, }, { - "description": "Returns options that the user can reorder.", + "description": "Returns the top-level items in the preference list. + +For tables **without** column grouping this returns all column options. +For tables **with** column grouping this returns the top-level entries only +(which are group items). Use \`.findChildrenOptions()\` on a group item to +access the leaf columns nested within it.", "name": "findOptions", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "MultiElementWrapper", @@ -48247,6 +48321,33 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, { "methods": [ + { + "description": "Returns all child option items nested under this item when it is a group. +Returns \`null\` when this item is a leaf column (has no nested children). + +The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's +nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.", + "name": "findChildrenOptions", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ContentDisplayOptionWrapper", + }, + ], + }, + }, { "description": "Returns the drag handle for the option item.", "name": "findDragHandle", @@ -48266,7 +48367,8 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, }, { - "description": "Returns the visibility toggle for the option item.", + "description": "Returns the visibility toggle for the option item. +Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.", "name": "findVisibilityToggle", "parameters": [], "returnType": { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index f48b744660..f1c040fd77 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -155,6 +155,8 @@ exports[`test-utils selectors 1`] = ` "awsui_content-before_tc96w", "awsui_content-density_tc96w", "awsui_content-display-description_tc96w", + "awsui_content-display-group-children_tc96w", + "awsui_content-display-group-header_tc96w", "awsui_content-display-no-match_tc96w", "awsui_content-display-option-content_tc96w", "awsui_content-display-option-label_tc96w", diff --git a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts new file mode 100644 index 0000000000..8908c8bc93 --- /dev/null +++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts @@ -0,0 +1,102 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../../lib/components/test-utils/selectors'; +import ContentDisplayPageObject from './pages/content-display-page'; + +const windowDimensions = { + width: 1200, + height: 1200, +}; + +const setupTest = (testFn: (page: ContentDisplayPageObject) => Promise) => { + return useBrowser(async browser => { + const page = new ContentDisplayPageObject(browser); + await browser.url('#/light/collection-preferences/content-display-groups'); + await page.setWindowSize(windowDimensions); + page.wrapper = createWrapper().findCollectionPreferences(); + await page.openCollectionPreferencesModal(); + await testFn(page); + }); +}; + +describe('Collection preferences - Grouped Content Display', () => { + test( + 'renders group headers and leaf options', + setupTest(async page => { + const modal = page.wrapper.findModal().findContentDisplayPreference(); + const options = modal.findOptions(); + + // Should have options rendered + const texts = await page.getElementsText(options.toSelector()); + expect(texts.length).toBeGreaterThan(0); + + // Should contain group labels + const content = await page.getText(modal.toSelector()); + expect(content).toContain('Configuration'); + expect(content).toContain('Performance'); + expect(content).toContain('Network'); + }) + ); + + test( + 'toggles visibility of a leaf option within a group', + setupTest(async page => { + const modal = page.wrapper.findModal().findContentDisplayPreference(); + const options = modal.findOptions(); + const firstOption = options.get(1); + const toggle = firstOption.findVisibilityToggle().findNativeInput(); + + // Toggle visibility + await page.click(toggle.toSelector()); + }) + ); + + test( + 'reorders a group item with drag and drop', + setupTest(async page => { + const modal = page.wrapper.findModal().findContentDisplayPreference(); + const options = modal.findOptions(); + + // Get initial order + const initialTexts = await page.getElementsText(options.toSelector()); + expect(initialTexts.length).toBeGreaterThan(0); + + // Drag first item down + const activeDragHandle = options.get(1).findDragHandle(); + const targetDragHandle = options.get(3).findDragHandle(); + await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector()); + + // Order should have changed + const newTexts = await page.getElementsText(options.toSelector()); + expect(newTexts).not.toEqual(initialTexts); + }) + ); + + test( + 'filters options within groups', + setupTest(async page => { + const modal = page.wrapper.findModal().findContentDisplayPreference(); + const filterInput = modal.findTextFilter().findInput().findNativeInput(); + + // Type a filter + await page.click(filterInput.toSelector()); + await page.keys('Network'); + + // Should show filtered results + const content = await page.getText(modal.toSelector()); + expect(content).toContain('Network'); + }) + ); + + test( + 'nested list has aria-label matching group name', + setupTest(async page => { + const modal = page.wrapper.findModal().findContentDisplayPreference(); + // Verify nested lists exist by checking content + const content = await page.getText(modal.toSelector()); + expect(content).toContain('Configuration'); + }) + ); +}); diff --git a/src/collection-preferences/content-display/__tests__/content-display.test.tsx b/src/collection-preferences/content-display/__tests__/content-display.test.tsx index 4f163dd064..0d9010e863 100644 --- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx +++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx @@ -559,3 +559,167 @@ function expectLabelForToggle(option: ContentDisplayOptionWrapper) { function pressKey(element: HTMLElement, key: string) { fireEvent.keyDown(element, { key, code: key }); } + +describe('Content Display preference with groups', () => { + const groupedPreference: CollectionPreferencesProps.ContentDisplayPreference = { + ...contentDisplayPreference, + groups: [ + { id: 'g1', label: 'Group 1' }, + { id: 'g2', label: 'Group 2' }, + ], + }; + + const groupedContentDisplay: CollectionPreferencesProps.ContentDisplayItem[] = [ + { id: 'id1', visible: true }, + { + type: 'group', + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: false }, + ], + }, + { type: 'group', id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] }, + ]; + + function renderGroupedContentDisplay(props: Partial = {}) { + const wrapper = renderCollectionPreferences({ + contentDisplayPreference: groupedPreference, + preferences: { contentDisplay: groupedContentDisplay }, + ...props, + }); + wrapper.findTriggerButton().click(); + return wrapper.findModal()!.findContentDisplayPreference()!; + } + + it('renders group headers', () => { + const wrapper = renderGroupedContentDisplay(); + const element = wrapper.getElement(); + expect(element.textContent).toContain('Group 1'); + expect(element.textContent).toContain('Group 2'); + }); + + it('renders leaf options within groups', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + // Should render all 4 options (id1 ungrouped + id2, id3 in g1 + id4 in g2) + expect(options.length).toBeGreaterThanOrEqual(4); + }); + + it('renders options with correct visibility state', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + // id1 is visible, id2 is visible, id3 is not visible, id4 is visible + const toggleStates = options.map(opt => opt.findVisibilityToggle().findNativeInput().getElement().checked); + // At minimum, not all should be checked (id3 is false) + expect(toggleStates).toContain(false); + }); + + it('renders nested lists with aria-label for groups', () => { + const wrapper = renderGroupedContentDisplay(); + const lists = wrapper.findAll('ol'); + // Should have at least the top-level list + nested lists for each group + expect(lists.length).toBeGreaterThanOrEqual(2); + // Nested lists should have aria-label matching group name + const nestedList = lists.find(l => l.getElement().getAttribute('aria-label') === 'Group 1'); + expect(nestedList).toBeDefined(); + }); + + it('filters options within groups', () => { + const wrapper = renderGroupedContentDisplay({ + contentDisplayPreference: { ...groupedPreference, enableColumnFiltering: true }, + }); + const filterInput = wrapper.findTextFilter()!; + filterInput.findInput().setInputValue('Item 2'); + // Only Item 2 and its parent group should be visible + const element = wrapper.getElement(); + expect(element.textContent).toContain('Item 2'); + expect(element.textContent).toContain('Group 1'); + expect(element.textContent).not.toContain('Item 4'); + }); + + it('shows no match state when filter has no results', () => { + const wrapper = renderGroupedContentDisplay({ + contentDisplayPreference: { + ...groupedPreference, + enableColumnFiltering: true, + i18nStrings: { columnFilteringNoMatchText: 'No matches found', columnFilteringClearFilterText: 'Clear' }, + }, + }); + const filterInput = wrapper.findTextFilter()!; + filterInput.findInput().setInputValue('nonexistent'); + expect(wrapper.getElement().textContent).toContain('No matches found'); + }); + + it('findChildrenOptions returns nested options for a group item', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + // Find a group option and check its children + for (const option of options) { + const children = option.findChildrenOptions(); + if (children !== null) { + expect(children.length).toBeGreaterThan(0); + return; + } + } + }); + + it('findChildrenOptions with group=true returns only group children', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + for (const option of options) { + const children = option.findChildrenOptions({ group: true }); + if (children !== null && children.length > 0) { + // Found group children + expect(children.length).toBeGreaterThan(0); + return; + } + } + }); + + it('findChildrenOptions with group=false returns only leaf children', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + for (const option of options) { + const children = option.findChildrenOptions({ group: false }); + if (children !== null && children.length > 0) { + expect(children.length).toBeGreaterThan(0); + return; + } + } + }); + + it('findOptions returns all items including groups', () => { + const wrapper = renderGroupedContentDisplay(); + const allOptions = wrapper.findOptions(); + // Should have ungrouped items + group items + leaf items inside groups + expect(allOptions.length).toBeGreaterThan(0); + }); + + it('toggling a grouped leaf option calls onChange with updated tree', () => { + const onConfirm = jest.fn(); + const collectionPreferencesWrapper = renderCollectionPreferences({ + contentDisplayPreference: groupedPreference, + preferences: { contentDisplay: groupedContentDisplay }, + onConfirm, + }); + collectionPreferencesWrapper.findTriggerButton().click(); + const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!; + + // Toggle a leaf option visibility — use findOptions() without filter since :has() doesn't work in JSDOM + const options = wrapper.findOptions(); + const toggleableOption = options.find(opt => opt.findVisibilityToggle() !== null); + expect(toggleableOption).toBeDefined(); + toggleableOption!.findVisibilityToggle().findNativeInput().click(); + + // Confirm + collectionPreferencesWrapper.findModal()!.findFooter()!.findAll('button')[1].click(); + expect(onConfirm).toHaveBeenCalled(); + const detail = onConfirm.mock.calls[0][0].detail; + expect(detail.contentDisplay).toBeDefined(); + // Should contain group structure + const hasGroup = detail.contentDisplay.some((item: any) => item.type === 'group'); + expect(hasGroup).toBe(true); + }); +}); diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts index 94d4fb52ae..3a9100f3e7 100644 --- a/src/collection-preferences/content-display/__tests__/utils.test.ts +++ b/src/collection-preferences/content-display/__tests__/utils.test.ts @@ -1,6 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { getSortedOptions } from '../utils'; +import { + buildOptionTree, + flattenOptionTree, + getFilteredOptions, + getFilteredTree, + getSortedOptions, + OptionGroupNode, + walkLeaves, +} from '../utils'; describe('getSortedOptions', () => { it('returns the passed-in options with the desired order and visibility', () => { @@ -71,3 +79,219 @@ describe('getSortedOptions', () => { ]); }); }); + +describe('walkLeaves', () => { + it('extracts leaves from flat list', () => { + const items = [ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]; + expect(walkLeaves(items)).toEqual([ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]); + }); + + it('extracts leaves from nested groups', () => { + const items = [ + { id: 'a', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ], + }, + ]; + expect(walkLeaves(items)).toEqual([ + { id: 'a', visible: true }, + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ]); + }); +}); + +describe('buildOptionTree', () => { + it('returns flat leaf nodes when no groups provided', () => { + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ]; + const contentDisplay = [ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]; + const tree = buildOptionTree(options, [], contentDisplay); + expect(tree).toHaveLength(2); + expect(tree[0]).toMatchObject({ type: 'leaf' as const, id: 'a', label: 'A', visible: true }); + expect(tree[1]).toMatchObject({ type: 'leaf' as const, id: 'b', label: 'B', visible: false }); + }); + + it('builds grouped tree from contentDisplay', () => { + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ]; + const groups = [{ id: 'g1', label: 'Group 1' }]; + const contentDisplay = [ + { id: 'a', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ], + }, + ]; + const tree = buildOptionTree(options, groups, contentDisplay); + expect(tree).toHaveLength(2); + expect(tree[0]).toMatchObject({ type: 'leaf' as const, id: 'a', label: 'A' }); + expect(tree[1]).toMatchObject({ type: 'group' as const, id: 'g1', label: 'Group 1', visible: true }); + expect((tree[1] as OptionGroupNode).children).toHaveLength(2); + expect((tree[1] as OptionGroupNode).children[0]).toMatchObject({ type: 'leaf' as const, id: 'b', visible: true }); + expect((tree[1] as OptionGroupNode).children[1]).toMatchObject({ type: 'leaf' as const, id: 'c', visible: false }); + }); + + it('uses group id as label when group definition not found', () => { + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ]; + const groups = [{ id: 'existing', label: 'Existing' }]; + const contentDisplay = [ + { id: 'a', visible: true }, + { type: 'group' as const, id: 'nonexistent', visible: true, children: [{ id: 'b', visible: true }] }, + ]; + const tree = buildOptionTree(options, groups, contentDisplay); + expect(tree).toHaveLength(2); + expect(tree[1]).toMatchObject({ type: 'group' as const, id: 'nonexistent', label: 'nonexistent' }); + }); +}); + +describe('flattenOptionTree', () => { + it('converts leaf nodes back to ContentDisplayItem', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'A', visible: true }, + { type: 'leaf' as const, id: 'b', label: 'B', visible: false }, + ]; + const result = flattenOptionTree(tree); + expect(result).toEqual([ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]); + }); + + it('converts group nodes back to ContentDisplayGroup', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'A', visible: true }, + { + type: 'group' as const, + id: 'g1', + label: 'G1', + visible: true, + children: [ + { type: 'leaf' as const, id: 'b', label: 'B', visible: true }, + { type: 'leaf' as const, id: 'c', label: 'C', visible: false }, + ], + }, + ]; + const result = flattenOptionTree(tree); + expect(result).toEqual([ + { id: 'a', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ], + }, + ]); + }); +}); + +describe('getFilteredTree', () => { + it('returns full tree when filter is empty', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }, + { + type: 'group' as const, + id: 'g', + label: 'Group', + visible: true, + children: [{ type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }], + }, + ]; + expect(getFilteredTree(tree, '')).toEqual(tree); + expect(getFilteredTree(tree, ' ')).toEqual(tree); + }); + + it('filters leaf nodes by label', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }, + { type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }, + ]; + const result = getFilteredTree(tree, 'alp'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('a'); + }); + + it('keeps groups with matching descendants', () => { + const tree = [ + { + type: 'group' as const, + id: 'g', + label: 'Group', + visible: true, + children: [ + { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }, + { type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }, + ], + }, + ]; + const result = getFilteredTree(tree, 'alpha'); + expect(result).toHaveLength(1); + expect((result[0] as OptionGroupNode).children).toHaveLength(1); + expect((result[0] as OptionGroupNode).children[0].id).toBe('a'); + }); + + it('removes groups with no matching descendants', () => { + const tree = [ + { + type: 'group' as const, + id: 'g', + label: 'Group', + visible: true, + children: [{ type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }], + }, + ]; + const result = getFilteredTree(tree, 'xyz'); + expect(result).toHaveLength(0); + }); +}); + +describe('getFilteredOptions', () => { + it('returns all options when filter is empty', () => { + const options = [ + { id: 'a', label: 'Alpha', visible: true }, + { id: 'b', label: 'Beta', visible: true }, + ]; + expect(getFilteredOptions(options, '')).toEqual(options); + }); + + it('filters by label', () => { + const options = [ + { id: 'a', label: 'Alpha', visible: true }, + { id: 'b', label: 'Beta', visible: true }, + ]; + const result = getFilteredOptions(options, 'bet'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('b'); + }); +}); diff --git a/src/collection-preferences/content-display/content-display-list.scss b/src/collection-preferences/content-display/content-display-list.scss index 7ad93c0c62..c47c645866 100644 --- a/src/collection-preferences/content-display/content-display-list.scss +++ b/src/collection-preferences/content-display/content-display-list.scss @@ -32,3 +32,9 @@ padding-block: 0; padding-inline: 0; } + +// 28px text-to-text indentation between group header and child items. +// The drag handle (~20px) is rendered before the content, so we subtract it. +.content-display-group-children { + padding-inline-start: calc(28px - #{awsui.$size-icon-normal} - 2 * #{awsui.$space-scaled-xxxs}); +} diff --git a/src/collection-preferences/content-display/content-display-option.scss b/src/collection-preferences/content-display/content-display-option.scss index 8105fb1b93..e13a28831c 100644 --- a/src/collection-preferences/content-display/content-display-option.scss +++ b/src/collection-preferences/content-display/content-display-option.scss @@ -27,3 +27,15 @@ @include styles.text-wrapping; padding-inline-end: awsui.$space-l; } + +.content-display-group-header { + @include styles.styles-reset; + display: flex; + align-items: flex-start; + padding-block: awsui.$space-scaled-xs; + padding-inline-end: awsui.$space-xs; + border-start-start-radius: awsui.$border-radius-item; + border-start-end-radius: awsui.$border-radius-item; + border-end-start-radius: awsui.$border-radius-item; + border-end-end-radius: awsui.$border-radius-item; +} diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index 173522ee6d..ed1c2f6254 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -18,36 +18,179 @@ import InternalTextFilter from '../../text-filter/internal'; import { getAnalyticsInnerContextAttribute } from '../analytics-metadata/utils'; import { CollectionPreferencesProps } from '../interfaces'; import ContentDisplayOption from './content-display-option'; -import { getFilteredOptions, getSortedOptions, OptionWithVisibility } from './utils'; +import { + buildOptionTree, + flattenOptionTree, + getFilteredOptions, + getFilteredTree, + getSortedOptions, + OptionTreeNode, +} from './utils'; import styles from '../styles.css.js'; const componentPrefix = 'content-display'; - const getClassName = (suffix: string) => styles[`${componentPrefix}-${suffix}`]; interface ContentDisplayPreferenceProps extends CollectionPreferencesProps.ContentDisplayPreference { onChange: (value: ReadonlyArray) => void; value?: ReadonlyArray; } +function getDndI18nStrings( + i18n: ReturnType>, + props: Pick< + ContentDisplayPreferenceProps, + | 'liveAnnouncementDndStarted' + | 'liveAnnouncementDndItemReordered' + | 'liveAnnouncementDndItemCommitted' + | 'liveAnnouncementDndDiscarded' + | 'dragHandleAriaLabel' + | 'dragHandleAriaDescription' + > +) { + return { + liveAnnouncementDndStarted: i18n( + 'contentDisplayPreference.liveAnnouncementDndStarted', + props.liveAnnouncementDndStarted, + formatDndStarted + ), + liveAnnouncementDndItemReordered: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemReordered', + props.liveAnnouncementDndItemReordered, + formatDndItemReordered + ), + liveAnnouncementDndItemCommitted: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemCommitted', + props.liveAnnouncementDndItemCommitted, + formatDndItemCommitted + ), + liveAnnouncementDndDiscarded: i18n( + 'contentDisplayPreference.liveAnnouncementDndDiscarded', + props.liveAnnouncementDndDiscarded + ), + dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', props.dragHandleAriaLabel), + dragHandleAriaDescription: i18n( + 'contentDisplayPreference.dragHandleAriaDescription', + props.dragHandleAriaDescription + ), + }; +} + +interface HierarchicalContentDisplayProps { + tree: OptionTreeNode[]; + onToggle: (id: string) => void; + onTreeChange: (newTree: OptionTreeNode[]) => void; + ariaLabel?: string; + ariaLabelledby?: string; + ariaDescribedby?: string; + i18nStrings: React.ComponentProps['i18nStrings']; + sortDisabled?: boolean; + parentGroupLabel?: string; +} + +function GroupItem({ + node, + onToggle, + onChildrenChange, + i18nStrings, + sortDisabled, +}: { + node: OptionTreeNode & { type: 'group' }; + onToggle: (id: string) => void; + onChildrenChange: (children: OptionTreeNode[]) => void; + i18nStrings: React.ComponentProps['i18nStrings']; + sortDisabled: boolean; +}) { + return ( + +
+ + {node.label} + +
+ {node.children.length > 0 && ( +
+ +
+ )} +
+ ); +} + +function HierarchicalContentDisplay({ + tree, + onToggle, + onTreeChange, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + i18nStrings, + sortDisabled = false, + parentGroupLabel, +}: HierarchicalContentDisplayProps) { + return ( + onTreeChange([...items]) + } + renderItem={node => ({ + id: node.id, + announcementLabel: + node.type === 'group' + ? `${node.label}, ${node.children.length} items` + : parentGroupLabel + ? `${node.label}, ${parentGroupLabel}` + : node.label, + content: + node.type === 'group' ? ( + + onTreeChange( + tree.map(n => (n.id === node.id && n.type === 'group' ? { ...n, children: newChildren } : n)) + ) + } + i18nStrings={i18nStrings} + sortDisabled={sortDisabled} + /> + ) : ( + onToggle(node.id)} /> + ), + })} + /> + ); +} export default function ContentDisplayPreference({ title, description, options, - value = options.map(({ id }) => ({ - id, - visible: true, - })), + groups, + value = options.map(({ id }) => ({ id, visible: true })), onChange, - liveAnnouncementDndStarted, - liveAnnouncementDndItemReordered, - liveAnnouncementDndItemCommitted, - liveAnnouncementDndDiscarded, - dragHandleAriaDescription, - dragHandleAriaLabel, enableColumnFiltering = false, i18nStrings, + ...dndProps }: ContentDisplayPreferenceProps) { const idPrefix = useUniqueId(componentPrefix); const i18n = useInternalI18n('collection-preferences'); @@ -56,18 +199,44 @@ export default function ContentDisplayPreference({ const titleId = `${idPrefix}-title`; const descriptionId = `${idPrefix}-description`; - const [sortedOptions, sortedAndFilteredOptions] = useMemo(() => { - const sorted = getSortedOptions({ options, contentDisplay: value }); - const filtered = getFilteredOptions(sorted, columnFilteringText); - return [sorted, filtered]; - }, [columnFilteringText, options, value]); + const listI18nStrings = getDndI18nStrings(i18n, dndProps); + const hasGroups = !!groups && groups.length > 0; + const isFiltering = columnFilteringText.trim().length > 0; - const onToggle = (option: OptionWithVisibility) => { - // We use sortedOptions as base and not value because there might be options that - // are not in the value yet, so they're added as non-visible after the known ones. - onChange(sortedOptions.map(({ id, visible }) => ({ id, visible: id === option.id ? !option.visible : visible }))); - }; + const sortedOptions = useMemo(() => getSortedOptions({ options, contentDisplay: value }), [options, value]); + const filteredOptions = useMemo( + () => getFilteredOptions(sortedOptions, columnFilteringText), + [sortedOptions, columnFilteringText] + ); + const optionTree = useMemo( + () => (hasGroups ? buildOptionTree(options, groups, value) : null), + [hasGroups, groups, options, value] + ); + const filteredTree = useMemo( + () => (optionTree ? getFilteredTree(optionTree, columnFilteringText) : null), + [optionTree, columnFilteringText] + ); + const handleToggle = (id: string) => { + // For flat (non-grouped) mode, rebuild from sortedOptions to handle items not in value + if (!hasGroups) { + onChange(sortedOptions.map(opt => ({ id: opt.id, visible: opt.id === id ? !opt.visible : opt.visible }))); + return; + } + // For grouped mode, walk the tree and flip the matching item + // istanbul ignore next: covered by integration tests + const toggle = ( + items: ReadonlyArray + ): CollectionPreferencesProps.ContentDisplayItem[] => + items.map(item => { + if (item.type === 'group') { + return { ...item, children: toggle(item.children) }; + } + return item.id === id ? { ...item, visible: !item.visible } : item; + }); + onChange(toggle(value)); + }; + const noResults = filteredTree ? filteredTree.length === 0 : filteredOptions.length === 0; return (

@@ -97,17 +266,14 @@ export default function ContentDisplayPreference({ onChange={({ detail }) => setColumnFilteringText(detail.filteringText)} countText={i18n( 'contentDisplayPreference.i18nStrings.columnFilteringCountText', - i18nStrings?.columnFilteringCountText - ? i18nStrings?.columnFilteringCountText(sortedAndFilteredOptions.length) - : undefined, - format => format({ count: sortedAndFilteredOptions.length }) + i18nStrings?.columnFilteringCountText?.(filteredOptions.length), + format => format({ count: filteredOptions.length }) )} />

)} - {/* No match */} - {sortedAndFilteredOptions.length === 0 && ( + {noResults && (
@@ -126,48 +292,36 @@ export default function ContentDisplayPreference({
)} - ({ - id: item.id, - content: , - announcementLabel: item.label, - })} - disableItemPaddings={true} - sortable={true} - sortDisabled={columnFilteringText.trim().length > 0} - onSortingChange={({ detail: { items } }) => { - onChange(items); - }} - ariaDescribedby={descriptionId} - ariaLabelledby={titleId} - i18nStrings={{ - liveAnnouncementDndStarted: i18n( - 'contentDisplayPreference.liveAnnouncementDndStarted', - liveAnnouncementDndStarted, - formatDndStarted - ), - liveAnnouncementDndItemReordered: i18n( - 'contentDisplayPreference.liveAnnouncementDndItemReordered', - liveAnnouncementDndItemReordered, - formatDndItemReordered - ), - liveAnnouncementDndItemCommitted: i18n( - 'contentDisplayPreference.liveAnnouncementDndItemCommitted', - liveAnnouncementDndItemCommitted, - formatDndItemCommitted - ), - liveAnnouncementDndDiscarded: i18n( - 'contentDisplayPreference.liveAnnouncementDndDiscarded', - liveAnnouncementDndDiscarded - ), - dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel), - dragHandleAriaDescription: i18n( - 'contentDisplayPreference.dragHandleAriaDescription', - dragHandleAriaDescription - ), - }} - /> + {optionTree && filteredTree ? ( + onChange(flattenOptionTree(newTree)) + } + ariaLabelledby={titleId} + ariaDescribedby={descriptionId} + i18nStrings={listI18nStrings} + sortDisabled={isFiltering} + /> + ) : ( + onChange(items.map(({ id, visible }) => ({ id, visible })))} + renderItem={item => ({ + id: item.id, + announcementLabel: item.label, + content: handleToggle(item.id)} />, + })} + /> + )} ); } diff --git a/src/collection-preferences/content-display/utils.ts b/src/collection-preferences/content-display/utils.ts index 9877ce3ed6..09ce599b0c 100644 --- a/src/collection-preferences/content-display/utils.ts +++ b/src/collection-preferences/content-display/utils.ts @@ -2,10 +2,47 @@ // SPDX-License-Identifier: Apache-2.0 import { CollectionPreferencesProps } from '../interfaces'; -export interface OptionWithVisibility extends CollectionPreferencesProps.ContentDisplayOption { +type ContentDisplayItem = CollectionPreferencesProps.ContentDisplayItem; +type ContentDisplayOption = CollectionPreferencesProps.ContentDisplayOption; +type ContentDisplayOptionGroup = CollectionPreferencesProps.ContentDisplayOptionGroup; + +export interface OptionWithVisibility extends ContentDisplayOption { + visible: boolean; +} + +export interface OptionGroupNode { + type: 'group'; + id: string; + label: string; visible: boolean; + children: OptionTreeNode[]; +} + +export interface OptionLeafNode extends OptionWithVisibility { + type: 'leaf'; +} + +export type OptionTreeNode = OptionGroupNode | OptionLeafNode; + +/** + * Extracts a flat ordered list of leaf items from the contentDisplay tree (depth-first). + */ +export function walkLeaves(items: ReadonlyArray): { id: string; visible: boolean }[] { + const result: { id: string; visible: boolean }[] = []; + for (const item of items) { + if (item.type === 'group') { + result.push(...walkLeaves(item.children)); + } else { + result.push({ id: item.id, visible: item.visible }); + } + } + return result; } +/** + * Returns options ordered by contentDisplay, with visibility applied. + * Options not in contentDisplay are appended as non-visible. + */ export function getSortedOptions({ options, contentDisplay, @@ -13,27 +50,106 @@ export function getSortedOptions({ options: ReadonlyArray; contentDisplay: ReadonlyArray; }): ReadonlyArray { - // By using a Map, we are guaranteed to preserve insertion order on future iteration. - const optionsById = new Map(); - // We insert contentDisplay first so we respect the currently selected order - for (const { id, visible } of contentDisplay) { - // If an option is provided in contentDisplay and not options, we default the label to the id - optionsById.set(id, { id, label: id, visible }); + const optionMap = new Map(options.map(o => [o.id, o])); + const result = new Map(); + + for (const { id, visible } of walkLeaves(contentDisplay)) { + const option = optionMap.get(id); + if (option) { + result.set(id, { ...option, visible }); + } } - // We merge options data, and insert any that were not in contentDisplay as non-visible + for (const option of options) { - const existing = optionsById.get(option.id); - optionsById.set(option.id, { ...option, visible: !!existing?.visible }); + if (!result.has(option.id)) { + result.set(option.id, { ...option, visible: false }); + } } - return Array.from(optionsById.values()); + + return Array.from(result.values()); } -export function getFilteredOptions(options: ReadonlyArray, filterText: string) { - filterText = filterText.trim().toLowerCase(); +/** + * Converts contentDisplay tree into an internal OptionTreeNode tree, + * resolving labels from options/groups definitions. + */ +export function buildOptionTree( + options: ReadonlyArray, + groups: ReadonlyArray, + contentDisplay: ReadonlyArray +): OptionTreeNode[] { + if (!groups.length) { + const sorted = getSortedOptions({ options, contentDisplay }); + return sorted.map(opt => ({ ...opt, type: 'leaf' as const })); + } - if (!filterText) { - return options; + const optionMap = new Map(options.map(o => [o.id, o])); + const groupMap = new Map(groups.map(g => [g.id, g])); + + const convert = (items: ReadonlyArray): OptionTreeNode[] => { + const result: OptionTreeNode[] = []; + for (const item of items) { + if (item.type === 'group') { + const group = groupMap.get(item.id); + result.push({ + type: 'group', + id: item.id, + label: group?.label ?? item.id, + visible: item.visible, + children: convert(item.children), + }); + } else { + const option = optionMap.get(item.id); + if (option) { + result.push({ type: 'leaf', ...option, visible: item.visible }); + } + } + } + return result; + }; + + return convert(contentDisplay); +} + +/** + * Converts OptionTreeNode[] back to ContentDisplayItem[]. + */ +export function flattenOptionTree(tree: OptionTreeNode[]): ContentDisplayItem[] { + return tree.map(node => { + if (node.type === 'group') { + return { type: 'group' as const, id: node.id, visible: node.visible, children: flattenOptionTree(node.children) }; + } + return { id: node.id, visible: node.visible }; + }); +} + +/** + * Filters tree, keeping leaves matching filterText and groups with matching descendants. + */ +export function getFilteredTree(tree: OptionTreeNode[], filterText: string): OptionTreeNode[] { + const text = filterText.trim().toLowerCase(); + if (!text) { + return tree; + } + + const result: OptionTreeNode[] = []; + for (const node of tree) { + if (node.type === 'group') { + const children = getFilteredTree(node.children, text); + if (children.length > 0) { + result.push({ ...node, children }); + } + } else if (node.label.toLowerCase().includes(text)) { + result.push(node); + } } + return result; +} - return options.filter(option => option.label.toLowerCase().trim().includes(filterText)); +export function getFilteredOptions(options: ReadonlyArray, filterText: string) { + const text = filterText.trim().toLowerCase(); + if (!text) { + return options; + } + return options.filter(option => option.label.toLowerCase().includes(text)); } diff --git a/src/collection-preferences/index.tsx b/src/collection-preferences/index.tsx index 6e9bdecea5..ec882f66c1 100644 --- a/src/collection-preferences/index.tsx +++ b/src/collection-preferences/index.tsx @@ -24,6 +24,7 @@ import { getComponentAnalyticsMetadata } from './analytics-metadata/utils'; import ContentDisplayPreference from './content-display'; import { CollectionPreferencesProps } from './interfaces'; import { + collectVisibleIds, ContentDensityPreference, copyPreferences, CustomPreference, @@ -138,9 +139,10 @@ export default function CollectionPreferences({ // When both are used contentDisplayPreference takes preference and so we always prefer to use this as our visible columns if available if (preferences?.contentDisplay) { - tableComponentContext.preferencesRef.current.visibleColumns = preferences?.contentDisplay - .filter(column => column.visible) - .map(column => column.id); + tableComponentContext.preferencesRef.current.visibleColumns = collectVisibleIds( + preferences.contentDisplay, + true + ); } else if (preferences?.visibleContent) { tableComponentContext.preferencesRef.current.visibleColumns = [...preferences.visibleContent]; } diff --git a/src/collection-preferences/interfaces.ts b/src/collection-preferences/interfaces.ts index 5768238b0a..9923014694 100644 --- a/src/collection-preferences/interfaces.ts +++ b/src/collection-preferences/interfaces.ts @@ -109,6 +109,9 @@ export interface CollectionPreferencesProps extends * - `title` (string) - Specifies the text displayed at the top of the preference. * - `description` (string) - Specifies the description displayed below the title. * - `options` - Specifies an array of options for reordering and visible content selection. + * - `groups` - (Optional) Specifies an array of column group definitions for multi-level content display. Each group contains: + * - `id` (string) - A unique identifier for the group. + * - `label` (string) - The text displayed as the group label. * - `enableColumnFiltering` (boolean) - Adds a columns filter. * - `liveAnnouncementDndStarted` ((position: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when an option is picked. * - `liveAnnouncementDndDiscarded` (string) - (Optional) Adds a message to be announced by screen readers when a reordering action is canceled. @@ -123,6 +126,16 @@ export interface CollectionPreferencesProps extends * - `alwaysVisible` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to `false` by default. * * You must provide an ordered list of the items to display in the `preferences.contentDisplay` property. + * Each content display item is one of the following: + * - `ContentDisplayColumn` - Represents a single column. + * - `type` ('column') - (Optional) Identifies the entry as a column. Defaults to `'column'` when omitted. + * - `id` (string) - The column identifier. + * - `visible` (boolean) - Whether the column is visible. + * - `ContentDisplayGroup` - Represents a column group. + * - `type` ('group') - Identifies the entry as a group. + * - `id` (string) - The group identifier. + * - `visible` (boolean) - Whether the group is visible. + * - `children` (ReadonlyArray) - The columns or nested groups within this group. * @i18n */ contentDisplayPreference?: CollectionPreferencesProps.ContentDisplayPreference; @@ -229,19 +242,35 @@ export namespace CollectionPreferencesProps { title?: string; description?: string; options: ReadonlyArray; + groups?: ReadonlyArray; enableColumnFiltering?: boolean; i18nStrings?: ContentDisplayPreferenceI18nStrings; } + export interface ContentDisplayColumn { + type?: 'column'; + id: string; + visible: boolean; + } + + export interface ContentDisplayGroup { + type: 'group'; + id: string; + visible: boolean; + children: ReadonlyArray; + } + + export type ContentDisplayItem = ContentDisplayColumn | ContentDisplayGroup; + export interface ContentDisplayOption { id: string; label: string; alwaysVisible?: boolean; } - export interface ContentDisplayItem { + export interface ContentDisplayOptionGroup { id: string; - visible: boolean; + label: string; } export interface VisibleContentPreference { diff --git a/src/collection-preferences/utils.tsx b/src/collection-preferences/utils.tsx index f02981cab2..96dfb12cf5 100644 --- a/src/collection-preferences/utils.tsx +++ b/src/collection-preferences/utils.tsx @@ -230,6 +230,24 @@ export const StickyColumnsPreference = ({ ); }; +export const collectVisibleIds = ( + items: ReadonlyArray, + ancestorVisible: boolean +): string[] => { + const result: string[] = []; + for (const item of items) { + if (item.type === 'group') { + // istanbul ignore next: covered by integration tests + if (ancestorVisible && item.visible) { + result.push(...collectVisibleIds(item.children, true)); + } + } else if (ancestorVisible && item.visible) { + result.push(item.id); + } + } + return result; +}; + interface CustomPreferenceProps extends Pick, 'customPreference'> { onChange: (value: T) => void; value: T; diff --git a/src/test-utils/dom/collection-preferences/content-display-preference.ts b/src/test-utils/dom/collection-preferences/content-display-preference.ts index f3a9952be4..94ab6f0f2c 100644 --- a/src/test-utils/dom/collection-preferences/content-display-preference.ts +++ b/src/test-utils/dom/collection-preferences/content-display-preference.ts @@ -14,6 +14,7 @@ export class ContentDisplayOptionWrapper extends ComponentWrapper { private getListItem(): ListItemWrapper { return new ListItemWrapper(this.getElement()); } + /** * Returns the drag handle for the option item. */ @@ -30,12 +31,53 @@ export class ContentDisplayOptionWrapper extends ComponentWrapper { /** * Returns the visibility toggle for the option item. + * Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle. */ findVisibilityToggle(): ToggleWrapper { return this.getListItem() .findContent() .findComponent(`.${styles['content-display-option-toggle']}`, ToggleWrapper)!; } + + /** + * Returns all child option items nested under this item when it is a group. + * Returns `null` when this item is a leaf column (has no nested children). + * + * The children are the leaf-level `ContentDisplayOptionWrapper`s inside the group's + * nested `InternalList` — i.e. they already carry a drag handle and visibility toggle. + * + * @param option.group When `true`, returns only group items. When `false`, returns only leaf column items. + * When omitted, returns all child items regardless of type. + */ + /* istanbul ignore next: :has() selector not supported in JSDOM */ + findChildrenOptions( + option: { + group?: boolean; + } = {} + ): Array | null { + const groupWrapper = this.getListItem().findContent().find('[data-item-type="group"]'); + if (!groupWrapper) { + return null; + } + const nestedList = groupWrapper.find(`.${ListWrapper.rootSelector}`); + if (!nestedList) { + return null; + } + const list = new ListWrapper(nestedList.getElement()); + + if (option.group === true) { + return list + .findAll(`li:has([data-item-type="group"])`) + .map(item => new ContentDisplayOptionWrapper(item.getElement())); + } + if (option.group === false) { + return list + .findAll(`li:has([data-item-type="column"])`) + .map(item => new ContentDisplayOptionWrapper(item.getElement())); + } + + return list.findItems().map(item => new ContentDisplayOptionWrapper(item.getElement())); + } } export default class ContentDisplayPreferenceWrapper extends ComponentWrapper { @@ -70,9 +112,38 @@ export default class ContentDisplayPreferenceWrapper extends ComponentWrapper { } /** - * Returns options that the user can reorder. + * Returns the top-level items in the preference list. + * + * For tables **without** column grouping this returns all column options. + * For tables **with** column grouping this returns the top-level entries only + * (which are group items). Use `.findChildrenOptions()` on a group item to + * access the leaf columns nested within it. + * + * @param option.group When `true`, returns only group items. When `false`, returns only leaf column items. + * When omitted, returns all top-level items regardless of type. + * @param option.visible When `true`, returns only visible items. When `false`, returns only hidden items. + * Note that group items have no visibility toggle and are excluded when this filter is active. */ - findOptions(): Array { + findOptions( + option: { + group?: boolean; + } = {} + ): Array { + /* istanbul ignore next: :has() selector not supported in JSDOM */ if (option.group === true) { + // Only group items — identified by the data-item-type="group" wrapper inside the list item + return this.getList() + .findAll(`li:has([data-item-type="group"])`) + .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); + } + /* istanbul ignore next: :has() selector not supported in JSDOM */ + if (option.group === false) { + // Only leaf column items — identified by the data-item-type="column" wrapper + return this.getList() + .findAll(`li:has([data-item-type="column"])`) + .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); + } + + // No group filter — return all top-level items return this.getList() .findItems() .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement()));