diff --git a/docs/config.json b/docs/config.json index bd0e510562..52209cf540 100644 --- a/docs/config.json +++ b/docs/config.json @@ -382,7 +382,19 @@ }, { "to": "framework/react/examples/column-groups", - "label": "Column Groups" + "label": "Header Groups" + }, + { + "to": "framework/react/examples/filters", + "label": "Column Filters" + }, + { + "to": "framework/react/examples/filters-faceted", + "label": "Column Filters (Faceted)" + }, + { + "to": "framework/react/examples/filters-fuzzy", + "label": "Fuzzy Search Filters" }, { "to": "framework/react/examples/column-ordering", @@ -424,10 +436,6 @@ "to": "framework/react/examples/sub-components", "label": "Sub Components" }, - { - "to": "framework/react/examples/filters", - "label": "Filters" - }, { "to": "framework/react/examples/fully-controlled", "label": "Fully Controlled" diff --git a/docs/guide/column-faceting.md b/docs/guide/column-faceting.md index 5e226a17a5..3931bb9e89 100644 --- a/docs/guide/column-faceting.md +++ b/docs/guide/column-faceting.md @@ -6,10 +6,84 @@ title: Column Faceting Guide Want to skip to the implementation? Check out these examples: -- [filters](../../framework/react/examples/filters) (includes faceting) +- [filters-faceted](../../framework/react/examples/filters-faceted) ## API [Column Faceting API](../../api/features/column-faceting) -## Column Faceting Guide \ No newline at end of file +## Column Faceting Guide + +Column Faceting is a feature that allows you to generate lists of values for a given column from that column's data. For example, a list of unique values in a column can be generated from all rows in that column to be used as search suggestions in an autocomplete filter component. Or, a tuple of minimum and maximum values can be generated from a column of numbers to be used as a range for a range slider filter component. + +### Column Faceting Row Models + +In order to use any of the column faceting features, you must include the appropriate row models in your table options. + +```ts +//only import the row models you need +import { + getCoreRowModel, + getFacetedRowModel, + getFacetedMinMaxValues, //depends on getFacetedRowModel + getFacetedUniqueValues, //depends on getFacetedRowModel +} +//... +const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + getFacetedRowModel: getFacetedRowModel(), //if you need a list of values for a column (other faceted row models depend on this one) + getFacetedMinMaxValues: getFacetedMinMaxValues(), //if you need min/max values + getFacetedUniqueValues: getFacetedUniqueValues(), //if you need a list of unique values + //... +}) +``` + +First, you must include the `getFacetedRowModel` row model. This row model will generate a list of values for a given column. If you need a list of unique values, include the `getFacetedUniqueValues` row model. If you need a tuple of minimum and maximum values, include the `getFacetedMinMaxValues` row model. + +### Use Faceted Row Models + +Once you have included the appropriate row models in your table options, you will be able to use the faceting column instance APIs to access the lists of values generated by the faceted row models. + +```ts +// list of unique values for autocomplete filter +const autoCompleteSuggestions = + Array.from(column.getFacetedUniqueValues().keys()) + .sort() + .slice(0, 5000); +``` + +```ts +// tuple of min and max values for range filter +const [min, max] = column.getFacetedMinMaxValues() ?? [0, 1]; +``` + +### Custom (Server-Side) Faceting + +If instead of using the built-in client-side faceting features, you can implement your own faceting logic on the server-side and pass the faceted values to the client-side. You can use the `getFacetedUniqueValues` and `getFacetedMinMaxValues` table options to resolve the faceted values from the server-side. + +```ts +const facetingQuery = useQuery( + //... +) + +const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: (table, columnId) => { + const uniqueValueMap = new Map(); + //... + return uniqueValueMap; + }, + getFacetedMinMaxValues: (table, columnId) => { + //... + return [min, max]; + }, + //... +}) +``` + +Alternatively, you don't have to put any of your faceting logic through the TanStack Table APIs at all. Just fetch your lists and pass them to your filter components directly. \ No newline at end of file diff --git a/docs/guide/column-filtering.md b/docs/guide/column-filtering.md index 6cfcb018a7..b5a093e468 100644 --- a/docs/guide/column-filtering.md +++ b/docs/guide/column-filtering.md @@ -7,6 +7,8 @@ title: Column Filtering Guide Want to skip to the implementation? Check out these examples: - [filters](../../framework/react/examples/filters) (includes faceting) +- [filters-faceted](../../framework/react/examples/filters-faceted) +- [filters-fuzzy](../../framework/react/examples/filters-fuzzy) - [editable-data](../../framework/react/examples/editable-data) - [expanding](../../framework/react/examples/expanding) - [grouping](../../framework/react/examples/grouping) diff --git a/docs/guide/filters.md b/docs/guide/filters.md index caf9d2379f..1a5dfe5211 100644 --- a/docs/guide/filters.md +++ b/docs/guide/filters.md @@ -9,4 +9,5 @@ The filter guides are now split into multiple guides: - [Column Filtering](../column-filtering) - [Global Filtering](../global-filtering) - [Fuzzy Filtering](../fuzzy-filtering) -- [Faceted Values](../faceted-values) \ No newline at end of file +- [Column Faceting](../column-faceting) +- [Global Faceting](../global-faceting) \ No newline at end of file diff --git a/docs/guide/fuzzy-filtering.md b/docs/guide/fuzzy-filtering.md index d5c289eafe..52afa9718e 100644 --- a/docs/guide/fuzzy-filtering.md +++ b/docs/guide/fuzzy-filtering.md @@ -6,7 +6,7 @@ title: Fuzzy Filtering Guide Want to skip to the implementation? Check out these examples: -- [filters](../../framework/react/examples/filters) +- [filters-fuzzy](../../framework/react/examples/filters-fuzzy) ## API diff --git a/docs/guide/global-faceting.md b/docs/guide/global-faceting.md index 9e5d745c28..ff116b492e 100644 --- a/docs/guide/global-faceting.md +++ b/docs/guide/global-faceting.md @@ -6,7 +6,7 @@ title: Global Faceting Guide Want to skip to the implementation? Check out these examples: -- [filters](../../framework/react/examples/filters) (includes faceting) +- [filters-faceted](../../framework/react/examples/filters) ## API diff --git a/docs/guide/global-filtering.md b/docs/guide/global-filtering.md index 930d55c4cb..45f22a4140 100644 --- a/docs/guide/global-filtering.md +++ b/docs/guide/global-filtering.md @@ -6,7 +6,7 @@ title: Global Filtering Guide Want to skip to the implementation? Check out these examples: -- [filters](../../framework/react/examples/filters) (includes faceting) +- [filters-fuzzy](../../framework/react/examples/filters) ## API diff --git a/docs/guide/row-models.md b/docs/guide/row-models.md index f51ddbb06e..7d5ec717a0 100644 --- a/docs/guide/row-models.md +++ b/docs/guide/row-models.md @@ -45,8 +45,8 @@ import { } //... const table = useReactTable({ - data, columns, + data, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), getFacetedMinMaxValues: getFacetedMinMaxValues(), diff --git a/examples/react/filters-faceted/.gitignore b/examples/react/filters-faceted/.gitignore new file mode 100644 index 0000000000..d451ff16c1 --- /dev/null +++ b/examples/react/filters-faceted/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local diff --git a/examples/react/filters-faceted/README.md b/examples/react/filters-faceted/README.md new file mode 100644 index 0000000000..b168d3c4b1 --- /dev/null +++ b/examples/react/filters-faceted/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm run start` or `yarn start` diff --git a/examples/react/filters-faceted/index.html b/examples/react/filters-faceted/index.html new file mode 100644 index 0000000000..3fc40c9367 --- /dev/null +++ b/examples/react/filters-faceted/index.html @@ -0,0 +1,13 @@ + + + + + + Vite App + + + +
+ + + diff --git a/examples/react/filters-faceted/package.json b/examples/react/filters-faceted/package.json new file mode 100644 index 0000000000..5a47800d3f --- /dev/null +++ b/examples/react/filters-faceted/package.json @@ -0,0 +1,26 @@ +{ + "name": "tanstack-table-example-filters-faceted", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview --port 3001", + "start": "vite" + }, + "dependencies": { + "@faker-js/faker": "^8.4.1", + "@tanstack/match-sorter-utils": "^8.15.1", + "@tanstack/react-table": "^8.16.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@rollup/plugin-replace": "^5.0.5", + "@types/react": "^18.2.70", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "5.4.3", + "vite": "^5.2.6" + } +} diff --git a/examples/react/filters-faceted/src/index.css b/examples/react/filters-faceted/src/index.css new file mode 100644 index 0000000000..43c09e0f6b --- /dev/null +++ b/examples/react/filters-faceted/src/index.css @@ -0,0 +1,26 @@ +html { + font-family: sans-serif; + font-size: 14px; +} + +table { + border: 1px solid lightgray; +} + +tbody { + border-bottom: 1px solid lightgray; +} + +th { + border-bottom: 1px solid lightgray; + border-right: 1px solid lightgray; + padding: 2px 4px; +} + +tfoot { + color: gray; +} + +tfoot th { + font-weight: normal; +} diff --git a/examples/react/filters-faceted/src/main.tsx b/examples/react/filters-faceted/src/main.tsx new file mode 100644 index 0000000000..76ae112325 --- /dev/null +++ b/examples/react/filters-faceted/src/main.tsx @@ -0,0 +1,366 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' + +import './index.css' + +import { + Column, + ColumnDef, + ColumnFiltersState, + RowData, + flexRender, + getCoreRowModel, + getFacetedMinMaxValues, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' + +import { makeData, Person } from './makeData' + +declare module '@tanstack/react-table' { + //allows us to define custom properties for our columns + interface ColumnMeta { + filterVariant?: 'text' | 'range' | 'select' + } +} + +function App() { + const rerender = React.useReducer(() => ({}), {})[1] + + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + + const columns = React.useMemo[]>( + () => [ + { + accessorKey: 'firstName', + cell: info => info.getValue(), + }, + { + accessorFn: row => row.lastName, + id: 'lastName', + cell: info => info.getValue(), + header: () => Last Name, + }, + { + accessorKey: 'age', + header: () => 'Age', + meta: { + filterVariant: 'range', + }, + }, + { + accessorKey: 'visits', + header: () => Visits, + meta: { + filterVariant: 'range', + }, + }, + { + accessorKey: 'status', + header: 'Status', + meta: { + filterVariant: 'select', + }, + }, + { + accessorKey: 'progress', + header: 'Profile Progress', + meta: { + filterVariant: 'range', + }, + }, + ], + [] + ) + + const [data, setData] = React.useState(() => makeData(5_000)) + const refreshData = () => setData(_old => makeData(100_000)) //stress test + + const table = useReactTable({ + data, + columns, + state: { + columnFilters, + }, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), //client-side filtering + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFacetedRowModel: getFacetedRowModel(), // client-side faceting + getFacetedUniqueValues: getFacetedUniqueValues(), // generate unique values for select filter/autocomplete + getFacetedMinMaxValues: getFacetedMinMaxValues(), // generate min/max values for range filter + debugTable: true, + debugHeaders: true, + debugColumns: false, + }) + + return ( +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + return ( + + ) + })} + + ))} + + + {table.getRowModel().rows.map(row => { + return ( + + {row.getVisibleCells().map(cell => { + return ( + + ) + })} + + ) + })} + +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: ' 🔼', + desc: ' 🔽', + }[header.column.getIsSorted() as string] ?? null} +
+ {header.column.getCanFilter() ? ( +
+ +
+ ) : null} + + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+
+ + + + + +
Page
+ + {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} + +
+ + | Go to page: + { + const page = e.target.value ? Number(e.target.value) - 1 : 0 + table.setPageIndex(page) + }} + className="border p-1 rounded w-16" + /> + + +
+
{table.getPrePaginationRowModel().rows.length} Rows
+
+ +
+
+ +
+
+        {JSON.stringify(
+          { columnFilters: table.getState().columnFilters },
+          null,
+          2
+        )}
+      
+
+ ) +} + +function Filter({ column }: { column: Column }) { + const { filterVariant } = column.columnDef.meta ?? {} + + const columnFilterValue = column.getFilterValue() + + const sortedUniqueValues = React.useMemo( + () => + filterVariant === 'range' + ? [] + : Array.from(column.getFacetedUniqueValues().keys()) + .sort() + .slice(0, 5000), + [column.getFacetedUniqueValues(), filterVariant] + ) + + return filterVariant === 'range' ? ( +
+
+ + column.setFilterValue((old: [number, number]) => [value, old?.[1]]) + } + placeholder={`Min ${ + column.getFacetedMinMaxValues()?.[0] !== undefined + ? `(${column.getFacetedMinMaxValues()?.[0]})` + : '' + }`} + className="w-24 border shadow rounded" + /> + + column.setFilterValue((old: [number, number]) => [old?.[0], value]) + } + placeholder={`Max ${ + column.getFacetedMinMaxValues()?.[1] + ? `(${column.getFacetedMinMaxValues()?.[1]})` + : '' + }`} + className="w-24 border shadow rounded" + /> +
+
+
+ ) : filterVariant === 'select' ? ( + + ) : ( + <> + {/* Autocomplete suggestions from faceted values feature */} + + {sortedUniqueValues.map((value: any) => ( + + column.setFilterValue(value)} + placeholder={`Search... (${column.getFacetedUniqueValues().size})`} + className="w-36 border shadow rounded" + list={column.id + 'list'} + /> +
+ + ) +} + +// A typical debounced input react component +function DebouncedInput({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit, 'onChange'>) { + const [value, setValue] = React.useState(initialValue) + + React.useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + React.useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + }, [value]) + + return ( + setValue(e.target.value)} /> + ) +} + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +ReactDOM.createRoot(rootElement).render( + + + +) diff --git a/examples/react/filters-faceted/src/makeData.ts b/examples/react/filters-faceted/src/makeData.ts new file mode 100644 index 0000000000..331dd1eb19 --- /dev/null +++ b/examples/react/filters-faceted/src/makeData.ts @@ -0,0 +1,48 @@ +import { faker } from '@faker-js/faker' + +export type Person = { + firstName: string + lastName: string + age: number + visits: number + progress: number + status: 'relationship' | 'complicated' | 'single' + subRows?: Person[] +} + +const range = (len: number) => { + const arr: number[] = [] + for (let i = 0; i < len; i++) { + arr.push(i) + } + return arr +} + +const newPerson = (): Person => { + return { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + age: faker.number.int(40), + visits: faker.number.int(1000), + progress: faker.number.int(100), + status: faker.helpers.shuffle([ + 'relationship', + 'complicated', + 'single', + ])[0]!, + } +} + +export function makeData(...lens: number[]) { + const makeDataLevel = (depth = 0): Person[] => { + const len = lens[depth]! + return range(len).map((d): Person => { + return { + ...newPerson(), + subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined, + } + }) + } + + return makeDataLevel() +} diff --git a/examples/react/filters-faceted/tsconfig.json b/examples/react/filters-faceted/tsconfig.json new file mode 100644 index 0000000000..6d545f543f --- /dev/null +++ b/examples/react/filters-faceted/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/react/filters-faceted/vite.config.js b/examples/react/filters-faceted/vite.config.js new file mode 100644 index 0000000000..2e1361723a --- /dev/null +++ b/examples/react/filters-faceted/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import rollupReplace from '@rollup/plugin-replace' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + rollupReplace({ + preventAssignment: true, + values: { + __DEV__: JSON.stringify(true), + 'process.env.NODE_ENV': JSON.stringify('development'), + }, + }), + react(), + ], +}) diff --git a/examples/react/filters-fuzzy/.gitignore b/examples/react/filters-fuzzy/.gitignore new file mode 100644 index 0000000000..d451ff16c1 --- /dev/null +++ b/examples/react/filters-fuzzy/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local diff --git a/examples/react/filters-fuzzy/README.md b/examples/react/filters-fuzzy/README.md new file mode 100644 index 0000000000..b168d3c4b1 --- /dev/null +++ b/examples/react/filters-fuzzy/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm run start` or `yarn start` diff --git a/examples/react/filters-fuzzy/index.html b/examples/react/filters-fuzzy/index.html new file mode 100644 index 0000000000..3fc40c9367 --- /dev/null +++ b/examples/react/filters-fuzzy/index.html @@ -0,0 +1,13 @@ + + + + + + Vite App + + + +
+ + + diff --git a/examples/react/filters-fuzzy/package.json b/examples/react/filters-fuzzy/package.json new file mode 100644 index 0000000000..9afffbaf0c --- /dev/null +++ b/examples/react/filters-fuzzy/package.json @@ -0,0 +1,26 @@ +{ + "name": "tanstack-table-example-filters-fuzzy", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview --port 3001", + "start": "vite" + }, + "dependencies": { + "@faker-js/faker": "^8.4.1", + "@tanstack/match-sorter-utils": "^8.15.1", + "@tanstack/react-table": "^8.16.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@rollup/plugin-replace": "^5.0.5", + "@types/react": "^18.2.70", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "5.4.3", + "vite": "^5.2.6" + } +} diff --git a/examples/react/filters-fuzzy/src/index.css b/examples/react/filters-fuzzy/src/index.css new file mode 100644 index 0000000000..43c09e0f6b --- /dev/null +++ b/examples/react/filters-fuzzy/src/index.css @@ -0,0 +1,26 @@ +html { + font-family: sans-serif; + font-size: 14px; +} + +table { + border: 1px solid lightgray; +} + +tbody { + border-bottom: 1px solid lightgray; +} + +th { + border-bottom: 1px solid lightgray; + border-right: 1px solid lightgray; + padding: 2px 4px; +} + +tfoot { + color: gray; +} + +tfoot th { + font-weight: normal; +} diff --git a/examples/react/filters-fuzzy/src/main.tsx b/examples/react/filters-fuzzy/src/main.tsx new file mode 100644 index 0000000000..5c207bc51a --- /dev/null +++ b/examples/react/filters-fuzzy/src/main.tsx @@ -0,0 +1,346 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' + +import './index.css' + +import { + Column, + ColumnDef, + ColumnFiltersState, + FilterFn, + SortingFn, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + sortingFns, + useReactTable, +} from '@tanstack/react-table' + +// A TanStack fork of Kent C. Dodds' match-sorter library that provides ranking information +import { + RankingInfo, + rankItem, + compareItems, +} from '@tanstack/match-sorter-utils' + +import { makeData, Person } from './makeData' + +declare module '@tanstack/react-table' { + //add fuzzy filter to the filterFns + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +// Define a custom fuzzy filter function that will apply ranking info to rows (using match-sorter utils) +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank, + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +// Define a custom fuzzy sort function that will sort by rank if the row has ranking information +const fuzzySort: SortingFn = (rowA, rowB, columnId) => { + let dir = 0 + + // Only sort by rank if the column has ranking information + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId]?.itemRank!, + rowB.columnFiltersMeta[columnId]?.itemRank! + ) + } + + // Provide an alphanumeric fallback for when the item ranks are equal + return dir === 0 ? sortingFns.alphanumeric(rowA, rowB, columnId) : dir +} + +function App() { + const rerender = React.useReducer(() => ({}), {})[1] + + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + const [globalFilter, setGlobalFilter] = React.useState('') + + const columns = React.useMemo[]>( + () => [ + { + accessorKey: 'id', + filterFn: 'equalsString', //note: normal non-fuzzy filter column - exact match required + }, + { + accessorKey: 'firstName', + cell: info => info.getValue(), + filterFn: 'includesStringSensitive', //note: normal non-fuzzy filter column + }, + { + accessorFn: row => row.lastName, //note: normal non-fuzzy filter column - case sensitive + id: 'lastName', + cell: info => info.getValue(), + header: () => Last Name, + filterFn: 'includesString', //note: normal non-fuzzy filter column - case insensitive + }, + { + accessorFn: row => `${row.firstName} ${row.lastName}`, + id: 'fullName', + header: 'Full Name', + cell: info => info.getValue(), + filterFn: 'fuzzy', //using our custom fuzzy filter function + // filterFn: fuzzyFilter, //or just define with the function + sortingFn: fuzzySort, //sort by fuzzy rank (falls back to alphanumeric) + }, + ], + [] + ) + + const [data, setData] = React.useState(() => makeData(5_000)) + const refreshData = () => setData(_old => makeData(50_000)) //stress test + + const table = useReactTable({ + data, + columns, + filterFns: { + fuzzy: fuzzyFilter, //define as a filter function that can be used in column definitions + }, + state: { + columnFilters, + globalFilter, + }, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: 'fuzzy', //apply fuzzy filter to the global filter (most common use case for fuzzy filter) + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), //client side filtering + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + debugTable: true, + debugHeaders: true, + debugColumns: false, + }) + + //apply the fuzzy sort if the fullName column is being filtered + React.useEffect(() => { + if (table.getState().columnFilters[0]?.id === 'fullName') { + if (table.getState().sorting[0]?.id !== 'fullName') { + table.setSorting([{ id: 'fullName', desc: false }]) + } + } + }, [table.getState().columnFilters[0]?.id]) + + return ( +
+
+ setGlobalFilter(String(value))} + className="p-2 font-lg shadow border border-block" + placeholder="Search all columns..." + /> +
+
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + return ( + + ) + })} + + ))} + + + {table.getRowModel().rows.map(row => { + return ( + + {row.getVisibleCells().map(cell => { + return ( + + ) + })} + + ) + })} + +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: ' 🔼', + desc: ' 🔽', + }[header.column.getIsSorted() as string] ?? null} +
+ {header.column.getCanFilter() ? ( +
+ +
+ ) : null} + + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+
+ + + + + +
Page
+ + {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} + +
+ + | Go to page: + { + const page = e.target.value ? Number(e.target.value) - 1 : 0 + table.setPageIndex(page) + }} + className="border p-1 rounded w-16" + /> + + +
+
{table.getPrePaginationRowModel().rows.length} Rows
+
+ +
+
+ +
+
+        {JSON.stringify(
+          {
+            columnFilters: table.getState().columnFilters,
+            globalFilter: table.getState().globalFilter,
+          },
+          null,
+          2
+        )}
+      
+
+ ) +} + +function Filter({ column }: { column: Column }) { + const columnFilterValue = column.getFilterValue() + + return ( + column.setFilterValue(value)} + placeholder={`Search...`} + className="w-36 border shadow rounded" + /> + ) +} + +// A typical debounced input react component +function DebouncedInput({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit, 'onChange'>) { + const [value, setValue] = React.useState(initialValue) + + React.useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + React.useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + }, [value]) + + return ( + setValue(e.target.value)} /> + ) +} + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +ReactDOM.createRoot(rootElement).render( + + + +) diff --git a/examples/react/filters-fuzzy/src/makeData.ts b/examples/react/filters-fuzzy/src/makeData.ts new file mode 100644 index 0000000000..6d4d5076a0 --- /dev/null +++ b/examples/react/filters-fuzzy/src/makeData.ts @@ -0,0 +1,50 @@ +import { faker } from '@faker-js/faker' + +export type Person = { + id: number + firstName: string + lastName: string + age: number + visits: number + progress: number + status: 'relationship' | 'complicated' | 'single' + subRows?: Person[] +} + +const range = (len: number) => { + const arr: number[] = [] + for (let i = 0; i < len; i++) { + arr.push(i) + } + return arr +} + +const newPerson = (num: number): Person => { + return { + id: num, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + age: faker.number.int(40), + visits: faker.number.int(1000), + progress: faker.number.int(100), + status: faker.helpers.shuffle([ + 'relationship', + 'complicated', + 'single', + ])[0]!, + } +} + +export function makeData(...lens: number[]) { + const makeDataLevel = (depth = 0): Person[] => { + const len = lens[depth]! + return range(len).map((index): Person => { + return { + ...newPerson(index), + subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined, + } + }) + } + + return makeDataLevel() +} diff --git a/examples/react/filters-fuzzy/tsconfig.json b/examples/react/filters-fuzzy/tsconfig.json new file mode 100644 index 0000000000..6d545f543f --- /dev/null +++ b/examples/react/filters-fuzzy/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/react/filters-fuzzy/vite.config.js b/examples/react/filters-fuzzy/vite.config.js new file mode 100644 index 0000000000..2e1361723a --- /dev/null +++ b/examples/react/filters-fuzzy/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import rollupReplace from '@rollup/plugin-replace' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + rollupReplace({ + preventAssignment: true, + values: { + __DEV__: JSON.stringify(true), + 'process.env.NODE_ENV': JSON.stringify('development'), + }, + }), + react(), + ], +}) diff --git a/examples/react/filters/src/main.tsx b/examples/react/filters/src/main.tsx index 8af956ff62..43a4c1370f 100644 --- a/examples/react/filters/src/main.tsx +++ b/examples/react/filters/src/main.tsx @@ -7,64 +7,22 @@ import { Column, ColumnDef, ColumnFiltersState, - FilterFn, - SortingFn, - Table, + RowData, flexRender, getCoreRowModel, - getFacetedMinMaxValues, - getFacetedRowModel, - getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, - sortingFns, useReactTable, } from '@tanstack/react-table' -import { - RankingInfo, - rankItem, - compareItems, -} from '@tanstack/match-sorter-utils' - import { makeData, Person } from './makeData' declare module '@tanstack/react-table' { - interface FilterFns { - fuzzy: FilterFn - } - interface FilterMeta { - itemRank: RankingInfo - } -} - -const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { - // Rank the item - const itemRank = rankItem(row.getValue(columnId), value) - - // Store the itemRank info - addMeta({ - itemRank, - }) - - // Return if the item should be filtered in/out - return itemRank.passed -} - -const fuzzySort: SortingFn = (rowA, rowB, columnId) => { - let dir = 0 - - // Only sort by rank if the column has ranking information - if (rowA.columnFiltersMeta[columnId]) { - dir = compareItems( - rowA.columnFiltersMeta[columnId]?.itemRank!, - rowB.columnFiltersMeta[columnId]?.itemRank! - ) + //allows us to define custom properties for our columns + interface ColumnMeta { + filterVariant?: 'text' | 'range' | 'select' } - - // Provide an alphanumeric fallback for when the item ranks are equal - return dir === 0 ? sortingFns.alphanumeric(rowA, rowB, columnId) : dir } function App() { @@ -73,119 +31,79 @@ function App() { const [columnFilters, setColumnFilters] = React.useState( [] ) - const [globalFilter, setGlobalFilter] = React.useState('') const columns = React.useMemo[]>( () => [ { - header: 'Name', - footer: props => props.column.id, - columns: [ - { - accessorKey: 'firstName', - cell: info => info.getValue(), - footer: props => props.column.id, - }, - { - accessorFn: row => row.lastName, - id: 'lastName', - cell: info => info.getValue(), - header: () => Last Name, - footer: props => props.column.id, - }, - { - accessorFn: row => `${row.firstName} ${row.lastName}`, - id: 'fullName', - header: 'Full Name', - cell: info => info.getValue(), - footer: props => props.column.id, - filterFn: 'fuzzy', - sortingFn: fuzzySort, - }, - ], + accessorKey: 'firstName', + cell: info => info.getValue(), + }, + { + accessorFn: row => row.lastName, + id: 'lastName', + cell: info => info.getValue(), + header: () => Last Name, + }, + { + accessorFn: row => `${row.firstName} ${row.lastName}`, + id: 'fullName', + header: 'Full Name', + cell: info => info.getValue(), + }, + { + accessorKey: 'age', + header: () => 'Age', + meta: { + filterVariant: 'range', + }, + }, + { + accessorKey: 'visits', + header: () => Visits, + meta: { + filterVariant: 'range', + }, + }, + { + accessorKey: 'status', + header: 'Status', + meta: { + filterVariant: 'select', + }, }, { - header: 'Info', - footer: props => props.column.id, - columns: [ - { - accessorKey: 'age', - header: () => 'Age', - footer: props => props.column.id, - }, - { - header: 'More Info', - columns: [ - { - accessorKey: 'visits', - header: () => Visits, - footer: props => props.column.id, - }, - { - accessorKey: 'status', - header: 'Status', - footer: props => props.column.id, - }, - { - accessorKey: 'progress', - header: 'Profile Progress', - footer: props => props.column.id, - }, - ], - }, - ], + accessorKey: 'progress', + header: 'Profile Progress', + meta: { + filterVariant: 'range', + }, }, ], [] ) - const [data, setData] = React.useState(() => makeData(50000)) - const refreshData = () => setData(_old => makeData(50000)) + const [data, setData] = React.useState(() => makeData(5_000)) + const refreshData = () => setData(_old => makeData(50_000)) //stress test const table = useReactTable({ data, columns, - filterFns: { - fuzzy: fuzzyFilter, - }, + filterFns: {}, state: { columnFilters, - globalFilter, }, onColumnFiltersChange: setColumnFilters, - onGlobalFilterChange: setGlobalFilter, - globalFilterFn: fuzzyFilter, getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), + getFilteredRowModel: getFilteredRowModel(), //client side filtering getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), - getFacetedRowModel: getFacetedRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), - getFacetedMinMaxValues: getFacetedMinMaxValues(), debugTable: true, debugHeaders: true, debugColumns: false, }) - React.useEffect(() => { - if (table.getState().columnFilters[0]?.id === 'fullName') { - if (table.getState().sorting[0]?.id !== 'fullName') { - table.setSorting([{ id: 'fullName', desc: false }]) - } - } - }, [table.getState().columnFilters[0]?.id]) - return (
-
- setGlobalFilter(String(value))} - className="p-2 font-lg shadow border border-block" - placeholder="Search all columns..." - /> -
-
{table.getHeaderGroups().map(headerGroup => ( @@ -214,7 +132,7 @@ function App() { {header.column.getCanFilter() ? (
- +
) : null} @@ -313,89 +231,70 @@ function App() {
-
{JSON.stringify(table.getState(), null, 2)}
+
+        {JSON.stringify(
+          { columnFilters: table.getState().columnFilters },
+          null,
+          2
+        )}
+      
) } -function Filter({ - column, - table, -}: { - column: Column - table: Table -}) { - const firstValue = table - .getPreFilteredRowModel() - .flatRows[0]?.getValue(column.id) - +function Filter({ column }: { column: Column }) { const columnFilterValue = column.getFilterValue() + const { filterVariant } = column.columnDef.meta ?? {} - const sortedUniqueValues = React.useMemo( - () => - typeof firstValue === 'number' - ? [] - : Array.from(column.getFacetedUniqueValues().keys()).sort(), - [column.getFacetedUniqueValues()] - ) - - return typeof firstValue === 'number' ? ( + return filterVariant === 'range' ? (
+ {/* See faceted column filters example for min max values functionality */} column.setFilterValue((old: [number, number]) => [value, old?.[1]]) } - placeholder={`Min ${ - column.getFacetedMinMaxValues()?.[0] - ? `(${column.getFacetedMinMaxValues()?.[0]})` - : '' - }`} + placeholder={`Min`} className="w-24 border shadow rounded" /> column.setFilterValue((old: [number, number]) => [old?.[0], value]) } - placeholder={`Max ${ - column.getFacetedMinMaxValues()?.[1] - ? `(${column.getFacetedMinMaxValues()?.[1]})` - : '' - }`} + placeholder={`Max`} className="w-24 border shadow rounded" />
+ ) : filterVariant === 'select' ? ( + ) : ( - <> - - {sortedUniqueValues.slice(0, 5000).map((value: any) => ( - - column.setFilterValue(value)} - placeholder={`Search... (${column.getFacetedUniqueValues().size})`} - className="w-36 border shadow rounded" - list={column.id + 'list'} - /> -
- + column.setFilterValue(value)} + placeholder={`Search...`} + type="text" + value={(columnFilterValue ?? '') as string} + /> + // See faceted column filters example for datalist search suggestions ) } -// A debounced input react component +// A typical debounced input react component function DebouncedInput({ value: initialValue, onChange, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdc7fa400d..3b81cec56d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -678,6 +678,80 @@ importers: specifier: ^5.2.6 version: 5.2.6(@types/node@20.11.30) + examples/react/filters-faceted: + dependencies: + '@faker-js/faker': + specifier: ^8.4.1 + version: 8.4.1 + '@tanstack/match-sorter-utils': + specifier: ^8.15.1 + version: link:../../../packages/match-sorter-utils + '@tanstack/react-table': + specifier: ^8.16.0 + version: link:../../../packages/react-table + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + devDependencies: + '@rollup/plugin-replace': + specifier: ^5.0.5 + version: 5.0.5(rollup@4.13.0) + '@types/react': + specifier: ^18.2.70 + version: 18.2.70 + '@types/react-dom': + specifier: ^18.2.22 + version: 18.2.22 + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.2.1(vite@5.2.6) + typescript: + specifier: 5.4.3 + version: 5.4.3 + vite: + specifier: ^5.2.6 + version: 5.2.6(@types/node@20.11.30) + + examples/react/filters-fuzzy: + dependencies: + '@faker-js/faker': + specifier: ^8.4.1 + version: 8.4.1 + '@tanstack/match-sorter-utils': + specifier: ^8.15.1 + version: link:../../../packages/match-sorter-utils + '@tanstack/react-table': + specifier: ^8.16.0 + version: link:../../../packages/react-table + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + devDependencies: + '@rollup/plugin-replace': + specifier: ^5.0.5 + version: 5.0.5(rollup@4.13.0) + '@types/react': + specifier: ^18.2.70 + version: 18.2.70 + '@types/react-dom': + specifier: ^18.2.22 + version: 18.2.22 + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.2.1(vite@5.2.6) + typescript: + specifier: 5.4.3 + version: 5.4.3 + vite: + specifier: ^5.2.6 + version: 5.2.6(@types/node@20.11.30) + examples/react/full-width-resizable-table: dependencies: '@faker-js/faker':