diff --git a/examples/preact/row-selection/tests/e2e/smoke.spec.ts b/examples/preact/row-selection/tests/e2e/smoke.spec.ts index 374fa77f02..444b50e783 100644 --- a/examples/preact/row-selection/tests/e2e/smoke.spec.ts +++ b/examples/preact/row-selection/tests/e2e/smoke.spec.ts @@ -72,11 +72,9 @@ function footerCheckbox(page: Page) { /** Anchored so the regex cannot also match an ancestor with extra text. */ function selectionSummary(page: Page) { - return page - .locator('div') - .filter({ - hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, - }) + return page.locator('div').filter({ + hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, + }) } function pageRowsLabel(page: Page) { diff --git a/examples/react/row-selection/tests/e2e/smoke.spec.ts b/examples/react/row-selection/tests/e2e/smoke.spec.ts index 374fa77f02..444b50e783 100644 --- a/examples/react/row-selection/tests/e2e/smoke.spec.ts +++ b/examples/react/row-selection/tests/e2e/smoke.spec.ts @@ -72,11 +72,9 @@ function footerCheckbox(page: Page) { /** Anchored so the regex cannot also match an ancestor with extra text. */ function selectionSummary(page: Page) { - return page - .locator('div') - .filter({ - hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, - }) + return page.locator('div').filter({ + hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, + }) } function pageRowsLabel(page: Page) { diff --git a/examples/solid/row-selection/tests/e2e/smoke.spec.ts b/examples/solid/row-selection/tests/e2e/smoke.spec.ts index 84c2fc6c29..dc77dcb9ae 100644 --- a/examples/solid/row-selection/tests/e2e/smoke.spec.ts +++ b/examples/solid/row-selection/tests/e2e/smoke.spec.ts @@ -72,11 +72,9 @@ function footerCheckbox(page: Page) { /** Anchored so the regex cannot also match an ancestor with extra text. */ function selectionSummary(page: Page) { - return page - .locator('div') - .filter({ - hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, - }) + return page.locator('div').filter({ + hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, + }) } function pageRowsLabel(page: Page) { diff --git a/examples/svelte/row-selection/tests/e2e/smoke.spec.ts b/examples/svelte/row-selection/tests/e2e/smoke.spec.ts index 89a4439ed8..572f068364 100644 --- a/examples/svelte/row-selection/tests/e2e/smoke.spec.ts +++ b/examples/svelte/row-selection/tests/e2e/smoke.spec.ts @@ -73,11 +73,9 @@ function footerCheckbox(page: Page) { /** Anchored so the regex cannot also match an ancestor with extra text. */ function selectionSummary(page: Page) { - return page - .locator('div') - .filter({ - hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, - }) + return page.locator('div').filter({ + hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, + }) } function pageRowsLabel(page: Page) { diff --git a/examples/vue/row-selection/tests/e2e/smoke.spec.ts b/examples/vue/row-selection/tests/e2e/smoke.spec.ts index 01c45940ba..6e4e058fc1 100644 --- a/examples/vue/row-selection/tests/e2e/smoke.spec.ts +++ b/examples/vue/row-selection/tests/e2e/smoke.spec.ts @@ -73,21 +73,15 @@ function footerCheckbox(page: Page) { /** Anchored so the regex cannot also match an ancestor with extra text. */ function selectionSummary(page: Page) { - return page - .locator('div') - .filter({ - hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, - }) + return page.locator('div').filter({ + hasText: /^\s*[\d,]+\s+of\s+[\d,]+\s+Total\s+Rows\s+Selected\s*$/, + }) } function pageRowsLabel(page: Page) { return page.locator('tfoot td').nth(1) } -function globalFilterInput(page: Page) { - return page.getByPlaceholder('Search all columns...') -} - function isIndeterminate(checkbox: Locator) { return checkbox.evaluate((el) => (el as HTMLInputElement).indeterminate) } @@ -113,13 +107,6 @@ async function expectSelectionCount(page: Page, expected: number) { ) } -async function readGlobalFilter(page: Page) { - const text = await page.getByTestId('table-state').textContent() - const state = JSON.parse(text ?? '{}') as { globalFilter?: string } - - return state.globalFilter ?? '' -} - async function getFirstBodyRowText(page: Page) { const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' diff --git a/packages/table-core/src/core/columns/coreColumnsFeature.utils.ts b/packages/table-core/src/core/columns/coreColumnsFeature.utils.ts index 2fae8a1fd7..7a6ea4bed7 100644 --- a/packages/table-core/src/core/columns/coreColumnsFeature.utils.ts +++ b/packages/table-core/src/core/columns/coreColumnsFeature.utils.ts @@ -106,6 +106,41 @@ export function table_getDefaultColumnDef< } as Partial> } +function constructColumns< + TFeatures extends TableFeatures, + TData extends RowData, +>( + table: Table_Internal, + columnDefs: ReadonlyArray>, + parent?: Column, + depth = 0, +): Array> { + const columns = new Array>( + columnDefs.length, + ) + + for (let i = 0; i < columnDefs.length; i++) { + if (!(i in columnDefs)) { + continue + } + + const columnDef = columnDefs[i]! + const column = constructColumn(table, columnDef, depth, parent) + const groupingColumnDef = columnDef as GroupColumnDef< + TFeatures, + TData, + unknown + > + + column.columns = groupingColumnDef.columns + ? constructColumns(table, groupingColumnDef.columns, column, depth + 1) + : [] + columns[i] = column + } + + return columns +} + /** * Normalizes `options.columns` into the table's nested column tree. * @@ -123,29 +158,7 @@ export function table_getAllColumns< >( table: Table_Internal, ): Array> { - const recurseColumns = ( - colDefs: ReadonlyArray>, - parent?: Column, - depth = 0, - ): Array> => { - return colDefs.map((columnDef) => { - const column = constructColumn(table, columnDef, depth, parent) - - const groupingColumnDef = columnDef as GroupColumnDef< - TFeatures, - TData, - unknown - > - - column.columns = groupingColumnDef.columns - ? recurseColumns(groupingColumnDef.columns, column, depth + 1) - : [] - - return column - }) - } - - return recurseColumns(table.options.columns) + return constructColumns(table, table.options.columns) } /** diff --git a/packages/table-core/src/core/headers/buildHeaderGroups.ts b/packages/table-core/src/core/headers/buildHeaderGroups.ts index bd913d189e..9272d86bda 100644 --- a/packages/table-core/src/core/headers/buildHeaderGroups.ts +++ b/packages/table-core/src/core/headers/buildHeaderGroups.ts @@ -8,182 +8,251 @@ import type { Header } from '../../types/Header' import type { HeaderGroup } from '../../types/HeaderGroup' import type { Column } from '../../types/Column' -/** - * Builds the nested header group structure for a table. - * - * The result accounts for visible leaf columns, pinned column groups, and placeholder headers needed to render multi-level headers. - */ -export function buildHeaderGroups< +type HeaderFamily = 'center' | 'start' | 'end' | undefined + +function getMaxHeaderDepth< TFeatures extends TableFeatures, TData extends RowData, - TValue extends CellData = CellData, ->( - allColumns: Array>, - columnsToGroup: Array>, - table: Table_Internal, - headerFamily?: 'center' | 'start' | 'end', -) { - // Find the max depth of the columns: - // build the leaf column row - // build each buffer row going up - // placeholder for non-existent level - // real column for existing level - - let maxDepth = 0 - - const findMaxDepth = ( - columns: Array>, - depth = 1, - ) => { - maxDepth = Math.max(maxDepth, depth) - - for (let i = 0; i < columns.length; i++) { - const column = columns[i]! - if (callMemoOrStaticFn(column, 'getIsVisible', column_getIsVisible)) { - if (column.columns.length) { - findMaxDepth(column.columns, depth + 1) - } - } + TValue extends CellData, +>(columns: Array>, depth = 1): number { + let maxDepth = depth + + for (let i = 0; i < columns.length; i++) { + const column = columns[i]! + if ( + callMemoOrStaticFn(column, 'getIsVisible', column_getIsVisible) && + column.columns.length + ) { + maxDepth = Math.max( + maxDepth, + getMaxHeaderDepth(column.columns, depth + 1), + ) } } - findMaxDepth(allColumns) + return maxDepth +} - const headerGroups: Array> = [] +function formatHeaderGroupId(headerFamily: HeaderFamily, depth: number) { + return headerFamily ? `${headerFamily}_${depth}` : String(depth) +} - const headerGroupInitFns = table._headerGroupInstanceInitFns +function formatHeaderId( + headerFamily: HeaderFamily, + depth: number, + columnId: string, + childHeaderId: string, +) { + let id = headerFamily ?? '' - const constructHeaderGroup = ( - headersToGroup: Array>, - depth: number, - ) => { - // The header group we are creating - const headerGroup: HeaderGroup = { - depth, - id: [headerFamily, `${depth}`].filter(Boolean).join('_'), - headers: [], - } + if (depth) { + id = id ? `${id}_${depth}` : String(depth) + } + if (columnId) { + id = id ? `${id}_${columnId}` : columnId + } + if (childHeaderId) { + id = id ? `${id}_${childHeaderId}` : childHeaderId + } - // The parent columns we're going to scan next - const pendingParentHeaders: Array> = [] + return id +} - // Scan each column for parents - headersToGroup.forEach((headerToGroup) => { - // What is the latest (last) parent column? +function countPendingHeadersForColumn< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +>( + headers: Array>, + column: Column, +) { + let count = 0 - const latestPendingParentHeader = - pendingParentHeaders[pendingParentHeaders.length - 1] + for (let i = 0; i < headers.length; i++) { + if (headers[i]!.column === column) { + count++ + } + } - const isLeafHeader = headerToGroup.column.depth === headerGroup.depth + return count +} - let column: Column - let isPlaceholder = false +function constructHeaderGroup< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +>( + headersToGroup: Array>, + depth: number, + table: Table_Internal, + headerFamily: HeaderFamily, + headerGroups: Array>, + headerGroupInitFns: Table_Internal< + TFeatures, + TData + >['_headerGroupInstanceInitFns'], +): void { + const headerGroup: HeaderGroup = { + depth, + id: formatHeaderGroupId(headerFamily, depth), + headers: [], + } + const pendingParentHeaders: Array> = [] - if (isLeafHeader && headerToGroup.column.parent) { - // The parent header is new - column = headerToGroup.column.parent - } else { - // The parent header is repeated - column = headerToGroup.column - isPlaceholder = true - } + for (let i = 0; i < headersToGroup.length; i++) { + if (!(i in headersToGroup)) { + continue + } - if ( - latestPendingParentHeader && - latestPendingParentHeader.column === column - ) { - // This column is repeated. Add it as a sub header to the next batch - latestPendingParentHeader.subHeaders.push(headerToGroup) - } else { - // This is a new header. Let's create it - const header = constructHeader(table, column, { - id: [headerFamily, depth, column.id, headerToGroup.id] - .filter(Boolean) - .join('_'), - isPlaceholder, - placeholderId: isPlaceholder - ? `${pendingParentHeaders.filter((d) => d.column === column).length}` - : undefined, - depth, - index: pendingParentHeaders.length, - }) - - // Add the headerToGroup as a subHeader of the new header - header.subHeaders.push(headerToGroup) - // Add the new header to the pendingParentHeaders to get grouped - // in the next batch - pendingParentHeaders.push(header) - } + const headerToGroup = headersToGroup[i]! + const latestPendingParentHeader = + pendingParentHeaders[pendingParentHeaders.length - 1] + const isLeafHeader = headerToGroup.column.depth === headerGroup.depth - headerGroup.headers.push( - headerToGroup as Header, - ) - headerToGroup.headerGroup = headerGroup - }) + let column: Column + let isPlaceholder = false - // Initialize instance-specific data for features that need it - for (let i = 0; i < headerGroupInitFns.length; i++) { - headerGroupInitFns[i]!(headerGroup) + if (isLeafHeader && headerToGroup.column.parent) { + column = headerToGroup.column.parent + } else { + column = headerToGroup.column + isPlaceholder = true } - headerGroups.push(headerGroup) - - if (depth > 0) { - constructHeaderGroup(pendingParentHeaders, depth - 1) + if ( + latestPendingParentHeader && + latestPendingParentHeader.column === column + ) { + latestPendingParentHeader.subHeaders.push(headerToGroup) + } else { + const header = constructHeader(table, column, { + id: formatHeaderId(headerFamily, depth, column.id, headerToGroup.id), + isPlaceholder, + placeholderId: isPlaceholder + ? String(countPendingHeadersForColumn(pendingParentHeaders, column)) + : undefined, + depth, + index: pendingParentHeaders.length, + }) + + header.subHeaders.push(headerToGroup) + pendingParentHeaders.push(header) } + + headerGroup.headers.push(headerToGroup as Header) + headerToGroup.headerGroup = headerGroup } - const bottomHeaders = columnsToGroup.map((column, index) => - constructHeader(table, column, { - depth: maxDepth, - index, - }), - ) + for (let i = 0; i < headerGroupInitFns.length; i++) { + headerGroupInitFns[i]!(headerGroup) + } - constructHeaderGroup(bottomHeaders, maxDepth - 1) + headerGroups.push(headerGroup) + + if (depth > 0) { + constructHeaderGroup( + pendingParentHeaders, + depth - 1, + table, + headerFamily, + headerGroups, + headerGroupInitFns, + ) + } +} - headerGroups.reverse() +function updateHeaderSpans< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +>(headers: Array>): void { + for (let i = 0; i < headers.length; i++) { + const header = headers[i]! + if ( + !callMemoOrStaticFn(header.column, 'getIsVisible', column_getIsVisible) + ) { + continue + } - const recurseHeadersForSpans = ( - headers: Array>, - ): Array<{ colSpan: number; rowSpan: number }> => { - const results: Array<{ colSpan: number; rowSpan: number }> = [] - - for (let i = 0; i < headers.length; i++) { - const header = headers[i]! - if ( - !callMemoOrStaticFn(header.column, 'getIsVisible', column_getIsVisible) - ) { - continue - } + let colSpan = 0 + let minChildRowSpan = Infinity + + if (header.subHeaders.length) { + updateHeaderSpans(header.subHeaders) + + for (let j = 0; j < header.subHeaders.length; j++) { + const child = header.subHeaders[j]! + if ( + !callMemoOrStaticFn(child.column, 'getIsVisible', column_getIsVisible) + ) { + continue + } - let colSpan = 0 - let minChildRowSpan = Infinity - - if (header.subHeaders.length) { - const childSpans = recurseHeadersForSpans(header.subHeaders) - for (let j = 0; j < childSpans.length; j++) { - const child = childSpans[j]! - colSpan += child.colSpan - if (child.rowSpan < minChildRowSpan) { - minChildRowSpan = child.rowSpan - } + colSpan += child.colSpan + if (child.rowSpan < minChildRowSpan) { + minChildRowSpan = child.rowSpan } - } else { - colSpan = 1 - minChildRowSpan = 0 } + } else { + colSpan = 1 + minChildRowSpan = 0 + } + + header.colSpan = colSpan + header.rowSpan = minChildRowSpan + } +} + +/** + * Builds the nested header group structure for a table. + * + * The result accounts for visible leaf columns, pinned column groups, and placeholder headers needed to render multi-level headers. + */ +export function buildHeaderGroups< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData = CellData, +>( + allColumns: Array>, + columnsToGroup: Array>, + table: Table_Internal, + headerFamily?: 'center' | 'start' | 'end', +) { + // Find the max depth of the columns: + // build the leaf column row + // build each buffer row going up + // placeholder for non-existent level + // real column for existing level - header.colSpan = colSpan - header.rowSpan = minChildRowSpan + const maxDepth = getMaxHeaderDepth(allColumns) + const headerGroups: Array> = [] + const headerGroupInitFns = table._headerGroupInstanceInitFns + const bottomHeaders = new Array>( + columnsToGroup.length, + ) - results.push({ colSpan, rowSpan: header.rowSpan }) + for (let i = 0; i < columnsToGroup.length; i++) { + if (!(i in columnsToGroup)) { + continue } - return results + bottomHeaders[i] = constructHeader(table, columnsToGroup[i]!, { + depth: maxDepth, + index: i, + }) } - recurseHeadersForSpans( + constructHeaderGroup( + bottomHeaders, + maxDepth - 1, + table, + headerFamily, + headerGroups, + headerGroupInitFns, + ) + + headerGroups.reverse() + updateHeaderSpans( (headerGroups[0]?.headers ?? []) as Array>, ) diff --git a/packages/table-core/src/core/headers/coreHeadersFeature.utils.ts b/packages/table-core/src/core/headers/coreHeadersFeature.utils.ts index f0cc17d100..2bf5f108e6 100644 --- a/packages/table-core/src/core/headers/coreHeadersFeature.utils.ts +++ b/packages/table-core/src/core/headers/coreHeadersFeature.utils.ts @@ -11,6 +11,21 @@ import type { RowData } from '../../types/type-utils' import type { TableFeatures } from '../../types/TableFeatures' import type { Header_Header } from './coreHeadersFeature.types' +function collectLeafHeaders< + TFeatures extends TableFeatures, + TData extends RowData, + TValue, +>( + header: Header_Header, + leafHeaders: Array>, +): void { + for (let i = 0; i < header.subHeaders.length; i++) { + collectLeafHeaders(header.subHeaders[i]!, leafHeaders) + } + + leafHeaders.push(header as Header) +} + /** * Walks a header tree and collects all descendant leaf headers. * @@ -29,14 +44,7 @@ export function header_getLeafHeaders< >(header: Header) { const leafHeaders: Array> = [] - const recurseHeader = (h: Header_Header) => { - if (h.subHeaders.length) { - h.subHeaders.map(recurseHeader) - } - leafHeaders.push(h as Header) - } - - recurseHeader(header) + collectLeafHeaders(header, leafHeaders) return leafHeaders } diff --git a/packages/table-core/src/features/column-sizing/columnSizingFeature.utils.ts b/packages/table-core/src/features/column-sizing/columnSizingFeature.utils.ts index 9ff1d581be..a00a4d9090 100644 --- a/packages/table-core/src/features/column-sizing/columnSizingFeature.utils.ts +++ b/packages/table-core/src/features/column-sizing/columnSizingFeature.utils.ts @@ -255,6 +255,22 @@ export function column_resetSize< }) } +function sumHeaderSize< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +>(header: Header): number { + if (!header.subHeaders.length) { + return column_getSize(header.column) + } + + let sum = 0 + for (let i = 0; i < header.subHeaders.length; i++) { + sum += sumHeaderSize(header.subHeaders[i]!) + } + return sum +} + /** * Computes a header's rendered size from its leaf headers. * @@ -271,19 +287,7 @@ export function header_getSize< TData extends RowData, TValue extends CellData = CellData, >(header: Header) { - let sum = 0 - - const recurse = (h: Header) => { - if (h.subHeaders.length) { - h.subHeaders.forEach(recurse) - } else { - sum += column_getSize(h.column) - } - } - - recurse(header) - - return sum + return sumHeaderSize(header) } /** diff --git a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.utils.ts b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.utils.ts index 9c28ed6d91..daa0097508 100644 --- a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.utils.ts +++ b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.utils.ts @@ -59,6 +59,54 @@ function resolveMaxAggregationDepth(maxDepth: number | undefined) { : Math.max(0, Math.floor(maxDepth)) } +function collectNormalizedAggregationRow< + TFeatures extends TableFeatures, + TData extends RowData, +>( + row: Row, + depth: number, + maxDepth: number, + seen: Set, + result: Array>, +): void { + if (row.subRows.length && depth < maxDepth) { + for (let i = 0; i < row.subRows.length; i++) { + collectNormalizedAggregationRow( + row.subRows[i]!, + depth + 1, + maxDepth, + seen, + result, + ) + } + return + } + + if (!seen.has(row.id)) { + seen.add(row.id) + result.push(row) + } +} + +function collectUniqueAggregationRow< + TFeatures extends TableFeatures, + TData extends RowData, +>( + row: Row, + depth: number, + maxDepth: number, + result: Array>, +): void { + if (row.subRows.length && depth < maxDepth) { + for (let i = 0; i < row.subRows.length; i++) { + collectUniqueAggregationRow(row.subRows[i]!, depth + 1, maxDepth, result) + } + return + } + + result.push(row) +} + /** * Selects unique rows at a maximum relative depth in encounter order. * Branches that end before the requested depth contribute their deepest row. @@ -74,22 +122,14 @@ export function normalizeAggregationRows< const seen = new Set() const normalizedMaxDepth = resolveMaxAggregationDepth(maxDepth) - const visit = (row: Row, depth: number) => { - if (row.subRows.length && depth < normalizedMaxDepth) { - for (let i = 0; i < row.subRows.length; i++) { - visit(row.subRows[i]!, depth + 1) - } - return - } - - if (!seen.has(row.id)) { - seen.add(row.id) - result.push(row) - } - } - for (let i = 0; i < rows.length; i++) { - visit(rows[i]!, 0) + collectNormalizedAggregationRow( + rows[i]!, + 0, + normalizedMaxDepth, + seen, + result, + ) } return result @@ -124,18 +164,8 @@ export function normalizeUniqueAggregationRows< const result: Array> = [] - const visit = (row: Row, depth: number) => { - if (row.subRows.length && depth < normalizedMaxDepth) { - for (let i = 0; i < row.subRows.length; i++) { - visit(row.subRows[i]!, depth + 1) - } - return - } - result.push(row) - } - for (let i = 0; i < rows.length; i++) { - visit(rows[i]!, 0) + collectUniqueAggregationRow(rows[i]!, 0, normalizedMaxDepth, result) } return result diff --git a/packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts b/packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts index ed252e7cd2..a0c449799e 100644 --- a/packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts +++ b/packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts @@ -793,6 +793,51 @@ const mutateRowIsSelected = < } } +function selectRowsRecursively< + TFeatures extends TableFeatures, + TData extends RowData, +>( + rows: Array>, + rowSelection: RowSelectionState, + selectedFlatRows: Array>, + selectedRowsById: Record>, +): Array> { + const result: Array> = [] + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]! + const isSelected = isRowSelected(row, rowSelection) + + if (isSelected) { + selectedFlatRows.push(row) + selectedRowsById[row.id] = row + } + + if (row.subRows.length) { + // Always recurse — selected descendants of unselected parents must + // still be collected into flatRows/rowsById. + const newSubRows = selectRowsRecursively( + row.subRows, + rowSelection, + selectedFlatRows, + selectedRowsById, + ) + + if (isSelected) { + // Preserve prototype chain so methods like getValue() remain accessible + const cloned = Object.create(Object.getPrototypeOf(row)) + copyInstancePropertiesWithoutMemos(cloned, row) + cloned.subRows = newSubRows + result.push(cloned) + } + } else if (isSelected) { + result.push(row) + } + } + + return result +} + /** * Builds a row model containing rows selected by the current row selection state. * @@ -814,42 +859,14 @@ export function selectRowsFn< const newSelectedFlatRows: Array> = [] const newSelectedRowsById = makeObjectMap>() const rowSelection = table.atoms.rowSelection?.get() ?? {} - // Filters top level and nested rows. - const recurseRows = ( - rows: Array>, - depth = 0, - ): Array> => { - const result: Array> = [] - for (let i = 0; i < rows.length; i++) { - const row = rows[i]! - const isSelected = isRowSelected(row, rowSelection) - - if (isSelected) { - newSelectedFlatRows.push(row) - newSelectedRowsById[row.id] = row - } - - if (row.subRows.length) { - // Always recurse — selected descendants of unselected parents must - // still be collected into flatRows/rowsById. - const newSubRows = recurseRows(row.subRows, depth + 1) - - if (isSelected) { - // Preserve prototype chain so methods like getValue() remain accessible - const cloned = Object.create(Object.getPrototypeOf(row)) - copyInstancePropertiesWithoutMemos(cloned, row) - cloned.subRows = newSubRows - result.push(cloned) - } - } else if (isSelected) { - result.push(row) - } - } - return result - } return { - rows: recurseRows(rowModel.rows), + rows: selectRowsRecursively( + rowModel.rows, + rowSelection, + newSelectedFlatRows, + newSelectedRowsById, + ), flatRows: newSelectedFlatRows, rowsById: newSelectedRowsById, } diff --git a/packages/table-core/tests/implementation/features/row-aggregation/rowAggregationFeature.test.ts b/packages/table-core/tests/implementation/features/row-aggregation/rowAggregationFeature.test.ts index adaceb74eb..5549395328 100644 --- a/packages/table-core/tests/implementation/features/row-aggregation/rowAggregationFeature.test.ts +++ b/packages/table-core/tests/implementation/features/row-aggregation/rowAggregationFeature.test.ts @@ -232,6 +232,56 @@ describe('rowAggregationFeature', () => { expect(table.getMaxSubRowDepth()).toBe(1) }) + it('preserves ragged frontier order and deduplicates overlapping row inputs', () => { + type Node = { label: string; subRows?: Array } + const collectLabels = constructAggregationFn< + any, + Node, + string, + Array + >({ + aggregate: ({ rows }) => rows.map((row) => row.original.label), + }) + const features = testFeatures({ + rowAggregationFeature, + aggregationFns: { collectLabels }, + }) + const data: Array = [ + { + label: 'a', + subRows: [ + { + label: 'a0', + subRows: [{ label: 'a00' }, { label: 'a01' }], + }, + ], + }, + { label: 'b' }, + ] + const table = constructTable({ + features, + data, + columns: [{ accessorKey: 'label', aggregationFn: 'collectLabels' }], + getSubRows: (row: Node) => row.subRows, + }) + const column = table.getColumn('label')! + const rows = table.getCoreRowModel().rows + + expect(column.getAggregationValue({ maxDepth: 0 })).toEqual(['a', 'b']) + expect(column.getAggregationValue({ maxDepth: 1 })).toEqual(['a0', 'b']) + expect(column.getAggregationValue({ maxDepth: Infinity })).toEqual([ + 'a00', + 'a01', + 'b', + ]) + expect( + column.getAggregationValue({ + maxDepth: Infinity, + rows: [rows[0]!, rows[0]!.subRows[0]!], + }), + ).toEqual(['a00', 'a01']) + }) + it('uses handled column values and configurable local fallback', () => { const features = testFeatures({ rowAggregationFeature, aggregationFns }) const resolver = vi.fn(({ rows }) => (rows ? { value: 99 } : undefined)) diff --git a/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts b/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts index 4b17003259..fec266e5cc 100644 --- a/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts +++ b/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts @@ -116,6 +116,72 @@ describe('rowSelectionFeature', () => { expect(result.rowsById).toHaveProperty('0.0') }) + it('should preserve three levels of selected row structure and prototypes', () => { + const data = generateTestData(1, 1, 1) + const columns = generateColumnDefs(data) + const table = constructTable({ + features, + enableRowSelection: true, + renderFallbackValue: '', + data, + getSubRows: (originalRow: Person) => originalRow.subRows, + initialState: { + rowSelection: { + '0': true, + '0.0': true, + '0.0.0': true, + }, + }, + columns, + }) + const rowModel = table.getCoreRowModel() + + const result = RowSelectionUtils.selectRowsFn(rowModel, table) + const selectedRoot = result.rows[0]! + const selectedChild = selectedRoot.subRows[0]! + const selectedGrandchild = selectedChild.subRows[0]! + + expect(result.flatRows.map((row) => row.id)).toEqual([ + '0', + '0.0', + '0.0.0', + ]) + expect(Object.keys(result.rowsById)).toEqual(['0', '0.0', '0.0.0']) + expect(selectedRoot).not.toBe(rowModel.rows[0]) + expect(selectedChild).not.toBe(rowModel.rows[0]!.subRows[0]) + expect(selectedGrandchild).toBe(rowModel.rows[0]!.subRows[0]!.subRows[0]) + expect(typeof selectedRoot.getValue).toBe('function') + expect(typeof selectedChild.getValue).toBe('function') + expect(typeof selectedGrandchild.getValue).toBe('function') + }) + + it('should collect a selected grandchild beneath two unselected ancestors', () => { + const data = generateTestData(1, 1, 1) + const columns = generateColumnDefs(data) + const table = constructTable({ + features, + enableRowSelection: true, + renderFallbackValue: '', + data, + getSubRows: (originalRow: Person) => originalRow.subRows, + initialState: { + rowSelection: { + '0.0.0': true, + }, + }, + columns, + }) + const rowModel = table.getCoreRowModel() + + const result = RowSelectionUtils.selectRowsFn(rowModel, table) + + expect(result.rows).toEqual([]) + expect(result.flatRows.map((row) => row.id)).toEqual(['0.0.0']) + expect(result.rowsById['0.0.0']).toBe( + rowModel.rows[0]!.subRows[0]!.subRows[0], + ) + }) + it('should preserve row prototype methods on cloned parent rows', () => { const data = generateTestData(3, 2) const columns = generateColumnDefs(data) diff --git a/packages/table-core/tests/unit/core/columns/coreColumnsFeature.utils.test.ts b/packages/table-core/tests/unit/core/columns/coreColumnsFeature.utils.test.ts index 4daedf854c..0e807e1575 100644 --- a/packages/table-core/tests/unit/core/columns/coreColumnsFeature.utils.test.ts +++ b/packages/table-core/tests/unit/core/columns/coreColumnsFeature.utils.test.ts @@ -57,6 +57,46 @@ describe('table_getAllColumns', () => { expect(allColumns[0]!.columns[0]!.parent?.id).toBe('group') expect(allColumns[0]!.columns[0]!.depth).toBe(1) }) + + it('should preserve ordering, parents, depths, and leaf children across three levels', () => { + const deepColumns: Array> = [ + { + id: 'outer', + columns: [ + { + id: 'inner', + columns: [ + { accessorKey: 'a', id: 'a' }, + { accessorKey: 'b', id: 'b' }, + ], + }, + { accessorKey: 'c', id: 'c' }, + ], + }, + ] + const table = constructTable({ + features, + columns: deepColumns, + data, + }) + + const [outer] = table_getAllColumns(table) + const [inner, c] = outer!.columns + const [a, b] = inner!.columns + + expect(outer!.columns.map((column) => column.id)).toEqual(['inner', 'c']) + expect(inner!.columns.map((column) => column.id)).toEqual(['a', 'b']) + expect([outer!.depth, inner!.depth, a!.depth, b!.depth, c!.depth]).toEqual([ + 0, 1, 2, 2, 1, + ]) + expect(inner!.parent).toBe(outer) + expect(c!.parent).toBe(outer) + expect(a!.parent).toBe(inner) + expect(b!.parent).toBe(inner) + expect(a!.columns).toEqual([]) + expect(b!.columns).toEqual([]) + expect(c!.columns).toEqual([]) + }) }) describe('column_getFlatColumns', () => { diff --git a/packages/table-core/tests/unit/core/headers/coreHeadersFeature.utils.test.ts b/packages/table-core/tests/unit/core/headers/coreHeadersFeature.utils.test.ts index 6126485a5f..8aede11114 100644 --- a/packages/table-core/tests/unit/core/headers/coreHeadersFeature.utils.test.ts +++ b/packages/table-core/tests/unit/core/headers/coreHeadersFeature.utils.test.ts @@ -88,6 +88,68 @@ describe('table_getHeaderGroups', () => { 'c', ]) }) + + it('should preserve nested spans and shrink them when a leaf is hidden', () => { + const deepColumns: Array> = [ + { + id: 'outer', + columns: [ + { + id: 'inner', + columns: [ + { accessorKey: 'a', id: 'a' }, + { accessorKey: 'b', id: 'b' }, + ], + }, + { accessorKey: 'c', id: 'c' }, + ], + }, + ] + const makeDeepTable = (hideB = false) => + constructTable({ + features, + columns: deepColumns, + data, + initialState: hideB ? { columnVisibility: { b: false } } : undefined, + }) + + const headerGroups = table_getHeaderGroups(makeDeepTable()) + const outer = headerGroups[0]!.headers.find( + (header) => header.column.id === 'outer', + )! + const inner = headerGroups[1]!.headers.find( + (header) => header.column.id === 'inner', + )! + + expect(headerGroups).toHaveLength(3) + expect(outer.colSpan).toBe(3) + expect(inner.colSpan).toBe(2) + expect( + headerGroups[2]!.headers.map((header) => [ + header.column.id, + header.colSpan, + header.rowSpan, + ]), + ).toEqual([ + ['a', 1, 0], + ['b', 1, 0], + ['c', 1, 0], + ]) + + const hiddenHeaderGroups = table_getHeaderGroups(makeDeepTable(true)) + const hiddenOuter = hiddenHeaderGroups[0]!.headers.find( + (header) => header.column.id === 'outer', + )! + const hiddenInner = hiddenHeaderGroups[1]!.headers.find( + (header) => header.column.id === 'inner', + )! + + expect(hiddenOuter.colSpan).toBe(2) + expect(hiddenInner.colSpan).toBe(1) + expect( + hiddenHeaderGroups[2]!.headers.map((header) => header.column.id), + ).toEqual(['a', 'c']) + }) }) describe('header_getLeafHeaders', () => { @@ -114,6 +176,42 @@ describe('header_getLeafHeaders', () => { expect(header_getLeafHeaders(leafHeader)).toEqual([leafHeader]) }) + + it('should preserve descendant-first identity across three levels', () => { + const deepColumns: Array> = [ + { + id: 'outer', + columns: [ + { + id: 'inner', + columns: [ + { accessorKey: 'a', id: 'a' }, + { accessorKey: 'b', id: 'b' }, + ], + }, + ], + }, + ] + const table = constructTable({ + features, + columns: deepColumns, + data, + }) + const headerGroups = table_getHeaderGroups(table) + const outer = headerGroups[0]!.headers[0]! + const inner = headerGroups[1]!.headers[0]! + const [a, b] = headerGroups[2]!.headers + + const leafHeaders = header_getLeafHeaders(outer) + + expect(leafHeaders.map((header) => header.column.id)).toEqual([ + 'a', + 'b', + 'inner', + 'outer', + ]) + expect(leafHeaders).toEqual([a, b, inner, outer]) + }) }) describe('header_getContext', () => { diff --git a/packages/table-core/tests/unit/features/column-pinning/columnPinningFeature.utils.test.ts b/packages/table-core/tests/unit/features/column-pinning/columnPinningFeature.utils.test.ts index db236120af..67f72ffaaf 100644 --- a/packages/table-core/tests/unit/features/column-pinning/columnPinningFeature.utils.test.ts +++ b/packages/table-core/tests/unit/features/column-pinning/columnPinningFeature.utils.test.ts @@ -567,6 +567,47 @@ describe('table_getCenterHeaderGroups', () => { }) }) +describe('header group ids', () => { + it('should preserve depth and family prefixes for grouped columns', () => { + const data = generateTestData(1) + const groupedColumns: Array> = [ + { + id: 'identity', + columns: [ + { accessorKey: 'firstName', id: 'firstName' }, + { accessorKey: 'age', id: 'age' }, + { accessorKey: 'lastName', id: 'lastName' }, + ], + }, + ] + const table = constructTable({ + features, + data, + columns: groupedColumns, + initialState: { + columnPinning: { + start: ['firstName'], + end: ['lastName'], + }, + }, + }) + + const start = table_getStartHeaderGroups(table) + const center = table_getCenterHeaderGroups(table) + const end = table_getEndHeaderGroups(table) + + expect(start.map((group) => group.id)).toEqual(['start_0', 'start_1']) + expect(center.map((group) => group.id)).toEqual(['center_0', 'center_1']) + expect(end.map((group) => group.id)).toEqual(['end_0', 'end_1']) + expect(start[0]!.headers[0]!.id).toBe('start_1_identity_firstName') + expect(center[0]!.headers[0]!.id).toBe('center_1_identity_age') + expect(end[0]!.headers[0]!.id).toBe('end_1_identity_lastName') + expect(start[1]!.headers[0]!.id).toBe('firstName') + expect(center[1]!.headers[0]!.id).toBe('age') + expect(end[1]!.headers[0]!.id).toBe('lastName') + }) +}) + describe('table_getStartLeafColumns', () => { it('should return start pinned leaf columns', () => { const table = makeTable(1, { diff --git a/packages/table-core/tests/unit/features/column-sizing/columnSizingFeature.utils.test.ts b/packages/table-core/tests/unit/features/column-sizing/columnSizingFeature.utils.test.ts index 40ca889cf1..f6431f34f5 100644 --- a/packages/table-core/tests/unit/features/column-sizing/columnSizingFeature.utils.test.ts +++ b/packages/table-core/tests/unit/features/column-sizing/columnSizingFeature.utils.test.ts @@ -91,6 +91,39 @@ describe('header_getSize', () => { const header = table.getHeaderGroups()[0]!.headers[0]! expect(header.getSize()).toBe(250) }) + + it('sums three levels and recomputes group sizes after a leaf resize', () => { + const table = makeTable({ + columns: [ + { + id: 'outer', + columns: [ + { + id: 'inner', + columns: [ + { id: 'a', accessorKey: 'a', size: 100 }, + { id: 'b', accessorKey: 'b', size: 200 }, + ], + }, + ], + }, + ], + }) + let headerGroups = table.getHeaderGroups() + let outer = headerGroups[0]!.headers[0]! + let inner = headerGroups[1]!.headers[0]! + + expect(inner.getSize()).toBe(300) + expect(outer.getSize()).toBe(300) + + table.setColumnSizing({ a: 400 }) + headerGroups = table.getHeaderGroups() + outer = headerGroups[0]!.headers[0]! + inner = headerGroups[1]!.headers[0]! + + expect(inner.getSize()).toBe(600) + expect(outer.getSize()).toBe(600) + }) }) describe('header_getStart', () => {