From 9524e3afb1865d6dc2cea72df6210e77ac7839b1 Mon Sep 17 00:00:00 2001 From: Joseerobles Date: Fri, 25 Jul 2025 14:33:29 -0700 Subject: [PATCH] feat(RowSelection): support indeterminate states --- .../table-core/src/features/RowSelection.ts | 122 ++++- .../table-core/tests/RowSelection.test.ts | 425 ++++++++++++++++++ 2 files changed, 546 insertions(+), 1 deletion(-) diff --git a/packages/table-core/src/features/RowSelection.ts b/packages/table-core/src/features/RowSelection.ts index 90166823aa..2faaaf7af7 100644 --- a/packages/table-core/src/features/RowSelection.ts +++ b/packages/table-core/src/features/RowSelection.ts @@ -32,6 +32,10 @@ export interface RowSelectionOptions { enableRowSelection?: boolean | ((row: Row) => boolean) /** * Enables/disables automatic sub-row selection when a parent row is selected, or a function that enables/disables automatic sub-row selection for each row. + * When enabled, also provides enhanced parent-child selection behavior: + * - Parent rows show as indeterminate when partially selected + * - Parent rows auto-select when all children are selected + * - Parent rows auto-unselect when no children are selected * (Use in combination with expanding or grouping features) * @link [API Docs](https://tanstack.com/table/v8/docs/api/features/row-selection#enablesubrowselection) * @link [Guide](https://tanstack.com/table/v8/docs/guide/row-selection) @@ -495,11 +499,77 @@ export const RowSelection: TableFeature = { } row.getIsSelected = () => { const { rowSelection } = table.getState() - return isRowSelected(row, rowSelection) + const directlySelected = isRowSelected(row, rowSelection) + + // For leaf rows, return direct selection state + if (!row.subRows?.length) { + return directlySelected + } + + // Enhanced parent-child logic only applies when enableSubRowSelection is enabled + if (!row.getCanSelectSubRows()) { + return directlySelected + } + + // For parent rows, implement enhanced selection logic + const allChildrenSelected = row.subRows.every(child => child.getIsSelected()) + const someChildrenSelected = row.subRows.some(child => + child.getIsSelected() || child.getIsSomeSelected()) + + // Parent shows as indeterminate (false) when: + // - Parent is explicitly selected AND + // - Not all children are selected AND + // - Some children are selected + if (directlySelected && !allChildrenSelected && someChildrenSelected) { + return false // Indeterminate state + } + + // Parent is selected when: + // - Explicitly selected (with no children selected), OR + // - All children are selected (implicit selection) + return directlySelected || allChildrenSelected } row.getIsSomeSelected = () => { const { rowSelection } = table.getState() + + // For rows with children, implement the enhanced indeterminate logic + if (row.subRows?.length) { + + // Enhanced indeterminate logic only applies when enableSubRowSelection is enabled + if (!row.getCanSelectSubRows()) { + return isSubRowSelected(row, rowSelection, table) === 'some' + } + + // Count children that are selected OR indeterminate (recursive check) + let fullySelectedCount = 0 + let partiallySelectedCount = 0 + + row.subRows.forEach(subRow => { + const isDirectlySelected = rowSelection[subRow.id] + const isChildIndeterminate = subRow.getIsSomeSelected() + + if (isDirectlySelected && !isChildIndeterminate) { + // Child is fully selected (and not indeterminate) + fullySelectedCount++; + } else if (isDirectlySelected || isChildIndeterminate) { + // Child is partially selected (indeterminate) or selected but has indeterminate descendants + partiallySelectedCount++; + } + }) + + const totalChildren = row.subRows.length + const hasAnySelection = fullySelectedCount > 0 || partiallySelectedCount > 0 + const hasPartialSelection = partiallySelectedCount > 0 || fullySelectedCount < totalChildren + + // Parent is indeterminate if: + // 1. Has some selection but not all children are fully selected, OR + // 2. Has any children that are themselves indeterminate + const result = hasAnySelection && hasPartialSelection + + return result + } + return isSubRowSelected(row, rowSelection, table) === 'some' } @@ -576,6 +646,56 @@ const mutateRowIsSelected = ( mutateRowIsSelected(selectedRowIds, row.id, value, includeChildren, table) ) } + + updateParentSelectionState(selectedRowIds, row, table) +} + +// Helper function to manage parent selection based on children state +const updateParentSelectionState = ( + selectedRowIds: Record, + row: Row, + table: Table +) => { + // Find the parent row by checking all rows in the table + const allRows = table.getCoreRowModel().flatRows + const parentRow = allRows.find(r => + r.subRows?.some(subRow => subRow.id === row.id) + ) + + if (!parentRow || !parentRow.subRows?.length) { + return // No parent found or parent has no children + } + + // Enhanced parent state logic only applies when enableSubRowSelection is enabled + if (!parentRow.getCanSelectSubRows()) { + return // Skip enhanced logic if sub-row selection is disabled + } + + // Count selected children - use direct selection state to avoid recursion issues + const selectedChildren = parentRow.subRows.filter(child => + selectedRowIds[child.id] + ) + const totalChildren = parentRow.subRows.length + + if (selectedChildren.length === 0) { + // No children selected at all, unselect parent + delete selectedRowIds[parentRow.id] + } else if (selectedChildren.length === totalChildren) { + // All children are directly selected, select parent fully + if (parentRow.getCanSelect()) { + selectedRowIds[parentRow.id] = true + } + } else { + // Some children selected, parent should remain selected but show indeterminate + // Keep parent as true so getIsSelected() returns true for checkbox state + // getIsSomeSelected() will handle the indeterminate visual recursively + if (parentRow.getCanSelect()) { + selectedRowIds[parentRow.id] = true + } + } + + // Recursively check the parent's parent + updateParentSelectionState(selectedRowIds, parentRow, table) } export function selectRowsFn( diff --git a/packages/table-core/tests/RowSelection.test.ts b/packages/table-core/tests/RowSelection.test.ts index 38bdfb7dcb..6cd2662778 100644 --- a/packages/table-core/tests/RowSelection.test.ts +++ b/packages/table-core/tests/RowSelection.test.ts @@ -320,4 +320,429 @@ describe('RowSelection', () => { expect(result).toEqual('some') }) }) + + describe('Parent-Child Selection Behavior', () => { + describe('getIsSelected for parent rows', () => { + it('should return false when no children are selected', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: {}, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(parentRow.getIsSelected()).toBe(false) + }) + + it('should return false when some children are selected', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0.0': true, // first child + '0.1': true, // second child + // third child not selected + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(parentRow.getIsSelected()).toBe(false) + }) + + it('should return true when all children are selected', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0.0': true, // first child + '0.1': true, // second child + '0.2': true, // third child + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(parentRow.getIsSelected()).toBe(true) + }) + + it('should return true when parent is explicitly selected', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0': true, // parent explicitly selected + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(parentRow.getIsSelected()).toBe(true) + }) + + it('should return true when parent is explicitly selected and all children are also selected', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0': true, // parent explicitly selected + '0.0': true, // first child + '0.1': true, // second child + '0.2': true, // third child + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(parentRow.getIsSelected()).toBe(true) + }) + }) + + + + describe('Checkbox UI behavior integration', () => { + function getCheckboxState(row: any) { + const isChecked = row.getIsSelected() + const isIndeterminate = row.getIsSomeSelected() + + if (isChecked) return 'checked' + if (isIndeterminate) return 'indeterminate' + return 'unchecked' + } + + it('should show unchecked when no children are selected', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: {}, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(getCheckboxState(parentRow)).toBe('unchecked') + }) + + it('should show indeterminate when some children are selected', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0.0': true, // first child + '0.1': true, // second child + // third child not selected + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(getCheckboxState(parentRow)).toBe('indeterminate') + }) + + it('should show checked when all children are selected', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0.0': true, // first child + '0.1': true, // second child + '0.2': true, // third child + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(getCheckboxState(parentRow)).toBe('checked') + }) + + it('should show checked when parent is explicitly selected', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0': true, // parent explicitly selected + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(getCheckboxState(parentRow)).toBe('checked') + }) + }) + + describe('Edge case scenarios', () => { + it('Parent should be checked when all children are selected individually', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { '0.0': true, '0.1': true, '0.2': true }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + expect(parentRow.getIsSelected()).toBe(true) // Auto-selected because all children selected + expect(parentRow.getIsSomeSelected()).toBe(false) + }) + + it('Parent explicitly selected but some children deselected should show indeterminate', () => { + const data = makeData(1, 1, 3) // 3-level hierarchy + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0.0': true, // 2nd level explicitly selected + '0.0.1': true, // Some children selected + '0.0.2': true, + // '0.0.0' NOT selected - creates indeterminate state + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const secondLevelRow = table.getCoreRowModel().rows[0].subRows[0] + + expect(secondLevelRow.getIsSelected()).toBe(false) // Indeterminate, not selected + expect(secondLevelRow.getIsSomeSelected()).toBe(true) + }) + + it('Multi-level hierarchy propagates indeterminate state correctly', () => { + const data = makeData(1, 1, 3) // Root > Level2 > 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: true, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0.0.0': true, // Only one grandchild selected + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const rootRow = table.getCoreRowModel().rows[0] + const level2Row = rootRow.subRows[0] + + // Both parent levels should be indeterminate + expect(level2Row.getIsSelected()).toBe(false) + expect(level2Row.getIsSomeSelected()).toBe(true) + expect(rootRow.getIsSelected()).toBe(false) + expect(rootRow.getIsSomeSelected()).toBe(true) + }) + }) + + describe('Backward compatibility when enableSubRowSelection is disabled', () => { + it('should use original logic when enableSubRowSelection is false', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: false, // Disabled + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0': true, // Parent explicitly selected + '0.0': true, // Some children selected + '0.1': true, + // '0.2' not selected + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + + // With enableSubRowSelection: false, parent should return direct selection (true) + // This is the original behavior - no enhanced indeterminate logic + expect(parentRow.getIsSelected()).toBe(true) + expect(parentRow.getIsSomeSelected()).toBe(true) // Uses original isSubRowSelected logic + }) + + it('should not auto-update parent state when enableSubRowSelection is false', () => { + const data = makeData(1, 3) // 1 parent with 3 children + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: false, + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: {}, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const parentRow = table.getCoreRowModel().rows[0] + const firstChild = parentRow.subRows[0] + const secondChild = parentRow.subRows[1] + const thirdChild = parentRow.subRows[2] + + // Select all children + firstChild.toggleSelected(true) + secondChild.toggleSelected(true) + thirdChild.toggleSelected(true) + + // With enableSubRowSelection: false, parent should NOT be auto-selected + // This preserves the original behavior + expect(parentRow.getIsSelected()).toBe(false) + }) + + it('should handle function-based enableSubRowSelection correctly', () => { + const data = makeData(2, 2) // 2 parents with 2 children each + const columns = generateColumns(data) + + const table = createTable({ + enableRowSelection: true, + enableSubRowSelection: (row) => row.id === '0', // Only enable for first parent + onStateChange() {}, + renderFallbackValue: '', + data, + getSubRows: row => row.subRows, + state: { + rowSelection: { + '0': true, // First parent selected + '0.0': true, // Some children selected + '1': true, // Second parent selected + '1.0': true, // Some children selected + }, + }, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const firstParent = table.getCoreRowModel().rows[0] // enableSubRowSelection: true + const secondParent = table.getCoreRowModel().rows[1] // enableSubRowSelection: false + + // First parent uses enhanced logic (indeterminate when partial selection) + expect(firstParent.getIsSelected()).toBe(false) // Enhanced logic: indeterminate + expect(firstParent.getIsSomeSelected()).toBe(true) + + // Second parent uses original logic (direct selection) + expect(secondParent.getIsSelected()).toBe(true) // Original logic: direct selection + expect(secondParent.getIsSomeSelected()).toBe(true) + }) + }) + }) })