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/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index 587f171af94..87636bdcc8c 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('Flowers Mcfarland').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); }); @@ -1005,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]); @@ -1014,8 +1038,9 @@ describe('AnalyticalTable', () => { }, []); setRelevantPayload({ allRowsSelected, + allVisibleRowsSelected, isSelected, - row: row.id, + row: row?.id, selectedFlatRows: selectedRowIdsArrayMapped.map((item) => ({ id: item?.id, })), @@ -1032,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'); @@ -1058,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'); @@ -1175,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', () => { @@ -1850,16 +1891,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', () => { @@ -3132,7 +3192,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]) { @@ -3148,6 +3208,7 @@ describe('AnalyticalTable', () => { id: item?.id, })), allRowsSelected, + allVisibleRowsSelected, }), ); select(e); @@ -3179,28 +3240,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 = [ { 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/AnalyticalTable.stories.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx index 40cefa4437f..aedcb2ae4b2 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx @@ -169,7 +169,29 @@ const meta = { chromatic: { disableSnapshot: true }, }, args: { - data: dataLarge, + // reactTableOptions: { autoResetSelectedRows: true }, + 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 new file mode 100644 index 00000000000..33c42ce8203 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -0,0 +1,377 @@ +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'; + +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' + * - 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) + * + * _Pagination specific implementation where adjusted as well, although they are currently not being used_ + */ +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', webComponentsReactProperties } = instance; + // UI5WCR: use className instead of inline style + const { classes } = webComponentsReactProperties; + 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); + }, + // UI5WCR: removed style/title, added className + className: classes.checkBox, + checked, + indeterminate: row.isSomeSelected, + }, + ]; +}; + +const defaultGetToggleAllRowsSelectedProps = ( + props: Record, + { instance }: { instance: TableInstance }, +) => { + // UI5WCR: use className instead of inline style + const { classes } = instance.webComponentsReactProperties; + return [ + props, + { + 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), + }, + ]; +}; + +const defaultGetToggleAllPageRowsSelectedProps = ( + props: Record, + { instance }: { instance: TableInstance }, +) => { + // UI5WCR: use className instead of inline style + const { classes } = instance.webComponentsReactProperties; + return [ + props, + { + onChange: (e: { target: { checked: boolean } }) => { + instance.toggleAllPageRowsSelected?.(e.target.checked); + }, + // UI5WCR: removed style/title, added className + className: classes.checkBox, + checked: instance.isAllPageRowsSelected, + indeterminate: Boolean( + !instance.isAllPageRowsSelected && + instance.page?.some(({ id }: { id: string }) => instance.state.selectedRowIds[id]), + ), + }, + ]; +}; + +const reducer: TableInstance['stateReducer'] = (state, action, _previousState, instance) => { + 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 }; + + for (const rowId in nonGroupedRowsById) { + if (selectAll) { + selectedRowIds[rowId] = true; + } else { + 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) { + const subRows = getSubRows(row); + if (subRows) { + subRows.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) { + const subRows = getSubRows(row); + + if (subRows) { + subRows.forEach((r: RowType) => { + handleRowById(r.id); + }); + } + } + }; + + if (page) { + 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 } = webComponentsReactProperties; + const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None; + + ensurePluginOrder(plugins, ['useFilters', 'useGroupBy', 'useSortBy', 'useExpanded', 'usePagination'], 'useRowSelect'); + + // UI5WCR: early exit when selection disabled + 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) { + selectedFlatRows.push(row); + } + }); + + // isAllRowsSelected + const rowIds = Object.keys(nonGroupedRowsById); + const selectedIds = Object.keys(selectedRowIds); + if (rowIds.length && selectedIds.length) { + isAllRowsSelected = rowIds.every((id) => selectedRowIds[id]); + } + + // isAllPageRowsSelected + if (page?.length) { + isAllPageRowsSelected = isAllRowsSelected || page.every((row) => selectedRowIds[row.id]); + } + } + + 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); + + // UI5WCR: use noop when selection disabled + 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 } = instance.webComponentsReactProperties; + const isSelectionEnabled = selectionMode !== AnalyticalTableSelectionMode.None; + + // UI5WCR: skip per-row setup when selection disabled + 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; + + for (const subRow of subRows) { + // Bail out early if we know both of these + if (someSelected && !allChildrenSelected) { + break; + } + + 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/hooks/useRowSelectionColumn.tsx b/packages/main/src/components/AnalyticalTable/hooks/useRowSelectionColumn.tsx index 4e2090cf84a..4d8045a3b08 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 */ @@ -18,10 +10,11 @@ const customCheckBoxStyling = { 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(); @@ -29,7 +22,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 +56,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 +66,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) => { @@ -192,23 +159,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); }; diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts index 4c0d42af9b5..b441ee71130 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts @@ -1,56 +1,69 @@ -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'; +import type { AnalyticalTablePropTypes, 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)); +type OnRowSelectEvent = Parameters>[0]; +type OnRowSelectDetail = OnRowSelectEvent['detail']; + +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: Partial = { + 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 as Event & { detail?: OnRowSelectDetail }, { + rowsById: payload.rowsById, + allRowsSelected: payload.allRowsSelected, + allVisibleRowsSelected: payload.allVisibleRowsSelected, + selectedRowIds: payload.selectedRowIds, + }) as OnRowSelectEvent, + ); + } else { + onRowSelect?.(enrichEventWithDetails(e as Event & { detail?: OnRowSelectDetail }, payload) as OnRowSelectEvent); + } + } + + 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/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/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/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/tableReducer/stateReducer.ts b/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts index fa8dba41345..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; @@ -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..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', @@ -109,6 +120,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; @@ -120,6 +140,8 @@ export interface TableInstance { type: string; payload?: Record | AnalyticalTableState['popInColumns'] | boolean | string | number; clientX?: number; + value?: boolean; + id?: string; }) => void; expandedDepth?: number; expandedRows?: RowType[]; @@ -151,10 +173,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[]; @@ -192,7 +216,7 @@ export interface TableInstance { state: AnalyticalTableState & Record; stateReducer?: ( state: TableInstance['state'], - action: any, + action: StateReducerAction, _prevState: TableInstance['state'], instance: TableInstance, ) => TableInstance['state']; @@ -219,6 +243,11 @@ export interface TableInstance { visibleColumns: ColumnType[]; visibleColumnsWidth?: number[]; webComponentsReactProperties: WCRPropertiesType; + pendingSelectEvent?: { + event: Event; + row?: RowType; + selectAll?: boolean; + }; [key: string]: any; } @@ -289,6 +318,7 @@ export interface RowType { id: string; index: number; isExpanded: boolean | undefined; + isGrouped?: boolean; isSelected: boolean; isSomeSelected: boolean; getRowProps: (props?: any) => any; @@ -1023,7 +1053,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; @@ -1110,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)[];