From d1897bea59638555fd5a0af479c1db27e417423c Mon Sep 17 00:00:00 2001 From: Andres Pinto <143480783+apsantiso@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:40:52 +0100 Subject: [PATCH] feat(list): add getItemId prop for custom item identity --- package.json | 2 +- src/components/data-display/list/List.tsx | 24 +++++--- src/components/data-display/list/ListItem.tsx | 2 +- .../data-display/list/__test__/List.test.tsx | 58 +++++++++++++++++++ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 18c618d..5375550 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internxt/ui", - "version": "0.1.29", + "version": "0.1.30", "description": "Library of Internxt components", "repository": { "type": "git", diff --git a/src/components/data-display/list/List.tsx b/src/components/data-display/list/List.tsx index 7273645..1e15224 100644 --- a/src/components/data-display/list/List.tsx +++ b/src/components/data-display/list/List.tsx @@ -33,6 +33,7 @@ export interface ListProps { onMouseEnter?: () => void; onMouseLeave?: () => void; headerBackgroundColor?: string; + getItemId?: (item: T) => string | number; keyBoardShortcutActions?: { onShiftFKeysPressed?: () => void; onRKeyPressed?: () => void; @@ -121,6 +122,10 @@ export interface ListProps { * @property {string} [headerBackgroundColor] * - Optional background color for the header. * + * @property {(item: T) => string | number} [getItemId] + * - Optional function returning the unique identity of an item (used for selection, context menu and React keys). + * Defaults to `item.id`. + * * @property { * { onShiftFKeysPressed?: () => void; onRKeyPressed?: () => void; onBackspaceKeyPressed?: () => void } * } [keyBoardShortcutActions] @@ -130,7 +135,7 @@ export interface ListProps { * - The rendered list component. */ -const List = ({ +const List = ({ header, checkboxDataCy, items, @@ -157,15 +162,18 @@ const List = ({ headerBackgroundColor = 'bg-surface', keyBoardShortcutActions, disableKeyboardShortcuts, + getItemId, }: ListProps): JSX.Element => { const containerRef = useRef(null); + const getKey = getItemId ?? ((item: T) => item.id as string | number); const isItemSelected = (item: T) => { - return selectedItems.some((i) => item.id === i.id); + const key = getKey(item); + return selectedItems.some((i) => getKey(i) === key); }; const container = document.getElementById('scrollableList'); const isVerticalScrollbarVisible = container && container.scrollHeight > container.clientHeight; const isEmptyState = !hasMoreItems && items.length === 0 && !isLoading; - const [idItemContextMenuOpen, setIdItemContextMenuOpen] = useState(null); + const [idItemContextMenuOpen, setIdItemContextMenuOpen] = useState(null); const skeletonData = new Array(25).fill(0).map(() => ({ skeletonItem: skinSkeleton, @@ -213,7 +221,7 @@ const List = ({ }; const selectAllItems = () => { - const notSelectedItems = items.filter((item) => !selectedItems.some((s) => s.id === item.id)); + const notSelectedItems = items.filter((item) => !selectedItems.some((s) => getKey(s) === getKey(item))); const changesToMake = notSelectedItems.map((item) => ({ props: item, value: true })); onSelectedItemsChanged(changesToMake); }; @@ -278,7 +286,7 @@ const List = ({ if (!isItemSelected(props)) { unselectAllItemsAndSelectOne(props); } - setIdItemContextMenuOpen(props.id); + setIdItemContextMenuOpen(getKey(props)); }; const onCloseContextMenu = () => { @@ -288,7 +296,7 @@ const List = ({ const handleThreeDotsButtonClick = (e: React.MouseEvent, item: T) => { e.stopPropagation(); - setIdItemContextMenuOpen((prevId) => (prevId ? null : item.id)); + setIdItemContextMenuOpen((prevId) => (prevId !== null ? null : getKey(item))); if (!isItemSelected(item)) { unselectAllItemsAndSelectOne(item); } @@ -330,10 +338,10 @@ const List = ({ > {items.map((item, index) => ( - key={item.id} + key={getKey(item)} item={item} listIndex={index} - isOpen={idItemContextMenuOpen === item.id} + isOpen={idItemContextMenuOpen === getKey(item)} onClose={onCloseContextMenu} itemComposition={itemComposition} selected={isItemSelected(item)} diff --git a/src/components/data-display/list/ListItem.tsx b/src/components/data-display/list/ListItem.tsx index 17257bc..27f3291 100644 --- a/src/components/data-display/list/ListItem.tsx +++ b/src/components/data-display/list/ListItem.tsx @@ -26,7 +26,7 @@ interface ListItemProps { } const TOP_MIN_HEIGHT = 500; -const ListItem = ({ +const ListItem = ({ item, listIndex, itemComposition, diff --git a/src/components/data-display/list/__test__/List.test.tsx b/src/components/data-display/list/__test__/List.test.tsx index f67a73d..1461075 100644 --- a/src/components/data-display/list/__test__/List.test.tsx +++ b/src/components/data-display/list/__test__/List.test.tsx @@ -348,3 +348,61 @@ describe('List component', () => { expect(mockOnEnterPressed).toHaveBeenCalledWith(items[0]); }); }); + +describe('List component with getItemId', () => { + type UuidItem = { id?: number; uuid: string; name: string; age: number }; + + const uuidHeader: HeaderProps[] = [ + { label: 'Name', width: '100px', name: 'name', orderable: true, defaultDirection: 'ASC' }, + ]; + const uuidItems: UuidItem[] = [ + { id: 0, uuid: 'uuid-1', name: 'File 1', age: 25 }, + { id: 0, uuid: 'uuid-2', name: 'File 2', age: 30 }, + { uuid: 'uuid-3', name: 'File 3', age: 35 }, + ]; + + const renderUuidList = (selected: UuidItem[]) => + render( + + header={uuidHeader} + items={uuidItems} + selectedItems={selected} + itemComposition={[(props) =>
{props.name}
]} + menu={menu} + onSelectedItemsChanged={mockOnSelectedItemsChanged} + getItemId={(item) => item.uuid} + />, + ); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should select all not selected items by uuid when ids collide or are missing', () => { + const { container } = renderUuidList([uuidItems[0]]); + + fireEvent.keyDown(container, { key: 'a', ctrlKey: true }); + + expect(mockOnSelectedItemsChanged).toHaveBeenCalledWith([ + { props: uuidItems[1], value: true }, + { props: uuidItems[2], value: true }, + ]); + }); + + it('should toggle selection by uuid when Ctrl key is pressed', () => { + const { getByText } = renderUuidList([uuidItems[0]]); + + fireEvent.click(getByText('File 2'), { ctrlKey: true }); + + expect(mockOnSelectedItemsChanged).toHaveBeenCalledWith([{ props: uuidItems[1], value: true }]); + }); + + it('should open context menu only for the right clicked item', () => { + const { getByText, getAllByText } = renderUuidList([uuidItems[1]]); + + fireEvent.contextMenu(getByText('File 2')); + + expect(mockOnSelectedItemsChanged).not.toHaveBeenCalled(); + expect(getAllByText('Option 1')).toHaveLength(1); + }); +});