From 0fc79a44e1006db408cf1baaa8598fa89e3c50d0 Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Wed, 1 Jan 2025 12:30:53 +0800 Subject: [PATCH 1/6] feat(components): update components --- .../docs/src/components/typescript-icon.tsx | 18 +++ packages/docs/src/components/ui/button.tsx | 2 +- .../components/ui/data-table-pagination.tsx | 92 +++++++++++++ .../docs/src/components/ui/data-table.tsx | 73 ++++++++++ packages/docs/src/components/ui/input.tsx | 22 +++ packages/docs/src/components/ui/table.tsx | 128 ++++++++++++++++++ packages/docs/src/lib/react-table.ts | 60 ++++++++ 7 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 packages/docs/src/components/typescript-icon.tsx create mode 100644 packages/docs/src/components/ui/data-table-pagination.tsx create mode 100644 packages/docs/src/components/ui/data-table.tsx create mode 100644 packages/docs/src/components/ui/input.tsx create mode 100644 packages/docs/src/components/ui/table.tsx create mode 100644 packages/docs/src/lib/react-table.ts diff --git a/packages/docs/src/components/typescript-icon.tsx b/packages/docs/src/components/typescript-icon.tsx new file mode 100644 index 00000000..64528341 --- /dev/null +++ b/packages/docs/src/components/typescript-icon.tsx @@ -0,0 +1,18 @@ +export function TypescriptIcon() { + return ( + + + + + ) +} diff --git a/packages/docs/src/components/ui/button.tsx b/packages/docs/src/components/ui/button.tsx index 3a21794e..efb89db8 100644 --- a/packages/docs/src/components/ui/button.tsx +++ b/packages/docs/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/src/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { diff --git a/packages/docs/src/components/ui/data-table-pagination.tsx b/packages/docs/src/components/ui/data-table-pagination.tsx new file mode 100644 index 00000000..55f6dbcd --- /dev/null +++ b/packages/docs/src/components/ui/data-table-pagination.tsx @@ -0,0 +1,92 @@ +import { Label } from '@/src/components/ui/label' +import { Table } from '@tanstack/react-table' + +import { + Pagination, + PaginationButton, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious +} from '@/src/components/ui/pagination' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/src/components/ui/select' +import { ELLIPSIS, getTablePaginationRange } from '@/src/lib/react-table' + +interface DataTablePaginationProps { + table: Table +} + +export function DataTablePagination({ + table +}: DataTablePaginationProps) { + const paginationRange = getTablePaginationRange(table) + + return ( +
+ + + + table.setPageIndex(p => Math.max(0, p - 1))} + /> + + {paginationRange.map((page, index) => ( +
+ {page === ELLIPSIS ? ( + + ) : ( + + table.setPageIndex(page - 1)} + > + {page} + + + )} +
+ ))} + + = + table.getPageCount() - 1 + } + onClick={() => + table.setPageIndex(p => + Math.min(table.getPageCount() - 1, p + 1) + ) + } + /> + +
+
+ +
+ ) +} diff --git a/packages/docs/src/components/ui/data-table.tsx b/packages/docs/src/components/ui/data-table.tsx new file mode 100644 index 00000000..058d2053 --- /dev/null +++ b/packages/docs/src/components/ui/data-table.tsx @@ -0,0 +1,73 @@ +'use client' + +import { + flexRender, + RowData, + Table as TanStackTable +} from '@tanstack/react-table' + +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow +} from '@/src/components/ui/table' + +interface DataTableProps { + table: TanStackTable +} + +export function DataTable({ + table +}: DataTableProps) { + return ( + + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ) +} diff --git a/packages/docs/src/components/ui/input.tsx b/packages/docs/src/components/ui/input.tsx new file mode 100644 index 00000000..9c4de7d5 --- /dev/null +++ b/packages/docs/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/src/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/packages/docs/src/components/ui/table.tsx b/packages/docs/src/components/ui/table.tsx new file mode 100644 index 00000000..2f111e91 --- /dev/null +++ b/packages/docs/src/components/ui/table.tsx @@ -0,0 +1,128 @@ +import * as React from 'react' + +import { cn } from '@/src/lib/utils' + +const TableContainer = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableContainer.displayName = 'TableContainer' + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +Table.displayName = 'Table' + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = 'TableHeader' + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = 'TableBody' + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0', + className + )} + {...props} + /> +)) +TableFooter.displayName = 'TableFooter' + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = 'TableRow' + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = 'TableHead' + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = 'TableCell' + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = 'TableCaption' + +export { + TableContainer, + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption +} diff --git a/packages/docs/src/lib/react-table.ts b/packages/docs/src/lib/react-table.ts new file mode 100644 index 00000000..1e32a33f --- /dev/null +++ b/packages/docs/src/lib/react-table.ts @@ -0,0 +1,60 @@ +import { Table } from '@tanstack/react-table' + +const range = (start: number, end: number) => { + const length = end - start + 1 + return Array.from({ length }, (_, index) => index + start) +} + +export const ELLIPSIS = 'ellipsis' as const + +export const getTablePaginationRange = ( + table: Table, + /** + * Siblings amount on left/right side of selected page, defaults to 1. + */ + siblings = 1, + /** + * Amount of elements visible on left/right edges, defaults to 1. + */ + boundaries = 1 +) => { + const total = table.getPageCount() + const activePage = table.getState().pagination.pageIndex + 1 + + const totalPageNumbers = siblings * 2 + 3 + boundaries * 2 + if (totalPageNumbers >= total) { + return range(1, total) + } + + const leftSiblingIndex = Math.max(activePage - siblings, boundaries) + const rightSiblingIndex = Math.min(activePage + siblings, total - boundaries) + + const shouldShowLeftDots = leftSiblingIndex > boundaries + 2 + const shouldShowRightDots = rightSiblingIndex < total - (boundaries + 1) + + if (!shouldShowLeftDots && shouldShowRightDots) { + const leftItemCount = siblings * 2 + boundaries + 2 + return [ + ...range(1, leftItemCount), + ELLIPSIS, + ...range(total - (boundaries - 1), total) + ] as const + } + + if (shouldShowLeftDots && !shouldShowRightDots) { + const rightItemCount = boundaries + 1 + 2 * siblings + return [ + ...range(1, boundaries), + ELLIPSIS, + ...range(total - rightItemCount, total) + ] as const + } + + return [ + ...range(1, boundaries), + ELLIPSIS, + ...range(leftSiblingIndex, rightSiblingIndex), + ELLIPSIS, + ...range(total - boundaries + 1, total) + ] as const +} From 3e090d45e9242e0afa5a201dc875297853d249ff Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Wed, 1 Jan 2025 12:31:17 +0800 Subject: [PATCH 2/6] build(deps): add react table --- packages/docs/package.json | 1 + pnpm-lock.yaml | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/docs/package.json b/packages/docs/package.json index 990822e3..8de2e92f 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -35,6 +35,7 @@ "@radix-ui/react-toggle-group": "^1.1.1", "@sentry/nextjs": "^8.46.0", "@tailwindcss/container-queries": "^0.1.1", + "@tanstack/react-table": "^8.20.6", "@tremor/react": "^3.18.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bec0e5ab..9956f3b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@tailwindcss/container-queries': specifier: ^0.1.1 version: 0.1.1(tailwindcss@3.4.17(ts-node@9.1.1(typescript@5.7.2))) + '@tanstack/react-table': + specifier: ^8.20.6 + version: 8.20.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@tremor/react': specifier: ^3.18.6 version: 3.18.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -3054,12 +3057,23 @@ packages: peerDependencies: tailwindcss: '>=3.2.0' + '@tanstack/react-table@8.20.6': + resolution: {integrity: sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-virtual@3.11.2': resolution: {integrity: sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.20.5': + resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} + engines: {node: '>=12'} + '@tanstack/virtual-core@3.11.2': resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==} @@ -11084,12 +11098,20 @@ snapshots: dependencies: tailwindcss: 3.4.17(ts-node@9.1.1(typescript@5.7.2)) + '@tanstack/react-table@8.20.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@tanstack/table-core': 8.20.5 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + '@tanstack/react-virtual@3.11.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@tanstack/virtual-core': 3.11.2 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) + '@tanstack/table-core@8.20.5': {} + '@tanstack/virtual-core@3.11.2': {} '@testing-library/dom@10.4.0': From 21fdb1fbd79fe8467b8b95df4926a0a7c4fd0176 Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Wed, 1 Jan 2025 12:32:13 +0800 Subject: [PATCH 3/6] doc(react-table): add react table parsers --- .../community/search-params.column-filters.ts | 25 ++ .../community/search-params.pagination.ts | 29 +++ .../community/search-params.sorting.ts | 26 +++ .../tanstack-table.column-filtering.tsx | 139 +++++++++++ .../community/tanstack-table.generator.tsx | 217 ------------------ .../community/tanstack-table.kitchen-sink.tsx | 118 ++++++++++ .../docs/parsers/community/tanstack-table.mdx | 63 ++++- .../community/tanstack-table.pagination.tsx | 132 +++++++++++ .../community/tanstack-table.sorting.tsx | 108 +++++++++ .../docs/parsers/community/tanstack-table.tsx | 153 ++++++++++++ 10 files changed, 787 insertions(+), 223 deletions(-) create mode 100644 packages/docs/content/docs/parsers/community/search-params.column-filters.ts create mode 100644 packages/docs/content/docs/parsers/community/search-params.pagination.ts create mode 100644 packages/docs/content/docs/parsers/community/search-params.sorting.ts create mode 100644 packages/docs/content/docs/parsers/community/tanstack-table.column-filtering.tsx delete mode 100644 packages/docs/content/docs/parsers/community/tanstack-table.generator.tsx create mode 100644 packages/docs/content/docs/parsers/community/tanstack-table.kitchen-sink.tsx create mode 100644 packages/docs/content/docs/parsers/community/tanstack-table.pagination.tsx create mode 100644 packages/docs/content/docs/parsers/community/tanstack-table.sorting.tsx create mode 100644 packages/docs/content/docs/parsers/community/tanstack-table.tsx diff --git a/packages/docs/content/docs/parsers/community/search-params.column-filters.ts b/packages/docs/content/docs/parsers/community/search-params.column-filters.ts new file mode 100644 index 00000000..44fbe950 --- /dev/null +++ b/packages/docs/content/docs/parsers/community/search-params.column-filters.ts @@ -0,0 +1,25 @@ +import { ColumnFilter } from '@tanstack/react-table' +import { createParser, parseAsArrayOf, useQueryState } from 'nuqs' + +// Each column filter is represented as `columnId=value`, +// for example: `?filter=email=john.doe@example.com&age=["7",null]` +const filterParser = createParser({ + parse: query => { + const [id, value] = query.split('=') + return { + id, + value: JSON.parse(value ?? '') + } as ColumnFilter + }, + serialize: value => `${value.id}=${JSON.stringify(value.value)}`, + // This is a simple equality check for comparing objects. + // It works by converting both objects to strings and comparing them. + // For more robust deep equality, consider using lodash's isEqual. + eq: (a, b) => JSON.stringify(a) === JSON.stringify(b) +}) + +const parseAsColumnFiltersState = parseAsArrayOf(filterParser).withDefault([]) + +export function useColumnFilterSearchParams(key = 'filter') { + return useQueryState(key, parseAsColumnFiltersState) +} diff --git a/packages/docs/content/docs/parsers/community/search-params.pagination.ts b/packages/docs/content/docs/parsers/community/search-params.pagination.ts new file mode 100644 index 00000000..bacee3c3 --- /dev/null +++ b/packages/docs/content/docs/parsers/community/search-params.pagination.ts @@ -0,0 +1,29 @@ +import { createParser, parseAsInteger, useQueryStates } from 'nuqs' + +// The page index parser is zero-indexed internally, +// but one-indexed when rendered in the URL, +// to align with your UI and what users might expect. +const pageIndexParser = createParser({ + parse: query => { + const page = parseAsInteger.parse(query) + return page === null ? null : page - 1 + }, + serialize: value => { + return parseAsInteger.serialize(value + 1) + } +}) + +const paginationParsers = { + pageIndex: pageIndexParser.withDefault(0), + pageSize: parseAsInteger.withDefault(10) +} +const paginationUrlKeys = { + pageIndex: 'page', + pageSize: 'perPage' +} + +export function usePaginationSearchParams(urlKeys = paginationUrlKeys) { + return useQueryStates(paginationParsers, { + urlKeys + }) +} diff --git a/packages/docs/content/docs/parsers/community/search-params.sorting.ts b/packages/docs/content/docs/parsers/community/search-params.sorting.ts new file mode 100644 index 00000000..d9419105 --- /dev/null +++ b/packages/docs/content/docs/parsers/community/search-params.sorting.ts @@ -0,0 +1,26 @@ +import { ColumnSort, SortDirection } from '@tanstack/react-table' +import { createParser, parseAsArrayOf, useQueryState } from 'nuqs' + +// Each sort is represented as `columnId:direction`, +// for example: `?orderBy=email:desc,status:asc` +const sortParser = createParser({ + parse: query => { + const [id, direction] = query.split(':') + return { + id, + desc: direction === 'desc' + } + }, + serialize: value => + `${value.id}:${value.desc ? 'desc' : 'asc'}` as `${string}:${SortDirection}`, + // This is a simple equality check for comparing objects. + // It works by converting both objects to strings and comparing them. + // For more robust deep equality, consider using lodash's isEqual. + eq: (a, b) => JSON.stringify(a) === JSON.stringify(b) +}) + +const parseAsSortingState = parseAsArrayOf(sortParser).withDefault([]) + +export function useSortingSearchParams(key = 'orderBy') { + return useQueryState(key, parseAsSortingState) +} diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.column-filtering.tsx b/packages/docs/content/docs/parsers/community/tanstack-table.column-filtering.tsx new file mode 100644 index 00000000..41a3dd96 --- /dev/null +++ b/packages/docs/content/docs/parsers/community/tanstack-table.column-filtering.tsx @@ -0,0 +1,139 @@ +'use client' + +import { CodeBlock } from '@/src/components/code-block.client' +import { Querystring } from '@/src/components/querystring' +import { TypescriptIcon } from '@/src/components/typescript-icon' +import { Input } from '@/src/components/ui/input' +import { Label } from '@/src/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/src/components/ui/select' +import { Separator } from '@/src/components/ui/separator' +import { parseAsString, useQueryState } from 'nuqs' +import { useDeferredValue } from 'react' +import { useColumnFilterSearchParams } from './search-params.column-filters' +import { useTanStackTable } from './tanstack-table' + +export function TanStackTableColumnFiltering() { + const [columnFiltersUrlKey, setColumnFiltersUrlKey] = useQueryState( + 'columnFiltersUrlKey', + parseAsString.withDefault('filter') + ) + const [columnFilters, setColumnFilters] = + useColumnFilterSearchParams(columnFiltersUrlKey) + + const parserCode = + useDeferredValue(`import { ColumnFilter } from '@tanstack/react-table' +import { createParser, parseAsArrayOf, useQueryState } from 'nuqs' + +// Each column filter is represented as \`columnId=value\`, +// for example: \`?filter=email=john.doe@example.com&age=["7",null]\` +const filterParser = createParser({ + parse: query => { + const [id, value] = query.split('=') + return { + id, + value: JSON.parse(value ?? '') + } as ColumnFilter + }, + serialize: value => \`\${value.id}=\${JSON.stringify(value.value)}\`, + // This is a simple equality check for comparing objects. + // It works by converting both objects to strings and comparing them. + // For more robust deep equality, consider using lodash's isEqual. + eq: (a, b) => JSON.stringify(a) === JSON.stringify(b) +}) + +const parseAsColumnFiltersState = parseAsArrayOf(filterParser).withDefault([]) + +export function useColumnFilterSearchParams() { + return useQueryState('${columnFiltersUrlKey}', parseAsColumnFiltersState) +}`) + + const table = useTanStackTable({ + data: [], + state: { columnFilters }, + onColumnFiltersChange: setColumnFilters, + enableSorting: false + }) + + const internalState = useDeferredValue( + JSON.stringify(table.getState().columnFilters, null, 2) + ) + + return ( +
+
+
+ + table.getColumn('email')?.setFilterValue(event.target.value) + } + /> + +
+
+

+ Configure and copy-paste this parser into your application: +

+
+ } + className="flex-grow" + code={parserCode} + /> + +
+
+ ) +} diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.generator.tsx b/packages/docs/content/docs/parsers/community/tanstack-table.generator.tsx deleted file mode 100644 index 0353b938..00000000 --- a/packages/docs/content/docs/parsers/community/tanstack-table.generator.tsx +++ /dev/null @@ -1,217 +0,0 @@ -'use client' - -import { CodeBlock } from '@/src/components/code-block.client' -import { Querystring } from '@/src/components/querystring' -import { Label } from '@/src/components/ui/label' -import { - Pagination, - PaginationButton, - PaginationContent, - PaginationItem, - PaginationNext, - PaginationPrevious -} from '@/src/components/ui/pagination' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/src/components/ui/select' -import { Separator } from '@/src/components/ui/separator' -import { - createParser, - parseAsInteger, - parseAsString, - useQueryState -} from 'nuqs' -import { useDeferredValue } from 'react' - -const NUM_PAGES = 5 - -// The page index parser is zero-indexed internally, -// but one-indexed when rendered in the URL, -// to align with your UI and what users might expect. -const pageIndexParser = createParser({ - parse: query => { - const page = parseAsInteger.parse(query) - return page === null ? null : page - 1 - }, - serialize: value => { - return parseAsInteger.serialize(value + 1) - } -}) - -export function TanStackTablePagination() { - const [pageIndexUrlKey, setPageIndexUrlKey] = useQueryState( - 'pageIndexUrlKey', - parseAsString.withDefault('page') - ) - const [pageSizeUrlKey, setPageSizeUrlKey] = useQueryState( - 'pageSizeUrlKey', - parseAsString.withDefault('perPage') - ) - const [page, setPage] = useQueryState( - pageIndexUrlKey, - pageIndexParser.withDefault(0) - ) - const [pageSize, setPageSize] = useQueryState( - pageSizeUrlKey, - parseAsInteger.withDefault(10) - ) - - const parserCode = useDeferredValue(`import { - createParser, - parseAsInteger, - parseAsString, - useQueryStates -} from 'nuqs' - -// The page index parser is zero-indexed internally, -// but one-indexed when rendered in the URL, -// to align with your UI and what users might expect. -const pageIndexParser = createParser({ - parse: query => { - const page = parseAsInteger.parse(query) - return page === null ? null : page - 1 - }, - serialize: value => { - return parseAsInteger.serialize(value + 1) - } -}) - -const paginationParsers = { - pageIndex: pageIndexParser.withDefault(0), - pageSize: parseAsInteger.withDefault(10) -} -const paginationUrlKeys = { - pageIndex: '${pageIndexUrlKey}', - pageSize: '${pageSizeUrlKey}' -} - -export function usePaginationSearchParams() { - return useQueryStates(paginationParsers, { - urlKeys: paginationUrlKeys - }) -}`) - - const internalState = useDeferredValue(`{ - // zero-indexed - pageIndex: ${page}, - pageSize: ${pageSize} -}`) - - return ( -
-
- - - - setPage(p => Math.max(0, p - 1))} - /> - - {Array.from({ length: NUM_PAGES }, (_, index) => ( - - setPage(index)} - > - {index + 1} - - - ))} - - = NUM_PAGES - 1} - onClick={() => setPage(p => Math.min(NUM_PAGES - 1, p + 1))} - /> - - - - -
-

- Configure and copy-paste this parser into your application: -

-
- - - - - } - className="flex-grow" - code={parserCode} - /> - -
-
- ) -} diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.kitchen-sink.tsx b/packages/docs/content/docs/parsers/community/tanstack-table.kitchen-sink.tsx new file mode 100644 index 00000000..f6342517 --- /dev/null +++ b/packages/docs/content/docs/parsers/community/tanstack-table.kitchen-sink.tsx @@ -0,0 +1,118 @@ +'use client' + +import { CodeBlock } from '@/src/components/code-block.client' +import { DataTable } from '@/src/components/ui/data-table' +import { DataTablePagination } from '@/src/components/ui/data-table-pagination' +import { Input } from '@/src/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/src/components/ui/select' +import { useDeferredValue } from 'react' +import { useColumnFilterSearchParams } from './search-params.column-filters' +import { usePaginationSearchParams } from './search-params.pagination' +import { useSortingSearchParams } from './search-params.sorting' +import { generateData, useTanStackTable } from './tanstack-table' +import { parseAsString } from 'nuqs' +import { useQueryState } from 'nuqs' + +const data = generateData(100) + +export function TanStackTableKitchenSink() { + const [pageIndexUrlKey] = useQueryState( + 'pageIndexUrlKey', + parseAsString.withDefault('page') + ) + const [pageSizeUrlKey] = useQueryState( + 'pageSizeUrlKey', + parseAsString.withDefault('perPage') + ) + + const [columnFiltersUrlKey] = useQueryState( + 'columnFiltersUrlKey', + parseAsString.withDefault('filter') + ) + + const [sortingUrlKey] = useQueryState( + 'sortingUrlKey', + parseAsString.withDefault('orderBy') + ) + + const [columnFilters, setColumnFilters] = + useColumnFilterSearchParams(columnFiltersUrlKey) + const [pagination, setPagination] = usePaginationSearchParams({ + pageIndex: pageIndexUrlKey, + pageSize: pageSizeUrlKey + }) + const [sorting, setSorting] = useSortingSearchParams(sortingUrlKey) + + const table = useTanStackTable({ + data, + state: { + columnFilters, + pagination, + sorting + }, + onColumnFiltersChange: setColumnFilters, + onPaginationChange: setPagination, + onSortingChange: setSorting + }) + + const internalState = useDeferredValue( + JSON.stringify( + { + columnFilters: table.getState().columnFilters, + pagination: table.getState().pagination, + sorting: table.getState().sorting + }, + null, + 2 + ) + ) + + return ( +
+
+
+ + table.getColumn('email')?.setFilterValue(event.target.value) + } + /> + +
+ + +
+ +
+ ) +} diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.mdx b/packages/docs/content/docs/parsers/community/tanstack-table.mdx index a395551f..85e2b648 100644 --- a/packages/docs/content/docs/parsers/community/tanstack-table.mdx +++ b/packages/docs/content/docs/parsers/community/tanstack-table.mdx @@ -19,20 +19,71 @@ TanStack Table stores pagination under two pieces of state: You will likely want the URL to follow your UI and be one-based for the page index: -import { TanStackTablePagination } from './tanstack-table.generator' +import { TanStackTablePagination } from './tanstack-table.pagination' -## Filtering +## Column Filtering - - This section is empty for now, [contributions](https://github.com/47ng/nuqs) are welcome! +TanStack Table stores filtering under a single piece of state: + +- `columnFilters`: an array of `ColumnFilter` objects with the following shape: + + ```ts + type ColumnFilter = { + id: string + value: unknown + } + ``` + +Column filters are managed by specifying the column ID and value in the URL as `columnId=value`. + + + Value is serialized to a JSON string, so you can use any valid JSON value. +import { TanStackTableColumnFiltering } from './tanstack-table.column-filtering' + + + + + ## Sorting - - This section is empty for now, [contributions](https://github.com/47ng/nuqs) are welcome! +TanStack Table stores sorting under a single piece of state: + +- `sorting`: an array of `ColumnSort` objects with the following shape: + + ```ts + type ColumnSort = { + id: string + desc: boolean + } + ``` + +Sorting is managed by specifying the column ID and direction in the URL as `columnId:direction`. + + + Hold the Shift key while clicking column headers to sort by + multiple columns simultaneously. + +import { TanStackTableSorting } from './tanstack-table.sorting' + + + + + +## Kitchen Sink + +This example demonstrates a fully-featured TanStack Table integration with Nuqs, +including column filtering, sorting, and pagination - all persisted in the URL. +The table's internal state is displayed below to show how the URL parameters sync with the table state. + +import { TanStackTableKitchenSink } from './tanstack-table.kitchen-sink' + + + + diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.pagination.tsx b/packages/docs/content/docs/parsers/community/tanstack-table.pagination.tsx new file mode 100644 index 00000000..a68862bd --- /dev/null +++ b/packages/docs/content/docs/parsers/community/tanstack-table.pagination.tsx @@ -0,0 +1,132 @@ +'use client' + +import { CodeBlock } from '@/src/components/code-block.client' +import { Querystring } from '@/src/components/querystring' +import { Input } from '@/src/components/ui/input' +import { Label } from '@/src/components/ui/label' +import { Separator } from '@/src/components/ui/separator' +import { parseAsString, useQueryState } from 'nuqs' +import { useDeferredValue } from 'react' + +import { TypescriptIcon } from '@/src/components/typescript-icon' +import { DataTablePagination } from '@/src/components/ui/data-table-pagination' +import { usePaginationSearchParams } from './search-params.pagination' +import { generateData, useTanStackTable } from './tanstack-table' + +const data = generateData(50) + +export function TanStackTablePagination() { + const [pageIndexUrlKey, setPageIndexUrlKey] = useQueryState( + 'pageIndexUrlKey', + parseAsString.withDefault('page') + ) + const [pageSizeUrlKey, setPageSizeUrlKey] = useQueryState( + 'pageSizeUrlKey', + parseAsString.withDefault('perPage') + ) + + const [pagination, setPagination] = usePaginationSearchParams({ + pageIndex: pageIndexUrlKey, + pageSize: pageSizeUrlKey + }) + + const parserCode = useDeferredValue(`import { + createParser, + parseAsInteger, + parseAsString, + useQueryStates +} from 'nuqs' + +// The page index parser is zero-indexed internally, +// but one-indexed when rendered in the URL, +// to align with your UI and what users might expect. +const pageIndexParser = createParser({ + parse: query => { + const page = parseAsInteger.parse(query) + return page === null ? null : page - 1 + }, + serialize: value => { + return parseAsInteger.serialize(value + 1) + } +}) + +const paginationParsers = { + pageIndex: pageIndexParser.withDefault(0), + pageSize: parseAsInteger.withDefault(10) +} +const paginationUrlKeys = { + pageIndex: '${pageIndexUrlKey}', + pageSize: '${pageSizeUrlKey}' +} + +export function usePaginationSearchParams() { + return useQueryStates(paginationParsers, { + urlKeys: paginationUrlKeys + }) +}`) + + const table = useTanStackTable({ + data, + state: { pagination }, + onPaginationChange: setPagination + }) + + const internalState = useDeferredValue( + JSON.stringify(table.getState().pagination, null, 2) + ) + + return ( +
+
+ +
+

+ Configure and copy-paste this parser into your application: +

+
+ } + className="flex-grow" + code={parserCode} + /> + +
+
+ ) +} diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.sorting.tsx b/packages/docs/content/docs/parsers/community/tanstack-table.sorting.tsx new file mode 100644 index 00000000..ca2e0d12 --- /dev/null +++ b/packages/docs/content/docs/parsers/community/tanstack-table.sorting.tsx @@ -0,0 +1,108 @@ +'use client' + +import { CodeBlock } from '@/src/components/code-block.client' +import { Querystring } from '@/src/components/querystring' +import { TypescriptIcon } from '@/src/components/typescript-icon' +import { DataTable } from '@/src/components/ui/data-table' +import { Input } from '@/src/components/ui/input' +import { Label } from '@/src/components/ui/label' +import { Separator } from '@/src/components/ui/separator' +import { parseAsString, useQueryState } from 'nuqs' +import { useDeferredValue } from 'react' +import { useSortingSearchParams } from './search-params.sorting' +import { generateData, useTanStackTable } from './tanstack-table' + +const data = generateData(100) + +export function TanStackTableSorting() { + const [sortingUrlKey, setSortingUrlKey] = useQueryState( + 'sortingUrlKey', + parseAsString.withDefault('orderBy') + ) + + const [sorting, setSorting] = useSortingSearchParams(sortingUrlKey) + + const parserCode = + useDeferredValue(`import { ColumnSort, SortDirection } from '@tanstack/react-table' +import { createParser, parseAsArrayOf, useQueryState } from 'nuqs' + +// Each sort is represented as \`columnId:direction\`, +// for example: \`?orderBy=email:desc,status:asc\` +const sortParser = createParser({ + parse: query => { + const [id, direction] = query.split(':') + return { + id, + desc: direction === 'desc' + } + }, + serialize: value => + \`\${value.id}:\${value.desc ? 'desc' : 'asc'}\` as \`\${string}:\${SortDirection}\`, + // This is a simple equality check for comparing objects. + // It works by converting both objects to strings and comparing them. + // For more robust deep equality, consider using lodash's isEqual. + eq: (a, b) => JSON.stringify(a) === JSON.stringify(b) +}) + +const parseAsSortingState = parseAsArrayOf(sortParser).withDefault([]) + +export function useSortingSearchParams(key = 'orderBy') { + return useQueryState('${sortingUrlKey}', parseAsSortingState) +}`) + + const table = useTanStackTable({ + data, + state: { sorting }, + onSortingChange: setSorting, + enableFilters: false + }) + + const internalState = useDeferredValue( + JSON.stringify(table.getState().sorting, null, 2) + ) + + return ( +
+
+ +
+

+ Configure and copy-paste this parser into your application: +

+
+ } + className="flex-grow" + code={parserCode} + /> + +
+
+ ) +} diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.tsx b/packages/docs/content/docs/parsers/community/tanstack-table.tsx new file mode 100644 index 00000000..86705700 --- /dev/null +++ b/packages/docs/content/docs/parsers/community/tanstack-table.tsx @@ -0,0 +1,153 @@ +'use client' + +import { Button } from '@/src/components/ui/button' +import { faker } from '@faker-js/faker' +import { + Column, + createColumnHelper, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + TableOptions, + useReactTable +} from '@tanstack/react-table' +import { ArrowDown, ArrowUp } from 'lucide-react' + +type Payment = { + id: string + amount: number + status: 'pending' | 'processing' | 'success' | 'failed' + email: string + date: Date + customer: string + reference: string + category: 'subscription' | 'one-time' | 'refund' | 'chargeback' +} + +const columnHelper = createColumnHelper() + +interface DataTableColumnHeaderProps + extends React.HTMLAttributes { + column: Column + title: string +} + +const DataTableColumnHeader = ({ + column, + title +}: DataTableColumnHeaderProps) => ( + +) + +const columns = [ + columnHelper.accessor('date', { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => getValue().toLocaleDateString() + }), + columnHelper.accessor('reference', { + header: ({ column }) => ( + + ), + cell: ({ getValue }) =>
{getValue()}
+ }), + columnHelper.accessor('customer', { + header: ({ column }) => ( + + ) + }), + columnHelper.accessor('email', { + header: ({ column }) => ( + + ), + cell: ({ getValue }) =>
{getValue()}
+ }), + columnHelper.accessor('category', { + header: ({ column }) => ( + + ), + cell: ({ getValue }) =>
{getValue()}
+ }), + columnHelper.accessor('status', { + header: ({ column }) => ( + + ), + cell: ({ getValue }) =>
{getValue()}
+ }), + columnHelper.accessor('amount', { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const amount = getValue() + const formatted = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amount) + return
{formatted}
+ } + }) +] + +export function generateData(length: number) { + return Array.from({ length }, () => ({ + id: faker.string.uuid(), + date: faker.date.recent({ days: 30 }), + reference: faker.string.alphanumeric(10).toUpperCase(), + customer: faker.person.fullName(), + email: faker.internet.email(), + amount: faker.number.int({ min: 100, max: 1000 }), + status: faker.helpers.arrayElement([ + 'pending', + 'processing', + 'success', + 'failed' + ]), + category: faker.helpers.arrayElement([ + 'subscription', + 'one-time', + 'refund', + 'chargeback' + ]) + })) satisfies Payment[] +} + +export function useTanStackTable( + options: Omit< + TableOptions, + | 'columns' + | 'getRowId' + | 'getCoreRowModel' + | 'getPaginationRowModel' + | 'getSortedRowModel' + | 'getFilteredRowModel' + > +) { + return useReactTable({ + columns, + getRowId: row => row.id, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + ...options + }) +} From 1373e258d74fd23631209f4250bc1350bc6c05ab Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Wed, 1 Jan 2025 12:33:55 +0800 Subject: [PATCH 4/6] doc(options): capitalise first letter in callout --- packages/docs/content/docs/options.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/content/docs/options.mdx b/packages/docs/content/docs/options.mdx index d6dae49e..17a39bc2 100644 --- a/packages/docs/content/docs/options.mdx +++ b/packages/docs/content/docs/options.mdx @@ -160,7 +160,7 @@ useQueryState('foo', { ``` -the state returned by the hook is always updated **instantly**, to keep UI responsive. +The state returned by the hook is always updated **instantly**, to keep UI responsive. Only changes to the URL, and server requests when using `shallow: false`, are throttled. From d24602ccd25ebb63213a91827bc7b69b28c18780 Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Wed, 1 Jan 2025 14:55:43 +0800 Subject: [PATCH 5/6] doc(tanstack-table): fix tanstack table sorting parser code --- .../content/docs/parsers/community/tanstack-table.sorting.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.sorting.tsx b/packages/docs/content/docs/parsers/community/tanstack-table.sorting.tsx index ca2e0d12..c7e6d0e8 100644 --- a/packages/docs/content/docs/parsers/community/tanstack-table.sorting.tsx +++ b/packages/docs/content/docs/parsers/community/tanstack-table.sorting.tsx @@ -46,7 +46,7 @@ const sortParser = createParser({ const parseAsSortingState = parseAsArrayOf(sortParser).withDefault([]) -export function useSortingSearchParams(key = 'orderBy') { +export function useSortingSearchParams() { return useQueryState('${sortingUrlKey}', parseAsSortingState) }`) From a16f4083e383f5ff9a33a8aef62e45e18a9571b3 Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Sun, 5 Jan 2025 21:15:17 +0800 Subject: [PATCH 6/6] doc(react-table): fix column filter hook naming --- .../docs/parsers/community/search-params.column-filters.ts | 2 +- .../parsers/community/tanstack-table.column-filtering.tsx | 6 +++--- .../docs/parsers/community/tanstack-table.kitchen-sink.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/docs/content/docs/parsers/community/search-params.column-filters.ts b/packages/docs/content/docs/parsers/community/search-params.column-filters.ts index 44fbe950..06b41723 100644 --- a/packages/docs/content/docs/parsers/community/search-params.column-filters.ts +++ b/packages/docs/content/docs/parsers/community/search-params.column-filters.ts @@ -20,6 +20,6 @@ const filterParser = createParser({ const parseAsColumnFiltersState = parseAsArrayOf(filterParser).withDefault([]) -export function useColumnFilterSearchParams(key = 'filter') { +export function useColumnFiltersSearchParams(key = 'filter') { return useQueryState(key, parseAsColumnFiltersState) } diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.column-filtering.tsx b/packages/docs/content/docs/parsers/community/tanstack-table.column-filtering.tsx index 41a3dd96..6ed8540d 100644 --- a/packages/docs/content/docs/parsers/community/tanstack-table.column-filtering.tsx +++ b/packages/docs/content/docs/parsers/community/tanstack-table.column-filtering.tsx @@ -15,7 +15,7 @@ import { import { Separator } from '@/src/components/ui/separator' import { parseAsString, useQueryState } from 'nuqs' import { useDeferredValue } from 'react' -import { useColumnFilterSearchParams } from './search-params.column-filters' +import { useColumnFiltersSearchParams } from './search-params.column-filters' import { useTanStackTable } from './tanstack-table' export function TanStackTableColumnFiltering() { @@ -24,7 +24,7 @@ export function TanStackTableColumnFiltering() { parseAsString.withDefault('filter') ) const [columnFilters, setColumnFilters] = - useColumnFilterSearchParams(columnFiltersUrlKey) + useColumnFiltersSearchParams(columnFiltersUrlKey) const parserCode = useDeferredValue(`import { ColumnFilter } from '@tanstack/react-table' @@ -49,7 +49,7 @@ const filterParser = createParser({ const parseAsColumnFiltersState = parseAsArrayOf(filterParser).withDefault([]) -export function useColumnFilterSearchParams() { +export function useColumnFiltersSearchParams() { return useQueryState('${columnFiltersUrlKey}', parseAsColumnFiltersState) }`) diff --git a/packages/docs/content/docs/parsers/community/tanstack-table.kitchen-sink.tsx b/packages/docs/content/docs/parsers/community/tanstack-table.kitchen-sink.tsx index f6342517..6a73f4f2 100644 --- a/packages/docs/content/docs/parsers/community/tanstack-table.kitchen-sink.tsx +++ b/packages/docs/content/docs/parsers/community/tanstack-table.kitchen-sink.tsx @@ -12,7 +12,7 @@ import { SelectValue } from '@/src/components/ui/select' import { useDeferredValue } from 'react' -import { useColumnFilterSearchParams } from './search-params.column-filters' +import { useColumnFiltersSearchParams } from './search-params.column-filters' import { usePaginationSearchParams } from './search-params.pagination' import { useSortingSearchParams } from './search-params.sorting' import { generateData, useTanStackTable } from './tanstack-table' @@ -42,7 +42,7 @@ export function TanStackTableKitchenSink() { ) const [columnFilters, setColumnFilters] = - useColumnFilterSearchParams(columnFiltersUrlKey) + useColumnFiltersSearchParams(columnFiltersUrlKey) const [pagination, setPagination] = usePaginationSearchParams({ pageIndex: pageIndexUrlKey, pageSize: pageSizeUrlKey