diff --git a/.changeset/cute-walls-judge.md b/.changeset/cute-walls-judge.md new file mode 100644 index 0000000000..455165e9b4 --- /dev/null +++ b/.changeset/cute-walls-judge.md @@ -0,0 +1,5 @@ +--- +'@tanstack/table-core': patch +--- + +Fixed table-core `row.getIsAllSubRowsSelected` now returns false when subrows are unselectable () diff --git a/packages/table-core/src/features/RowSelection.ts b/packages/table-core/src/features/RowSelection.ts index e5fddaba9d..b394e33783 100644 --- a/packages/table-core/src/features/RowSelection.ts +++ b/packages/table-core/src/features/RowSelection.ts @@ -76,7 +76,7 @@ export interface RowSelectionRow { */ getCanSelectSubRows: () => boolean /** - * Returns whether or not all of the row's sub rows are selected. + * Returns whether or not all of the row's selectable sub rows are selected. * @link [API Docs](https://tanstack.com/table/v8/docs/api/features/row-selection#getisallsubrowsselected) * @link [Guide](https://tanstack.com/table/v8/docs/guide/row-selection) */ @@ -88,7 +88,7 @@ export interface RowSelectionRow { */ getIsSelected: () => boolean /** - * Returns whether or not some of the row's sub rows are selected. + * Returns whether or not some of the row's selectable sub rows are selected. * @link [API Docs](https://tanstack.com/table/v8/docs/api/features/row-selection#getissomeselected) * @link [Guide](https://tanstack.com/table/v8/docs/guide/row-selection) */ @@ -503,13 +503,14 @@ export const RowSelection: TableFeature = { } row.getIsSomeSelected = () => { - const { rowSelection } = table.getState() - return isSubRowSelected(row, rowSelection, table) === 'some' + const { selectableCount, selectedCount } = getSubRowSelectionsCount(row); + return selectedCount > 0 && selectedCount < selectableCount; } row.getIsAllSubRowsSelected = () => { - const { rowSelection } = table.getState() - return isSubRowSelected(row, rowSelection, table) === 'all' + const { selectableCount, selectedCount } = getSubRowSelectionsCount(row); + + return selectedCount > 0 && selectedCount === selectableCount; } row.getCanSelect = () => { @@ -636,43 +637,41 @@ export function isRowSelected( return selection[row.id] ?? false } -export function isSubRowSelected( - row: Row, - selection: Record, - table: Table, -): boolean | 'some' | 'all' { - if (!row.subRows?.length) return false - - let allChildrenSelected = true - let someSelected = false - - row.subRows.forEach((subRow) => { - // Bail out early if we know both of these - if (someSelected && !allChildrenSelected) { - return - } - - if (subRow.getCanSelect()) { - if (isRowSelected(subRow, selection)) { - someSelected = true - } else { - allChildrenSelected = false +/** + * Determines the number of selectable sub-rows and selected sub-rows (checked via BFS across all nested sub-rows). + * + * @param row The table row to evaluate. + * @returns The number of sub-rows that satisfy the condition. + */ +export function getSubRowSelectionsCount(row: Row): { selectableCount: number, selectedCount: number } { + const q = [...row.subRows]; + let selectableCount = 0; + let selectedCount = 0; + let index = 0; + while (index < q.length) { + const node = q[index]!; + if (node.getCanSelect()) { + selectableCount += 1; + if (node.getIsSelected()) { + selectedCount += 1; } } + q.push(...node.subRows); + index += 1; + } - // Check row selection of nested subrows - if (subRow.subRows && subRow.subRows.length) { - const subRowChildrenSelected = isSubRowSelected(subRow, selection, table) - if (subRowChildrenSelected === 'all') { - someSelected = true - } else if (subRowChildrenSelected === 'some') { - someSelected = true - allChildrenSelected = false - } else { - allChildrenSelected = false - } - } - }) + return { selectableCount, selectedCount }; +} - return allChildrenSelected ? 'all' : someSelected ? 'some' : false +export function isSubRowSelected( + row: Row, + _selection: Record, + _table: Table, +): boolean | 'some' | 'all' { + if (row.getIsSomeSelected()) { + return 'some'; + } else if (row.getIsAllSubRowsSelected()) { + return 'all'; + } + return false; } diff --git a/packages/table-core/tests/RowSelection.test.ts b/packages/table-core/tests/RowSelection.test.ts index 266bf27806..759f51b6f6 100644 --- a/packages/table-core/tests/RowSelection.test.ts +++ b/packages/table-core/tests/RowSelection.test.ts @@ -320,4 +320,471 @@ describe('RowSelection', () => { expect(result).toEqual('some') }) }) + + describe('getSubRowSelectionsCount', () => { + describe('selectableCount', () => { + it('should be 0 when there are no sub-rows', () => { + const data = makeData(1) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + const { selectableCount } = RowSelection.getSubRowSelectionsCount(firstRow) + expect(selectableCount).toBe(0) + }) + + it('should be 0 when all sub-rows are not selectable', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: (row) => row.depth === 0, // only root rows are selectable + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + const { selectableCount } = RowSelection.getSubRowSelectionsCount(firstRow) + expect(selectableCount).toBe(0) + }) + + it('should be number of selectable sub-rows', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + const { selectableCount } = RowSelection.getSubRowSelectionsCount(firstRow) + expect(selectableCount).toBe(2) + }) + + it('should include all nested selectable sub-rows', () => { + const data = makeData(1, 2, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: (row) => row.id !== '0.0.1', // make one of the sub-rows non-selectable + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + const { selectableCount } = RowSelection.getSubRowSelectionsCount(firstRow) + expect(selectableCount).toBe(5) // 2 direct sub-rows + 3 nested sub-rows + }) + }) + + describe('selectedCount', () => { + it('should be 0 when there are no sub-rows', () => { + const data = makeData(3) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + const { selectedCount } = RowSelection.getSubRowSelectionsCount(firstRow) + expect(selectedCount).toBe(0) + }) + + it('should be 0 when all sub-rows are not selectable', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: (row) => row.depth === 0, // only root rows are selectable + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + const { selectedCount } = RowSelection.getSubRowSelectionsCount(firstRow) + expect(selectedCount).toBe(0) + }) + + it('should be 0 when no sub-rows are selected', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + const { selectedCount } = RowSelection.getSubRowSelectionsCount(firstRow) + expect(selectedCount).toBe(0) + }) + + it('should be number of selectable and selected sub-rows', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: (row) => row.id !== '0.1', // second sub-row is not selectable + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { + rowSelection: { + '0.0': true, + '0.1': true, + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + const { selectedCount } = RowSelection.getSubRowSelectionsCount(firstRow) + expect(selectedCount).toBe(1) + }) + + it('should include all nested selectable and selected sub-rows', () => { + const data = makeData(1, 2, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: (row) => row.id !== '0.0.1', // make one of the sub-rows non-selectable + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { + rowSelection: { + '0.0': true, + '0.0.0': true, + '0.0.1': true, + '0.1.1': true, + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + const { selectedCount } = RowSelection.getSubRowSelectionsCount(firstRow) + expect(selectedCount).toBe(3) + }) + }); + }); + + describe('RowSelectionRow.getIsSomeSelected', () => { + it('should return false if there are no sub-rows', () => { + const data = makeData(2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const { rows } = table.getCoreRowModel(); + rows.forEach(row => { + expect(row.getIsSomeSelected()).toBe(false) + }); + }) + + it('should return false if all sub-rows are not selectable', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: (row) => row.depth === 0, // only root rows selectable + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: { '0.0': true, '0.1': true } }, // even if all sub-rows are selected, they are not selectable + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsSomeSelected()).toBe(false) + }) + + it('should return false if all selectable sub-rows are not selected', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: { '0': true } }, // parent row's own selection does not impact outcome + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsSomeSelected()).toBe(false) + }) + + it('should return false if all selectable sub-rows are selected', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: { '0.0': true, '0.1': true } }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsSomeSelected()).toBe(false) + }) + + it('should return true if some selectable sub-rows are selected', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: { '0.0': true } }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsSomeSelected()).toBe(true) + }) + + it('should return true if one nested selectable sub-row is selected', () => { + const data = makeData(1, 2, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: { '0.0.0': true } }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsSomeSelected()).toBe(true) + }) + }); + + describe('RowSelectionRow.getIsAllSubRowsSelected', () => { + it('should return false if there are no sub-rows', () => { + const data = makeData(2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const { rows } = table.getCoreRowModel(); + rows.forEach(row => { + expect(row.getIsAllSubRowsSelected()).toBe(false) + }); + }) + + it('should return false if all sub-rows are not selectable', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: (row) => row.depth === 0, // only root rows selectable + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: {} }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsAllSubRowsSelected()).toBe(false) + }) + + it('should return false if any selectable sub-row is not selected', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: { '0.0': true } }, // only one of two selected + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsAllSubRowsSelected()).toBe(false) + }) + + it('should return false if any nested selectable sub-row is not selected', () => { + const data = makeData(1, 2, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { + rowSelection: { + '0.0': true, + '0.0.0': true, + '0.0.1': true, + '0.1': true, + '0.1.0': true, + // '0.1.1' not selected + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsAllSubRowsSelected()).toBe(false) + }) + + it('should return true if all selectable sub-rows are selected', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: { '0.0': true, '0.1': true } }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsAllSubRowsSelected()).toBe(true) + }) + + it('should return true if all selectable sub-rows including nested ones are selected', () => { + const data = makeData(1, 2, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { + rowSelection: { + '0.0': true, + '0.0.0': true, + '0.0.1': true, + '0.1': true, + '0.1.0': true, + '0.1.1': true, + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsAllSubRowsSelected()).toBe(true) + }) + + it('should return true when all selectable sub-rows are selected and some are non-selectable', () => { + const data = makeData(1, 2) + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: (row) => row.id !== '0.1', // second sub-row is NOT selectable + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: (row) => row.subRows, + state: { rowSelection: { '0.0': true } }, // only selectable sub-row selected + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstRow = table.getCoreRowModel().rows[0] + expect(firstRow.getIsAllSubRowsSelected()).toBe(true) + }) + }); })