diff --git a/.changeset/quick-dryers-attend.md b/.changeset/quick-dryers-attend.md new file mode 100644 index 0000000000..6a10ca3639 --- /dev/null +++ b/.changeset/quick-dryers-attend.md @@ -0,0 +1,5 @@ +--- +'@tanstack/table-core': patch +--- + +Fix `columnFiltersMeta` being dropped from every row of the filtered row model when `filterFromLeafRows` is enabled. The leaf-up filter path rebuilds each row with `constructRow` and copied only `columnFilters` onto the copy, leaving the meta as the empty map that `constructRow` initialises. Rank metadata recorded by a filter function's `addMeta` callback — the basis for rank-aware sorting in the fuzzy filtering guide — now survives on parent rows and sub-rows alike, and each row keeps the meta produced by its own filter run. diff --git a/packages/table-core/src/features/column-filtering/filterRowsUtils.ts b/packages/table-core/src/features/column-filtering/filterRowsUtils.ts index e8f3e09336..e4020426b9 100644 --- a/packages/table-core/src/features/column-filtering/filterRowsUtils.ts +++ b/packages/table-core/src/features/column-filtering/filterRowsUtils.ts @@ -62,7 +62,13 @@ function filterRowModelFromLeafs< row.parentId, ) as Row & Partial> + // `constructRow` gives the copy empty filter maps, so both halves of the + // row's filter state have to be carried over from the source row. The + // meta belongs to the row that produced it: each row is tagged + // independently in the pre-filter pass, so it is copied across rather + // than inherited from a parent or aggregated from `subRows` newRow.columnFilters = row.columnFilters + newRow.columnFiltersMeta = row.columnFiltersMeta if (row.subRows.length && depth < maxDepth) { newRow.subRows = recurseFilterRows(row.subRows, depth + 1) diff --git a/packages/table-core/tests/implementation/features/column-filtering/createFilteredRowModel.test.ts b/packages/table-core/tests/implementation/features/column-filtering/createFilteredRowModel.test.ts index 67637123f7..16adf65699 100644 --- a/packages/table-core/tests/implementation/features/column-filtering/createFilteredRowModel.test.ts +++ b/packages/table-core/tests/implementation/features/column-filtering/createFilteredRowModel.test.ts @@ -512,6 +512,99 @@ describe('createFilteredRowModel', () => { expect(preRows[0]!.columnFiltersMeta.name).toEqual({ globalHit: 'keep' }) expect(preRows[1]!.columnFiltersMeta.name).toEqual({ globalHit: 'drop' }) }) + + // A rank-scoring filter in the shape used by the fuzzy filtering docs: it + // records a score for every row it inspects so a sortingFn can order rows + // by match quality afterwards. + const rankFilterFn: FilterFn = ( + row, + columnId, + filterValue, + addMeta, + ) => { + const value = row.getValue(columnId) + const rank = value.startsWith(filterValue as string) ? value.length : 0 + addMeta?.({ itemRank: { rank } }) + return rank > 0 + } + + const rankColumns: Array> = [ + { accessorKey: 'name', id: 'name', filterFn: rankFilterFn }, + ] + + function makeRankTable(options: { + data: Array + filterFromLeafRows?: boolean + }) { + return constructTable({ + features, + columns: rankColumns, + data: options.data, + getSubRows: (row) => row.subRows, + filterFromLeafRows: options.filterFromLeafRows, + initialState: { + columnFilters: [{ id: 'name', value: 'keep' }], + }, + }) + } + + it('should keep each row its own columnFiltersMeta on sub-rows when filtering from leaf rows', () => { + const table = makeRankTable({ + filterFromLeafRows: true, + data: [ + { name: 'drop-parent', subRows: [{ name: 'keep-child' }] }, + { name: 'keep-parent-long', subRows: [{ name: 'drop-child' }] }, + ], + }) + + const rows = table.getFilteredRowModel().rows + expect(rowNames(rows)).toEqual(['drop-parent', 'keep-parent-long']) + + // The sub-row is retained because it matched, and it carries the rank + // that its own filter run produced + const subRow = rows[0]!.subRows[0]! + expect(rowNames(rows[0]!.subRows)).toEqual(['keep-child']) + expect(subRow.columnFiltersMeta.name).toEqual({ + itemRank: { rank: 'keep-child'.length }, + }) + + // Meta belongs to the row that produced it: the retained parent keeps its + // own non-matching score rather than inheriting the sub-row's, and the + // matching parent keeps a score derived from its own value + expect(rows[0]!.columnFiltersMeta.name).toEqual({ + itemRank: { rank: 0 }, + }) + expect(rows[1]!.columnFiltersMeta.name).toEqual({ + itemRank: { rank: 'keep-parent-long'.length }, + }) + }) + + it('should keep columnFiltersMeta on top-level rows that have no sub-rows when filtering from leaf rows', () => { + const table = makeRankTable({ + filterFromLeafRows: true, + data: [{ name: 'keep-a' }, { name: 'keep-bb' }, { name: 'drop-c' }], + }) + + const rows = table.getFilteredRowModel().rows + expect(rowNames(rows)).toEqual(['keep-a', 'keep-bb']) + expect(rows.map((row) => row.columnFiltersMeta.name)).toEqual([ + { itemRank: { rank: 'keep-a'.length } }, + { itemRank: { rank: 'keep-bb'.length } }, + ]) + }) + + it('should keep columnFiltersMeta on top-level rows when filtering from the root down', () => { + const table = makeRankTable({ + data: [{ name: 'keep-a' }, { name: 'keep-bb' }, { name: 'drop-c' }], + }) + + const rows = table.getFilteredRowModel().rows + expect(rowNames(rows)).toEqual(['keep-a', 'keep-bb']) + expect(rows.map((row) => row.columnFiltersMeta.name)).toEqual([ + { itemRank: { rank: 'keep-a'.length } }, + { itemRank: { rank: 'keep-bb'.length } }, + ]) + }) }) describe('row.columnFilters flags', () => {