From e3b471e1e81b08756c6443c830f6388626ff92d3 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 9 Feb 2026 15:24:29 +0100 Subject: [PATCH 01/17] feat(AnalyticalTable): refactor onRowSelect to use instance-based pending event --- .../AnalyticalTable.module.css | 22 ++-- .../hooks/useRowSelectionColumn.tsx | 40 +------ .../hooks/useSelectionChangeCallback.ts | 104 ++++++++++-------- .../hooks/useSingleRowStateSelection.ts | 10 +- .../tableReducer/stateReducer.ts | 2 - .../components/AnalyticalTable/types/index.ts | 14 +++ 6 files changed, 93 insertions(+), 99 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css b/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css index 8e1149631f6..570c239800e 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css @@ -369,14 +369,20 @@ user-select: none; } -.checkBox::part(root) { - display: flex; - width: unset; - height: unset; - justify-content: center; - min-height: unset; - min-width: unset; - padding: 0; +.checkBox { + vertical-align: middle; + pointer-events: none; + display: block; + + &::part(root) { + display: flex; + width: unset; + height: unset; + justify-content: center; + min-height: unset; + min-width: unset; + padding: 0; + } } /* ========================================================================== diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx b/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx index 4e2090cf84a..abd7a91c903 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx @@ -1,16 +1,8 @@ -import { CssSizeVariablesNames, enrichEventWithDetails } from '@ui5/webcomponents-react-base'; -import type { CSSProperties } from 'react'; +import { CssSizeVariablesNames } from '@ui5/webcomponents-react-base/CssSizeVariables'; import { AnalyticalTableSelectionBehavior } from '../../../enums/AnalyticalTableSelectionBehavior.js'; import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; import { CheckBox } from '../../../webComponents/CheckBox/index.js'; import type { ReactTableHooks, TableInstance } from '../types/index.js'; - -const customCheckBoxStyling = { - verticalAlign: 'middle', - pointerEvents: 'none', - display: 'block', -} as CSSProperties; - /* * COMPONENTS */ @@ -29,7 +21,6 @@ const Header = (instance: TableInstance) => { <> { {...row.getToggleRowSelectedProps()} tabIndex={-1} aria-hidden="true" - style={customCheckBoxStyling} data-name="internal_selection_column" /> ); }; -function getNextSelectedRowIds(rowsById) { - return Object.keys(rowsById).reduce((acc, cur) => { - acc[cur] = true; - return acc; - }, {}); -} - const headerProps = (props, { instance }: { instance: TableInstance }) => { const { webComponentsReactProperties: { @@ -72,14 +55,8 @@ const headerProps = (props, { instance }: { instance: TableInstance }) => { }, toggleAllRowsSelected, isAllRowsSelected, - rowsById, - preFilteredRowsById, - dispatch, - state: { filters, globalFilter }, } = instance; const style = { ...props.style, cursor: 'pointer', display: 'flex', justifyContent: 'center' }; - const isFiltered = filters?.length > 0 || !!globalFilter; - const _rowsById = isFiltered ? preFilteredRowsById : rowsById; if ( props.key === 'header___ui5wcr__internal_selection_column' && selectionMode === AnalyticalTableSelectionMode.Multiple @@ -88,21 +65,10 @@ const headerProps = (props, { instance }: { instance: TableInstance }) => { if (typeof props.onClick === 'function') { props.onClick(e); } - toggleAllRowsSelected(!isAllRowsSelected); if (typeof onRowSelect === 'function') { - if (isFiltered) { - dispatch({ type: 'SELECT_ROW_CB', payload: { event: e, row: undefined, selectAll: true, fired: true } }); - } else { - onRowSelect( - // cannot use instance.selectedFlatRows here as it only returns all rows on the first level - enrichEventWithDetails(e, { - rowsById: _rowsById, - allRowsSelected: !isAllRowsSelected, - selectedRowIds: !isAllRowsSelected ? getNextSelectedRowIds(rowsById) : {}, - }), - ); - } + instance.pendingSelectEvent = { event: e, row: undefined, selectAll: true }; } + toggleAllRowsSelected(!isAllRowsSelected); }; const onKeyDown = (e) => { diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts index 4c0d42af9b5..21993162503 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts @@ -1,56 +1,66 @@ -import { enrichEventWithDetails } from '@ui5/webcomponents-react-base'; -import { useEffect } from 'react'; +import { enrichEventWithDetails } from '@ui5/webcomponents-react-base/internal/utils'; +import { useEffect, useRef } from 'react'; import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; import type { ReactTableHooks, TableInstance } from '../types/index.js'; -export const useSelectionChangeCallback = (hooks: ReactTableHooks) => { - hooks.useControlledState.push((state, { instance }: { instance: TableInstance }) => { - const { selectedRowPayload, selectedRowIds, filters, globalFilter } = state; - const { rowsById, preFilteredRowsById, webComponentsReactProperties, dispatch } = instance; - const isFiltered = filters?.length > 0 || !!globalFilter; - - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - if (selectedRowPayload?.fired) { - const { event: e, row: selRow, selectAll } = selectedRowPayload; - const row = rowsById[selRow?.id]; - // when selecting a row on a filtered table, `preFilteredRowsById` has to be used, otherwise filtered out rows are undefined - const _rowsById = isFiltered ? preFilteredRowsById : rowsById; - - if (row || selectAll) { - const payload = { - row: row, - rowsById: _rowsById, - isSelected: row?.isSelected, - allRowsSelected: false, - selectedRowIds, - }; - - if (webComponentsReactProperties.selectionMode === AnalyticalTableSelectionMode.Multiple) { - if (Object.keys(selectedRowIds).length === Object.keys(_rowsById).length) { - payload.allRowsSelected = true; - } - - if (selectAll) { - dispatch({ type: 'SELECT_ROW_CB', payload: { event: e, row, selectAll: false, fired: false } }); - webComponentsReactProperties?.onRowSelect( - enrichEventWithDetails(e, { - rowsById: payload.rowsById, - allRowsSelected: payload.allRowsSelected, - selectedRowIds: payload.selectedRowIds, - }), - ); - return; - } - } - dispatch({ type: 'SELECT_ROW_CB', payload: { event: e, row, fired: false } }); - webComponentsReactProperties?.onRowSelect(enrichEventWithDetails(e, payload)); +const useInstance = (instance: TableInstance) => { + const { webComponentsReactProperties, rowsById, preFilteredRowsById, state } = instance; + const { selectedRowIds, filters, globalFilter } = state; + const { onRowSelect, selectionMode } = webComponentsReactProperties; + + const prevSelectedRowIdsRef = useRef(selectedRowIds); + + useEffect(() => { + const pendingEvent = instance.pendingSelectEvent; + + // Only fire callback if there's a pending event and selection changed + if (pendingEvent && prevSelectedRowIdsRef.current !== selectedRowIds) { + // Clear pending event - instance is mutable + // eslint-disable-next-line react-hooks/immutability + instance.pendingSelectEvent = undefined; + + const { event: e, row: eventRow, selectAll } = pendingEvent; + const row = eventRow ? rowsById[eventRow.id] : undefined; + const isFiltered = filters?.length > 0 || !!globalFilter; + const _rowsById = isFiltered ? preFilteredRowsById : rowsById; + + const payload: Record = { + row, + rowsById: _rowsById, + isSelected: row?.isSelected, + allRowsSelected: false, + allVisibleRowsSelected: !!instance.isAllRowsSelected, + selectedRowIds, + }; + + if (selectionMode === AnalyticalTableSelectionMode.Multiple) { + // Check if all rows (including filtered) are selected + if (Object.keys(selectedRowIds).length === Object.keys(_rowsById).length) { + payload.allRowsSelected = true; } } - }, [selectedRowPayload?.fired, rowsById, webComponentsReactProperties.selectionMode, selectedRowIds, isFiltered]); - return state; - }); + if (selectAll) { + // For select-all click, don't include row-specific fields + onRowSelect?.( + enrichEventWithDetails(e, { + rowsById: payload.rowsById, + allRowsSelected: payload.allRowsSelected, + allVisibleRowsSelected: payload.allVisibleRowsSelected, + selectedRowIds: payload.selectedRowIds, + }), + ); + } else { + onRowSelect?.(enrichEventWithDetails(e, payload)); + } + } + + prevSelectedRowIdsRef.current = selectedRowIds; + }, [selectedRowIds, rowsById, preFilteredRowsById, filters, globalFilter, selectionMode, instance, onRowSelect]); +}; + +export const useSelectionChangeCallback = (hooks: ReactTableHooks) => { + hooks.useInstance.push(useInstance); }; useSelectionChangeCallback.pluginName = 'useSelectionChangeCallback'; diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSingleRowStateSelection.ts b/packages/main/src/components/AnalyticalTable/hooks/useSingleRowStateSelection.ts index 15c6a210f1d..1bc56c79a78 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSingleRowStateSelection.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSingleRowStateSelection.ts @@ -5,7 +5,7 @@ import { getTagNameWithoutScopingSuffix } from '../../../internal/utils.js'; import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js'; const getRowProps = (rowProps, { row, instance }: { row: RowType; instance: TableInstance }) => { - const { webComponentsReactProperties, toggleRowSelected, selectedFlatRows, dispatch } = instance; + const { webComponentsReactProperties, toggleRowSelected, selectedFlatRows } = instance; const handleRowSelect = (e) => { const isSelectionCell = e.target.dataset.selectionCell === 'true'; if ( @@ -47,12 +47,12 @@ const getRowProps = (rowProps, { row, instance }: { row: RowType; instance: Tabl } } - toggleRowSelected(row.id); - if (typeof onRowSelect === 'function') { - // update state to return instance values after update (see useSelectionChangeCallback hook) - dispatch({ type: 'SELECT_ROW_CB', payload: { event: e, row, fired: true } }); + // Store pending event on table instance + instance.pendingSelectEvent = { event: e, row }; } + + toggleRowSelected(row.id); }; const handleKeyDown = (e) => { diff --git a/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts b/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts index fa8dba41345..5e59d99d349 100644 --- a/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts +++ b/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts @@ -67,8 +67,6 @@ export const stateReducer = (state, action, _prevState, instance: TableInstance) return { ...state, subComponentsHeight: payload }; case 'TABLE_COL_RESIZED': return { ...state, tableColResized: payload }; - case 'SELECT_ROW_CB': - return { ...state, selectedRowPayload: payload }; case 'ROW_COLLAPSED_FLAG': return { ...state, rowCollapsed: payload }; case 'COLUMN_DND_START': diff --git a/packages/main/src/components/AnalyticalTable/types/index.ts b/packages/main/src/components/AnalyticalTable/types/index.ts index 39b03294319..4efb072c9df 100644 --- a/packages/main/src/components/AnalyticalTable/types/index.ts +++ b/packages/main/src/components/AnalyticalTable/types/index.ts @@ -219,6 +219,11 @@ export interface TableInstance { visibleColumns: ColumnType[]; visibleColumnsWidth?: number[]; webComponentsReactProperties: WCRPropertiesType; + pendingSelectEvent?: { + event: Event; + row?: RowType; + selectAll?: boolean; + }; [key: string]: any; } @@ -1023,7 +1028,16 @@ export interface AnalyticalTablePropTypes extends Omit { * __Note:__ If the event invoked by select-all press, this property is not available. */ isSelected?: boolean; + /** + * Indicates if all rows (including filtered out rows) are selected. + */ allRowsSelected: boolean; + /** + * Indicates if all currently visible rows are selected. + * + * __Note:__ When the table is filtered, this reflects only the non-filtered (visible) rows. + */ + allVisibleRowsSelected: boolean; rowsById: Record; selectedRowIds: Record; nativeDetail: number; From 3d43b768ea010a4d71b2320be556e5808fff0272 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 12 Feb 2026 10:39:51 +0100 Subject: [PATCH 02/17] Update AnalyticalTable.cy.tsx --- .../AnalyticalTable/AnalyticalTable.cy.tsx | 147 +++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index 587f171af94..7d2984f9af1 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -627,7 +627,7 @@ describe('AnalyticalTable', () => { filterable columns={columns} onRowSelect={(e) => { - const { allRowsSelected, isSelected, row, rowsById, selectedRowIds } = e.detail; + const { allRowsSelected, allVisibleRowsSelected, isSelected, row, rowsById, selectedRowIds } = e.detail; const selectedRowIdsArrayMapped = Object.keys(selectedRowIds).reduce((acc, key) => { if (selectedRowIds[key]) { acc.push(rowsById[key]); @@ -636,6 +636,7 @@ describe('AnalyticalTable', () => { }, []); setRelevantPayload({ allRowsSelected, + allVisibleRowsSelected, isSelected, row: row.id, selectedFlatRows: selectedRowIdsArrayMapped.map((item) => ({ @@ -654,6 +655,8 @@ describe('AnalyticalTable', () => { {JSON.stringify(relevantPayload?.selectedFlatRows?.filter(Boolean).length)} {JSON.stringify(relevantPayload?.selectedRowIds)} +
{`${relevantPayload?.allRowsSelected ?? false}`}
+
{`${relevantPayload?.allVisibleRowsSelected ?? false}`}
); }; @@ -716,6 +719,16 @@ describe('AnalyticalTable', () => { }); cy.get('@onRowSelectSpy').should('have.callCount', 4); cy.findByTestId('payloadHelper').should('have.text', '4{"0":true,"1":true,"0.2":true,"0.2.0":true}'); + + cy.findByTestId('payloadAllRowsSelected').should('have.text', 'false'); + cy.findByTestId('payloadAllVisibleRowsSelected').should('have.text', 'true'); + + cy.findByText('Name').click(); + cy.get(`[ui5-input][show-clear-icon]`).typeIntoUi5Input('{selectall}{backspace}{enter}', { force: true }); + + cy.findByText('Robin Moreno').click(); + cy.findByTestId('payloadAllRowsSelected').should('have.text', 'false'); + cy.findByTestId('payloadAllVisibleRowsSelected').should('have.text', 'false'); }); it('programmatic and user selection + filtering', () => { @@ -737,6 +750,7 @@ describe('AnalyticalTable', () => { const [selectedFlatRows, setSelectedFlatRows] = useState([]); const [selectedRowIdsCb, setSelectedRowIdsCb] = useState({}); const [allRowsSelected, setAllRowsSelected] = useState(false); + const [allVisibleRowsSelected, setAllVisibleRowsSelected] = useState(false); const [globalFilterVal, setGlobalFilterVal] = useState(''); return ( <> @@ -764,6 +778,7 @@ describe('AnalyticalTable', () => { setSelectedFlatRows(selectedRowIdsArrayMapped.map((item) => item.id)); setSelectedRowIdsCb(e.detail.selectedRowIds); setAllRowsSelected(e.detail.allRowsSelected); + setAllVisibleRowsSelected(e.detail.allVisibleRowsSelected); onRowSelect(e); }} onFilter={filterSpy} @@ -781,6 +796,10 @@ describe('AnalyticalTable', () => { "e.detail.allRowsSelected:" {`${allRowsSelected}`}

+

+ "e.detail.allVisibleRowsSelected:" + {`${allVisibleRowsSelected}`} +

); }; @@ -868,6 +887,7 @@ describe('AnalyticalTable', () => { cy.findByTestId('payload').should('have.text', '["0","1","5","7","17","20"]'); cy.findByTestId('payloadRowsById').should('have.text', '{"0":true,"1":true,"5":true,"7":true,"17":true,"20":true}'); cy.findByTestId('payloadAllRowsSelected').should('have.text', 'false'); + cy.findByTestId('payloadAllVisibleRowsSelected').should('have.text', 'true'); cy.findByText('Name').click(); cy.get('[ui5-li-custom]').shadow().get('[ui5-input]').typeIntoUi5Input('{selectall}{backspace}{enter}'); @@ -892,6 +912,7 @@ describe('AnalyticalTable', () => { '{"0":true,"1":true,"2":true,"3":true,"4":true,"5":true,"6":true,"7":true,"8":true,"9":true,"10":true,"11":true,"12":true,"13":true,"14":true,"15":true,"16":true,"18":true,"19":true,"20":true}', ); cy.findByTestId('payloadAllRowsSelected').should('have.text', 'false'); + cy.findByTestId('payloadAllVisibleRowsSelected').should('have.text', 'false'); cy.findByText('Name-17').click(); cy.findByTestId('payload').should( 'have.text', @@ -902,6 +923,7 @@ describe('AnalyticalTable', () => { '{"0":true,"1":true,"2":true,"3":true,"4":true,"5":true,"6":true,"7":true,"8":true,"9":true,"10":true,"11":true,"12":true,"13":true,"14":true,"15":true,"16":true,"17":true,"18":true,"19":true,"20":true}', ); cy.findByTestId('payloadAllRowsSelected').should('have.text', 'true'); + cy.findByTestId('payloadAllVisibleRowsSelected').should('have.text', 'true'); cy.findByText('Name').click(); cy.get('[ui5-li-custom]').shadow().get('[ui5-input]').typeIntoUi5Input('{selectall}{backspace}{enter}'); @@ -918,6 +940,7 @@ describe('AnalyticalTable', () => { '{"0":true,"1":true,"2":true,"3":true,"4":true,"5":true,"6":true,"7":true,"8":true,"9":true,"10":true,"11":true,"12":true,"13":true,"14":true,"15":true,"16":true,"18":true,"19":true,"20":true}', ); cy.findByTestId('payloadAllRowsSelected').should('have.text', 'false'); + cy.findByTestId('payloadAllVisibleRowsSelected').should('have.text', 'false'); cy.findByText('Name-17').click(); cy.findByTestId('payload').should( 'have.text', @@ -928,6 +951,7 @@ describe('AnalyticalTable', () => { '{"0":true,"1":true,"2":true,"3":true,"4":true,"5":true,"6":true,"7":true,"8":true,"9":true,"10":true,"11":true,"12":true,"13":true,"14":true,"15":true,"16":true,"17":true,"18":true,"19":true,"20":true}', ); cy.findByTestId('payloadAllRowsSelected').should('have.text', 'true'); + cy.findByTestId('payloadAllVisibleRowsSelected').should('have.text', 'true'); cy.get('@onRowSelectSpy').should('have.callCount', 19); }); @@ -1260,6 +1284,127 @@ describe('AnalyticalTable', () => { ); }); + it('useFilteredRowsSelection', () => { + const onRowSelectSpy = cy.spy().as('onRowSelectSpy'); + const TestComp = () => { + const [globalFilterVal, setGlobalFilterVal] = useState(''); + const [selectedRowIds, setSelectedRowIds] = useState>({}); + const [allRowsSelected, setAllRowsSelected] = useState(false); + const [allVisibleRowsSelected, setAllVisibleRowsSelected] = useState(false); + return ( + <> + setGlobalFilterVal((e.target as HTMLInputElement).value)} + /> + { + setSelectedRowIds(e.detail.selectedRowIds); + setAllRowsSelected(e.detail.allRowsSelected); + setAllVisibleRowsSelected(e.detail.allVisibleRowsSelected); + onRowSelectSpy(e); + }} + /> +
{JSON.stringify(selectedRowIds)}
+
{`${allRowsSelected}`}
+
{`${allVisibleRowsSelected}`}
+ + ); + }; + cy.mount(); + + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.findByTestId('selectedRowIds').should('have.text', '{"0":true,"1":true,"2":true,"3":true}'); + cy.findByTestId('allRowsSelected').should('have.text', 'true'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); + + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.findByTestId('selectedRowIds').should('have.text', '{}'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + + cy.findByTestId('globalFilter').type('B'); + cy.findByText('B').should('be.visible'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.findByTestId('selectedRowIds').should('have.text', '{"1":true}'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); + + cy.findByTestId('globalFilter').clear(); + cy.findByTestId('selectedRowIds').should('have.text', '{"1":true}'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( + 'have.attr', + 'indeterminate', + ); + + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.findByTestId('selectedRowIds').should('have.text', '{"0":true,"1":true,"2":true,"3":true}'); + cy.findByTestId('allRowsSelected').should('have.text', 'true'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); + + cy.findByTestId('globalFilter').type('A'); + cy.findByText('A').should('be.visible'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.findByTestId('selectedRowIds').should('have.text', '{"1":true,"2":true,"3":true}'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + }); + + it('useFilteredRowsSelection with selectSubRows', () => { + const TestComp = () => { + const [globalFilterVal, setGlobalFilterVal] = useState(''); + const [selectedRowIds, setSelectedRowIds] = useState>({}); + return ( + <> + setGlobalFilterVal((e.target as HTMLInputElement).value)} + /> + { + setSelectedRowIds(e.detail.selectedRowIds); + }} + /> +
{JSON.stringify(Object.keys(selectedRowIds).length)}
+ + ); + }; + cy.mount(); + + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.findByTestId('selectedRowIds').should('have.text', '170'); + + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.findByTestId('selectedRowIds').should('have.text', '0'); + + cy.findByTestId('globalFilter').type('Katy Bradshaw'); + cy.findByText('Katy Bradshaw').should('be.visible'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.findByTestId('selectedRowIds').should('have.text', '85'); + + cy.findByTestId('globalFilter').clear(); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( + 'have.attr', + 'indeterminate', + ); + }); + [AnalyticalTableScaleWidthMode.Grow, AnalyticalTableScaleWidthMode.Smart].forEach((scaleWidthMode) => { it(`scaleWidthMode: ${scaleWidthMode}`, () => { const isGrow = scaleWidthMode === AnalyticalTableScaleWidthMode.Grow; From 612e87b3f1b4b1e77f9d78e62624c27613840d9f Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 12 Feb 2026 16:07:36 +0100 Subject: [PATCH 03/17] fork react-table `useRowSelect` and adjust it --- REUSE.toml | 6 + .../AnalyticalTable/hooks/useRowSelect.ts | 373 ++++++++++++++++++ .../src/components/AnalyticalTable/index.tsx | 2 +- .../pluginHooks/useManualRowSelect.ts | 11 +- .../pluginHooks/useRowDisableSelection.tsx | 8 +- .../components/AnalyticalTable/types/index.ts | 2 + 6 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts diff --git a/REUSE.toml b/REUSE.toml index 0ab823c6285..0aac9260a43 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -38,3 +38,9 @@ path = "packages/main/src/internal/safeGetChildrenArray.ts" precedence = "aggregate" SPDX-FileCopyrightText = "2022 Adobe Inc." SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = "packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts" +precedence = "aggregate" +SPDX-FileCopyrightText = "2019-2021 Tanner Linsley" +SPDX-License-Identifier = "MIT" diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts new file mode 100644 index 00000000000..244486f3e6d --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -0,0 +1,373 @@ +import { useCallback, useMemo } from 'react'; +import { actions, makePropGetter, ensurePluginOrder, useGetLatest, useMountedLayoutEffect } from 'react-table'; +import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; +import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js'; + +const pluginName = 'useRowSelect'; + +// Actions +actions.resetSelectedRows = 'resetSelectedRows'; +actions.toggleAllRowsSelected = 'toggleAllRowsSelected'; +actions.toggleRowSelected = 'toggleRowSelected'; +actions.toggleAllPageRowsSelected = 'toggleAllPageRowsSelected'; + +const noopToggle = () => {}; +const noopGetProps = () => ({}); +const emptyArray: RowType[] = []; + +/** + * UI5WCR optimized version of react-table v7's useRowSelect hook. + * Original source: https://github.com/TanStack/table/blob/v7/src/plugin-hooks/useRowSelect.js + * + * This is a fork of react-table's useRowSelect with performance optimizations: + * - Early exit when `selectionMode` is 'None' or loading/showOverlay is `true` + * - Skips `selectedFlatRows` computation, `isAllRowsSelected` checks, and `prepareRow` overhead when selection is disabled + * - `isAllRowsSelected` computation is memoized + * - Uses stable noop references when disabled + */ +export const useRowSelect = (hooks: ReactTableHooks) => { + hooks.getToggleRowSelectedProps = [defaultGetToggleRowSelectedProps]; + hooks.getToggleAllRowsSelectedProps = [defaultGetToggleAllRowsSelectedProps]; + hooks.getToggleAllPageRowsSelectedProps = [defaultGetToggleAllPageRowsSelectedProps]; + hooks.stateReducers.push(reducer); + hooks.useInstance.push(useInstance); + hooks.prepareRow.push(prepareRow); +}; + +useRowSelect.pluginName = pluginName; + +const defaultGetToggleRowSelectedProps = ( + props: Record, + { instance, row }: { instance: TableInstance; row: RowType }, +) => { + const { manualRowSelectedKey = 'isSelected' } = instance; + let checked = false; + + if (row.original && row.original[manualRowSelectedKey]) { + checked = true; + } else { + checked = row.isSelected; + } + + return [ + props, + { + onChange: (e: { target: { checked: boolean } }) => { + row.toggleRowSelected(e.target.checked); + }, + style: { cursor: 'pointer' }, + checked, + title: 'Toggle Row Selected', + indeterminate: row.isSomeSelected, + }, + ]; +}; + +const defaultGetToggleAllRowsSelectedProps = ( + props: Record, + { instance }: { instance: TableInstance }, +) => [ + props, + { + onChange: (e: { target: { checked: boolean } }) => { + instance.toggleAllRowsSelected?.(e.target.checked); + }, + style: { cursor: 'pointer' }, + checked: instance.isAllRowsSelected, + title: 'Toggle All Rows Selected', + indeterminate: Boolean(!instance.isAllRowsSelected && Object.keys(instance.state.selectedRowIds).length), + }, +]; + +const defaultGetToggleAllPageRowsSelectedProps = ( + props: Record, + { instance }: { instance: TableInstance }, +) => [ + props, + { + onChange: (e: { target: { checked: boolean } }) => { + instance.toggleAllPageRowsSelected?.(e.target.checked); + }, + style: { cursor: 'pointer' }, + checked: instance.isAllPageRowsSelected, + title: 'Toggle All Current Page Rows Selected', + indeterminate: Boolean( + !instance.isAllPageRowsSelected && + instance.page?.some(({ id }: { id: string }) => instance.state.selectedRowIds[id]), + ), + }, +]; + +function reducer( + state: TableInstance['state'], + action: { type: string; value?: boolean; id?: string }, + _previousState: TableInstance['state'], + instance: TableInstance, +) { + if (action.type === actions.init) { + return { + selectedRowIds: {}, + ...state, + }; + } + + if (action.type === actions.resetSelectedRows) { + return { + ...state, + selectedRowIds: instance.initialState.selectedRowIds || {}, + }; + } + + if (action.type === actions.toggleAllRowsSelected) { + const { value: setSelected } = action; + const { isAllRowsSelected, rowsById, nonGroupedRowsById = rowsById } = instance; + + const selectAll = typeof setSelected !== 'undefined' ? setSelected : !isAllRowsSelected; + + const selectedRowIds = { ...state.selectedRowIds }; + + if (selectAll) { + Object.keys(nonGroupedRowsById).forEach((rowId) => { + selectedRowIds[rowId] = true; + }); + } else { + Object.keys(nonGroupedRowsById).forEach((rowId) => { + delete selectedRowIds[rowId]; + }); + } + + return { ...state, selectedRowIds }; + } + + if (action.type === actions.toggleRowSelected) { + const { id, value: setSelected } = action; + const { rowsById, selectSubRows = true, getSubRows } = instance; + const isSelected = state.selectedRowIds[id]; + const shouldExist = typeof setSelected !== 'undefined' ? setSelected : !isSelected; + + if (isSelected === shouldExist) { + return state; + } + + const newSelectedRowIds = { ...state.selectedRowIds }; + + const handleRowById = (rowId: string) => { + const row = rowsById[rowId]; + + if (row) { + if (!row.isGrouped) { + if (shouldExist) { + newSelectedRowIds[rowId] = true; + } else { + delete newSelectedRowIds[rowId]; + } + } + + if (selectSubRows && getSubRows(row)) { + getSubRows(row).forEach((r: RowType) => handleRowById(r.id)); + } + } + }; + + handleRowById(id); + + return { ...state, selectedRowIds: newSelectedRowIds }; + } + + if (action.type === actions.toggleAllPageRowsSelected) { + const { value: setSelected } = action; + const { page, rowsById, selectSubRows = true, isAllPageRowsSelected, getSubRows } = instance; + + const selectAll = typeof setSelected !== 'undefined' ? setSelected : !isAllPageRowsSelected; + + const newSelectedRowIds = { ...state.selectedRowIds }; + + const handleRowById = (rowId: string) => { + const row = rowsById[rowId]; + + if (!row.isGrouped) { + if (selectAll) { + newSelectedRowIds[rowId] = true; + } else { + delete newSelectedRowIds[rowId]; + } + } + + if (selectSubRows && getSubRows(row)) { + getSubRows(row).forEach((r: RowType) => handleRowById(r.id)); + } + }; + + page?.forEach((row: RowType) => handleRowById(row.id)); + + return { ...state, selectedRowIds: newSelectedRowIds }; + } + + return state; +} + +function useInstance(instance: TableInstance) { + const { + data, + rows, + getHooks, + plugins, + rowsById, + nonGroupedRowsById = rowsById, + autoResetSelectedRows = true, + state: { selectedRowIds }, + selectSubRows = true, + dispatch, + page, + getSubRows, + webComponentsReactProperties, + } = instance; + + const { selectionMode, loading, showOverlay } = webComponentsReactProperties ?? {}; + const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None && !loading && !showOverlay; + + ensurePluginOrder(plugins, ['useFilters', 'useGroupBy', 'useSortBy', 'useExpanded', 'usePagination'], 'useRowSelect'); + + const selectedFlatRows = useMemo(() => { + if (!isSelectionEnabled) { + return emptyArray; + } + + const result: RowType[] = []; + + rows.forEach((row) => { + const isSelected = selectSubRows ? getRowIsSelected(row, selectedRowIds, getSubRows) : !!selectedRowIds[row.id]; + row.isSelected = !!isSelected; + row.isSomeSelected = isSelected === null; + + if (isSelected) { + result.push(row); + } + }); + + return result; + }, [rows, selectSubRows, selectedRowIds, getSubRows, isSelectionEnabled]); + + const isAllRowsSelected = useMemo(() => { + if (!isSelectionEnabled) { + return false; + } + + const rowCount = Object.keys(nonGroupedRowsById).length; + const selectedCount = Object.keys(selectedRowIds).length; + + if (!rowCount || !selectedCount) { + return false; + } + + return !Object.keys(nonGroupedRowsById).some((id) => !selectedRowIds[id]); + }, [nonGroupedRowsById, selectedRowIds, isSelectionEnabled]); + + const isAllPageRowsSelected = useMemo(() => { + if (!isSelectionEnabled) { + return false; + } + if (!page || !page.length) { + return false; + } + if (isAllRowsSelected) { + return true; + } + + return !page.some(({ id }: { id: string }) => !selectedRowIds[id]); + }, [page, selectedRowIds, isAllRowsSelected, isSelectionEnabled]); + + const getAutoResetSelectedRows = useGetLatest(autoResetSelectedRows); + + useMountedLayoutEffect(() => { + if (getAutoResetSelectedRows()) { + dispatch({ type: actions.resetSelectedRows }); + } + }, [dispatch, data]); + + const toggleAllRowsSelected = useCallback( + (value?: boolean) => dispatch({ type: actions.toggleAllRowsSelected, value }), + [dispatch], + ); + + const toggleAllPageRowsSelected = useCallback( + (value?: boolean) => dispatch({ type: actions.toggleAllPageRowsSelected, value }), + [dispatch], + ); + + const toggleRowSelected = useCallback( + (id: string, value?: boolean) => dispatch({ type: actions.toggleRowSelected, id, value }), + [dispatch], + ); + + const getInstance = useGetLatest(instance); + + const getToggleAllRowsSelectedProps = isSelectionEnabled + ? makePropGetter(getHooks().getToggleAllRowsSelectedProps, { instance: getInstance() }) + : noopGetProps; + + const getToggleAllPageRowsSelectedProps = isSelectionEnabled + ? makePropGetter(getHooks().getToggleAllPageRowsSelectedProps, { instance: getInstance() }) + : noopGetProps; + + Object.assign(instance, { + selectedFlatRows, + isAllRowsSelected, + isAllPageRowsSelected, + toggleRowSelected, + toggleAllRowsSelected, + getToggleAllRowsSelectedProps, + getToggleAllPageRowsSelectedProps, + toggleAllPageRowsSelected, + }); +} + +function prepareRow(row: RowType, { instance }: { instance: TableInstance }) { + const { selectionMode, loading, showOverlay } = instance.webComponentsReactProperties ?? {}; + const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None && !loading && !showOverlay; + + if (!isSelectionEnabled) { + row.isSelected = false; + row.isSomeSelected = false; + row.toggleRowSelected = noopToggle; + row.getToggleRowSelectedProps = noopGetProps; + return; + } + + row.toggleRowSelected = (set?: boolean) => instance.toggleRowSelected?.(row.id, set); + + row.getToggleRowSelectedProps = makePropGetter(instance.getHooks().getToggleRowSelectedProps, { instance, row }); +} + +function getRowIsSelected( + row: RowType, + selectedRowIds: Record, + getSubRows: (row: RowType) => RowType[], +): boolean | null { + if (selectedRowIds[row.id]) { + return true; + } + + const subRows = getSubRows(row); + + if (subRows && subRows.length) { + let allChildrenSelected = true; + let someSelected = false; + + subRows.forEach((subRow) => { + // Bail out early if we know both of these + if (someSelected && !allChildrenSelected) { + return; + } + + if (getRowIsSelected(subRow, selectedRowIds, getSubRows)) { + someSelected = true; + } else { + allChildrenSelected = false; + } + }); + return allChildrenSelected ? true : someSelected ? null : false; + } + + return false; +} diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index 0f3b5806b60..8e9e86b4073 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -21,7 +21,6 @@ import { useGlobalFilter, useGroupBy, useResizeColumns, - useRowSelect, useSortBy, useTable, } from 'react-table'; @@ -75,6 +74,7 @@ import { usePopIn } from './hooks/usePopIn.js'; import { useResizeColumnsConfig } from './hooks/useResizeColumnsConfig.js'; import { useRowHighlight } from './hooks/useRowHighlight.js'; import { useRowNavigationIndicators } from './hooks/useRowNavigationIndicator.js'; +import { useRowSelect } from './hooks/useRowSelect.js'; import { useRowSelectionColumn } from './hooks/useRowSelectionColumn.js'; import { useScrollToRef } from './hooks/useScrollToRef.js'; import { useSelectionChangeCallback } from './hooks/useSelectionChangeCallback.js'; diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useManualRowSelect.ts b/packages/main/src/components/AnalyticalTable/pluginHooks/useManualRowSelect.ts index 377a29f6adb..95dbd3a791d 100644 --- a/packages/main/src/components/AnalyticalTable/pluginHooks/useManualRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useManualRowSelect.ts @@ -1,6 +1,7 @@ 'use client'; import { useEffect } from 'react'; +import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; import type { ReactTableHooks, TableInstance } from '../types/index.js'; /** @@ -12,18 +13,26 @@ export const useManualRowSelect = (manualRowSelectedKey = 'isSelected') => { const instanceAfterData = ({ flatRows, toggleRowSelected, + webComponentsReactProperties, }: { flatRows: TableInstance['flatRows']; toggleRowSelected: TableInstance['toggleRowSelected']; + webComponentsReactProperties: TableInstance['webComponentsReactProperties']; }) => { + const { selectionMode } = webComponentsReactProperties; + // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { + if (selectionMode === AnalyticalTableSelectionMode.None) { + return; + } + flatRows.forEach(({ id, original }) => { if (manualRowSelectedKey in original) { toggleRowSelected(id, original.isSelected); } }); - }, [flatRows, toggleRowSelected]); + }, [flatRows, toggleRowSelected, selectionMode]); }; const manualRowSelect = (hooks: ReactTableHooks) => { diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx b/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx index 38752544c48..eac5de1a552 100644 --- a/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx +++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx @@ -94,6 +94,9 @@ export const useRowDisableSelection = (disableRowSelection: DisableRowSelectionT const getRowProps = (rowProps, { row, instance }: { row: RowType; instance: TableInstance }) => { const { webComponentsReactProperties } = instance; + if (webComponentsReactProperties.selectionMode === AnalyticalTableSelectionMode.None) { + return rowProps; + } if (disableRowAccessor(row) === true) { row.disableSelect = true; const handleClick = (e) => { @@ -155,7 +158,10 @@ export const useRowDisableSelection = (disableRowSelection: DisableRowSelectionT return cellProps; }; - const toggleRowSelectedProps = (rowProps, { row }: { row: RowType }) => { + const toggleRowSelectedProps = (rowProps, { row, instance }: { row: RowType; instance: TableInstance }) => { + if (instance.webComponentsReactProperties.selectionMode === AnalyticalTableSelectionMode.None) { + return rowProps; + } if (disableRowAccessor(row) === true) { const { title: _0, ...updatedRowProps } = rowProps; return updatedRowProps; diff --git a/packages/main/src/components/AnalyticalTable/types/index.ts b/packages/main/src/components/AnalyticalTable/types/index.ts index 4efb072c9df..6364d73c1d8 100644 --- a/packages/main/src/components/AnalyticalTable/types/index.ts +++ b/packages/main/src/components/AnalyticalTable/types/index.ts @@ -120,6 +120,8 @@ export interface TableInstance { type: string; payload?: Record | AnalyticalTableState['popInColumns'] | boolean | string | number; clientX?: number; + value?: boolean; + id?: string; }) => void; expandedDepth?: number; expandedRows?: RowType[]; From 8c23fb8ef6bb56dc86193c02e2726120a2922b8f Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 12 Feb 2026 17:02:28 +0100 Subject: [PATCH 04/17] fix select-all indeterminate --- .../src/components/AnalyticalTable/hooks/useRowSelect.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts index 244486f3e6d..9596291b958 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -19,11 +19,12 @@ const emptyArray: RowType[] = []; * UI5WCR optimized version of react-table v7's useRowSelect hook. * Original source: https://github.com/TanStack/table/blob/v7/src/plugin-hooks/useRowSelect.js * - * This is a fork of react-table's useRowSelect with performance optimizations: + * This is a fork of react-table's `useRowSelect` with performance optimizations: * - Early exit when `selectionMode` is 'None' or loading/showOverlay is `true` * - Skips `selectedFlatRows` computation, `isAllRowsSelected` checks, and `prepareRow` overhead when selection is disabled * - `isAllRowsSelected` computation is memoized * - Uses stable noop references when disabled + * - Fixes select-all indeterminate state considering filtered-out rows (now only visible rows are considered) */ export const useRowSelect = (hooks: ReactTableHooks) => { hooks.getToggleRowSelectedProps = [defaultGetToggleRowSelectedProps]; @@ -75,7 +76,7 @@ const defaultGetToggleAllRowsSelectedProps = ( style: { cursor: 'pointer' }, checked: instance.isAllRowsSelected, title: 'Toggle All Rows Selected', - indeterminate: Boolean(!instance.isAllRowsSelected && Object.keys(instance.state.selectedRowIds).length), + indeterminate: Boolean(!instance.isAllRowsSelected && instance.selectedFlatRows?.length), }, ]; From 6bfac8bf9630201492c7cd6102b046555e14769e Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 12 Feb 2026 17:30:56 +0100 Subject: [PATCH 05/17] remove redundant defaults --- .../AnalyticalTable/hooks/useRowSelect.ts | 60 ++++++++++--------- .../hooks/useRowSelectionColumn.tsx | 14 ----- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts index 9596291b958..d861299b904 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -41,7 +41,8 @@ const defaultGetToggleRowSelectedProps = ( props: Record, { instance, row }: { instance: TableInstance; row: RowType }, ) => { - const { manualRowSelectedKey = 'isSelected' } = instance; + const { manualRowSelectedKey = 'isSelected', webComponentsReactProperties } = instance; + const { classes } = webComponentsReactProperties; let checked = false; if (row.original && row.original[manualRowSelectedKey]) { @@ -56,9 +57,8 @@ const defaultGetToggleRowSelectedProps = ( onChange: (e: { target: { checked: boolean } }) => { row.toggleRowSelected(e.target.checked); }, - style: { cursor: 'pointer' }, + className: classes.checkBox, checked, - title: 'Toggle Row Selected', indeterminate: row.isSomeSelected, }, ]; @@ -67,37 +67,41 @@ const defaultGetToggleRowSelectedProps = ( const defaultGetToggleAllRowsSelectedProps = ( props: Record, { instance }: { instance: TableInstance }, -) => [ - props, - { - onChange: (e: { target: { checked: boolean } }) => { - instance.toggleAllRowsSelected?.(e.target.checked); +) => { + const { classes } = instance.webComponentsReactProperties; + return [ + props, + { + onChange: (e: { target: { checked: boolean } }) => { + instance.toggleAllRowsSelected?.(e.target.checked); + }, + className: classes.checkBox, + checked: instance.isAllRowsSelected, + indeterminate: Boolean(!instance.isAllRowsSelected && instance.selectedFlatRows?.length), }, - style: { cursor: 'pointer' }, - checked: instance.isAllRowsSelected, - title: 'Toggle All Rows Selected', - indeterminate: Boolean(!instance.isAllRowsSelected && instance.selectedFlatRows?.length), - }, -]; + ]; +}; const defaultGetToggleAllPageRowsSelectedProps = ( props: Record, { instance }: { instance: TableInstance }, -) => [ - props, - { - onChange: (e: { target: { checked: boolean } }) => { - instance.toggleAllPageRowsSelected?.(e.target.checked); +) => { + const { classes } = instance.webComponentsReactProperties; + return [ + props, + { + onChange: (e: { target: { checked: boolean } }) => { + instance.toggleAllPageRowsSelected?.(e.target.checked); + }, + className: classes.checkBox, + checked: instance.isAllPageRowsSelected, + indeterminate: Boolean( + !instance.isAllPageRowsSelected && + instance.page?.some(({ id }: { id: string }) => instance.state.selectedRowIds[id]), + ), }, - style: { cursor: 'pointer' }, - checked: instance.isAllPageRowsSelected, - title: 'Toggle All Current Page Rows Selected', - indeterminate: Boolean( - !instance.isAllPageRowsSelected && - instance.page?.some(({ id }: { id: string }) => instance.state.selectedRowIds[id]), - ), - }, -]; + ]; +}; function reducer( state: TableInstance['state'], diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx b/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx index abd7a91c903..69f7cea3e54 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx @@ -158,23 +158,9 @@ const getCellProps = (props, { cell }: { cell: TableInstance['cell'] }) => { return props; }; -const setToggleAllRowsSelectedProps = ( - props, - { instance: { webComponentsReactProperties } }: { instance: TableInstance }, -) => { - const { classes } = webComponentsReactProperties; - return [props, { className: classes.checkBox, title: undefined }]; -}; -const setToggleRowSelectedProps = (props, { instance: { webComponentsReactProperties } }) => { - const { classes } = webComponentsReactProperties; - return [props, { className: classes.checkBox, title: undefined }]; -}; - export const useRowSelectionColumn = (hooks: ReactTableHooks) => { hooks.getCellProps.push(getCellProps); hooks.getHeaderProps.push(headerProps); - hooks.getToggleRowSelectedProps.push(setToggleRowSelectedProps); - hooks.getToggleAllRowsSelectedProps.push(setToggleAllRowsSelectedProps); hooks.columns.push(columns); hooks.visibleColumns.push(visibleColumns); }; From e2481c724f42d2565400a655affb731b295df1f2 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 16 Feb 2026 08:40:04 +0100 Subject: [PATCH 06/17] useRowSelect: comments & cleanup --- .../AnalyticalTable/hooks/useRowSelect.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts index d861299b904..91a594d5cf5 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -25,6 +25,8 @@ const emptyArray: RowType[] = []; * - `isAllRowsSelected` computation is memoized * - Uses stable noop references when disabled * - Fixes select-all indeterminate state considering filtered-out rows (now only visible rows are considered) + * + * _Pagination specific implementation where adjusted as well, although they are currently not being used_ */ export const useRowSelect = (hooks: ReactTableHooks) => { hooks.getToggleRowSelectedProps = [defaultGetToggleRowSelectedProps]; @@ -42,6 +44,7 @@ const defaultGetToggleRowSelectedProps = ( { instance, row }: { instance: TableInstance; row: RowType }, ) => { const { manualRowSelectedKey = 'isSelected', webComponentsReactProperties } = instance; + // UI5WCR: use className instead of inline style const { classes } = webComponentsReactProperties; let checked = false; @@ -57,6 +60,7 @@ const defaultGetToggleRowSelectedProps = ( onChange: (e: { target: { checked: boolean } }) => { row.toggleRowSelected(e.target.checked); }, + // UI5WCR: removed style/title, added className className: classes.checkBox, checked, indeterminate: row.isSomeSelected, @@ -68,6 +72,7 @@ const defaultGetToggleAllRowsSelectedProps = ( props: Record, { instance }: { instance: TableInstance }, ) => { + // UI5WCR: use className instead of inline style const { classes } = instance.webComponentsReactProperties; return [ props, @@ -75,8 +80,10 @@ const defaultGetToggleAllRowsSelectedProps = ( onChange: (e: { target: { checked: boolean } }) => { instance.toggleAllRowsSelected?.(e.target.checked); }, + // UI5WCR: removed style/title, added className className: classes.checkBox, checked: instance.isAllRowsSelected, + // UI5WCR: use selectedFlatRows (visible rows only) instead of selectedRowIds indeterminate: Boolean(!instance.isAllRowsSelected && instance.selectedFlatRows?.length), }, ]; @@ -86,6 +93,7 @@ const defaultGetToggleAllPageRowsSelectedProps = ( props: Record, { instance }: { instance: TableInstance }, ) => { + // UI5WCR: use className instead of inline style const { classes } = instance.webComponentsReactProperties; return [ props, @@ -93,6 +101,7 @@ const defaultGetToggleAllPageRowsSelectedProps = ( onChange: (e: { target: { checked: boolean } }) => { instance.toggleAllPageRowsSelected?.(e.target.checked); }, + // UI5WCR: removed style/title, added className className: classes.checkBox, checked: instance.isAllPageRowsSelected, indeterminate: Boolean( @@ -228,11 +237,12 @@ function useInstance(instance: TableInstance) { webComponentsReactProperties, } = instance; - const { selectionMode, loading, showOverlay } = webComponentsReactProperties ?? {}; + const { selectionMode, loading, showOverlay } = webComponentsReactProperties; const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None && !loading && !showOverlay; ensurePluginOrder(plugins, ['useFilters', 'useGroupBy', 'useSortBy', 'useExpanded', 'usePagination'], 'useRowSelect'); + // UI5WCR: early exit when selection disabled const selectedFlatRows = useMemo(() => { if (!isSelectionEnabled) { return emptyArray; @@ -253,6 +263,7 @@ function useInstance(instance: TableInstance) { return result; }, [rows, selectSubRows, selectedRowIds, getSubRows, isSelectionEnabled]); + // UI5WCR: memoized const isAllRowsSelected = useMemo(() => { if (!isSelectionEnabled) { return false; @@ -268,6 +279,7 @@ function useInstance(instance: TableInstance) { return !Object.keys(nonGroupedRowsById).some((id) => !selectedRowIds[id]); }, [nonGroupedRowsById, selectedRowIds, isSelectionEnabled]); + // UI5WCR: memoized const isAllPageRowsSelected = useMemo(() => { if (!isSelectionEnabled) { return false; @@ -307,10 +319,10 @@ function useInstance(instance: TableInstance) { const getInstance = useGetLatest(instance); + // UI5WCR: use noop when selection disabled const getToggleAllRowsSelectedProps = isSelectionEnabled ? makePropGetter(getHooks().getToggleAllRowsSelectedProps, { instance: getInstance() }) : noopGetProps; - const getToggleAllPageRowsSelectedProps = isSelectionEnabled ? makePropGetter(getHooks().getToggleAllPageRowsSelectedProps, { instance: getInstance() }) : noopGetProps; @@ -328,9 +340,10 @@ function useInstance(instance: TableInstance) { } function prepareRow(row: RowType, { instance }: { instance: TableInstance }) { - const { selectionMode, loading, showOverlay } = instance.webComponentsReactProperties ?? {}; + const { selectionMode, loading, showOverlay } = instance.webComponentsReactProperties; const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None && !loading && !showOverlay; + // UI5WCR: skip per-row setup when selection disabled if (!isSelectionEnabled) { row.isSelected = false; row.isSomeSelected = false; From 08a2abb00f920bca68354d5cc0932575f38db52a Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 16 Feb 2026 08:50:48 +0100 Subject: [PATCH 07/17] Make `isSelectionEnabled` available on table instance --- .../src/components/AnalyticalTable/hooks/useRowSelect.ts | 7 ++----- packages/main/src/components/AnalyticalTable/index.tsx | 2 ++ .../main/src/components/AnalyticalTable/types/index.ts | 1 + 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts index 91a594d5cf5..8875c0d780e 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo } from 'react'; import { actions, makePropGetter, ensurePluginOrder, useGetLatest, useMountedLayoutEffect } from 'react-table'; -import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js'; const pluginName = 'useRowSelect'; @@ -237,8 +236,7 @@ function useInstance(instance: TableInstance) { webComponentsReactProperties, } = instance; - const { selectionMode, loading, showOverlay } = webComponentsReactProperties; - const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None && !loading && !showOverlay; + const { isSelectionEnabled } = webComponentsReactProperties; ensurePluginOrder(plugins, ['useFilters', 'useGroupBy', 'useSortBy', 'useExpanded', 'usePagination'], 'useRowSelect'); @@ -340,8 +338,7 @@ function useInstance(instance: TableInstance) { } function prepareRow(row: RowType, { instance }: { instance: TableInstance }) { - const { selectionMode, loading, showOverlay } = instance.webComponentsReactProperties; - const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None && !loading && !showOverlay; + const { isSelectionEnabled } = instance.webComponentsReactProperties; // UI5WCR: skip per-row setup when selection disabled if (!isSelectionEnabled) { diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index 8e9e86b4073..3c252da89bb 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -203,6 +203,7 @@ const AnalyticalTable = forwardRef Date: Mon, 16 Feb 2026 09:29:13 +0100 Subject: [PATCH 08/17] add missing instance types --- .../AnalyticalTable/AnalyticalTable.stories.tsx | 1 + .../src/components/AnalyticalTable/types/index.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx index 40cefa4437f..db1e5c7e5f4 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx @@ -169,6 +169,7 @@ const meta = { chromatic: { disableSnapshot: true }, }, args: { + // reactTableOptions: { autoResetSelectedRows: true }, data: dataLarge, columns: [ { diff --git a/packages/main/src/components/AnalyticalTable/types/index.ts b/packages/main/src/components/AnalyticalTable/types/index.ts index 2b385430fb9..e8fe4126bac 100644 --- a/packages/main/src/components/AnalyticalTable/types/index.ts +++ b/packages/main/src/components/AnalyticalTable/types/index.ts @@ -109,6 +109,15 @@ export interface CellType { export interface TableInstance { allColumns: ColumnType[]; allColumnsHidden?: boolean; + autoResetExpanded?: boolean; + autoResetFilters?: boolean; + autoResetGroupBy?: boolean; + autoResetHiddenColumns?: boolean; + autoResetPage?: boolean; + autoResetResize?: boolean; + autoResetRowState?: boolean; + autoResetSelectedRows?: boolean; + autoResetSortBy?: boolean; columns: ColumnType[]; data: Record[]; defaultColumn: Record; @@ -153,10 +162,12 @@ export interface TableInstance { isAllPageRowsSelected?: boolean; isAllRowsExpanded?: boolean; isAllRowsSelected?: boolean; + manualRowSelectedKey?: string; nonGroupedFlatRows?: RowType[]; nonGroupedRowsById?: Record; onlyGroupedFlatRows?: RowType[]; onlyGroupedRowsById?: Record; + page?: RowType[]; plugins: ((hooks: ReactTableHooks) => void)[]; preExpandedRows?: RowType[]; preFilteredFlatRows?: RowType[]; @@ -297,6 +308,7 @@ export interface RowType { id: string; index: number; isExpanded: boolean | undefined; + isGrouped?: boolean; isSelected: boolean; isSomeSelected: boolean; getRowProps: (props?: any) => any; From 92956ff575e5c68bc38ec0294cc033c88814ead4 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 16 Feb 2026 09:39:57 +0100 Subject: [PATCH 09/17] keep selection when `loading` or overlay is active --- .../src/components/AnalyticalTable/hooks/useRowSelect.ts | 7 +++++-- packages/main/src/components/AnalyticalTable/index.tsx | 2 -- .../main/src/components/AnalyticalTable/types/index.ts | 1 - 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts index 8875c0d780e..c3b3c595e6a 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from 'react'; import { actions, makePropGetter, ensurePluginOrder, useGetLatest, useMountedLayoutEffect } from 'react-table'; +import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js'; const pluginName = 'useRowSelect'; @@ -236,7 +237,8 @@ function useInstance(instance: TableInstance) { webComponentsReactProperties, } = instance; - const { isSelectionEnabled } = webComponentsReactProperties; + const { selectionMode } = webComponentsReactProperties; + const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None; ensurePluginOrder(plugins, ['useFilters', 'useGroupBy', 'useSortBy', 'useExpanded', 'usePagination'], 'useRowSelect'); @@ -338,7 +340,8 @@ function useInstance(instance: TableInstance) { } function prepareRow(row: RowType, { instance }: { instance: TableInstance }) { - const { isSelectionEnabled } = instance.webComponentsReactProperties; + const { selectionMode } = instance.webComponentsReactProperties; + const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None; // UI5WCR: skip per-row setup when selection disabled if (!isSelectionEnabled) { diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index 3c252da89bb..8e9e86b4073 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -203,7 +203,6 @@ const AnalyticalTable = forwardRef Date: Mon, 16 Feb 2026 11:58:59 +0100 Subject: [PATCH 10/17] add tests --- .../AnalyticalTable/AnalyticalTable.cy.tsx | 307 ++++++++++-------- 1 file changed, 178 insertions(+), 129 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index 7d2984f9af1..80f859cce1f 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -726,7 +726,7 @@ describe('AnalyticalTable', () => { cy.findByText('Name').click(); cy.get(`[ui5-input][show-clear-icon]`).typeIntoUi5Input('{selectall}{backspace}{enter}', { force: true }); - cy.findByText('Robin Moreno').click(); + cy.findByText('Flowers Mcfarland').click(); cy.findByTestId('payloadAllRowsSelected').should('have.text', 'false'); cy.findByTestId('payloadAllVisibleRowsSelected').should('have.text', 'false'); }); @@ -1029,7 +1029,7 @@ describe('AnalyticalTable', () => { columns={columns} tableInstance={tableInstance} onRowSelect={(e) => { - const { allRowsSelected, isSelected, row, rowsById, selectedRowIds } = e.detail; + const { allRowsSelected, allVisibleRowsSelected, isSelected, row, rowsById, selectedRowIds } = e.detail; const selectedRowIdsArrayMapped = Object.keys(selectedRowIds).reduce((acc, key) => { if (selectedRowIds[key]) { acc.push(rowsById[key]); @@ -1038,8 +1038,9 @@ describe('AnalyticalTable', () => { }, []); setRelevantPayload({ allRowsSelected, + allVisibleRowsSelected, isSelected, - row: row.id, + row: row?.id, selectedFlatRows: selectedRowIdsArrayMapped.map((item) => ({ id: item?.id, })), @@ -1056,17 +1057,25 @@ describe('AnalyticalTable', () => {
{JSON.stringify(relevantPayload?.selectedRowIds)}
{`${relevantPayload.isSelected}`}
+
{`${relevantPayload.allRowsSelected}`}
+
{`${relevantPayload.allVisibleRowsSelected}`}
); }; const select = cy.spy().as('onRowSelectSpy'); cy.mount(); + const selectAllCheckbox = '[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]'; + const selectAllCell = '[data-column-id="__ui5wcr__internal_selection_column"]'; + cy.findByText('QWE').click(); cy.get('@onRowSelectSpy').should('have.callCount', 1); cy.findByTestId('selectedFlatRowsLength').should('have.text', '1'); cy.findByTestId('selectedRowIds').should('have.text', '{"2":true}'); cy.findByTestId('isSelected').should('have.text', 'true'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); cy.findByText('Friend Name').click(); cy.get('[ui5-list]').clickUi5ListItemByText('Group'); @@ -1082,12 +1091,28 @@ describe('AnalyticalTable', () => { cy.findByTestId('selectedFlatRowsLength').should('have.text', '2'); cy.findByTestId('selectedRowIds').should('have.text', '{"2":true,"4":true}'); cy.findByTestId('isSelected').should('have.text', 'true'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); cy.findByText('25').click(); cy.get('@onRowSelectSpy').should('have.callCount', 3); cy.findByTestId('selectedFlatRowsLength').should('have.text', '1'); cy.findByTestId('selectedRowIds').should('have.text', '{"2":true}'); cy.findByTestId('isSelected').should('have.text', 'false'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + + cy.get(selectAllCell).click(); + cy.get('@onRowSelectSpy').should('have.callCount', 4); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('have.attr', 'checked'); + + cy.get(selectAllCell).click(); + cy.get('@onRowSelectSpy').should('have.callCount', 5); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('not.have.attr', 'checked'); cy.findByText('Friend Name').click(); cy.get('[ui5-list]').clickUi5ListItemByText('Ungroup'); @@ -1284,127 +1309,6 @@ describe('AnalyticalTable', () => { ); }); - it('useFilteredRowsSelection', () => { - const onRowSelectSpy = cy.spy().as('onRowSelectSpy'); - const TestComp = () => { - const [globalFilterVal, setGlobalFilterVal] = useState(''); - const [selectedRowIds, setSelectedRowIds] = useState>({}); - const [allRowsSelected, setAllRowsSelected] = useState(false); - const [allVisibleRowsSelected, setAllVisibleRowsSelected] = useState(false); - return ( - <> - setGlobalFilterVal((e.target as HTMLInputElement).value)} - /> - { - setSelectedRowIds(e.detail.selectedRowIds); - setAllRowsSelected(e.detail.allRowsSelected); - setAllVisibleRowsSelected(e.detail.allVisibleRowsSelected); - onRowSelectSpy(e); - }} - /> -
{JSON.stringify(selectedRowIds)}
-
{`${allRowsSelected}`}
-
{`${allVisibleRowsSelected}`}
- - ); - }; - cy.mount(); - - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.findByTestId('selectedRowIds').should('have.text', '{"0":true,"1":true,"2":true,"3":true}'); - cy.findByTestId('allRowsSelected').should('have.text', 'true'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); - - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.findByTestId('selectedRowIds').should('have.text', '{}'); - cy.findByTestId('allRowsSelected').should('have.text', 'false'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); - - cy.findByTestId('globalFilter').type('B'); - cy.findByText('B').should('be.visible'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.findByTestId('selectedRowIds').should('have.text', '{"1":true}'); - cy.findByTestId('allRowsSelected').should('have.text', 'false'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); - - cy.findByTestId('globalFilter').clear(); - cy.findByTestId('selectedRowIds').should('have.text', '{"1":true}'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( - 'have.attr', - 'indeterminate', - ); - - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.findByTestId('selectedRowIds').should('have.text', '{"0":true,"1":true,"2":true,"3":true}'); - cy.findByTestId('allRowsSelected').should('have.text', 'true'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); - - cy.findByTestId('globalFilter').type('A'); - cy.findByText('A').should('be.visible'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.findByTestId('selectedRowIds').should('have.text', '{"1":true,"2":true,"3":true}'); - cy.findByTestId('allRowsSelected').should('have.text', 'false'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); - }); - - it('useFilteredRowsSelection with selectSubRows', () => { - const TestComp = () => { - const [globalFilterVal, setGlobalFilterVal] = useState(''); - const [selectedRowIds, setSelectedRowIds] = useState>({}); - return ( - <> - setGlobalFilterVal((e.target as HTMLInputElement).value)} - /> - { - setSelectedRowIds(e.detail.selectedRowIds); - }} - /> -
{JSON.stringify(Object.keys(selectedRowIds).length)}
- - ); - }; - cy.mount(); - - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.findByTestId('selectedRowIds').should('have.text', '170'); - - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.findByTestId('selectedRowIds').should('have.text', '0'); - - cy.findByTestId('globalFilter').type('Katy Bradshaw'); - cy.findByText('Katy Bradshaw').should('be.visible'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.findByTestId('selectedRowIds').should('have.text', '85'); - - cy.findByTestId('globalFilter').clear(); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( - 'have.attr', - 'indeterminate', - ); - }); - [AnalyticalTableScaleWidthMode.Grow, AnalyticalTableScaleWidthMode.Smart].forEach((scaleWidthMode) => { it(`scaleWidthMode: ${scaleWidthMode}`, () => { const isGrow = scaleWidthMode === AnalyticalTableScaleWidthMode.Grow; @@ -3277,7 +3181,7 @@ describe('AnalyticalTable', () => { const TestComp = () => { const [stringifiedPl, setStringifiedPl] = useState(''); const handleSelect = (e) => { - const { allRowsSelected, rowsById, selectedRowIds } = e.detail; + const { allRowsSelected, allVisibleRowsSelected, rowsById, selectedRowIds } = e.detail; const selectedRowIdsArrayMapped = Object.keys(selectedRowIds).reduce((acc, key) => { if (selectedRowIds[key]) { @@ -3293,6 +3197,7 @@ describe('AnalyticalTable', () => { id: item?.id, })), allRowsSelected, + allVisibleRowsSelected, }), ); select(e); @@ -3324,28 +3229,172 @@ describe('AnalyticalTable', () => { cy.get('@selAll').should('have.attr', 'title', 'Deselect All'); cy.findByTestId('payload').should( 'have.text', - '{"selectedRowIds":{"0":true,"1":true,"2":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}],"allRowsSelected":true}', + '{"selectedRowIds":{"0":true,"1":true,"2":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}],"allRowsSelected":true,"allVisibleRowsSelected":true}', ); cy.findByText('X').click(); cy.get('@selectSpy').should('have.been.calledTwice'); cy.findByTestId('payload').should( 'have.text', - '{"selectedRowIds":{"0":true,"1":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"3"}],"allRowsSelected":false}', + '{"selectedRowIds":{"0":true,"1":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"3"}],"allRowsSelected":false,"allVisibleRowsSelected":false}', ); cy.get('@selAll').should('have.attr', 'title', 'Select All').click(); cy.get('@selectSpy').should('have.been.calledThrice'); cy.findByTestId('payload').should( 'have.text', - '{"selectedRowIds":{"0":true,"1":true,"2":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}],"allRowsSelected":true}', + '{"selectedRowIds":{"0":true,"1":true,"2":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}],"allRowsSelected":true,"allVisibleRowsSelected":true}', ); cy.get('@selAll').click(); cy.get('@selectSpy').should('have.callCount', 4); cy.findByTestId('payload').should( 'have.text', - '{"selectedRowIds":{},"selectedFlatRows":[],"allRowsSelected":false}', + '{"selectedRowIds":{},"selectedFlatRows":[],"allRowsSelected":false,"allVisibleRowsSelected":false}', ); }); + it('select-all with filtered rows', () => { + const select = cy.spy().as('selectSpy'); + // John, Jane, Bob, Alice - filtering 'J' gives John, Jane (excludes Bob, Alice) + const filterData = mockNames.slice(0, 4).map((name) => ({ name })); + const filterColumns: AnalyticalTableColumnDefinition[] = [{ Header: 'Name', accessor: 'name' }]; + const TestComp = () => { + const [filter, setFilter] = useState(''); + const [payload, setPayload] = useState<{ + allRowsSelected?: boolean; + allVisibleRowsSelected?: boolean; + selectedRowIds?: Record; + }>({}); + + const handleRowSelect: AnalyticalTablePropTypes['onRowSelect'] = (e) => { + const { allRowsSelected, allVisibleRowsSelected, selectedRowIds } = e.detail; + setPayload({ allRowsSelected, allVisibleRowsSelected, selectedRowIds }); + select(e); + }; + return ( + <> + setFilter(e.target.value)} /> + +
{`${payload.allRowsSelected}`}
+
{`${payload.allVisibleRowsSelected}`}
+
{JSON.stringify(payload.selectedRowIds)}
+ + ); + }; + cy.mount(); + + const selectAllCheckbox = '[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]'; + const selectAllCell = '[data-column-id="__ui5wcr__internal_selection_column"]'; + + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('not.have.attr', 'checked'); + + // filtered 0/2 (0/4) + cy.findByTestId('filterInput').typeIntoUi5Input('J'); + cy.findByText('Bob').should('not.exist'); + cy.findByText('Alice').should('not.exist'); + + // filtered 1/2 (1/4) + cy.findByText('John').click(); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + + // filtered 2/2 (2/4) + cy.findByText('Jane').click(); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('have.attr', 'checked'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); + + // 2/4 + cy.findByTestId('filterInput').typeIntoUi5Input('{selectall}{backspace}'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + // 3/4 + cy.findByText('Bob').click(); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + // filtered 2/2 (3/4) + cy.findByTestId('filterInput').typeIntoUi5Input('J'); + cy.get(selectAllCheckbox).should('have.attr', 'checked'); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + + // filtered 1/2 (2/4) + cy.findByText('John').click(); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + // filtered 0/2 (1/4) + cy.findByText('Jane').click(); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('not.have.attr', 'checked'); + + // filtered 2/2 (3/4) + cy.get(selectAllCell).click(); + cy.get(selectAllCheckbox).should('have.attr', 'checked'); + + // 3/4 + cy.findByTestId('filterInput').typeIntoUi5Input('{selectall}{backspace}'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + }); + + it('selection state preserved during loading/overlay', () => { + const TestComp = () => { + const [loading, setLoading] = useState(false); + const [showOverlay, setShowOverlay] = useState(false); + return ( + <> + + + + + ); + }; + cy.mount(); + + const selectAllCheckbox = '[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]'; + + cy.findByText('A').click(); + cy.findByText('B').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + cy.findByTestId('toggleLoading').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + cy.findByTestId('toggleLoading').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + cy.findByTestId('toggleOverlay').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + cy.findByTestId('toggleOverlay').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + }); + it('manualGroupBy - backend grouping', () => { const cols = [ { From d466a8cc87aedc4f46e11023d1daeffdf3114ac3 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 16 Feb 2026 12:03:08 +0100 Subject: [PATCH 11/17] no-data: hide select-all cb --- .../components/AnalyticalTable/hooks/useRowSelectionColumn.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx b/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx index 69f7cea3e54..4d8045a3b08 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx @@ -10,10 +10,11 @@ import type { ReactTableHooks, TableInstance } from '../types/index.js'; const Header = (instance: TableInstance) => { const { getToggleAllRowsSelectedProps, + rows, webComponentsReactProperties: { selectionMode, translatableTexts, classes }, } = instance; - if (selectionMode === AnalyticalTableSelectionMode.Single) { + if (selectionMode === AnalyticalTableSelectionMode.Single || !rows.length) { return null; } const checkBoxProps = getToggleAllRowsSelectedProps(); From a08bccd0f1a430e9686a88ccbacb05f6c4876bc3 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 16 Feb 2026 12:07:42 +0100 Subject: [PATCH 12/17] add test for hidden cb --- .../AnalyticalTable/AnalyticalTable.cy.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index 80f859cce1f..320237bff34 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -1899,16 +1899,35 @@ describe('AnalyticalTable', () => { ); cy.mount(); cy.get('.ui5-busy-indicator-busy-area', { timeout: 2000 }).should('not.exist'); - cy.mount(); + cy.mount(); cy.findByText('No data').should('be.visible'); - cy.mount(); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should('not.exist'); + cy.mount( + , + ); cy.findByText('No data found. Try adjusting the filter settings.').should('be.visible'); - cy.mount(); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should('not.exist'); + cy.mount( + , + ); cy.findByText('Lorem').should('be.visible'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should('exist'); cy.findByText('Name').realClick(); cy.get('[ui5-input]').typeIntoUi5Input('test123'); cy.findByText('Lorem').should('not.exist'); cy.findByText('No data found. Try adjusting the filter settings.').should('be.visible'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should('not.exist'); }); it('NoDataComponent', () => { From 3c8b4c6e105d138d10169a34d576c2fbbb0e1b71 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 16 Feb 2026 12:49:19 +0100 Subject: [PATCH 13/17] fix event type --- .../AnalyticalTable/hooks/useSelectionChangeCallback.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts index 21993162503..43cc6e3eeae 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts @@ -1,7 +1,9 @@ import { enrichEventWithDetails } from '@ui5/webcomponents-react-base/internal/utils'; import { useEffect, useRef } from 'react'; import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; -import type { ReactTableHooks, TableInstance } from '../types/index.js'; +import type { AnalyticalTablePropTypes, ReactTableHooks, TableInstance } from '../types/index.js'; + +type OnRowSelectEvent = Parameters>[0]; const useInstance = (instance: TableInstance) => { const { webComponentsReactProperties, rowsById, preFilteredRowsById, state } = instance; @@ -48,10 +50,10 @@ const useInstance = (instance: TableInstance) => { allRowsSelected: payload.allRowsSelected, allVisibleRowsSelected: payload.allVisibleRowsSelected, selectedRowIds: payload.selectedRowIds, - }), + }) as OnRowSelectEvent, ); } else { - onRowSelect?.(enrichEventWithDetails(e, payload)); + onRowSelect?.(enrichEventWithDetails(e, payload) as OnRowSelectEvent); } } From 2fa937ed0cf493b000a5b9b4f66dd94e0ea3c57c Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 16 Feb 2026 12:52:17 +0100 Subject: [PATCH 14/17] Update useSelectionChangeCallback.ts --- .../AnalyticalTable/hooks/useSelectionChangeCallback.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts index 43cc6e3eeae..b441ee71130 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts @@ -4,6 +4,7 @@ import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSele import type { AnalyticalTablePropTypes, ReactTableHooks, TableInstance } from '../types/index.js'; type OnRowSelectEvent = Parameters>[0]; +type OnRowSelectDetail = OnRowSelectEvent['detail']; const useInstance = (instance: TableInstance) => { const { webComponentsReactProperties, rowsById, preFilteredRowsById, state } = instance; @@ -26,7 +27,7 @@ const useInstance = (instance: TableInstance) => { const isFiltered = filters?.length > 0 || !!globalFilter; const _rowsById = isFiltered ? preFilteredRowsById : rowsById; - const payload: Record = { + const payload: Partial = { row, rowsById: _rowsById, isSelected: row?.isSelected, @@ -45,7 +46,7 @@ const useInstance = (instance: TableInstance) => { if (selectAll) { // For select-all click, don't include row-specific fields onRowSelect?.( - enrichEventWithDetails(e, { + enrichEventWithDetails(e as Event & { detail?: OnRowSelectDetail }, { rowsById: payload.rowsById, allRowsSelected: payload.allRowsSelected, allVisibleRowsSelected: payload.allVisibleRowsSelected, @@ -53,7 +54,7 @@ const useInstance = (instance: TableInstance) => { }) as OnRowSelectEvent, ); } else { - onRowSelect?.(enrichEventWithDetails(e, payload) as OnRowSelectEvent); + onRowSelect?.(enrichEventWithDetails(e as Event & { detail?: OnRowSelectDetail }, payload) as OnRowSelectEvent); } } From 34656448d9999656980fc24fd81c99f68865e997 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 16 Feb 2026 12:58:25 +0100 Subject: [PATCH 15/17] Update useRowSelect.ts --- .../main/src/components/AnalyticalTable/hooks/useRowSelect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts index c3b3c595e6a..b1302a1f7fe 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -20,7 +20,7 @@ const emptyArray: RowType[] = []; * Original source: https://github.com/TanStack/table/blob/v7/src/plugin-hooks/useRowSelect.js * * This is a fork of react-table's `useRowSelect` with performance optimizations: - * - Early exit when `selectionMode` is 'None' or loading/showOverlay is `true` + * - Early exit when `selectionMode` is 'None' * - Skips `selectedFlatRows` computation, `isAllRowsSelected` checks, and `prepareRow` overhead when selection is disabled * - `isAllRowsSelected` computation is memoized * - Uses stable noop references when disabled From 5623342ba0a1fdf0ad4b0ece82524a2ed5bac49c Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Tue, 17 Feb 2026 14:56:26 +0100 Subject: [PATCH 16/17] fix parent row selection in `useIndeterminateRowSelection` & improve `stateReducer` type --- .../AnalyticalTable/AnalyticalTable.cy.tsx | 18 ++--- .../AnalyticalTable/hooks/useRowSelect.ts | 9 +-- .../useIndeterminateRowSelection.tsx | 69 ++++++++++++++----- .../tableReducer/stateReducer.ts | 2 +- .../components/AnalyticalTable/types/index.ts | 15 +++- 5 files changed, 73 insertions(+), 40 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index 320237bff34..87636bdcc8c 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -1224,19 +1224,11 @@ describe('AnalyticalTable', () => { cy.findByTestId('selectedRows').should('have.text', '{"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true}'); cy.findByText('Selma Kaufman').click(); - if (reactVersion.startsWith('19')) { - // ToDo: the parent row isn't included in the `setSelectedRowIds` anymore - check if it's feasible to include it again, otherwise add a note to the hook - cy.findByTestId('selectedRows').should( - 'have.text', - // '{"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true,"1.0.0":true}' - '{"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true}', - ); - } else { - cy.findByTestId('selectedRows').should( - 'have.text', - '{"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true,"1.0.0":true}', - ); - } + // parent row "1.0.0" is automatically added when all children are selected + cy.findByTestId('selectedRows').should( + 'have.text', + '{"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true,"1.0.0":true}', + ); }); it('useIndeterminateRowSelection', () => { diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts index b1302a1f7fe..8cbf42dfc29 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -112,12 +112,7 @@ const defaultGetToggleAllPageRowsSelectedProps = ( ]; }; -function reducer( - state: TableInstance['state'], - action: { type: string; value?: boolean; id?: string }, - _previousState: TableInstance['state'], - instance: TableInstance, -) { +const reducer: TableInstance['stateReducer'] = (state, action, _previousState, instance) => { if (action.type === actions.init) { return { selectedRowIds: {}, @@ -218,7 +213,7 @@ function reducer( } return state; -} +}; function useInstance(instance: TableInstance) { const { diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useIndeterminateRowSelection.tsx b/packages/main/src/components/AnalyticalTable/pluginHooks/useIndeterminateRowSelection.tsx index f4e5a64df2d..5cc393e2084 100644 --- a/packages/main/src/components/AnalyticalTable/pluginHooks/useIndeterminateRowSelection.tsx +++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useIndeterminateRowSelection.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { AnalyticalTableSelectionBehavior } from '../../../enums/AnalyticalTableSelectionBehavior.js'; import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; -import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js'; +import type { AnalyticalTableState, ReactTableHooks, RowType, TableInstance } from '../types/index.js'; type onIndeterminateChange = (e: { indeterminateRowsById: Record; @@ -19,8 +19,8 @@ const getParentRow = (id: string, rowsById: TableInstance['rowsById']): [RowType return [rowsById[parentRowId], lastDotIndex]; }; -const getIndeterminateRowIds = (id) => { - const indeterminateRowsById = {}; +const getIndeterminateRowIds = (id: string): Record => { + const indeterminateRowsById: Record = {}; const lastDotIndex = id.lastIndexOf('.'); indeterminateRowsById[id] = true; if (lastDotIndex !== -1) { @@ -30,17 +30,21 @@ const getIndeterminateRowIds = (id) => { return indeterminateRowsById; }; -const getIndeterminate = (rows, rowsById, state) => { - const indeterminateRowsById = {}; +const getIndeterminate = ( + rows: RowType[], + rowsById: TableInstance['rowsById'], + state: { selectedRowIds: AnalyticalTableState['selectedRowIds'] }, +): Record => { + const indeterminateRowsById: Record = {}; let usedParentIndex = ''; - const getIndeterminateRecursive = (subRows, rowIdScope = null) => { + const getIndeterminateRecursive = (subRows: RowType[], rowIdScope: string | null = null) => { for (const row of subRows) { if (row.subRows.length > 0) { // find leaf nodes getIndeterminateRecursive(row.subRows, row.id); } else if (rowIdScope !== null && usedParentIndex !== rowIdScope) { usedParentIndex = rowIdScope; - const checkIndeterminate = (rowId) => { + const checkIndeterminate = (rowId: string) => { const [parentRow, dotIndex] = getParentRow(rowId, rowsById); const selectedRows = parentRow.subRows.filter((item) => state.selectedRowIds[item.id]); const areAllSelected = parentRow.subRows.length === selectedRows.length; @@ -79,7 +83,10 @@ const getIndeterminate = (rows, rowsById, state) => { * @param {event} onIndeterminateChange Fired when the indeterminate state of rows is changed. */ export const useIndeterminateRowSelection = (onIndeterminateChange?: onIndeterminateChange) => { - const toggleRowProps = (rowProps, { row, instance }: { row: RowType; instance: TableInstance }) => { + const toggleRowProps = ( + rowProps: { checked?: boolean }, + { row, instance }: { row: RowType; instance: TableInstance }, + ) => { let indeterminate: boolean; if (instance.isAllRowsSelected) { indeterminate = false; @@ -87,10 +94,6 @@ export const useIndeterminateRowSelection = (onIndeterminateChange?: onIndetermi indeterminate = instance?.state?.indeterminateRows?.[row.id] ?? false; } - if (rowProps.checked && !instance.state.selectedRowIds[row.id]) { - row.toggleRowSelected(true); - } - return [ rowProps, { @@ -100,8 +103,33 @@ export const useIndeterminateRowSelection = (onIndeterminateChange?: onIndetermi ]; }; - const stateReducer = (newState, action, prevState, instance) => { - const { rowsById, state, rows } = instance; + const stateReducer: TableInstance['stateReducer'] = (newState, action, _prevState, instance) => { + const { rowsById, rows } = instance; + + // check if parent row should be auto-selected + if (action.type === 'toggleRowSelected') { + const rowId = action.id; + const isSelected = newState.selectedRowIds[rowId]; + if (isSelected) { + // check if row has parent and if all subRows are selected + const parentId = rowId.substring(0, rowId.lastIndexOf('.')); + if (parentId && rowsById[parentId]) { + const parentRow = rowsById[parentId]; + const allSiblingsSelected = parentRow.subRows?.every((subRow: RowType) => newState.selectedRowIds[subRow.id]); + if (allSiblingsSelected && !newState.selectedRowIds[parentId]) { + // auto-select parent row + return { + ...newState, + selectedRowIds: { + ...newState.selectedRowIds, + [parentId]: true, + }, + }; + } + } + } + } + if (action.type === 'INDETERMINATE_ROW_IDS') { if (action.payload === 'reset') { return { @@ -110,7 +138,7 @@ export const useIndeterminateRowSelection = (onIndeterminateChange?: onIndetermi }; } - const indeterminateRowsById = getIndeterminate(rows, rowsById, state); + const indeterminateRowsById = getIndeterminate(rows, rowsById, { selectedRowIds: newState.selectedRowIds }); return { ...newState, @@ -128,7 +156,14 @@ export const useIndeterminateRowSelection = (onIndeterminateChange?: onIndetermi webComponentsReactProperties: { selectionMode, selectionBehavior, isTreeTable }, } = instance; + const lastProcessedSelectedRowIdsRef = useRef(selectedRowIds); + useEffect(() => { + if (lastProcessedSelectedRowIdsRef.current === selectedRowIds) { + return; + } + lastProcessedSelectedRowIdsRef.current = selectedRowIds; + if ( isTreeTable && selectionMode === AnalyticalTableSelectionMode.Multiple && @@ -140,7 +175,7 @@ export const useIndeterminateRowSelection = (onIndeterminateChange?: onIndetermi } else if (typeof indeterminateRows === 'object' && Object.keys(indeterminateRows).length) { dispatch({ type: 'INDETERMINATE_ROW_IDS', payload: 'reset' }); } - }, [data, selectedRowIds, isTreeTable, selectionMode, selectionBehavior]); + }, [data, selectedRowIds, isTreeTable, selectionMode, selectionBehavior, dispatch]); useEffect(() => { if (typeof onIndeterminateChange === 'function' && indeterminateRows) { diff --git a/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts b/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts index 5e59d99d349..3c29e79951d 100644 --- a/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts +++ b/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts @@ -1,7 +1,7 @@ import { actions } from 'react-table'; import type { TableInstance } from '../types/index.js'; -export const stateReducer = (state, action, _prevState, instance: TableInstance) => { +export const stateReducer: TableInstance['stateReducer'] = (state, action, _prevState, instance) => { const { payload } = action; if (state.isRtl && action.type === actions.columnResizing) { const { clientX } = action; diff --git a/packages/main/src/components/AnalyticalTable/types/index.ts b/packages/main/src/components/AnalyticalTable/types/index.ts index 4505379cec2..e73196d7671 100644 --- a/packages/main/src/components/AnalyticalTable/types/index.ts +++ b/packages/main/src/components/AnalyticalTable/types/index.ts @@ -27,6 +27,17 @@ import type { classNames } from '../AnalyticalTable.module.css.js'; export type ClassNames = typeof classNames; +interface StateReducerAction { + type: string; + id?: string; + value?: boolean; + payload?: any; + columnId?: string; + filterValue?: string; + clientX?: number; + [key: string]: unknown; +} + export enum RenderColumnTypes { Filter = 'Filter', Grouped = 'Grouped', @@ -205,7 +216,7 @@ export interface TableInstance { state: AnalyticalTableState & Record; stateReducer?: ( state: TableInstance['state'], - action: any, + action: StateReducerAction, _prevState: TableInstance['state'], instance: TableInstance, ) => TableInstance['state']; @@ -1138,7 +1149,7 @@ interface ConfigParam { export interface ReactTableHooks { useOptions: any[]; - stateReducers: any[]; + stateReducers: NonNullable[]; useControlledState: any[]; columns: ((columns: ColumnType[], config: ConfigParam) => ColumnType[])[]; columnsDeps: ((deps: DependencyList, config: ConfigParam) => DependencyList)[]; From ef00e9eb0354b066599a7d13dbf7716db416094e Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Tue, 17 Feb 2026 16:27:07 +0100 Subject: [PATCH 17/17] fix unecessary memoization & subRows early exit, small performance improvements --- .../AnalyticalTable.stories.tsx | 23 ++++- .../AnalyticalTable/hooks/useRowSelect.ts | 95 +++++++++---------- 2 files changed, 65 insertions(+), 53 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx index db1e5c7e5f4..aedcb2ae4b2 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx @@ -170,7 +170,28 @@ const meta = { }, args: { // reactTableOptions: { autoResetSelectedRows: true }, - data: dataLarge, + data: [ + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ], columns: [ { Header: 'Name', diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts index 8cbf42dfc29..33c42ce8203 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { actions, makePropGetter, ensurePluginOrder, useGetLatest, useMountedLayoutEffect } from 'react-table'; import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js'; @@ -135,14 +135,12 @@ const reducer: TableInstance['stateReducer'] = (state, action, _previousState, i const selectedRowIds = { ...state.selectedRowIds }; - if (selectAll) { - Object.keys(nonGroupedRowsById).forEach((rowId) => { + for (const rowId in nonGroupedRowsById) { + if (selectAll) { selectedRowIds[rowId] = true; - }); - } else { - Object.keys(nonGroupedRowsById).forEach((rowId) => { + } else { delete selectedRowIds[rowId]; - }); + } } return { ...state, selectedRowIds }; @@ -172,8 +170,13 @@ const reducer: TableInstance['stateReducer'] = (state, action, _previousState, i } } - if (selectSubRows && getSubRows(row)) { - getSubRows(row).forEach((r: RowType) => handleRowById(r.id)); + if (selectSubRows) { + const subRows = getSubRows(row); + if (subRows) { + subRows.forEach((r: RowType) => { + handleRowById(r.id); + }); + } } } }; @@ -202,12 +205,22 @@ const reducer: TableInstance['stateReducer'] = (state, action, _previousState, i } } - if (selectSubRows && getSubRows(row)) { - getSubRows(row).forEach((r: RowType) => handleRowById(r.id)); + if (selectSubRows) { + const subRows = getSubRows(row); + + if (subRows) { + subRows.forEach((r: RowType) => { + handleRowById(r.id); + }); + } } }; - page?.forEach((row: RowType) => handleRowById(row.id)); + if (page) { + page.forEach((row: RowType) => { + handleRowById(row.id); + }); + } return { ...state, selectedRowIds: newSelectedRowIds }; } @@ -238,56 +251,34 @@ function useInstance(instance: TableInstance) { ensurePluginOrder(plugins, ['useFilters', 'useGroupBy', 'useSortBy', 'useExpanded', 'usePagination'], 'useRowSelect'); // UI5WCR: early exit when selection disabled - const selectedFlatRows = useMemo(() => { - if (!isSelectionEnabled) { - return emptyArray; - } - - const result: RowType[] = []; + let selectedFlatRows: RowType[] = emptyArray; + let isAllRowsSelected = false; + let isAllPageRowsSelected = false; + if (isSelectionEnabled) { + selectedFlatRows = []; rows.forEach((row) => { const isSelected = selectSubRows ? getRowIsSelected(row, selectedRowIds, getSubRows) : !!selectedRowIds[row.id]; row.isSelected = !!isSelected; row.isSomeSelected = isSelected === null; if (isSelected) { - result.push(row); + selectedFlatRows.push(row); } }); - return result; - }, [rows, selectSubRows, selectedRowIds, getSubRows, isSelectionEnabled]); - - // UI5WCR: memoized - const isAllRowsSelected = useMemo(() => { - if (!isSelectionEnabled) { - return false; - } - - const rowCount = Object.keys(nonGroupedRowsById).length; - const selectedCount = Object.keys(selectedRowIds).length; - - if (!rowCount || !selectedCount) { - return false; + // isAllRowsSelected + const rowIds = Object.keys(nonGroupedRowsById); + const selectedIds = Object.keys(selectedRowIds); + if (rowIds.length && selectedIds.length) { + isAllRowsSelected = rowIds.every((id) => selectedRowIds[id]); } - return !Object.keys(nonGroupedRowsById).some((id) => !selectedRowIds[id]); - }, [nonGroupedRowsById, selectedRowIds, isSelectionEnabled]); - - // UI5WCR: memoized - const isAllPageRowsSelected = useMemo(() => { - if (!isSelectionEnabled) { - return false; - } - if (!page || !page.length) { - return false; + // isAllPageRowsSelected + if (page?.length) { + isAllPageRowsSelected = isAllRowsSelected || page.every((row) => selectedRowIds[row.id]); } - if (isAllRowsSelected) { - return true; - } - - return !page.some(({ id }: { id: string }) => !selectedRowIds[id]); - }, [page, selectedRowIds, isAllRowsSelected, isSelectionEnabled]); + } const getAutoResetSelectedRows = useGetLatest(autoResetSelectedRows); @@ -367,10 +358,10 @@ function getRowIsSelected( let allChildrenSelected = true; let someSelected = false; - subRows.forEach((subRow) => { + for (const subRow of subRows) { // Bail out early if we know both of these if (someSelected && !allChildrenSelected) { - return; + break; } if (getRowIsSelected(subRow, selectedRowIds, getSubRows)) { @@ -378,7 +369,7 @@ function getRowIsSelected( } else { allChildrenSelected = false; } - }); + } return allChildrenSelected ? true : someSelected ? null : false; }