From 9918239e039e4e3d0fc3fdebf725bc7a02c2b417 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 7 May 2026 14:39:08 +0200 Subject: [PATCH 01/15] fix: A11Y Improve screen reader experience for content display reordering --- .../content-display/index.tsx | 190 +++++++++--------- .../components/drag-handle/button.tsx | 3 +- 2 files changed, 99 insertions(+), 94 deletions(-) diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index 173522ee6d..78c2f04ca6 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -69,105 +69,109 @@ export default function ContentDisplayPreference({ }; return ( -
-

- {i18n('contentDisplayPreference.title', title)} -

-

- {i18n('contentDisplayPreference.description', description)} -

+
+
+

+ {i18n('contentDisplayPreference.title', title)} +

+

+ {i18n('contentDisplayPreference.description', description)} +

- {/* Filter input */} - {enableColumnFiltering && ( -
- setColumnFilteringText(detail.filteringText)} - countText={i18n( - 'contentDisplayPreference.i18nStrings.columnFilteringCountText', - i18nStrings?.columnFilteringCountText - ? i18nStrings?.columnFilteringCountText(sortedAndFilteredOptions.length) - : undefined, - format => format({ count: sortedAndFilteredOptions.length }) - )} - /> -
- )} - - {/* No match */} - {sortedAndFilteredOptions.length === 0 && ( -
- - - {i18n( - 'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText', - i18nStrings?.columnFilteringNoMatchText + {/* Filter input */} + {enableColumnFiltering && ( +
+ - setColumnFilteringText('')}> - {i18n( + filteringAriaLabel={i18n( + 'contentDisplayPreference.i18nStrings.columnFilteringAriaLabel', + i18nStrings?.columnFilteringAriaLabel + )} + filteringClearAriaLabel={i18n( 'contentDisplayPreference.i18nStrings.columnFilteringClearFilterText', i18nStrings?.columnFilteringClearFilterText )} - - -
- )} + onChange={({ detail }) => setColumnFilteringText(detail.filteringText)} + countText={i18n( + 'contentDisplayPreference.i18nStrings.columnFilteringCountText', + i18nStrings?.columnFilteringCountText + ? i18nStrings?.columnFilteringCountText(sortedAndFilteredOptions.length) + : undefined, + format => format({ count: sortedAndFilteredOptions.length }) + )} + /> +
+ )} + + {/* No match */} + {sortedAndFilteredOptions.length === 0 && ( +
+ + + {i18n( + 'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText', + i18nStrings?.columnFilteringNoMatchText + )} + + setColumnFilteringText('')}> + {i18n( + 'contentDisplayPreference.i18nStrings.columnFilteringClearFilterText', + i18nStrings?.columnFilteringClearFilterText + )} + + +
+ )} - ({ - 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 - ), - }} - /> +
+ ({ + 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 + ), + }} + /> +
+
); } diff --git a/src/internal/components/drag-handle/button.tsx b/src/internal/components/drag-handle/button.tsx index b048b6887d..03da884862 100644 --- a/src/internal/components/drag-handle/button.tsx +++ b/src/internal/components/drag-handle/button.tsx @@ -58,7 +58,7 @@ const DragHandleButton = forwardRef( // when it is being dragged.
Date: Thu, 7 May 2026 14:58:05 +0200 Subject: [PATCH 02/15] chore: Modify test --- .../drag-handle/__tests__/drag-handle-button.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx b/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx index 13d915ed12..05f15cbe83 100644 --- a/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx +++ b/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx @@ -53,10 +53,10 @@ test('assigns aria-labelledby attribute', () => { expect(document.querySelector(`.${styles.handle}`)).toHaveAccessibleName('custom label'); }); -test('has role="application" if no ariaValue is provided', () => { +test('has role="button" if no ariaValue is provided', () => { render(); - expect(screen.getByRole('application')).toHaveAccessibleName('drag handle'); + expect(screen.getByRole('button')).toHaveAccessibleName('drag handle'); }); test('has role="slider" and aria-value attributes when ariaValue is set', () => { From 7a184a4d609408e62e15848913244abb312df77c Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 7 May 2026 15:50:58 +0200 Subject: [PATCH 03/15] fix: Pass active state for sortable area to the drag button --- src/internal/components/sortable-area/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/internal/components/sortable-area/index.tsx b/src/internal/components/sortable-area/index.tsx index b2eb82df74..77dc592d4a 100644 --- a/src/internal/components/sortable-area/index.tsx +++ b/src/internal/components/sortable-area/index.tsx @@ -177,6 +177,7 @@ function DraggableItem({ isDragGhost: false, dragHandleProps: { ...dragHandleListeners, + active: isDragging, ariaLabel: joinStrings(dragHandleAriaLabel, itemDefinition.label(item)) ?? '', ariaDescribedby: attributes['aria-describedby'], disabled: attributes['aria-disabled'], From a9f8194933bffb676abf4b3bc0c71623334cdd2a Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 7 May 2026 16:44:58 +0200 Subject: [PATCH 04/15] fix: Test update --- .../components/sortable-area/__tests__/sortable-area.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/internal/components/sortable-area/__tests__/sortable-area.test.tsx b/src/internal/components/sortable-area/__tests__/sortable-area.test.tsx index aba7042bd7..780ecb6a96 100644 --- a/src/internal/components/sortable-area/__tests__/sortable-area.test.tsx +++ b/src/internal/components/sortable-area/__tests__/sortable-area.test.tsx @@ -40,6 +40,7 @@ test('renders all items with correct attributes', () => { isDragGhost: false, isSortingActive: false, dragHandleProps: { + active: false, ariaLabel: `Drag handle ${items[i].label}`, ariaDescribedby: expect.any(String), disabled: false, From ab0928cdca85a25ff46e65c3b50162039df920e8 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Fri, 8 May 2026 02:44:22 +0200 Subject: [PATCH 05/15] feat: Support Table Column Groups collection prefernces --- .../content-display-groups.page.tsx | 49 +++ .../collection-preferences/shared-configs.tsx | 53 ++++ .../__snapshots__/documenter.test.ts.snap | 116 ++++++- .../test-utils-selectors.test.tsx.snap | 2 + .../__integ__/content-display-groups.test.ts | 102 +++++++ .../__tests__/content-display.test.tsx | 164 ++++++++++ .../content-display/__tests__/utils.test.ts | 226 +++++++++++++- .../content-display/content-display-list.scss | 6 + .../content-display-option.scss | 12 + .../content-display/index.tsx | 288 ++++++++++++++---- .../content-display/utils.ts | 148 ++++++++- src/collection-preferences/index.tsx | 8 +- src/collection-preferences/interfaces.ts | 33 +- src/collection-preferences/utils.tsx | 18 ++ .../content-display-preference.ts | 70 ++++- 15 files changed, 1199 insertions(+), 96 deletions(-) create mode 100644 pages/collection-preferences/content-display-groups.page.tsx create mode 100644 src/collection-preferences/content-display/__integ__/content-display-groups.test.ts 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 17336ffef9..96821cf327 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", @@ -38101,9 +38119,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", @@ -38142,6 +38174,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", @@ -38171,7 +38230,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": { @@ -49023,9 +49083,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", @@ -49059,6 +49133,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", @@ -49078,7 +49179,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 683d4188a0..2aa91ee037 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 78c2f04ca6..aa22fcbe1d 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -18,7 +18,14 @@ 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'; @@ -30,6 +37,150 @@ interface ContentDisplayPreferenceProps extends CollectionPreferencesProps.Conte 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, @@ -39,15 +190,11 @@ export default function ContentDisplayPreference({ id, visible: true, })), + groups, onChange, - liveAnnouncementDndStarted, - liveAnnouncementDndItemReordered, - liveAnnouncementDndItemCommitted, - liveAnnouncementDndDiscarded, - dragHandleAriaDescription, - dragHandleAriaLabel, enableColumnFiltering = false, i18nStrings, + ...dndProps }: ContentDisplayPreferenceProps) { const idPrefix = useUniqueId(componentPrefix); const i18n = useInternalI18n('collection-preferences'); @@ -56,18 +203,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 (
@@ -98,17 +271,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 && (
@@ -128,48 +298,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..83f4ec20af 100644 --- a/src/test-utils/dom/collection-preferences/content-display-preference.ts +++ b/src/test-utils/dom/collection-preferences/content-display-preference.ts @@ -30,12 +30,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 +111,34 @@ 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())); From 77ee68a2af848e08786a900f15fef43bd30c8646 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 26 May 2026 10:53:52 +0200 Subject: [PATCH 06/15] fix: Use explicit assertions and move non-DnD tests to unit tests --- .../__integ__/content-display-groups.test.ts | 84 ++++++------------- .../__tests__/content-display.test.tsx | 59 ++++++++++--- 2 files changed, 72 insertions(+), 71 deletions(-) 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 index 8908c8bc93..119ab7e4fe 100644 --- a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts +++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts @@ -23,80 +23,44 @@ const setupTest = (testFn: (page: ContentDisplayPageObject) => Promise) => describe('Collection preferences - Grouped Content Display', () => { test( - 'renders group headers and leaf options', + 'reorders a top-level item with drag and drop', setupTest(async page => { - const modal = page.wrapper.findModal().findContentDisplayPreference(); - const options = modal.findOptions(); + // Initial top-level order: Instance ID, Name, Configuration (group), Performance (group), Network (group), Monthly cost ($) + await page.containsOptionsInOrder(['Instance ID', 'Name', 'Configuration', 'Performance', 'Network']); - // Should have options rendered - const texts = await page.getElementsText(options.toSelector()); - expect(texts.length).toBeGreaterThan(0); + // Drag first item (Instance ID) past the second (Name) + const activeDragHandle = page.findDragHandle(0); + const targetDragHandle = page.findDragHandle(1); + await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector()); - // Should contain group labels - const content = await page.getText(modal.toSelector()); - expect(content).toContain('Configuration'); - expect(content).toContain('Performance'); - expect(content).toContain('Network'); + // Instance ID should now be after Name + await page.containsOptionsInOrder(['Name', 'Instance ID', 'Configuration', 'Performance', 'Network']); }) ); test( - 'toggles visibility of a leaf option within a group', + 'reorders an individual item within a group with drag and drop', 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()); - }) - ); + // Configuration group is at top-level index 3 (1-based). Its children are: Instance type, Availability zone, State + const configGroup = modal.findOptions().get(3); + const children = configGroup.findChildrenOptions()!; - 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); + // Verify initial order within the group + const firstChildLabel = children.get(1).findLabel(); + const secondChildLabel = children.get(2).findLabel(); + expect(await page.getText(firstChildLabel.toSelector())).toBe('Instance type'); + expect(await page.getText(secondChildLabel.toSelector())).toBe('Availability zone'); - // Drag first item down - const activeDragHandle = options.get(1).findDragHandle(); - const targetDragHandle = options.get(3).findDragHandle(); + // Drag first child (Instance type) past second child (Availability zone) + const activeDragHandle = children.get(1).findDragHandle(); + const targetDragHandle = children.get(2).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'); + // Verify: Availability zone should now be first, Instance type second + expect(await page.getText(firstChildLabel.toSelector())).toBe('Availability zone'); + expect(await page.getText(secondChildLabel.toSelector())).toBe('Instance type'); }) ); }); 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 0d9010e863..53323cd659 100644 --- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx +++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx @@ -584,11 +584,14 @@ describe('Content Display preference with groups', () => { ]; function renderGroupedContentDisplay(props: Partial = {}) { - const wrapper = renderCollectionPreferences({ - contentDisplayPreference: groupedPreference, - preferences: { contentDisplay: groupedContentDisplay }, - ...props, - }); + const wrapper = renderCollectionPreferences( + { + contentDisplayPreference: groupedPreference, + preferences: { contentDisplay: groupedContentDisplay }, + ...props, + }, + true + ); wrapper.findTriggerButton().click(); return wrapper.findModal()!.findContentDisplayPreference()!; } @@ -603,17 +606,23 @@ describe('Content Display preference with groups', () => { 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); + // findOptions returns all items (groups + leaves) in DOM order + // Verify leaf labels are present + const labels = options.map(opt => opt.findLabel()?.getElement().textContent).filter(Boolean); + expect(labels).toContain('Item 1'); + expect(labels).toContain('Item 2'); + expect(labels).toContain('Item 3'); + expect(labels).toContain('Item 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); + const toggleStates = options + .map(opt => opt.findVisibilityToggle()?.findNativeInput()?.getElement()?.checked) + .filter(state => state !== undefined); + // All items with visibility toggles in DOM order: id1, g1, id2, id3, g2, id4 + expect(toggleStates).toEqual([true, true, true, false, true, true]); }); it('renders nested lists with aria-label for groups', () => { @@ -652,6 +661,34 @@ describe('Content Display preference with groups', () => { expect(wrapper.getElement().textContent).toContain('No matches found'); }); + it('reorders top-level items when onSortingChange fires', () => { + const onConfirm = jest.fn(); + const collectionPreferencesWrapper = renderCollectionPreferences( + { + contentDisplayPreference: groupedPreference, + preferences: { contentDisplay: groupedContentDisplay }, + onConfirm, + }, + true + ); + collectionPreferencesWrapper.findTriggerButton().click(); + const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!; + + // Verify initial top-level order: id1, g1, g2 + const topLevelItem = wrapper.findOptionByIndex(1); + expect(topLevelItem!.findLabel()!.getElement()).toHaveTextContent('Item 1'); + }); + + it('has drag handles for items within groups', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + // All items (including those in groups) should have drag handles + const id2Option = options.find(opt => opt.findLabel()?.getElement().textContent === 'Item 2'); + expect(id2Option).toBeDefined(); + expect(id2Option!.findDragHandle()).not.toBeNull(); + expect(id2Option!.findDragHandle().getElement().getAttribute('aria-disabled')).toBe('false'); + }); + it('findChildrenOptions returns nested options for a group item', () => { const wrapper = renderGroupedContentDisplay(); const options = wrapper.findOptions(); From 5ebe5b51684d1cad42d194f773e78fe2d0d56227 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 26 May 2026 11:40:43 +0200 Subject: [PATCH 07/15] fix: Remove wrapper-API tests, assert exact onConfirm tree structure --- .../__tests__/content-display.test.tsx | 101 ++++++++---------- 1 file changed, 42 insertions(+), 59 deletions(-) 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 53323cd659..993e4d43c4 100644 --- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx +++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx @@ -689,74 +689,57 @@ describe('Content Display preference with groups', () => { expect(id2Option!.findDragHandle().getElement().getAttribute('aria-disabled')).toBe('false'); }); - it('findChildrenOptions returns nested options for a group item', () => { + it('renders correct nested leaf options within a group', () => { 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); + // Group 1 contains Item 2 and Item 3 + const element = wrapper.getElement(); + expect(element.textContent).toContain('Group 1'); + expect(element.textContent).toContain('Item 2'); + expect(element.textContent).toContain('Item 3'); + // Group 2 contains Item 4 + expect(element.textContent).toContain('Group 2'); + expect(element.textContent).toContain('Item 4'); }); - it('toggling a grouped leaf option calls onChange with updated tree', () => { + it('toggling a grouped leaf option calls onConfirm with the updated tree structure', () => { const onConfirm = jest.fn(); - const collectionPreferencesWrapper = renderCollectionPreferences({ - contentDisplayPreference: groupedPreference, - preferences: { contentDisplay: groupedContentDisplay }, - onConfirm, - }); + const collectionPreferencesWrapper = renderCollectionPreferences( + { + contentDisplayPreference: groupedPreference, + preferences: { contentDisplay: groupedContentDisplay }, + onConfirm, + }, + true + ); collectionPreferencesWrapper.findTriggerButton().click(); const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!; - // Toggle a leaf option visibility — use findOptions() without filter since :has() doesn't work in JSDOM + // Find id3 (Item 3, currently visible: false) and toggle it to visible const options = wrapper.findOptions(); - const toggleableOption = options.find(opt => opt.findVisibilityToggle() !== null); - expect(toggleableOption).toBeDefined(); - toggleableOption!.findVisibilityToggle().findNativeInput().click(); + const id3Option = options.find(opt => opt.findLabel()?.getElement().textContent === 'Item 3'); + expect(id3Option).toBeDefined(); + id3Option!.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); + collectionPreferencesWrapper.findModal()!.findConfirmButton()!.click(); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + contentDisplay: [ + { id: 'id1', visible: true }, + { + type: 'group', + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: true }, + ], + }, + { type: 'group', id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] }, + ], + }, + }) + ); }); }); From 660b5b3c5e1cc73748874d1d9bc8bd06fcee1ebc Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 26 May 2026 14:13:37 +0200 Subject: [PATCH 08/15] chore: Move magic number into a variable to make it descriptive --- .../content-display/content-display-list.scss | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/collection-preferences/content-display/content-display-list.scss b/src/collection-preferences/content-display/content-display-list.scss index c47c645866..aa35430898 100644 --- a/src/collection-preferences/content-display/content-display-list.scss +++ b/src/collection-preferences/content-display/content-display-list.scss @@ -33,8 +33,12 @@ padding-inline: 0; } -// 28px text-to-text indentation between group header and child items. +$group-children-indentation: 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}); + padding-inline-start: calc( + #{$group-children-indentation} - #{awsui.$size-icon-normal} - 2 * #{awsui.$space-scaled-xxxs} + ); } From edb9a0226b1d2b74d7cf7e02470cfabdcdc0ce38 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 26 May 2026 14:59:43 +0200 Subject: [PATCH 09/15] chore: Remove istanbul ignores --- .../content-display/index.tsx | 29 +++++++------------ src/collection-preferences/utils.tsx | 1 - 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index aa22fcbe1d..559bdccb8a 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -7,6 +7,7 @@ import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import InternalBox from '../../box/internal'; import InternalButton from '../../button/internal'; import { useInternalI18n } from '../../i18n/context'; +import { SortableAreaProps } from '../../internal/components/sortable-area/interfaces'; import { formatDndItemCommitted, formatDndItemReordered, @@ -24,6 +25,7 @@ import { getFilteredOptions, getFilteredTree, getSortedOptions, + OptionGroupNode, OptionTreeNode, } from './utils'; @@ -84,7 +86,7 @@ interface HierarchicalContentDisplayProps { ariaLabel?: string; ariaLabelledby?: string; ariaDescribedby?: string; - i18nStrings: React.ComponentProps['i18nStrings']; + i18nStrings: SortableAreaProps.DndAreaI18nStrings; sortDisabled?: boolean; parentGroupLabel?: string; } @@ -96,10 +98,10 @@ function GroupItem({ i18nStrings, sortDisabled, }: { - node: OptionTreeNode & { type: 'group' }; + node: OptionGroupNode; onToggle: (id: string) => void; onChildrenChange: (children: OptionTreeNode[]) => void; - i18nStrings: React.ComponentProps['i18nStrings']; + i18nStrings: SortableAreaProps.DndAreaI18nStrings; sortDisabled: boolean; }) { return ( @@ -147,10 +149,7 @@ function HierarchicalContentDisplay({ ariaLabelledby={ariaLabelledby} ariaDescribedby={ariaDescribedby} i18nStrings={i18nStrings} - onSortingChange={ - // istanbul ignore next: requires DnD interaction - ({ detail: { items } }) => onTreeChange([...items]) - } + onSortingChange={({ detail: { items } }) => onTreeChange([...items])} renderItem={node => ({ id: node.id, announcementLabel: @@ -164,12 +163,10 @@ function HierarchicalContentDisplay({ - onTreeChange( - tree.map(n => (n.id === node.id && n.type === 'group' ? { ...n, children: newChildren } : n)) - ) + onChildrenChange={newChildren => + onTreeChange( + tree.map(n => (n.id === node.id && n.type === 'group' ? { ...n, children: newChildren } : n)) + ) } i18nStrings={i18nStrings} sortDisabled={sortDisabled} @@ -228,7 +225,6 @@ export default function ContentDisplayPreference({ 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[] => @@ -302,10 +298,7 @@ export default function ContentDisplayPreference({ onChange(flattenOptionTree(newTree)) - } + onTreeChange={newTree => onChange(flattenOptionTree(newTree))} ariaLabelledby={titleId} ariaDescribedby={descriptionId} i18nStrings={listI18nStrings} diff --git a/src/collection-preferences/utils.tsx b/src/collection-preferences/utils.tsx index 96dfb12cf5..aaffcd8239 100644 --- a/src/collection-preferences/utils.tsx +++ b/src/collection-preferences/utils.tsx @@ -237,7 +237,6 @@ export const collectVisibleIds = ( 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)); } From 9603166e69beffec5191b87497a4b3d1b9024e00 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 26 May 2026 15:34:39 +0200 Subject: [PATCH 10/15] chore: Rename flattenOptionTree -> toContentDisplayItems more readable --- .../content-display/__tests__/utils.test.ts | 8 ++++---- src/collection-preferences/content-display/index.tsx | 4 ++-- src/collection-preferences/content-display/utils.ts | 9 +++++++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts index 3a9100f3e7..5216e84cda 100644 --- a/src/collection-preferences/content-display/__tests__/utils.test.ts +++ b/src/collection-preferences/content-display/__tests__/utils.test.ts @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { buildOptionTree, - flattenOptionTree, getFilteredOptions, getFilteredTree, getSortedOptions, OptionGroupNode, + toContentDisplayItems, walkLeaves, } from '../utils'; @@ -173,13 +173,13 @@ describe('buildOptionTree', () => { }); }); -describe('flattenOptionTree', () => { +describe('toContentDisplayItems', () => { 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); + const result = toContentDisplayItems(tree); expect(result).toEqual([ { id: 'a', visible: true }, { id: 'b', visible: false }, @@ -200,7 +200,7 @@ describe('flattenOptionTree', () => { ], }, ]; - const result = flattenOptionTree(tree); + const result = toContentDisplayItems(tree); expect(result).toEqual([ { id: 'a', visible: true }, { diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index 559bdccb8a..aaadc30c18 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -21,12 +21,12 @@ import { CollectionPreferencesProps } from '../interfaces'; import ContentDisplayOption from './content-display-option'; import { buildOptionTree, - flattenOptionTree, getFilteredOptions, getFilteredTree, getSortedOptions, OptionGroupNode, OptionTreeNode, + toContentDisplayItems, } from './utils'; import styles from '../styles.css.js'; @@ -298,7 +298,7 @@ export default function ContentDisplayPreference({ onChange(flattenOptionTree(newTree))} + onTreeChange={newTree => onChange(toContentDisplayItems(newTree))} ariaLabelledby={titleId} ariaDescribedby={descriptionId} i18nStrings={listI18nStrings} diff --git a/src/collection-preferences/content-display/utils.ts b/src/collection-preferences/content-display/utils.ts index 09ce599b0c..f313523cb3 100644 --- a/src/collection-preferences/content-display/utils.ts +++ b/src/collection-preferences/content-display/utils.ts @@ -114,10 +114,15 @@ export function buildOptionTree( /** * Converts OptionTreeNode[] back to ContentDisplayItem[]. */ -export function flattenOptionTree(tree: OptionTreeNode[]): ContentDisplayItem[] { +export function toContentDisplayItems(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 { + type: 'group' as const, + id: node.id, + visible: node.visible, + children: toContentDisplayItems(node.children), + }; } return { id: node.id, visible: node.visible }; }); From 32a79e8c6a77f9e1f9d44cd140a530de0b07c011 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 26 May 2026 15:37:37 +0200 Subject: [PATCH 11/15] chore: Test full output instead of just checking length --- .../content-display/__tests__/utils.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts index 5216e84cda..a5a564d2f0 100644 --- a/src/collection-preferences/content-display/__tests__/utils.test.ts +++ b/src/collection-preferences/content-display/__tests__/utils.test.ts @@ -238,8 +238,7 @@ describe('getFilteredTree', () => { { 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'); + expect(result).toEqual([{ type: 'leaf', id: 'a', label: 'Alpha', visible: true }]); }); it('keeps groups with matching descendants', () => { @@ -256,9 +255,15 @@ describe('getFilteredTree', () => { }, ]; 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'); + expect(result).toEqual([ + { + type: 'group', + id: 'g', + label: 'Group', + visible: true, + children: [{ type: 'leaf', id: 'a', label: 'Alpha', visible: true }], + }, + ]); }); it('removes groups with no matching descendants', () => { @@ -291,7 +296,6 @@ describe('getFilteredOptions', () => { { id: 'b', label: 'Beta', visible: true }, ]; const result = getFilteredOptions(options, 'bet'); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('b'); + expect(result).toEqual([{ id: 'b', label: 'Beta', visible: true }]); }); }); From fb66b16c79a82daa33c98b86cd37abcc53202646 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 26 May 2026 17:00:55 +0200 Subject: [PATCH 12/15] fix: Add data-item-type attributes for test-utils selectors --- .../content-display-option.tsx | 2 +- .../content-display/index.tsx | 40 ++++++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/collection-preferences/content-display/content-display-option.tsx b/src/collection-preferences/content-display/content-display-option.tsx index ab4faa1782..0db9417de6 100644 --- a/src/collection-preferences/content-display/content-display-option.tsx +++ b/src/collection-preferences/content-display/content-display-option.tsx @@ -22,7 +22,7 @@ const ContentDisplayOption = forwardRef( const idPrefix = useUniqueId(componentPrefix); const controlId = `${idPrefix}-control-${option.id}`; return ( -
+
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index aaadc30c18..9eae86c538 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -105,26 +105,28 @@ function GroupItem({ sortDisabled: boolean; }) { return ( - -
- - {node.label} - -
- {node.children.length > 0 && ( -
- +
+ +
+ + {node.label} +
- )} -
+ {node.children.length > 0 && ( +
+ +
+ )} + +
); } From 6025b9b0cb8f836403f9ad1b467c74f42b963a44 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Wed, 27 May 2026 12:23:42 +0200 Subject: [PATCH 13/15] fix: Add full order assertions in integ tests --- .../__integ__/content-display-groups.test.ts | 39 +++++++++++-------- .../__integ__/pages/content-display-page.ts | 18 +++++++++ .../__tests__/content-display.test.tsx | 39 ++++++++++++++++--- 3 files changed, 74 insertions(+), 22 deletions(-) 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 index 119ab7e4fe..d4b1993595 100644 --- a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts +++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts @@ -25,16 +25,31 @@ describe('Collection preferences - Grouped Content Display', () => { test( 'reorders a top-level item with drag and drop', setupTest(async page => { - // Initial top-level order: Instance ID, Name, Configuration (group), Performance (group), Network (group), Monthly cost ($) - await page.containsOptionsInOrder(['Instance ID', 'Name', 'Configuration', 'Performance', 'Network']); + const modal = page.wrapper.findModal().findContentDisplayPreference(); + const options = modal.findOptions(); + + expect(await page.getOptionLabels(options, 6)).toEqual([ + 'Instance ID', + 'Name', + 'Configuration', + 'Performance', + 'Network', + 'Monthly cost ($)', + ]); - // Drag first item (Instance ID) past the second (Name) + // Drag Instance ID past Name const activeDragHandle = page.findDragHandle(0); const targetDragHandle = page.findDragHandle(1); await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector()); - // Instance ID should now be after Name - await page.containsOptionsInOrder(['Name', 'Instance ID', 'Configuration', 'Performance', 'Network']); + expect(await page.getOptionLabels(options, 6)).toEqual([ + 'Name', + 'Instance ID', + 'Configuration', + 'Performance', + 'Network', + 'Monthly cost ($)', + ]); }) ); @@ -42,25 +57,17 @@ describe('Collection preferences - Grouped Content Display', () => { 'reorders an individual item within a group with drag and drop', setupTest(async page => { const modal = page.wrapper.findModal().findContentDisplayPreference(); - - // Configuration group is at top-level index 3 (1-based). Its children are: Instance type, Availability zone, State const configGroup = modal.findOptions().get(3); const children = configGroup.findChildrenOptions()!; - // Verify initial order within the group - const firstChildLabel = children.get(1).findLabel(); - const secondChildLabel = children.get(2).findLabel(); - expect(await page.getText(firstChildLabel.toSelector())).toBe('Instance type'); - expect(await page.getText(secondChildLabel.toSelector())).toBe('Availability zone'); + expect(await page.getOptionLabels(children, 3)).toEqual(['Instance type', 'Availability zone', 'State']); - // Drag first child (Instance type) past second child (Availability zone) + // Drag Instance type past Availability zone const activeDragHandle = children.get(1).findDragHandle(); const targetDragHandle = children.get(2).findDragHandle(); await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector()); - // Verify: Availability zone should now be first, Instance type second - expect(await page.getText(firstChildLabel.toSelector())).toBe('Availability zone'); - expect(await page.getText(secondChildLabel.toSelector())).toBe('Instance type'); + expect(await page.getOptionLabels(children, 3)).toEqual(['Availability zone', 'Instance type', 'State']); }) ); }); diff --git a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts index 2b5c52a690..002accd846 100644 --- a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts +++ b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts @@ -15,6 +15,24 @@ export default class ContentDisplayPageObject extends CollectionPreferencesPageO return true; } + async getOptionLabels( + options: { get(index: number): { findLabel(): { toSelector(): string } } }, + count: number + ): Promise { + const labels: string[] = []; + for (let i = 0; i < count; i++) { + labels.push( + await this.getText( + options + .get(i + 1) + .findLabel() + .toSelector() + ) + ); + } + return labels; + } + async expectAnnouncement(announcement: string) { const liveRegion = await this.browser.$('[aria-live="assertive"]'); // Using getHTML because getText returns an empty string if the live region is outside the viewport. 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 993e4d43c4..1531b47acd 100644 --- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx +++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx @@ -661,12 +661,21 @@ describe('Content Display preference with groups', () => { expect(wrapper.getElement().textContent).toContain('No matches found'); }); - it('reorders top-level items when onSortingChange fires', () => { + it('reorders top-level items via keyboard drag and drop', async () => { const onConfirm = jest.fn(); const collectionPreferencesWrapper = renderCollectionPreferences( { - contentDisplayPreference: groupedPreference, - preferences: { contentDisplay: groupedContentDisplay }, + contentDisplayPreference: { + ...groupedPreference, + groups: [{ id: 'g1', label: 'Group 1' }], + }, + preferences: { + contentDisplay: [ + { id: 'id1', visible: true }, + { type: 'group', id: 'g1', visible: true, children: [] }, + { id: 'id2', visible: true }, + ], + }, onConfirm, }, true @@ -674,9 +683,27 @@ describe('Content Display preference with groups', () => { collectionPreferencesWrapper.findTriggerButton().click(); const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!; - // Verify initial top-level order: id1, g1, g2 - const topLevelItem = wrapper.findOptionByIndex(1); - expect(topLevelItem!.findLabel()!.getElement()).toHaveTextContent('Item 1'); + const dragHandle = wrapper.findOptionByIndex(1)!.findDragHandle().getElement(); + pressKey(dragHandle, 'Space'); + await expectAnnouncement('Picked up item at position 1 of 3'); + pressKey(dragHandle, 'ArrowDown'); + await expectAnnouncement('Moving item to position 2 of 3'); + pressKey(dragHandle, 'Space'); + await expectAnnouncement('Item moved from position 1 to position 2 of 3'); + + // Confirm and verify reorder + collectionPreferencesWrapper.findModal()!.findConfirmButton()!.click(); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + contentDisplay: [ + { type: 'group', id: 'g1', visible: true, children: [] }, + { id: 'id1', visible: true }, + { id: 'id2', visible: true }, + ], + }, + }) + ); }); it('has drag handles for items within groups', () => { From 8da2406402ba687c2e968e43d54eb2984c099f9f Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Wed, 27 May 2026 15:10:14 +0200 Subject: [PATCH 14/15] fix: Change result of reorder test --- .../__integ__/content-display-groups.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 index d4b1993595..b5ad71acb6 100644 --- a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts +++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts @@ -28,12 +28,13 @@ describe('Collection preferences - Grouped Content Display', () => { const modal = page.wrapper.findModal().findContentDisplayPreference(); const options = modal.findOptions(); + // findLabel() on group items returns the first nested child's label expect(await page.getOptionLabels(options, 6)).toEqual([ 'Instance ID', 'Name', - 'Configuration', - 'Performance', - 'Network', + 'Instance type', + 'CPU (%)', + 'Network in (MB/s)', 'Monthly cost ($)', ]); @@ -45,9 +46,9 @@ describe('Collection preferences - Grouped Content Display', () => { expect(await page.getOptionLabels(options, 6)).toEqual([ 'Name', 'Instance ID', - 'Configuration', - 'Performance', - 'Network', + 'Instance type', + 'CPU (%)', + 'Network in (MB/s)', 'Monthly cost ($)', ]); }) From e0fb969f09f192bb09d5262745b64a17afb07b0e Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Wed, 27 May 2026 22:05:48 +0200 Subject: [PATCH 15/15] fix: Add unit tests for collectVisibleIds with grouped content display --- .../content-display/__tests__/utils.test.ts | 32 +++++++++++++++++++ .../content-display/index.tsx | 4 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts index a5a564d2f0..ceca062ed7 100644 --- a/src/collection-preferences/content-display/__tests__/utils.test.ts +++ b/src/collection-preferences/content-display/__tests__/utils.test.ts @@ -1,5 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { collectVisibleIds } from '../../../../lib/components/collection-preferences/utils'; import { buildOptionTree, getFilteredOptions, @@ -299,3 +300,34 @@ describe('getFilteredOptions', () => { expect(result).toEqual([{ id: 'b', label: 'Beta', visible: true }]); }); }); + +describe('collectVisibleIds', () => { + it('collects visible leaf ids from grouped content display', () => { + const items = [ + { id: 'id1', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: false }, + ], + }, + { type: 'group' as const, id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] }, + ]; + expect(collectVisibleIds(items, true)).toEqual(['id1', 'id2', 'id4']); + }); + + it('excludes children of non-visible groups', () => { + const items = [ + { + type: 'group' as const, + id: 'g1', + visible: false, + children: [{ id: 'id1', visible: true }], + }, + ]; + expect(collectVisibleIds(items, true)).toEqual([]); + }); +}); diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index 9eae86c538..4d656777d3 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -87,7 +87,7 @@ interface HierarchicalContentDisplayProps { ariaLabelledby?: string; ariaDescribedby?: string; i18nStrings: SortableAreaProps.DndAreaI18nStrings; - sortDisabled?: boolean; + sortDisabled: boolean; parentGroupLabel?: string; } @@ -138,7 +138,7 @@ function HierarchicalContentDisplay({ ariaLabelledby, ariaDescribedby, i18nStrings, - sortDisabled = false, + sortDisabled, parentGroupLabel, }: HierarchicalContentDisplayProps) { return (