From ca7b003d3bbf2cda4447cec0602960f84867567c Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Tue, 1 Apr 2025 20:26:19 +0000 Subject: [PATCH] Implement data-table with nuqs and zod filters --- packages/components/package.json | 4 + packages/components/src/ui/badge.tsx | 36 ++ packages/components/src/ui/command.tsx | 108 +++++ .../data-table/data-table-column-header.tsx | 66 +++ .../data-table/data-table-faceted-filter.tsx | 144 ++++++ .../ui/data-table/data-table-router-form.tsx | 448 ++++++++++++++++++ .../ui/data-table/data-table-zod-schemas.ts | 134 ++++++ .../components/src/ui/data-table/index.ts | 4 + packages/components/src/ui/index.ts | 1 + packages/components/src/ui/separator.tsx | 29 ++ 10 files changed, 974 insertions(+) create mode 100644 packages/components/src/ui/badge.tsx create mode 100644 packages/components/src/ui/command.tsx create mode 100644 packages/components/src/ui/data-table/data-table-column-header.tsx create mode 100644 packages/components/src/ui/data-table/data-table-faceted-filter.tsx create mode 100644 packages/components/src/ui/data-table/data-table-router-form.tsx create mode 100644 packages/components/src/ui/data-table/data-table-zod-schemas.ts create mode 100644 packages/components/src/ui/data-table/index.ts create mode 100644 packages/components/src/ui/separator.tsx diff --git a/packages/components/package.json b/packages/components/package.json index fefc831b..44396f61 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -50,18 +50,22 @@ "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", "@remix-run/node": "^2.15.1", "@remix-run/react": "^2.15.1", "@remix-run/serve": "^2.15.1", + "@tanstack/react-table": "^8.21.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "next-themes": "^0.4.4", + "nuqs": "^1.17.1", "react-day-picker": "8.10.1", "react-hook-form": "^7.53.1", "remix-hook-form": "5.1.1", diff --git a/packages/components/src/ui/badge.tsx b/packages/components/src/ui/badge.tsx new file mode 100644 index 00000000..1542a6b5 --- /dev/null +++ b/packages/components/src/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from './utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; \ No newline at end of file diff --git a/packages/components/src/ui/command.tsx b/packages/components/src/ui/command.tsx new file mode 100644 index 00000000..f6e52ec8 --- /dev/null +++ b/packages/components/src/ui/command.tsx @@ -0,0 +1,108 @@ +import { Dialog, DialogContent, type DialogProps } from '@radix-ui/react-dialog'; +import { Command as CommandPrimitive } from 'cmdk'; +import { Search } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from './utils'; + +const Command = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( + +); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( +
+ + +
+); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( + +); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = (props: React.ComponentPropsWithoutRef) => ( + +); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( + +); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +); + +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( + +); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; +}; +CommandShortcut.displayName = 'CommandShortcut'; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-column-header.tsx b/packages/components/src/ui/data-table/data-table-column-header.tsx new file mode 100644 index 00000000..d453a402 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-column-header.tsx @@ -0,0 +1,66 @@ +import type { Column } from '@tanstack/react-table'; +import { ArrowDown, ArrowUp, ArrowUpDown, EyeOff } from 'lucide-react'; +import type * as React from 'react'; + +import { Button } from '../button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../dropdown-menu'; +import { cn } from '../utils'; + +interface DataTableColumnHeaderProps extends React.HTMLAttributes { + column: Column; + title: string; +} + +export function DataTableColumnHeader({ + column, + title, + className, +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
; + } + + return ( +
+ + + + + + column.toggleSorting(false)}> + + Asc + + column.toggleSorting(true)}> + + Desc + + {column.getCanHide() && ( + <> + + column.toggleVisibility(false)}> + + Hide + + + )} + + +
+ ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-faceted-filter.tsx b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx new file mode 100644 index 00000000..56fda0e9 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,144 @@ +import type { Column } from '@tanstack/react-table'; +import { Check, PlusCircle } from 'lucide-react'; +import type * as React from 'react'; + +import { Badge } from '../badge'; +import { Button } from '../button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '../command'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Separator } from '../separator'; +import { cn } from '../utils'; + +interface DataTableFacetedFilterProps { + column?: Column; + title?: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + formMode?: boolean; +} + +export function DataTableFacetedFilter({ + column, + title, + options, + formMode = false, +}: DataTableFacetedFilterProps) { + const facets = column?.getFacetedUniqueValues(); + const selectedValues = new Set(column?.getFilterValue() as string[]); + + return ( + + + + + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( + { + if (formMode) { + // In form mode, we don't modify the column directly + return; + } + + if (isSelected) { + selectedValues.delete(option.value); + } else { + selectedValues.add(option.value); + } + const filterValues = Array.from(selectedValues); + column?.setFilterValue(filterValues.length ? filterValues : undefined); + }} + > +
+ +
+ {option.icon && ( + + )} + {option.label} + {facets?.get(option.value) && ( + + {facets.get(option.value)} + + )} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + { + if (formMode) { + // In form mode, we don't modify the column directly + return; + } + column?.setFilterValue(undefined); + }} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-router-form.tsx b/packages/components/src/ui/data-table/data-table-router-form.tsx new file mode 100644 index 00000000..45c10669 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-router-form.tsx @@ -0,0 +1,448 @@ +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X } from 'lucide-react'; +import { useSearchParams } from 'next/navigation'; +import { createSearchParamsCache, useQueryState } from 'nuqs'; +import * as React from 'react'; +import { z } from 'zod'; + +import { Button } from '../button'; +import { Input } from '../input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '../table'; +import { cn } from '../utils'; +import { DataTableFacetedFilter } from './data-table-faceted-filter'; + +// Define Zod schemas for query parameters +const sortSchema = z.object({ + id: z.string(), + desc: z.boolean(), +}); + +const filterSchema = z.object({ + id: z.string(), + value: z.array(z.string()), +}); + +const paginationSchema = z.object({ + pageIndex: z.number().int().min(0), + pageSize: z.number().int().min(1), +}); + +const searchSchema = z.object({ + value: z.string(), + column: z.string(), +}); + +// Create a cache for search params +const cache = createSearchParamsCache(); + +interface DataTableRouterFormProps { + columns: ColumnDef[]; + data: TData[]; + pageCount?: number; + formAction?: string; + formMethod?: 'get' | 'post'; + defaultSort?: { id: string; desc: boolean }; + filterableColumns?: { + id: string; + title: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + }[]; + searchableColumns?: { + id: string; + title: string; + }[]; + isLoading?: boolean; +} + +export function DataTableRouterForm({ + columns, + data, + pageCount = 1, + formAction = '/', + formMethod = 'get', + defaultSort, + filterableColumns = [], + searchableColumns = [], + isLoading = false, +}: DataTableRouterFormProps) { + const searchParams = useSearchParams(); + + // Initialize state with values from URL or defaults + const [sorting, setSorting] = useQueryState( + 'sort', + cache.json(sortSchema).withDefault(defaultSort ? [defaultSort] : []) + ); + + const [columnFilters, setColumnFilters] = useQueryState( + 'filters', + cache.json(z.array(filterSchema)).withDefault([]) + ); + + const [pagination, setPagination] = useQueryState( + 'pagination', + cache.json(paginationSchema).withDefault({ + pageIndex: 0, + pageSize: 10, + }) + ); + + const [search, setSearch] = useQueryState( + 'search', + cache.json(searchSchema.partial()).withDefault({}) + ); + + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [rowSelection, setRowSelection] = React.useState({}); + + // Create a form ref to submit the form + const formRef = React.useRef(null); + + // Initialize the table + const table = useReactTable({ + data, + columns, + pageCount, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: (updater) => { + const newSorting = typeof updater === 'function' ? updater(sorting) : updater; + setSorting(newSorting); + if (formRef.current) { + formRef.current.requestSubmit(); + } + }, + onColumnFiltersChange: (updater) => { + const newFilters = typeof updater === 'function' ? updater(columnFilters) : updater; + setColumnFilters(newFilters); + if (formRef.current) { + formRef.current.requestSubmit(); + } + }, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: (updater) => { + const newPagination = typeof updater === 'function' ? updater(pagination) : updater; + setPagination(newPagination); + if (formRef.current) { + formRef.current.requestSubmit(); + } + }, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + manualPagination: true, + manualSorting: true, + manualFiltering: true, + }); + + // Handle search input change + const handleSearchChange = React.useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + const column = searchableColumns[0]?.id || ''; + setSearch(value ? { value, column } : {}); + if (formRef.current) { + formRef.current.requestSubmit(); + } + }, + [searchableColumns, setSearch] + ); + + // Create hidden inputs for form submission + const renderHiddenInputs = () => { + const inputs = []; + + // Add sorting inputs + if (sorting.length > 0) { + inputs.push( + , + + ); + } + + // Add pagination inputs + inputs.push( + , + + ); + + // Add filter inputs + columnFilters.forEach((filter) => { + if (Array.isArray(filter.value)) { + filter.value.forEach((value, i) => { + inputs.push( + + ); + }); + } + }); + + // Add search input + if (search.value && search.column) { + inputs.push( + + ); + } + + return inputs; + }; + + return ( +
+
+ {renderHiddenInputs()} + +
+ {searchableColumns.length > 0 && ( +
+ + {search.value && ( + + )} +
+ )} + + {filterableColumns.length > 0 && + filterableColumns.map((column) => { + // Find the column filter + const columnFilter = columnFilters.find( + (filter) => filter.id === column.id + ); + + return ( + + ); + })} +
+
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {isLoading ? ( + + + Loading... + + + ) : table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{' '} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{' '} + {pageCount} +
+
+ + + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-zod-schemas.ts b/packages/components/src/ui/data-table/data-table-zod-schemas.ts new file mode 100644 index 00000000..50f67cc6 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-zod-schemas.ts @@ -0,0 +1,134 @@ +import { z } from 'zod'; + +/** + * Schema for sorting parameters + */ +export const sortSchema = z.object({ + id: z.string(), + desc: z.boolean(), +}); + +export type SortSchemaType = z.infer; + +/** + * Schema for filter parameters + */ +export const filterSchema = z.object({ + id: z.string(), + value: z.array(z.string()), +}); + +export type FilterSchemaType = z.infer; + +/** + * Schema for pagination parameters + */ +export const paginationSchema = z.object({ + pageIndex: z.number().int().min(0), + pageSize: z.number().int().min(1), +}); + +export type PaginationSchemaType = z.infer; + +/** + * Schema for search parameters + */ +export const searchSchema = z.object({ + value: z.string(), + column: z.string(), +}); + +export type SearchSchemaType = z.infer; + +/** + * Helper function to parse URL search params using zod schemas + * @param searchParams URLSearchParams object + * @returns Parsed parameters object + */ +export function parseDataTableParams(searchParams: URLSearchParams) { + // Parse sort params + const sortField = searchParams.get('sortField'); + const sortOrder = searchParams.get('sortOrder'); + const sort = sortField + ? [{ id: sortField, desc: sortOrder === 'desc' }] + : []; + + // Parse pagination params + const page = parseInt(searchParams.get('page') || '0', 10); + const pageSize = parseInt(searchParams.get('pageSize') || '10', 10); + const pagination = { + pageIndex: isNaN(page) ? 0 : page, + pageSize: isNaN(pageSize) ? 10 : pageSize, + }; + + // Parse filter params + const filters: Record = {}; + for (const [key, value] of searchParams.entries()) { + if (key !== 'sortField' && key !== 'sortOrder' && key !== 'page' && key !== 'pageSize' && key !== 'search') { + if (!filters[key]) { + filters[key] = []; + } + filters[key].push(value); + } + } + + const columnFilters = Object.entries(filters).map(([id, value]) => ({ + id, + value, + })); + + // Parse search params + const searchValue = searchParams.get('search'); + const search = searchValue ? { value: searchValue, column: '' } : {}; + + return { + sort, + pagination, + columnFilters, + search, + }; +} + +/** + * Helper function to convert data table state to URL search params + * @param state Data table state object + * @returns URLSearchParams object + */ +export function dataTableStateToSearchParams(state: { + sort?: SortSchemaType[]; + pagination?: PaginationSchemaType; + columnFilters?: FilterSchemaType[]; + search?: Partial; +}) { + const searchParams = new URLSearchParams(); + + // Add sort params + if (state.sort && state.sort.length > 0) { + searchParams.set('sortField', state.sort[0].id); + searchParams.set('sortOrder', state.sort[0].desc ? 'desc' : 'asc'); + } + + // Add pagination params + if (state.pagination) { + searchParams.set('page', state.pagination.pageIndex.toString()); + searchParams.set('pageSize', state.pagination.pageSize.toString()); + } + + // Add filter params + if (state.columnFilters) { + state.columnFilters.forEach((filter) => { + if (Array.isArray(filter.value)) { + filter.value.forEach((value) => { + searchParams.append(filter.id, value); + }); + } + }); + } + + // Add search params + if (state.search?.value) { + searchParams.set('search', state.search.value); + } + + return searchParams; +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/index.ts b/packages/components/src/ui/data-table/index.ts new file mode 100644 index 00000000..219741b7 --- /dev/null +++ b/packages/components/src/ui/data-table/index.ts @@ -0,0 +1,4 @@ +export * from './data-table-column-header'; +export * from './data-table-faceted-filter'; +export * from './data-table-router-form'; +export * from './data-table-zod-schemas'; \ No newline at end of file diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 567fd5e1..18c214e6 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -1,5 +1,6 @@ export * from './button'; export * from './checkbox-field'; +export * from './data-table'; export * from './date-picker'; export * from './date-picker-field'; export * from './dropdown-menu'; diff --git a/packages/components/src/ui/separator.tsx b/packages/components/src/ui/separator.tsx new file mode 100644 index 00000000..cb34d63e --- /dev/null +++ b/packages/components/src/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import * as SeparatorPrimitive from '@radix-ui/react-separator'; + +import { cn } from './utils'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = 'horizontal', decorative = true, ...props }, + ref + ) => ( + + ) +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; \ No newline at end of file