From 38ddefab70de9865c36f269fc7f2eb9c12144166 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Tue, 1 Apr 2025 00:40:21 +0000 Subject: [PATCH 01/32] Implement Data Table Component with ShadCN and TanStack (LC-187) --- .../docs/src/ui/data-table-server.stories.tsx | 300 ++++++++++++++++++ apps/docs/src/ui/data-table.stories.tsx | 213 +++++++++++++ packages/components/src/ui/badge.tsx | 36 +++ packages/components/src/ui/command.tsx | 153 +++++++++ .../data-table/data-table-column-header.tsx | 71 +++++ .../data-table/data-table-faceted-filter.tsx | 143 +++++++++ .../ui/data-table/data-table-pagination.tsx | 93 ++++++ .../src/ui/data-table/data-table-toolbar.tsx | 80 +++++ .../ui/data-table/data-table-view-options.tsx | 58 ++++ .../src/ui/data-table/data-table.tsx | 162 ++++++++++ .../components/src/ui/data-table/index.ts | 6 + packages/components/src/ui/index.ts | 7 + packages/components/src/ui/input.tsx | 25 ++ packages/components/src/ui/select.tsx | 90 ++++++ packages/components/src/ui/separator.tsx | 29 ++ packages/components/src/ui/table.tsx | 79 +++++ 16 files changed, 1545 insertions(+) create mode 100644 apps/docs/src/ui/data-table-server.stories.tsx create mode 100644 apps/docs/src/ui/data-table.stories.tsx 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-pagination.tsx create mode 100644 packages/components/src/ui/data-table/data-table-toolbar.tsx create mode 100644 packages/components/src/ui/data-table/data-table-view-options.tsx create mode 100644 packages/components/src/ui/data-table/data-table.tsx create mode 100644 packages/components/src/ui/data-table/index.ts create mode 100644 packages/components/src/ui/input.tsx create mode 100644 packages/components/src/ui/select.tsx create mode 100644 packages/components/src/ui/separator.tsx create mode 100644 packages/components/src/ui/table.tsx diff --git a/apps/docs/src/ui/data-table-server.stories.tsx b/apps/docs/src/ui/data-table-server.stories.tsx new file mode 100644 index 00000000..8c4b7479 --- /dev/null +++ b/apps/docs/src/ui/data-table-server.stories.tsx @@ -0,0 +1,300 @@ +import { DataTable } from '@lambdacurry/forms/ui/data-table'; +import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; +import type { Meta, StoryObj } from '@storybook/react'; +import { type ActionFunctionArgs, useLoaderData, useSearchParams } from 'react-router'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; +import { z } from 'zod'; +import { useEffect, useState } from 'react'; + +// Define the data schema +const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + role: z.enum(['admin', 'user', 'editor']), + status: z.enum(['active', 'inactive', 'pending']), + createdAt: z.string().datetime(), +}); + +type User = z.infer; + +// Sample data +const users: User[] = Array.from({ length: 100 }).map((_, i) => ({ + id: `user-${i + 1}`, + name: `User ${i + 1}`, + email: `user${i + 1}@example.com`, + role: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'user' : 'editor', + status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', + createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), +})); + +// Define the columns +const columns = [ + { + accessorKey: 'id', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('id')}
, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('name')}
, + }, + { + accessorKey: 'email', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('email')}
, + }, + { + accessorKey: 'role', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('role')}
, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue('status')}
+ ), + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{new Date(row.getValue('createdAt')).toLocaleDateString()}
+ ), + }, +]; + +// Mock API handler for data fetching with filters and pagination +const handleDataFetch = async (request: Request) => { + const url = new URL(request.url); + + // Get query parameters + const page = parseInt(url.searchParams.get('page') || '0'); + const pageSize = parseInt(url.searchParams.get('pageSize') || '10'); + const sortField = url.searchParams.get('sortField'); + const sortOrder = url.searchParams.get('sortOrder'); + const roleFilter = url.searchParams.getAll('role'); + const statusFilter = url.searchParams.getAll('status'); + const search = url.searchParams.get('search'); + + // Apply filters + let filteredData = [...users]; + + if (roleFilter.length > 0) { + filteredData = filteredData.filter(user => roleFilter.includes(user.role)); + } + + if (statusFilter.length > 0) { + filteredData = filteredData.filter(user => statusFilter.includes(user.status)); + } + + if (search) { + const searchLower = search.toLowerCase(); + filteredData = filteredData.filter( + user => + user.name.toLowerCase().includes(searchLower) || + user.email.toLowerCase().includes(searchLower) + ); + } + + // Apply sorting + if (sortField && sortOrder) { + filteredData.sort((a, b) => { + const aValue = a[sortField as keyof User]; + const bValue = b[sortField as keyof User]; + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + + // Apply pagination + const start = page * pageSize; + const paginatedData = filteredData.slice(start, start + pageSize); + + return { + data: paginatedData, + meta: { + total: filteredData.length, + page, + pageSize, + } + }; +}; + +// Component to display the data table with server-side filtering and pagination +const ServerSideDataTableExample = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const [data, setData] = useState([]); + const [totalItems, setTotalItems] = useState(0); + + // Fetch data when search params change + useEffect(() => { + const fetchData = async () => { + const queryString = searchParams.toString(); + const response = await fetch(`/api/users?${queryString}`); + const result = await response.json(); + setData(result.data); + setTotalItems(result.meta.total); + }; + + fetchData(); + }, [searchParams]); + + // Handle pagination change + const handlePaginationChange = (pageIndex: number, pageSize: number) => { + setSearchParams(prev => { + const newParams = new URLSearchParams(prev); + newParams.set('page', pageIndex.toString()); + newParams.set('pageSize', pageSize.toString()); + return newParams; + }); + }; + + // Handle sorting change + const handleSortingChange = (sorting: any) => { + if (sorting.length > 0) { + const { id, desc } = sorting[0]; + setSearchParams(prev => { + const newParams = new URLSearchParams(prev); + newParams.set('sortField', id); + newParams.set('sortOrder', desc ? 'desc' : 'asc'); + return newParams; + }); + } else { + setSearchParams(prev => { + const newParams = new URLSearchParams(prev); + newParams.delete('sortField'); + newParams.delete('sortOrder'); + return newParams; + }); + } + }; + + // Handle filter change + const handleFilterChange = (filters: any) => { + setSearchParams(prev => { + const newParams = new URLSearchParams(); + + // Preserve pagination and sorting + const page = prev.get('page'); + const pageSize = prev.get('pageSize'); + const sortField = prev.get('sortField'); + const sortOrder = prev.get('sortOrder'); + + if (page) newParams.set('page', page); + if (pageSize) newParams.set('pageSize', pageSize); + if (sortField) newParams.set('sortField', sortField); + if (sortOrder) newParams.set('sortOrder', sortOrder); + + // Add new filters + filters.forEach((filter: any) => { + if (filter.value && filter.value.length > 0) { + filter.value.forEach((val: string) => { + newParams.append(filter.id, val); + }); + } + }); + + return newParams; + }); + }; + + return ( +
+

Users Table (Server-side Filtering)

+

+ This example demonstrates server-side filtering, sorting, and pagination using URL query parameters. +

+ +
+ Total items: {totalItems} +
+
+ ); +}; + +const meta: Meta = { + title: 'UI/DataTableServer', + component: DataTable, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const ServerSide: Story = { + render: () => , + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/api/users', + action: async ({ request }: ActionFunctionArgs) => handleDataFetch(request), + }, + ], + }), + ], +}; \ No newline at end of file diff --git a/apps/docs/src/ui/data-table.stories.tsx b/apps/docs/src/ui/data-table.stories.tsx new file mode 100644 index 00000000..c0b31d30 --- /dev/null +++ b/apps/docs/src/ui/data-table.stories.tsx @@ -0,0 +1,213 @@ +import { DataTable } from '@lambdacurry/forms/ui/data-table'; +import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; +import type { Meta, StoryObj } from '@storybook/react'; +import { type ActionFunctionArgs } from 'react-router'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; +import { z } from 'zod'; + +// Define the data schema +const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + role: z.enum(['admin', 'user', 'editor']), + status: z.enum(['active', 'inactive', 'pending']), + createdAt: z.string().datetime(), +}); + +type User = z.infer; + +// Sample data +const users: User[] = Array.from({ length: 50 }).map((_, i) => ({ + id: `user-${i + 1}`, + name: `User ${i + 1}`, + email: `user${i + 1}@example.com`, + role: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'user' : 'editor', + status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', + createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), +})); + +// Define the columns +const columns = [ + { + accessorKey: 'id', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('id')}
, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('name')}
, + }, + { + accessorKey: 'email', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('email')}
, + }, + { + accessorKey: 'role', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('role')}
, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue('status')}
+ ), + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{new Date(row.getValue('createdAt')).toLocaleDateString()}
+ ), + }, +]; + +// Mock API handler for data fetching with filters and pagination +const handleDataFetch = async (request: Request) => { + const url = new URL(request.url); + + // Get query parameters + const page = parseInt(url.searchParams.get('page') || '0'); + const pageSize = parseInt(url.searchParams.get('pageSize') || '10'); + const sortField = url.searchParams.get('sortField'); + const sortOrder = url.searchParams.get('sortOrder'); + const roleFilter = url.searchParams.getAll('role'); + const statusFilter = url.searchParams.getAll('status'); + const search = url.searchParams.get('search'); + + // Apply filters + let filteredData = [...users]; + + if (roleFilter.length > 0) { + filteredData = filteredData.filter(user => roleFilter.includes(user.role)); + } + + if (statusFilter.length > 0) { + filteredData = filteredData.filter(user => statusFilter.includes(user.status)); + } + + if (search) { + const searchLower = search.toLowerCase(); + filteredData = filteredData.filter( + user => + user.name.toLowerCase().includes(searchLower) || + user.email.toLowerCase().includes(searchLower) + ); + } + + // Apply sorting + if (sortField && sortOrder) { + filteredData.sort((a, b) => { + const aValue = a[sortField as keyof User]; + const bValue = b[sortField as keyof User]; + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + + // Apply pagination + const start = page * pageSize; + const paginatedData = filteredData.slice(start, start + pageSize); + + return { + data: paginatedData, + meta: { + total: filteredData.length, + page, + pageSize, + } + }; +}; + +// Component to display the data table +const DataTableExample = () => { + return ( +
+ +
+ ); +}; + +const meta: Meta = { + title: 'UI/DataTable', + component: DataTable, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/api/users', + action: async ({ request }: ActionFunctionArgs) => handleDataFetch(request), + }, + ], + }), + ], +}; \ No newline at end of file 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..b7e7678f --- /dev/null +++ b/packages/components/src/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import { type DialogProps } from '@radix-ui/react-dialog'; +import { Command as CommandPrimitive } from 'cmdk'; +import { Search } from 'lucide-react'; + +import { cn } from './utils'; +import { Dialog, DialogContent } from './dialog'; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +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..29cf29c6 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-column-header.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { Column } from '@tanstack/react-table'; +import { ArrowDown, ArrowUp, ArrowUpDown, EyeOff } from 'lucide-react'; + +import { cn } from '../utils'; +import { Button } from '../button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../dropdown-menu'; + +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..03c1b093 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import { Column } from '@tanstack/react-table'; +import { Check, PlusCircle } from 'lucide-react'; + +import { cn } from '../utils'; +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'; + +interface DataTableFacetedFilterProps { + column?: Column; + title?: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; +} + +export function DataTableFacetedFilter({ + column, + title, + options, +}: 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 (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 && ( + <> + + + 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-pagination.tsx b/packages/components/src/ui/data-table/data-table-pagination.tsx new file mode 100644 index 00000000..1911f83e --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-pagination.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { Table } from '@tanstack/react-table'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; + +import { Button } from '../button'; +import { Select } from '../select'; + +interface DataTablePaginationProps { + table: Table; + onPaginationChange?: (pageIndex: number, pageSize: number) => void; +} + +export function DataTablePagination({ + table, + onPaginationChange, +}: DataTablePaginationProps) { + const { pageSize, pageIndex } = table.getState().pagination; + + React.useEffect(() => { + if (onPaginationChange) { + onPaginationChange(pageIndex, pageSize); + } + }, [pageIndex, pageSize, onPaginationChange]); + + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{' '} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+

Rows per page

+ + table.getColumn(column.id as string)?.setFilterValue(event.target.value) + } + className="h-8 w-[150px] lg:w-[250px]" + /> + ) + )} + {filterableColumns.length > 0 && + filterableColumns.map( + (column) => + table.getColumn(column.id as string) && ( + + ) + )} + {isFiltered && ( + + )} +
+ +
+ ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-view-options.tsx b/packages/components/src/ui/data-table/data-table-view-options.tsx new file mode 100644 index 00000000..1f36375e --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-view-options.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { Table } from '@tanstack/react-table'; +import { Settings2 } from 'lucide-react'; + +import { Button } from '../button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../dropdown-menu'; + +interface DataTableViewOptionsProps { + table: Table; +} + +export function DataTableViewOptions({ + table, +}: DataTableViewOptionsProps) { + return ( + + + + + + Toggle columns + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== 'undefined' && column.getCanHide() + ) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.id} + + ); + })} + + + ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table.tsx b/packages/components/src/ui/data-table/data-table.tsx new file mode 100644 index 00000000..3a7fc74f --- /dev/null +++ b/packages/components/src/ui/data-table/data-table.tsx @@ -0,0 +1,162 @@ +import * as React from 'react'; +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '../table'; +import { DataTablePagination } from './data-table-pagination'; +import { DataTableToolbar } from './data-table-toolbar'; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + filterableColumns?: { + id: keyof TData; + title: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + }[]; + searchableColumns?: { + id: keyof TData; + title: string; + }[]; + pagination?: boolean; + onPaginationChange?: (pageIndex: number, pageSize: number) => void; + onSortingChange?: (sorting: SortingState) => void; + onFilterChange?: (filters: ColumnFiltersState) => void; +} + +export function DataTable({ + columns, + data, + filterableColumns = [], + searchableColumns = [], + pagination = true, + onPaginationChange, + onSortingChange, + onFilterChange, +}: DataTableProps) { + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [columnFilters, setColumnFilters] = React.useState([]); + const [sorting, setSorting] = React.useState([]); + + // Handle external state changes + React.useEffect(() => { + if (onFilterChange) { + onFilterChange(columnFilters); + } + }, [columnFilters, onFilterChange]); + + React.useEffect(() => { + if (onSortingChange) { + onSortingChange(sorting); + } + }, [sorting, onSortingChange]); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }); + + return ( +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {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. + + + )} + +
+
+ {pagination && ( + + )} +
+ ); +} \ 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..b75e61b0 --- /dev/null +++ b/packages/components/src/ui/data-table/index.ts @@ -0,0 +1,6 @@ +export * from './data-table'; +export * from './data-table-column-header'; +export * from './data-table-faceted-filter'; +export * from './data-table-pagination'; +export * from './data-table-toolbar'; +export * from './data-table-view-options'; \ No newline at end of file diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 567fd5e1..28d7f995 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -18,3 +18,10 @@ export * from './text-input'; export * from './textarea-field'; export * from './textarea'; export * from './utils'; +export * from './table'; +export * from './data-table'; +export * from './badge'; +export * from './command'; +export * from './input'; +export * from './select'; +export * from './separator'; diff --git a/packages/components/src/ui/input.tsx b/packages/components/src/ui/input.tsx new file mode 100644 index 00000000..e8a7244d --- /dev/null +++ b/packages/components/src/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; + +import { cn } from './utils'; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; \ No newline at end of file diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx new file mode 100644 index 00000000..8abf40ee --- /dev/null +++ b/packages/components/src/ui/select.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { Check, ChevronDown } from 'lucide-react'; + +import { cn } from './utils'; +import { Button } from './button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from './command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from './popover'; + +export interface SelectOption { + label: string; + value: string; +} + +interface SelectProps { + options: SelectOption[]; + value?: string; + onValueChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function Select({ + options, + value, + onValueChange, + placeholder = 'Select an option', + disabled = false, + className, +}: SelectProps) { + const [open, setOpen] = React.useState(false); + + const selectedOption = options.find(option => option.value === value); + + return ( + + + + + + + + No option found. + + + {options.map((option) => ( + { + onValueChange?.(option.value); + setOpen(false); + }} + className="flex items-center" + > + + {option.label} + + ))} + + + + + + ); +} \ No newline at end of file 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 diff --git a/packages/components/src/ui/table.tsx b/packages/components/src/ui/table.tsx new file mode 100644 index 00000000..0c5c8fab --- /dev/null +++ b/packages/components/src/ui/table.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; + +import { cn } from './utils'; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ) +); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef>( + ({ className, ...props }, ref) => +); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef>( + ({ className, ...props }, ref) => +); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( + tr]:last:border-b-0', className)} {...props} /> + ) +); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +TableCell.displayName = 'TableCell'; + +const TableCaption = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +TableCaption.displayName = 'TableCaption'; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; \ No newline at end of file From d6ca8588bd33166d0f3a7bc2d30c24cdfc430742 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Tue, 1 Apr 2025 19:09:01 +0000 Subject: [PATCH 02/32] Implement DataTableRouterForm and remove Input component (LC-187) --- .../src/ui/data-table-router-form.stories.tsx | 251 ++++++++++++++++++ .../data-table/data-table-faceted-filter.tsx | 17 +- .../ui/data-table/data-table-router-form.tsx | 201 ++++++++++++++ .../data-table/data-table-router-toolbar.tsx | 92 +++++++ .../src/ui/data-table/data-table-toolbar.tsx | 4 +- .../components/src/ui/data-table/index.ts | 4 +- packages/components/src/ui/index.ts | 2 +- packages/components/src/ui/input.tsx | 25 -- 8 files changed, 566 insertions(+), 30 deletions(-) create mode 100644 apps/docs/src/ui/data-table-router-form.stories.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-router-toolbar.tsx delete mode 100644 packages/components/src/ui/input.tsx diff --git a/apps/docs/src/ui/data-table-router-form.stories.tsx b/apps/docs/src/ui/data-table-router-form.stories.tsx new file mode 100644 index 00000000..12612ec8 --- /dev/null +++ b/apps/docs/src/ui/data-table-router-form.stories.tsx @@ -0,0 +1,251 @@ +import { DataTableRouterForm } from '@lambdacurry/forms/ui/data-table/data-table-router-form'; +import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; +import type { Meta, StoryObj } from '@storybook/react'; +import { type ActionFunctionArgs, useLoaderData, useNavigation } from 'react-router'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; +import { z } from 'zod'; +import { useEffect, useState } from 'react'; + +// Define the data schema +const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + role: z.enum(['admin', 'user', 'editor']), + status: z.enum(['active', 'inactive', 'pending']), + createdAt: z.string().datetime(), +}); + +type User = z.infer; + +// Sample data +const users: User[] = Array.from({ length: 100 }).map((_, i) => ({ + id: `user-${i + 1}`, + name: `User ${i + 1}`, + email: `user${i + 1}@example.com`, + role: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'user' : 'editor', + status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', + createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), +})); + +// Define the columns +const columns = [ + { + accessorKey: 'id', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('id')}
, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('name')}
, + }, + { + accessorKey: 'email', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('email')}
, + }, + { + accessorKey: 'role', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('role')}
, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue('status')}
+ ), + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{new Date(row.getValue('createdAt')).toLocaleDateString()}
+ ), + }, +]; + +// Mock API handler for data fetching with filters and pagination +const handleDataFetch = async (request: Request) => { + // Simulate server delay + await new Promise(resolve => setTimeout(resolve, 500)); + + // Get form data + const formData = await request.formData(); + + // Get query parameters + const page = parseInt(formData.get('page')?.toString() || '0'); + const pageSize = parseInt(formData.get('pageSize')?.toString() || '10'); + const sortField = formData.get('sortField')?.toString(); + const sortOrder = formData.get('sortOrder')?.toString(); + const roleFilter = formData.getAll('role').map(val => val.toString()); + const statusFilter = formData.getAll('status').map(val => val.toString()); + const search = formData.get('search')?.toString(); + + // Apply filters + let filteredData = [...users]; + + if (roleFilter.length > 0) { + filteredData = filteredData.filter(user => roleFilter.includes(user.role)); + } + + if (statusFilter.length > 0) { + filteredData = filteredData.filter(user => statusFilter.includes(user.status)); + } + + if (search) { + const searchLower = search.toLowerCase(); + filteredData = filteredData.filter( + user => + user.name.toLowerCase().includes(searchLower) || + user.email.toLowerCase().includes(searchLower) + ); + } + + // Apply sorting + if (sortField && sortOrder) { + filteredData.sort((a, b) => { + const aValue = a[sortField as keyof User]; + const bValue = b[sortField as keyof User]; + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + + // Apply pagination + const start = page * pageSize; + const paginatedData = filteredData.slice(start, start + pageSize); + + return { + data: paginatedData, + meta: { + total: filteredData.length, + page, + pageSize, + pageCount: Math.ceil(filteredData.length / pageSize), + } + }; +}; + +// Component to display the data table with router form integration +const DataTableRouterFormExample = () => { + const [data, setData] = useState([]); + const [pageCount, setPageCount] = useState(0); + const navigation = useNavigation(); + + // Get data from the router action + const actionData = useLoaderData() as { + data: User[]; + meta: { + total: number; + page: number; + pageSize: number; + pageCount: number; + } + } | null; + + // Update state when action data changes + useEffect(() => { + if (actionData) { + setData(actionData.data); + setPageCount(actionData.meta.pageCount); + } + }, [actionData]); + + return ( +
+

Users Table (React Router Form Integration)

+

+ This example demonstrates integration with React Router forms, including: +

+
    +
  • Form-based filtering with automatic submission
  • +
  • Loading state while waiting for data
  • +
  • Server-side filtering and pagination
  • +
  • URL-based state management
  • +
+ +
+ ); +}; + +const meta: Meta = { + title: 'UI/DataTableRouterForm', + component: DataTableRouterForm, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/api/users', + action: async ({ request }: ActionFunctionArgs) => handleDataFetch(request), + }, + ], + }), + ], +}; \ 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 index 03c1b093..3bcf96f9 100644 --- a/packages/components/src/ui/data-table/data-table-faceted-filter.tsx +++ b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx @@ -25,12 +25,14 @@ interface DataTableFacetedFilterProps { 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[]); @@ -38,7 +40,7 @@ export function DataTableFacetedFilter({ return ( - + )} + + + + ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-toolbar.tsx b/packages/components/src/ui/data-table/data-table-toolbar.tsx index f8b36ade..58c2ddbd 100644 --- a/packages/components/src/ui/data-table/data-table-toolbar.tsx +++ b/packages/components/src/ui/data-table/data-table-toolbar.tsx @@ -3,7 +3,7 @@ import { Table } from '@tanstack/react-table'; import { X } from 'lucide-react'; import { Button } from '../button'; -import { Input } from '../input'; +import { TextInput } from '../text-input'; import { DataTableViewOptions } from './data-table-view-options'; import { DataTableFacetedFilter } from './data-table-faceted-filter'; @@ -38,7 +38,7 @@ export function DataTableToolbar({ searchableColumns.map( (column) => table.getColumn(column.id as string) && ( - {} - -const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( - - ); - } -); -Input.displayName = 'Input'; - -export { Input }; \ No newline at end of file From 5969c418a3371b4e0d5047e87664e9becee20e6d Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Tue, 1 Apr 2025 14:27:05 -0500 Subject: [PATCH 03/32] add tanstack to package json --- packages/components/package.json | 1 + yarn.lock | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/components/package.json b/packages/components/package.json index 272fc7a1..5edaa88e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -53,6 +53,7 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", + "@tanstack/react-table": "^8.21.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/yarn.lock b/yarn.lock index f5ffb62e..da7442ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1790,6 +1790,7 @@ __metadata: "@radix-ui/react-tooltip": "npm:^1.1.6" "@react-router/dev": "npm:^7.0.0" "@react-router/node": "npm:^7.0.0" + "@tanstack/react-table": "npm:^8.21.2" "@types/glob": "npm:^8.1.0" "@types/react": "npm:^19.0.0" "@typescript-eslint/eslint-plugin": "npm:^6.21.0" @@ -3961,6 +3962,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-table@npm:^8.21.2": + version: 8.21.2 + resolution: "@tanstack/react-table@npm:8.21.2" + dependencies: + "@tanstack/table-core": "npm:8.21.2" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 10c0/192392c7d945bf50a21a0904fdce07e4abb74bc6187bcd32ae843031d48e851a10899e0218443d742ee01c30ca1ff890a8677a5878b8904561ebc23479adfdf1 + languageName: node + linkType: hard + +"@tanstack/table-core@npm:8.21.2": + version: 8.21.2 + resolution: "@tanstack/table-core@npm:8.21.2" + checksum: 10c0/836326c6aef7e0b9b4566f9b2ade5ea73b90ce94f2b0cc35a3b3e1f44a9b3512d5507e4818c509824443b3d0488f2026df54e7a9f8f0cdccae6040347e6ff079 + languageName: node + linkType: hard + "@testing-library/dom@npm:10.4.0": version: 10.4.0 resolution: "@testing-library/dom@npm:10.4.0" From 99c3e80353e21cbcbe7f264aaebfcb167b8941c9 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Tue, 1 Apr 2025 14:27:33 -0500 Subject: [PATCH 04/32] Refactor data table components to use type imports for React and TanStack types, improving code clarity and consistency. Clean up JSX formatting for better readability across data table files. --- .../data-table/data-table-column-header.tsx | 17 ++--- .../data-table/data-table-faceted-filter.tsx | 45 ++++--------- .../ui/data-table/data-table-pagination.tsx | 17 ++--- .../ui/data-table/data-table-router-form.tsx | 66 +++++-------------- .../data-table/data-table-router-toolbar.tsx | 31 +++------ .../src/ui/data-table/data-table-toolbar.tsx | 26 +++----- .../ui/data-table/data-table-view-options.tsx | 20 ++---- .../src/ui/data-table/data-table.tsx | 55 ++++------------ 8 files changed, 77 insertions(+), 200 deletions(-) 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 index 29cf29c6..c137fb5b 100644 --- a/packages/components/src/ui/data-table/data-table-column-header.tsx +++ b/packages/components/src/ui/data-table/data-table-column-header.tsx @@ -1,8 +1,7 @@ -import * as React from 'react'; -import { Column } from '@tanstack/react-table'; +import type { Column } from '@tanstack/react-table'; import { ArrowDown, ArrowUp, ArrowUpDown, EyeOff } from 'lucide-react'; +import type * as React from 'react'; -import { cn } from '../utils'; import { Button } from '../button'; import { DropdownMenu, @@ -11,9 +10,9 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '../dropdown-menu'; +import { cn } from '../utils'; -interface DataTableColumnHeaderProps - extends React.HTMLAttributes { +interface DataTableColumnHeaderProps extends React.HTMLAttributes { column: Column; title: string; } @@ -31,11 +30,7 @@ export function DataTableColumnHeader({
-
); -} \ 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 index 3bcf96f9..54e01da8 100644 --- a/packages/components/src/ui/data-table/data-table-faceted-filter.tsx +++ b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx @@ -1,8 +1,7 @@ -import * as React from 'react'; -import { Column } from '@tanstack/react-table'; +import type { Column } from '@tanstack/react-table'; import { Check, PlusCircle } from 'lucide-react'; +import type * as React from 'react'; -import { cn } from '../utils'; import { Badge } from '../badge'; import { Button } from '../button'; import { @@ -16,6 +15,7 @@ import { } from '../command'; import { Popover, PopoverContent, PopoverTrigger } from '../popover'; import { Separator } from '../separator'; +import { cn } from '../utils'; interface DataTableFacetedFilterProps { column?: Column; @@ -46,29 +46,19 @@ export function DataTableFacetedFilter({ {selectedValues?.size > 0 && ( <> - + {selectedValues.size}
{selectedValues.size > 2 ? ( - + {selectedValues.size} selected ) : ( options .filter((option) => selectedValues.has(option.value)) .map((option) => ( - + {option.label} )) @@ -96,24 +86,18 @@ export function DataTableFacetedFilter({ selectedValues.add(option.value); } const filterValues = Array.from(selectedValues); - column?.setFilterValue( - filterValues.length ? filterValues : undefined - ); + column?.setFilterValue(filterValues.length ? filterValues : undefined); }} >
- {option.icon && ( - - )} + {option.icon && } {option.label} {facets?.get(option.value) && ( @@ -140,19 +124,14 @@ export function DataTableFacetedFilter({ - + {formMode && selectedValues.size > 0 && column && (
{Array.from(selectedValues).map((value) => ( - + ))}
)} ); -} \ No newline at end of file +} diff --git a/packages/components/src/ui/data-table/data-table-pagination.tsx b/packages/components/src/ui/data-table/data-table-pagination.tsx index 1911f83e..9af2bfd6 100644 --- a/packages/components/src/ui/data-table/data-table-pagination.tsx +++ b/packages/components/src/ui/data-table/data-table-pagination.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; -import { Table } from '@tanstack/react-table'; +import type { Table } from '@tanstack/react-table'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import * as React from 'react'; import { Button } from '../button'; import { Select } from '../select'; @@ -10,10 +10,7 @@ interface DataTablePaginationProps { onPaginationChange?: (pageIndex: number, pageSize: number) => void; } -export function DataTablePagination({ - table, - onPaginationChange, -}: DataTablePaginationProps) { +export function DataTablePagination({ table, onPaginationChange }: DataTablePaginationProps) { const { pageSize, pageIndex } = table.getState().pagination; React.useEffect(() => { @@ -25,8 +22,7 @@ export function DataTablePagination({ return (
- {table.getFilteredSelectedRowModel().rows.length} of{' '} - {table.getFilteredRowModel().rows.length} row(s) selected. + {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
@@ -46,8 +42,7 @@ export function DataTablePagination({ />
- Page {table.getState().pagination.pageIndex + 1} of{' '} - {table.getPageCount()} + Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
); -} \ 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 index cc682f87..7f2292d9 100644 --- a/packages/components/src/ui/data-table/data-table-router-form.tsx +++ b/packages/components/src/ui/data-table/data-table-router-form.tsx @@ -1,9 +1,8 @@ -import * as React from 'react'; import { - ColumnDef, - ColumnFiltersState, - SortingState, - VisibilityState, + type ColumnDef, + type ColumnFiltersState, + type SortingState, + type VisibilityState, flexRender, getCoreRowModel, getFacetedRowModel, @@ -13,6 +12,7 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; +import * as React from 'react'; import { Form, useNavigation, useSubmit } from 'react-router-dom'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table'; @@ -57,9 +57,7 @@ export function DataTableRouterForm({ const [rowSelection, setRowSelection] = React.useState({}); const [columnVisibility, setColumnVisibility] = React.useState({}); const [columnFilters, setColumnFilters] = React.useState([]); - const [sorting, setSorting] = React.useState( - defaultSort ? [defaultSort] : [] - ); + const [sorting, setSorting] = React.useState(defaultSort ? [defaultSort] : []); const submit = useSubmit(); const navigation = useNavigation(); @@ -109,30 +107,14 @@ export function DataTableRouterForm({ {/* Hidden inputs for sorting */} {sorting.length > 0 && ( <> - - + + )} {/* Hidden inputs for pagination */} - - + +
@@ -142,12 +124,7 @@ export function DataTableRouterForm({ {headerGroup.headers.map((header) => { return ( - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ); })} @@ -157,10 +134,7 @@ export function DataTableRouterForm({ {isLoading ? ( - +
Loading... @@ -169,23 +143,15 @@ export function DataTableRouterForm({ ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - + {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) ) : ( - + No results. @@ -198,4 +164,4 @@ export function DataTableRouterForm({
); -} \ No newline at end of file +} diff --git a/packages/components/src/ui/data-table/data-table-router-toolbar.tsx b/packages/components/src/ui/data-table/data-table-router-toolbar.tsx index b9f85e0e..6656ddf6 100644 --- a/packages/components/src/ui/data-table/data-table-router-toolbar.tsx +++ b/packages/components/src/ui/data-table/data-table-router-toolbar.tsx @@ -1,11 +1,11 @@ -import * as React from 'react'; -import { Table } from '@tanstack/react-table'; +import type { Table } from '@tanstack/react-table'; import { X } from 'lucide-react'; +import * as React from 'react'; import { Button } from '../button'; import { TextInput } from '../text-input'; -import { DataTableViewOptions } from './data-table-view-options'; import { DataTableFacetedFilter } from './data-table-faceted-filter'; +import { DataTableViewOptions } from './data-table-view-options'; interface DataTableRouterToolbarProps { table: Table; @@ -42,24 +42,18 @@ export function DataTableRouterToolbar({ - table.getColumn(column.id as string)?.setFilterValue(event.target.value) - } + value={(table.getColumn(column.id as string)?.getFilterValue() as string) ?? ''} + onChange={(event) => table.getColumn(column.id as string)?.setFilterValue(event.target.value)} className="h-8 w-[150px] lg:w-[250px]" /> {/* Hidden input for form submission */} - ) + ), )} {filterableColumns.length > 0 && filterableColumns.map( @@ -72,15 +66,10 @@ export function DataTableRouterToolbar({ options={column.options} formMode={true} /> - ) + ), )} {isFiltered && ( - @@ -89,4 +78,4 @@ export function DataTableRouterToolbar({ ); -} \ No newline at end of file +} diff --git a/packages/components/src/ui/data-table/data-table-toolbar.tsx b/packages/components/src/ui/data-table/data-table-toolbar.tsx index 58c2ddbd..3b1b76bf 100644 --- a/packages/components/src/ui/data-table/data-table-toolbar.tsx +++ b/packages/components/src/ui/data-table/data-table-toolbar.tsx @@ -1,11 +1,11 @@ -import * as React from 'react'; -import { Table } from '@tanstack/react-table'; +import type { Table } from '@tanstack/react-table'; import { X } from 'lucide-react'; +import type * as React from 'react'; import { Button } from '../button'; import { TextInput } from '../text-input'; -import { DataTableViewOptions } from './data-table-view-options'; import { DataTableFacetedFilter } from './data-table-faceted-filter'; +import { DataTableViewOptions } from './data-table-view-options'; interface DataTableToolbarProps { table: Table; @@ -41,15 +41,11 @@ export function DataTableToolbar({ - table.getColumn(column.id as string)?.setFilterValue(event.target.value) - } + value={(table.getColumn(column.id as string)?.getFilterValue() as string) ?? ''} + onChange={(event) => table.getColumn(column.id as string)?.setFilterValue(event.target.value)} className="h-8 w-[150px] lg:w-[250px]" /> - ) + ), )} {filterableColumns.length > 0 && filterableColumns.map( @@ -61,14 +57,10 @@ export function DataTableToolbar({ title={column.title} options={column.options} /> - ) + ), )} {isFiltered && ( - @@ -77,4 +69,4 @@ export function DataTableToolbar({ ); -} \ No newline at end of file +} diff --git a/packages/components/src/ui/data-table/data-table-view-options.tsx b/packages/components/src/ui/data-table/data-table-view-options.tsx index 1f36375e..635aeb83 100644 --- a/packages/components/src/ui/data-table/data-table-view-options.tsx +++ b/packages/components/src/ui/data-table/data-table-view-options.tsx @@ -1,5 +1,4 @@ -import * as React from 'react'; -import { Table } from '@tanstack/react-table'; +import type { Table } from '@tanstack/react-table'; import { Settings2 } from 'lucide-react'; import { Button } from '../button'; @@ -16,17 +15,11 @@ interface DataTableViewOptionsProps { table: Table; } -export function DataTableViewOptions({ - table, -}: DataTableViewOptionsProps) { +export function DataTableViewOptions({ table }: DataTableViewOptionsProps) { return ( - @@ -36,10 +29,7 @@ export function DataTableViewOptions({ {table .getAllColumns() - .filter( - (column) => - typeof column.accessorFn !== 'undefined' && column.getCanHide() - ) + .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()) .map((column) => { return ( ({ ); -} \ No newline at end of file +} diff --git a/packages/components/src/ui/data-table/data-table.tsx b/packages/components/src/ui/data-table/data-table.tsx index 3a7fc74f..2baa528e 100644 --- a/packages/components/src/ui/data-table/data-table.tsx +++ b/packages/components/src/ui/data-table/data-table.tsx @@ -1,9 +1,8 @@ -import * as React from 'react'; import { - ColumnDef, - ColumnFiltersState, - SortingState, - VisibilityState, + type ColumnDef, + type ColumnFiltersState, + type SortingState, + type VisibilityState, flexRender, getCoreRowModel, getFacetedRowModel, @@ -13,15 +12,9 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; +import * as React from 'react'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '../table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table'; import { DataTablePagination } from './data-table-pagination'; import { DataTableToolbar } from './data-table-toolbar'; @@ -99,11 +92,7 @@ export function DataTable({ return (
- +
@@ -112,12 +101,7 @@ export function DataTable({ {headerGroup.headers.map((header) => { return ( - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ); })} @@ -127,26 +111,15 @@ export function DataTable({ {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - + {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) ) : ( - + No results. @@ -154,9 +127,7 @@ export function DataTable({
- {pagination && ( - - )} + {pagination && }
); -} \ No newline at end of file +} From 0bdc8646fe6f1828df5cb2d9e7f5c25a824d4b3d Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Tue, 1 Apr 2025 14:37:31 -0500 Subject: [PATCH 05/32] Update dependencies in package.json and yarn.lock; add new Radix UI components and cmdk package. Refactor form components for improved readability and consistency, including type imports for React. Enhance DataTableRouterForm with auto-submit functionality on filter changes. --- packages/components/package.json | 2 + .../components/src/remix-hook-form/form.tsx | 45 +++-- .../src/remix-hook-form/text-field.tsx | 5 +- packages/components/src/ui/command.tsx | 105 +++------- .../ui/data-table/data-table-router-form.tsx | 6 +- .../data-table/data-table-router-toolbar.tsx | 2 +- packages/components/src/ui/text-field.tsx | 2 +- yarn.lock | 182 +++++++++++++++++- 8 files changed, 243 insertions(+), 106 deletions(-) diff --git a/packages/components/package.json b/packages/components/package.json index 5edaa88e..f342dd27 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -50,12 +50,14 @@ "@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", "@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", diff --git a/packages/components/src/remix-hook-form/form.tsx b/packages/components/src/remix-hook-form/form.tsx index 914f0e4a..4d7d50a2 100644 --- a/packages/components/src/remix-hook-form/form.tsx +++ b/packages/components/src/remix-hook-form/form.tsx @@ -1,11 +1,16 @@ -// biome-ignore lint/style/noNamespaceImport: prevents React undefined errors when exporting as a component library import * as React from 'react'; import type { FieldValues, KeepStateOptions, UseFormReturn } from 'react-hook-form'; import { useRemixFormContext } from 'remix-hook-form'; -import { FormControl as BaseFormControl, FormDescription as BaseFormDescription, FormFieldContext as BaseFormFieldContext, FormItemContext as BaseFormItemContext, FormLabel as BaseFormLabel, FormMessage as BaseFormMessage } from '../ui/form'; +import { + FormControl as BaseFormControl, + FormDescription as BaseFormDescription, + FormFieldContext as BaseFormFieldContext, + FormItemContext as BaseFormItemContext, + FormLabel as BaseFormLabel, + FormMessage as BaseFormMessage, +} from '../ui/form'; -export interface FormProviderProps - extends Omit, 'handleSubmit' | 'reset'> { +export interface FormProviderProps extends Omit, 'handleSubmit' | 'reset'> { children: React.ReactNode; handleSubmit: (e?: React.BaseSyntheticEvent) => Promise; reset: (values?: T, keepStateOptions?: KeepStateOptions) => void; @@ -19,7 +24,9 @@ export const useFormField = () => { const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { - throw new Error('useFormField must be used within a component. Ensure this hook is called from a component that is a child of FormField.'); + throw new Error( + 'useFormField must be used within a component. Ensure this hook is called from a component that is a child of FormField.', + ); } const { id, formItemId, formDescriptionId, formMessageId } = itemContext; @@ -39,7 +46,7 @@ export const FormLabel = React.forwardRef>( +export const FormControl = React.forwardRef>( (props, ref) => { const { error, formItemId, formDescriptionId, formMessageId } = useFormField(); return ( @@ -56,20 +63,20 @@ export const FormControl = React.forwardRef ->((props, ref) => { +export const FormDescription = (props: React.ComponentPropsWithoutRef) => { const { formDescriptionId } = useFormField(); - return ; -}); + return ; +}; FormDescription.displayName = 'FormDescription'; -export const FormMessage = React.forwardRef< - HTMLParagraphElement, - React.ComponentPropsWithoutRef ->((props, ref) => { +export const FormMessage = (props: React.ComponentPropsWithoutRef) => { const { error, formMessageId } = useFormField(); - return ; -}); -FormMessage.displayName = 'FormMessage'; \ No newline at end of file + return ( + + ); +}; +FormMessage.displayName = 'FormMessage'; diff --git a/packages/components/src/remix-hook-form/text-field.tsx b/packages/components/src/remix-hook-form/text-field.tsx index 8db979bb..45ded662 100644 --- a/packages/components/src/remix-hook-form/text-field.tsx +++ b/packages/components/src/remix-hook-form/text-field.tsx @@ -1,7 +1,8 @@ -import { useRemixFormContext } from 'remix-hook-form'; -import { TextField as BaseTextField, type TextFieldProps as BaseTextFieldProps } from '../ui/text-field'; +import { TextField as BaseTextField, type TextInputProps as BaseTextFieldProps } from '../ui/text-field'; import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; +import { useRemixFormContext } from 'remix-hook-form'; + export type TextFieldProps = Omit; export function TextField(props: TextFieldProps) { diff --git a/packages/components/src/ui/command.tsx b/packages/components/src/ui/command.tsx index b7e7678f..06f66182 100644 --- a/packages/components/src/ui/command.tsx +++ b/packages/components/src/ui/command.tsx @@ -1,24 +1,19 @@ -import * as React from 'react'; -import { type DialogProps } from '@radix-ui/react-dialog'; +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'; -import { Dialog, DialogContent } from './dialog'; -const Command = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const Command = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( -)); +); Command.displayName = CommandPrimitive.displayName; interface CommandDialogProps extends DialogProps {} @@ -35,108 +30,68 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => { ); }; -const CommandInput = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const CommandInput = ({ className, ...props }: React.ComponentPropsWithoutRef) => (
-)); +); CommandInput.displayName = CommandPrimitive.Input.displayName; -const CommandList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +const CommandList = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( + +); CommandList.displayName = CommandPrimitive.List.displayName; -const CommandEmpty = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->((props, ref) => ( - -)); +const CommandEmpty = (props: React.ComponentPropsWithoutRef) => ( + +); CommandEmpty.displayName = CommandPrimitive.Empty.displayName; -const CommandGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const CommandGroup = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( -)); +); CommandGroup.displayName = CommandPrimitive.Group.displayName; -const CommandSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +const CommandSeparator = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +); + CommandSeparator.displayName = CommandPrimitive.Separator.displayName; -const CommandItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const CommandItem = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( -)); +); CommandItem.displayName = CommandPrimitive.Item.displayName; -const CommandShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); +const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; }; CommandShortcut.displayName = 'CommandShortcut'; @@ -150,4 +105,4 @@ export { CommandItem, CommandShortcut, CommandSeparator, -}; \ 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 index 7f2292d9..26a434f2 100644 --- a/packages/components/src/ui/data-table/data-table-router-form.tsx +++ b/packages/components/src/ui/data-table/data-table-router-form.tsx @@ -15,6 +15,7 @@ import { import * as React from 'react'; import { Form, useNavigation, useSubmit } from 'react-router-dom'; +import { useEffect } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table'; import { DataTablePagination } from './data-table-pagination'; import { DataTableRouterToolbar } from './data-table-router-toolbar'; @@ -88,7 +89,7 @@ export function DataTableRouterForm({ }); // Auto-submit the form when filters change - React.useEffect(() => { + useEffect(() => { const formElement = document.getElementById('data-table-router-form') as HTMLFormElement; if (formElement) { submit(formElement); @@ -136,11 +137,12 @@ export function DataTableRouterForm({
-
+
Loading...
+ // biome-ignore lint/style/useExplicitLengthCheck: ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( diff --git a/packages/components/src/ui/data-table/data-table-router-toolbar.tsx b/packages/components/src/ui/data-table/data-table-router-toolbar.tsx index 6656ddf6..852529b2 100644 --- a/packages/components/src/ui/data-table/data-table-router-toolbar.tsx +++ b/packages/components/src/ui/data-table/data-table-router-toolbar.tsx @@ -40,7 +40,7 @@ export function DataTableRouterToolbar({ table.getColumn(column.id as string) && ( table.getColumn(column.id as string)?.setFilterValue(event.target.value)} diff --git a/packages/components/src/ui/text-field.tsx b/packages/components/src/ui/text-field.tsx index 1483a73b..437b9dd5 100644 --- a/packages/components/src/ui/text-field.tsx +++ b/packages/components/src/ui/text-field.tsx @@ -51,7 +51,7 @@ export const FieldSuffix = ({ }; // Create a specific interface for the input props that includes className explicitly -interface TextInputProps extends Omit { +export interface TextInputProps extends Omit { control?: Control; name: FieldPath; label?: string; diff --git a/yarn.lock b/yarn.lock index da7442ad..65f78ed2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1785,6 +1785,7 @@ __metadata: "@radix-ui/react-popover": "npm:^1.1.4" "@radix-ui/react-radio-group": "npm:^1.2.2" "@radix-ui/react-scroll-area": "npm:^1.2.2" + "@radix-ui/react-separator": "npm:^1.1.2" "@radix-ui/react-slot": "npm:^1.1.2" "@radix-ui/react-switch": "npm:^1.1.2" "@radix-ui/react-tooltip": "npm:^1.1.6" @@ -1799,6 +1800,7 @@ __metadata: autoprefixer: "npm:^10.4.20" class-variance-authority: "npm:^0.7.1" clsx: "npm:^2.1.1" + cmdk: "npm:^1.1.1" date-fns: "npm:^4.1.0" glob: "npm:^11.0.0" input-otp: "npm:^1.4.1" @@ -2146,7 +2148,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-compose-refs@npm:1.1.1": +"@radix-ui/react-compose-refs@npm:1.1.1, @radix-ui/react-compose-refs@npm:^1.1.1": version: 1.1.1 resolution: "@radix-ui/react-compose-refs@npm:1.1.1" peerDependencies: @@ -2204,6 +2206,38 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-dialog@npm:^1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-dialog@npm:1.1.6" + dependencies: + "@radix-ui/primitive": "npm:1.1.1" + "@radix-ui/react-compose-refs": "npm:1.1.1" + "@radix-ui/react-context": "npm:1.1.1" + "@radix-ui/react-dismissable-layer": "npm:1.1.5" + "@radix-ui/react-focus-guards": "npm:1.1.1" + "@radix-ui/react-focus-scope": "npm:1.1.2" + "@radix-ui/react-id": "npm:1.1.0" + "@radix-ui/react-portal": "npm:1.1.4" + "@radix-ui/react-presence": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.0.2" + "@radix-ui/react-slot": "npm:1.1.2" + "@radix-ui/react-use-controllable-state": "npm:1.1.0" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.6.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/98e425549573c5d6fb0fee94ecd40427a8b8897bb2d9bb2a44fe64e484754376ff23b64fcf64e061d42fc774b9627a28cb5b1bb5652e567908dac9a8d8618705 + languageName: node + linkType: hard + "@radix-ui/react-direction@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-direction@npm:1.1.0" @@ -2240,6 +2274,29 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-dismissable-layer@npm:1.1.5": + version: 1.1.5 + resolution: "@radix-ui/react-dismissable-layer@npm:1.1.5" + dependencies: + "@radix-ui/primitive": "npm:1.1.1" + "@radix-ui/react-compose-refs": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.0.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.0" + "@radix-ui/react-use-escape-keydown": "npm:1.1.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/05c5adfcd42a736c456f50bdca25bf7f6b25eef7328e4c05de535fea128328666433a89d68cb1445e039c188d7f1397df6a4a02e2da0970762f2a80fd29b48ea + languageName: node + linkType: hard + "@radix-ui/react-dropdown-menu@npm:^2.1.4": version: 2.1.4 resolution: "@radix-ui/react-dropdown-menu@npm:2.1.4" @@ -2299,6 +2356,27 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-focus-scope@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-focus-scope@npm:1.1.2" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.0.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/7b93866a9980bc938fc3fcfacfc49467c13144931c9b7a3b5423c0c3817685dc421499d73f58335f6c3c1c0f4fea9c9b7c16aa06a1d30571620787086082bea0 + languageName: node + linkType: hard + "@radix-ui/react-icons@npm:^1.3.2": version: 1.3.2 resolution: "@radix-ui/react-icons@npm:1.3.2" @@ -2308,7 +2386,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-id@npm:1.1.0": +"@radix-ui/react-id@npm:1.1.0, @radix-ui/react-id@npm:^1.1.0": version: 1.1.0 resolution: "@radix-ui/react-id@npm:1.1.0" dependencies: @@ -2459,6 +2537,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-portal@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-portal@npm:1.1.4" + dependencies: + "@radix-ui/react-primitive": "npm:2.0.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/e4038eb2f20be10d9754d099d00620f429711919d20c4c630946d9c4941f1c83ef1a3f4110c221c70486e65bc565ebba4ada22a0e7e2d179c039f2a014300793 + languageName: node + linkType: hard + "@radix-ui/react-presence@npm:1.1.2": version: 1.1.2 resolution: "@radix-ui/react-presence@npm:1.1.2" @@ -2498,6 +2596,25 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-primitive@npm:2.0.2, @radix-ui/react-primitive@npm:^2.0.2": + version: 2.0.2 + resolution: "@radix-ui/react-primitive@npm:2.0.2" + dependencies: + "@radix-ui/react-slot": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/1af7a33a86f8bd2467f2300b1bb6ca9af67cae3950953ba543d2a625c17f341dff05d19056ece7b03e5ced8b9f8de99c74f806710ce0da6b9a000f2af063fffe + languageName: node + linkType: hard + "@radix-ui/react-radio-group@npm:^1.2.2": version: 1.2.2 resolution: "@radix-ui/react-radio-group@npm:1.2.2" @@ -2580,6 +2697,25 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-separator@npm:^1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-separator@npm:1.1.2" + dependencies: + "@radix-ui/react-primitive": "npm:2.0.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/9efffd4319ab25210702cbacd5a3fe15f22ab9e29afe407b778112056e6a2e1e43847f1ad5f5b73bff5d604722a4fdabd66816216e7ad8f627f7b4c20a19174e + languageName: node + linkType: hard + "@radix-ui/react-slot@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-slot@npm:1.1.1" @@ -2595,7 +2731,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-slot@npm:^1.1.2": +"@radix-ui/react-slot@npm:1.1.2, @radix-ui/react-slot@npm:^1.1.2": version: 1.1.2 resolution: "@radix-ui/react-slot@npm:1.1.2" dependencies: @@ -4757,7 +4893,7 @@ __metadata: languageName: node linkType: hard -"aria-hidden@npm:^1.1.1": +"aria-hidden@npm:^1.1.1, aria-hidden@npm:^1.2.4": version: 1.2.4 resolution: "aria-hidden@npm:1.2.4" dependencies: @@ -5303,6 +5439,21 @@ __metadata: languageName: node linkType: hard +"cmdk@npm:^1.1.1": + version: 1.1.1 + resolution: "cmdk@npm:1.1.1" + dependencies: + "@radix-ui/react-compose-refs": "npm:^1.1.1" + "@radix-ui/react-dialog": "npm:^1.1.6" + "@radix-ui/react-id": "npm:^1.1.0" + "@radix-ui/react-primitive": "npm:^2.0.2" + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + checksum: 10c0/5605ac4396ec9bc65c82f954da19dd89a0636a54026df72780e2470da1381f9d57434a80a53f2d57eaa4e759660a3ebba9232b74258dc09970576591eae03116 + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -9466,6 +9617,25 @@ __metadata: languageName: node linkType: hard +"react-remove-scroll@npm:^2.6.3": + version: 2.6.3 + resolution: "react-remove-scroll@npm:2.6.3" + dependencies: + react-remove-scroll-bar: "npm:^2.3.7" + react-style-singleton: "npm:^2.2.3" + tslib: "npm:^2.1.0" + use-callback-ref: "npm:^1.3.3" + use-sidecar: "npm:^1.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/068e9704ff26816fffc4c8903e2c6c8df7291ee08615d7c1ab0cf8751f7080e2c5a5d78ef5d908b11b9cfc189f176d312e44cb02ea291ca0466d8283b479b438 + languageName: node + linkType: hard + "react-router-dom@npm:^7.0.0": version: 7.4.0 resolution: "react-router-dom@npm:7.4.0" @@ -9496,7 +9666,7 @@ __metadata: languageName: node linkType: hard -"react-style-singleton@npm:^2.2.1, react-style-singleton@npm:^2.2.2": +"react-style-singleton@npm:^2.2.1, react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": version: 2.2.3 resolution: "react-style-singleton@npm:2.2.3" dependencies: @@ -10876,7 +11046,7 @@ __metadata: languageName: node linkType: hard -"use-sidecar@npm:^1.1.2": +"use-sidecar@npm:^1.1.2, use-sidecar@npm:^1.1.3": version: 1.1.3 resolution: "use-sidecar@npm:1.1.3" dependencies: From a447b90e5d34337fb173dfcb0b6338ae736fc7bf Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Tue, 1 Apr 2025 15:05:52 -0500 Subject: [PATCH 06/32] Refactor ControlledTextFieldExample in text-field stories; remove unused submitHandlers and update route path. Delete unused data table story files for improved project clarity. --- .../data-table-router-form.stories.tsx | 292 +++++++++++++++++ .../remix-hook-form/text-field.stories.tsx | 20 +- .../src/ui/data-table-router-form.stories.tsx | 251 --------------- .../docs/src/ui/data-table-server.stories.tsx | 300 ------------------ apps/docs/src/ui/data-table.stories.tsx | 213 ------------- 5 files changed, 296 insertions(+), 780 deletions(-) create mode 100644 apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx delete mode 100644 apps/docs/src/ui/data-table-router-form.stories.tsx delete mode 100644 apps/docs/src/ui/data-table-server.stories.tsx delete mode 100644 apps/docs/src/ui/data-table.stories.tsx diff --git a/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx new file mode 100644 index 00000000..d4c8bc26 --- /dev/null +++ b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx @@ -0,0 +1,292 @@ +import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; +import { DataTableRouterForm } from '@lambdacurry/forms/ui/data-table/data-table-router-form'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { useEffect, useState } from 'react'; +import { useLoaderData } from 'react-router'; +import { z } from 'zod'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; + +// Define the data schema +const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + role: z.enum(['admin', 'user', 'editor']), + status: z.enum(['active', 'inactive', 'pending']), + createdAt: z.string().datetime(), +}); + +type User = z.infer; + +// Sample data +const users: User[] = Array.from({ length: 100 }).map((_, i) => ({ + id: `user-${i + 1}`, + name: `User ${i + 1}`, + email: `user${i + 1}@example.com`, + role: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'user' : 'editor', + status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', + createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), +})); + +// Define response type +interface DataResponse { + data: User[]; + meta: { + total: number; + page: number; + pageSize: number; + pageCount: number; + }; +} + +// Define the columns +const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('id')}
, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('name')}
, + }, + { + accessorKey: 'email', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('email')}
, + }, + { + accessorKey: 'role', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('role')}
, + enableColumnFilter: true, + filterFn: (row, id, value: string[]) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('status')}
, + enableColumnFilter: true, + filterFn: (row, id, value: string[]) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'createdAt', + header: ({ column }) => , + cell: ({ row }) =>
{new Date(row.getValue('createdAt')).toLocaleDateString()}
, + }, +]; + +// Component to display the data table with router form integration +const DataTableRouterFormExample = () => { + const [data, setData] = useState([]); + const [pageCount, setPageCount] = useState(0); + const loaderData = useLoaderData(); + + // Update state when loader data changes + useEffect(() => { + if (loaderData) { + setData(loaderData.data); + setPageCount(loaderData.meta.pageCount); + } + }, [loaderData]); + + return ( +
+

Users Table (React Router Form Integration)

+

This example demonstrates integration with React Router forms, including:

+
    +
  • Form-based filtering with automatic submission
  • +
  • Loading state while waiting for data
  • +
  • Server-side filtering and pagination
  • +
  • URL-based state management
  • +
+ +
+ ); +}; + +const meta: Meta = { + title: 'UI/DataTableRouterForm', + component: DataTableRouterForm, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: DataTableRouterFormExample, + loader: async ({ request }: { request: Request }) => { + // Simulate server delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // For initial load without URL params, create a base URL + const url = request.url ? new URL(request.url) : new URL('http://localhost'); + + // Set default values if not provided + const page = Number.parseInt(url.searchParams.get('page') || '0'); + const pageSize = Number.parseInt(url.searchParams.get('pageSize') || '10'); + const sortField = url.searchParams.get('sortField') || 'name'; + const sortOrder = url.searchParams.get('sortOrder') || 'asc'; + const roleFilter = url.searchParams.getAll('role'); + const statusFilter = url.searchParams.getAll('status'); + const search = url.searchParams.get('search'); + + // Apply filters + let filteredData = [...users]; + + if (roleFilter.length > 0) { + filteredData = filteredData.filter((user) => roleFilter.includes(user.role)); + } + + if (statusFilter.length > 0) { + filteredData = filteredData.filter((user) => statusFilter.includes(user.status)); + } + + if (search) { + const searchLower = search.toLowerCase(); + filteredData = filteredData.filter( + (user) => + user.name.toLowerCase().includes(searchLower) || user.email.toLowerCase().includes(searchLower), + ); + } + + // Apply sorting + if (sortField && sortOrder) { + filteredData.sort((a, b) => { + const aValue = a[sortField as keyof User]; + const bValue = b[sortField as keyof User]; + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + + // Apply pagination + const start = page * pageSize; + const paginatedData = filteredData.slice(start, start + pageSize); + + return { + data: paginatedData, + meta: { + total: filteredData.length, + page, + pageSize, + pageCount: Math.ceil(filteredData.length / pageSize), + }, + }; + }, + action: async ({ request }: { request: Request }) => { + // Simulate server delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + const formData = await request.formData(); + const page = Number.parseInt(formData.get('page')?.toString() || '0'); + const pageSize = Number.parseInt(formData.get('pageSize')?.toString() || '10'); + const sortField = formData.get('sortField')?.toString() || 'name'; + const sortOrder = formData.get('sortOrder')?.toString() || 'asc'; + const roleFilter = formData.getAll('role').map((val: FormDataEntryValue) => val.toString()); + const statusFilter = formData.getAll('status').map((val: FormDataEntryValue) => val.toString()); + const search = formData.get('search')?.toString(); + + // Apply filters + let filteredData = [...users]; + + if (roleFilter.length > 0) { + filteredData = filteredData.filter((user) => roleFilter.includes(user.role)); + } + + if (statusFilter.length > 0) { + filteredData = filteredData.filter((user) => statusFilter.includes(user.status)); + } + + if (search) { + const searchLower = search.toLowerCase(); + filteredData = filteredData.filter( + (user) => + user.name.toLowerCase().includes(searchLower) || user.email.toLowerCase().includes(searchLower), + ); + } + + // Apply sorting + if (sortField && sortOrder) { + filteredData.sort((a, b) => { + const aValue = a[sortField as keyof User]; + const bValue = b[sortField as keyof User]; + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + + // Apply pagination + const start = page * pageSize; + const paginatedData = filteredData.slice(start, start + pageSize); + + return { + data: paginatedData, + meta: { + total: filteredData.length, + page, + pageSize, + pageCount: Math.ceil(filteredData.length / pageSize), + }, + }; + }, + }, + ], + }), + ], +}; diff --git a/apps/docs/src/remix-hook-form/text-field.stories.tsx b/apps/docs/src/remix-hook-form/text-field.stories.tsx index 5dbb4b83..9e8dc3ef 100644 --- a/apps/docs/src/remix-hook-form/text-field.stories.tsx +++ b/apps/docs/src/remix-hook-form/text-field.stories.tsx @@ -1,10 +1,10 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field'; import { Button } from '@lambdacurry/forms/ui/button'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryContext, StoryObj } from '@storybook/react'; import { expect, userEvent } from '@storybook/test'; import { type ActionFunctionArgs, useFetcher } from 'react-router'; -import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; import { z } from 'zod'; import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; @@ -36,19 +36,6 @@ const ControlledTextFieldExample = () => { action: '/', method: 'post', }, - submitHandlers: { - onValid: (data) => { - fetcher.submit( - createFormData({ - email: data.email, - }), - { - method: 'post', - action: '/', - }, - ); - }, - }, }); return ( @@ -162,7 +149,8 @@ export const Examples: Story = { withReactRouterStubDecorator({ routes: [ { - path: '/username', + path: '/', + Component: ControlledTextFieldExample, action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), }, ], diff --git a/apps/docs/src/ui/data-table-router-form.stories.tsx b/apps/docs/src/ui/data-table-router-form.stories.tsx deleted file mode 100644 index 12612ec8..00000000 --- a/apps/docs/src/ui/data-table-router-form.stories.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { DataTableRouterForm } from '@lambdacurry/forms/ui/data-table/data-table-router-form'; -import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; -import type { Meta, StoryObj } from '@storybook/react'; -import { type ActionFunctionArgs, useLoaderData, useNavigation } from 'react-router'; -import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; -import { z } from 'zod'; -import { useEffect, useState } from 'react'; - -// Define the data schema -const userSchema = z.object({ - id: z.string(), - name: z.string(), - email: z.string().email(), - role: z.enum(['admin', 'user', 'editor']), - status: z.enum(['active', 'inactive', 'pending']), - createdAt: z.string().datetime(), -}); - -type User = z.infer; - -// Sample data -const users: User[] = Array.from({ length: 100 }).map((_, i) => ({ - id: `user-${i + 1}`, - name: `User ${i + 1}`, - email: `user${i + 1}@example.com`, - role: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'user' : 'editor', - status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', - createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), -})); - -// Define the columns -const columns = [ - { - accessorKey: 'id', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('id')}
, - enableSorting: false, - enableHiding: false, - }, - { - accessorKey: 'name', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('name')}
, - }, - { - accessorKey: 'email', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('email')}
, - }, - { - accessorKey: 'role', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('role')}
, - filterFn: (row, id, value) => { - return value.includes(row.getValue(id)); - }, - }, - { - accessorKey: 'status', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
{row.getValue('status')}
- ), - filterFn: (row, id, value) => { - return value.includes(row.getValue(id)); - }, - }, - { - accessorKey: 'createdAt', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
{new Date(row.getValue('createdAt')).toLocaleDateString()}
- ), - }, -]; - -// Mock API handler for data fetching with filters and pagination -const handleDataFetch = async (request: Request) => { - // Simulate server delay - await new Promise(resolve => setTimeout(resolve, 500)); - - // Get form data - const formData = await request.formData(); - - // Get query parameters - const page = parseInt(formData.get('page')?.toString() || '0'); - const pageSize = parseInt(formData.get('pageSize')?.toString() || '10'); - const sortField = formData.get('sortField')?.toString(); - const sortOrder = formData.get('sortOrder')?.toString(); - const roleFilter = formData.getAll('role').map(val => val.toString()); - const statusFilter = formData.getAll('status').map(val => val.toString()); - const search = formData.get('search')?.toString(); - - // Apply filters - let filteredData = [...users]; - - if (roleFilter.length > 0) { - filteredData = filteredData.filter(user => roleFilter.includes(user.role)); - } - - if (statusFilter.length > 0) { - filteredData = filteredData.filter(user => statusFilter.includes(user.status)); - } - - if (search) { - const searchLower = search.toLowerCase(); - filteredData = filteredData.filter( - user => - user.name.toLowerCase().includes(searchLower) || - user.email.toLowerCase().includes(searchLower) - ); - } - - // Apply sorting - if (sortField && sortOrder) { - filteredData.sort((a, b) => { - const aValue = a[sortField as keyof User]; - const bValue = b[sortField as keyof User]; - - if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; - if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; - return 0; - }); - } - - // Apply pagination - const start = page * pageSize; - const paginatedData = filteredData.slice(start, start + pageSize); - - return { - data: paginatedData, - meta: { - total: filteredData.length, - page, - pageSize, - pageCount: Math.ceil(filteredData.length / pageSize), - } - }; -}; - -// Component to display the data table with router form integration -const DataTableRouterFormExample = () => { - const [data, setData] = useState([]); - const [pageCount, setPageCount] = useState(0); - const navigation = useNavigation(); - - // Get data from the router action - const actionData = useLoaderData() as { - data: User[]; - meta: { - total: number; - page: number; - pageSize: number; - pageCount: number; - } - } | null; - - // Update state when action data changes - useEffect(() => { - if (actionData) { - setData(actionData.data); - setPageCount(actionData.meta.pageCount); - } - }, [actionData]); - - return ( -
-

Users Table (React Router Form Integration)

-

- This example demonstrates integration with React Router forms, including: -

-
    -
  • Form-based filtering with automatic submission
  • -
  • Loading state while waiting for data
  • -
  • Server-side filtering and pagination
  • -
  • URL-based state management
  • -
- -
- ); -}; - -const meta: Meta = { - title: 'UI/DataTableRouterForm', - component: DataTableRouterForm, - parameters: { - layout: 'fullscreen', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => , - decorators: [ - withReactRouterStubDecorator({ - routes: [ - { - path: '/api/users', - action: async ({ request }: ActionFunctionArgs) => handleDataFetch(request), - }, - ], - }), - ], -}; \ No newline at end of file diff --git a/apps/docs/src/ui/data-table-server.stories.tsx b/apps/docs/src/ui/data-table-server.stories.tsx deleted file mode 100644 index 8c4b7479..00000000 --- a/apps/docs/src/ui/data-table-server.stories.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { DataTable } from '@lambdacurry/forms/ui/data-table'; -import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; -import type { Meta, StoryObj } from '@storybook/react'; -import { type ActionFunctionArgs, useLoaderData, useSearchParams } from 'react-router'; -import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; -import { z } from 'zod'; -import { useEffect, useState } from 'react'; - -// Define the data schema -const userSchema = z.object({ - id: z.string(), - name: z.string(), - email: z.string().email(), - role: z.enum(['admin', 'user', 'editor']), - status: z.enum(['active', 'inactive', 'pending']), - createdAt: z.string().datetime(), -}); - -type User = z.infer; - -// Sample data -const users: User[] = Array.from({ length: 100 }).map((_, i) => ({ - id: `user-${i + 1}`, - name: `User ${i + 1}`, - email: `user${i + 1}@example.com`, - role: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'user' : 'editor', - status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', - createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), -})); - -// Define the columns -const columns = [ - { - accessorKey: 'id', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('id')}
, - enableSorting: false, - enableHiding: false, - }, - { - accessorKey: 'name', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('name')}
, - }, - { - accessorKey: 'email', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('email')}
, - }, - { - accessorKey: 'role', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('role')}
, - filterFn: (row, id, value) => { - return value.includes(row.getValue(id)); - }, - }, - { - accessorKey: 'status', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
{row.getValue('status')}
- ), - filterFn: (row, id, value) => { - return value.includes(row.getValue(id)); - }, - }, - { - accessorKey: 'createdAt', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
{new Date(row.getValue('createdAt')).toLocaleDateString()}
- ), - }, -]; - -// Mock API handler for data fetching with filters and pagination -const handleDataFetch = async (request: Request) => { - const url = new URL(request.url); - - // Get query parameters - const page = parseInt(url.searchParams.get('page') || '0'); - const pageSize = parseInt(url.searchParams.get('pageSize') || '10'); - const sortField = url.searchParams.get('sortField'); - const sortOrder = url.searchParams.get('sortOrder'); - const roleFilter = url.searchParams.getAll('role'); - const statusFilter = url.searchParams.getAll('status'); - const search = url.searchParams.get('search'); - - // Apply filters - let filteredData = [...users]; - - if (roleFilter.length > 0) { - filteredData = filteredData.filter(user => roleFilter.includes(user.role)); - } - - if (statusFilter.length > 0) { - filteredData = filteredData.filter(user => statusFilter.includes(user.status)); - } - - if (search) { - const searchLower = search.toLowerCase(); - filteredData = filteredData.filter( - user => - user.name.toLowerCase().includes(searchLower) || - user.email.toLowerCase().includes(searchLower) - ); - } - - // Apply sorting - if (sortField && sortOrder) { - filteredData.sort((a, b) => { - const aValue = a[sortField as keyof User]; - const bValue = b[sortField as keyof User]; - - if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; - if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; - return 0; - }); - } - - // Apply pagination - const start = page * pageSize; - const paginatedData = filteredData.slice(start, start + pageSize); - - return { - data: paginatedData, - meta: { - total: filteredData.length, - page, - pageSize, - } - }; -}; - -// Component to display the data table with server-side filtering and pagination -const ServerSideDataTableExample = () => { - const [searchParams, setSearchParams] = useSearchParams(); - const [data, setData] = useState([]); - const [totalItems, setTotalItems] = useState(0); - - // Fetch data when search params change - useEffect(() => { - const fetchData = async () => { - const queryString = searchParams.toString(); - const response = await fetch(`/api/users?${queryString}`); - const result = await response.json(); - setData(result.data); - setTotalItems(result.meta.total); - }; - - fetchData(); - }, [searchParams]); - - // Handle pagination change - const handlePaginationChange = (pageIndex: number, pageSize: number) => { - setSearchParams(prev => { - const newParams = new URLSearchParams(prev); - newParams.set('page', pageIndex.toString()); - newParams.set('pageSize', pageSize.toString()); - return newParams; - }); - }; - - // Handle sorting change - const handleSortingChange = (sorting: any) => { - if (sorting.length > 0) { - const { id, desc } = sorting[0]; - setSearchParams(prev => { - const newParams = new URLSearchParams(prev); - newParams.set('sortField', id); - newParams.set('sortOrder', desc ? 'desc' : 'asc'); - return newParams; - }); - } else { - setSearchParams(prev => { - const newParams = new URLSearchParams(prev); - newParams.delete('sortField'); - newParams.delete('sortOrder'); - return newParams; - }); - } - }; - - // Handle filter change - const handleFilterChange = (filters: any) => { - setSearchParams(prev => { - const newParams = new URLSearchParams(); - - // Preserve pagination and sorting - const page = prev.get('page'); - const pageSize = prev.get('pageSize'); - const sortField = prev.get('sortField'); - const sortOrder = prev.get('sortOrder'); - - if (page) newParams.set('page', page); - if (pageSize) newParams.set('pageSize', pageSize); - if (sortField) newParams.set('sortField', sortField); - if (sortOrder) newParams.set('sortOrder', sortOrder); - - // Add new filters - filters.forEach((filter: any) => { - if (filter.value && filter.value.length > 0) { - filter.value.forEach((val: string) => { - newParams.append(filter.id, val); - }); - } - }); - - return newParams; - }); - }; - - return ( -
-

Users Table (Server-side Filtering)

-

- This example demonstrates server-side filtering, sorting, and pagination using URL query parameters. -

- -
- Total items: {totalItems} -
-
- ); -}; - -const meta: Meta = { - title: 'UI/DataTableServer', - component: DataTable, - parameters: { - layout: 'fullscreen', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const ServerSide: Story = { - render: () => , - decorators: [ - withReactRouterStubDecorator({ - routes: [ - { - path: '/api/users', - action: async ({ request }: ActionFunctionArgs) => handleDataFetch(request), - }, - ], - }), - ], -}; \ No newline at end of file diff --git a/apps/docs/src/ui/data-table.stories.tsx b/apps/docs/src/ui/data-table.stories.tsx deleted file mode 100644 index c0b31d30..00000000 --- a/apps/docs/src/ui/data-table.stories.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { DataTable } from '@lambdacurry/forms/ui/data-table'; -import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; -import type { Meta, StoryObj } from '@storybook/react'; -import { type ActionFunctionArgs } from 'react-router'; -import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; -import { z } from 'zod'; - -// Define the data schema -const userSchema = z.object({ - id: z.string(), - name: z.string(), - email: z.string().email(), - role: z.enum(['admin', 'user', 'editor']), - status: z.enum(['active', 'inactive', 'pending']), - createdAt: z.string().datetime(), -}); - -type User = z.infer; - -// Sample data -const users: User[] = Array.from({ length: 50 }).map((_, i) => ({ - id: `user-${i + 1}`, - name: `User ${i + 1}`, - email: `user${i + 1}@example.com`, - role: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'user' : 'editor', - status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', - createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), -})); - -// Define the columns -const columns = [ - { - accessorKey: 'id', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('id')}
, - enableSorting: false, - enableHiding: false, - }, - { - accessorKey: 'name', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('name')}
, - }, - { - accessorKey: 'email', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('email')}
, - }, - { - accessorKey: 'role', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('role')}
, - filterFn: (row, id, value) => { - return value.includes(row.getValue(id)); - }, - }, - { - accessorKey: 'status', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
{row.getValue('status')}
- ), - filterFn: (row, id, value) => { - return value.includes(row.getValue(id)); - }, - }, - { - accessorKey: 'createdAt', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
{new Date(row.getValue('createdAt')).toLocaleDateString()}
- ), - }, -]; - -// Mock API handler for data fetching with filters and pagination -const handleDataFetch = async (request: Request) => { - const url = new URL(request.url); - - // Get query parameters - const page = parseInt(url.searchParams.get('page') || '0'); - const pageSize = parseInt(url.searchParams.get('pageSize') || '10'); - const sortField = url.searchParams.get('sortField'); - const sortOrder = url.searchParams.get('sortOrder'); - const roleFilter = url.searchParams.getAll('role'); - const statusFilter = url.searchParams.getAll('status'); - const search = url.searchParams.get('search'); - - // Apply filters - let filteredData = [...users]; - - if (roleFilter.length > 0) { - filteredData = filteredData.filter(user => roleFilter.includes(user.role)); - } - - if (statusFilter.length > 0) { - filteredData = filteredData.filter(user => statusFilter.includes(user.status)); - } - - if (search) { - const searchLower = search.toLowerCase(); - filteredData = filteredData.filter( - user => - user.name.toLowerCase().includes(searchLower) || - user.email.toLowerCase().includes(searchLower) - ); - } - - // Apply sorting - if (sortField && sortOrder) { - filteredData.sort((a, b) => { - const aValue = a[sortField as keyof User]; - const bValue = b[sortField as keyof User]; - - if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; - if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; - return 0; - }); - } - - // Apply pagination - const start = page * pageSize; - const paginatedData = filteredData.slice(start, start + pageSize); - - return { - data: paginatedData, - meta: { - total: filteredData.length, - page, - pageSize, - } - }; -}; - -// Component to display the data table -const DataTableExample = () => { - return ( -
- -
- ); -}; - -const meta: Meta = { - title: 'UI/DataTable', - component: DataTable, - parameters: { - layout: 'fullscreen', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => , - decorators: [ - withReactRouterStubDecorator({ - routes: [ - { - path: '/api/users', - action: async ({ request }: ActionFunctionArgs) => handleDataFetch(request), - }, - ], - }), - ], -}; \ No newline at end of file From 546f30594f1d45d1262195ab9e092058199e48ed 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:38:20 +0000 Subject: [PATCH 07/32] Implement DataTable with nuqs and zod filters --- .../src/ui/data-table-nuqs-form.stories.tsx | 320 ++++++++++++++++++ packages/components/package.json | 1 + .../ui/data-table/data-table-date-filter.tsx | 157 +++++++++ .../data-table/data-table-filter-factory.tsx | 70 ++++ .../data-table/data-table-number-filter.tsx | 148 ++++++++ .../ui/data-table/data-table-nuqs-filter.tsx | 181 ++++++++++ .../ui/data-table/data-table-nuqs-form.tsx | 199 +++++++++++ .../ui/data-table/data-table-nuqs-toolbar.tsx | 93 +++++ .../src/ui/data-table/data-table-schemas.ts | 59 ++++ .../ui/data-table/data-table-text-filter.tsx | 127 +++++++ .../components/src/ui/data-table/index.ts | 18 +- packages/components/src/ui/index.ts | 2 +- 12 files changed, 1371 insertions(+), 4 deletions(-) create mode 100644 apps/docs/src/ui/data-table-nuqs-form.stories.tsx create mode 100644 packages/components/src/ui/data-table/data-table-date-filter.tsx create mode 100644 packages/components/src/ui/data-table/data-table-filter-factory.tsx create mode 100644 packages/components/src/ui/data-table/data-table-number-filter.tsx create mode 100644 packages/components/src/ui/data-table/data-table-nuqs-filter.tsx create mode 100644 packages/components/src/ui/data-table/data-table-nuqs-form.tsx create mode 100644 packages/components/src/ui/data-table/data-table-nuqs-toolbar.tsx create mode 100644 packages/components/src/ui/data-table/data-table-schemas.ts create mode 100644 packages/components/src/ui/data-table/data-table-text-filter.tsx diff --git a/apps/docs/src/ui/data-table-nuqs-form.stories.tsx b/apps/docs/src/ui/data-table-nuqs-form.stories.tsx new file mode 100644 index 00000000..3ce0ea07 --- /dev/null +++ b/apps/docs/src/ui/data-table-nuqs-form.stories.tsx @@ -0,0 +1,320 @@ +import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; +import { DataTableNuqsForm } from '@lambdacurry/forms/ui/data-table/data-table-nuqs-form'; +import { + createColumnFilterSchema, + selectFilterSchema, + textFilterSchema, + numberFilterSchema, + dateFilterSchema +} from '@lambdacurry/forms/ui/data-table/data-table-schemas'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { useEffect, useState } from 'react'; +import { z } from 'zod'; + +// Define the data schema +const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + role: z.enum(['admin', 'user', 'editor']), + status: z.enum(['active', 'inactive', 'pending']), + age: z.number().min(18).max(100), + createdAt: z.string().datetime(), +}); + +type User = z.infer; + +// Sample data +const users: User[] = Array.from({ length: 100 }).map((_, i) => ({ + id: `user-${i + 1}`, + name: `User ${i + 1}`, + email: `user${i + 1}@example.com`, + role: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'user' : 'editor', + status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', + age: 18 + Math.floor(Math.random() * 50), + createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), +})); + +// Define response type +interface DataResponse { + data: User[]; + meta: { + total: number; + page: number; + pageSize: number; + pageCount: number; + }; +} + +// Define the columns +const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('id')}
, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('name')}
, + }, + { + accessorKey: 'email', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('email')}
, + }, + { + accessorKey: 'role', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('role')}
, + enableColumnFilter: true, + filterFn: (row, id, value: string[]) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('status')}
, + enableColumnFilter: true, + filterFn: (row, id, value: string[]) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: 'age', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('age')}
, + enableColumnFilter: true, + filterFn: (row, id, value: { min?: number; max?: number }) => { + const age = row.getValue(id); + if (value.min !== undefined && value.max !== undefined) { + return age >= value.min && age <= value.max; + } else if (value.min !== undefined) { + return age >= value.min; + } else if (value.max !== undefined) { + return age <= value.max; + } + return true; + }, + }, + { + accessorKey: 'createdAt', + header: ({ column }) => , + cell: ({ row }) =>
{new Date(row.getValue('createdAt')).toLocaleDateString()}
, + enableColumnFilter: true, + filterFn: (row, id, value: { from?: string; to?: string }) => { + const date = new Date(row.getValue(id)).getTime(); + if (value.from && value.to) { + return date >= new Date(value.from).getTime() && date <= new Date(value.to).getTime(); + } else if (value.from) { + return date >= new Date(value.from).getTime(); + } else if (value.to) { + return date <= new Date(value.to).getTime(); + } + return true; + }, + }, +]; + +// Component to display the data table with nuqs integration +const DataTableNuqsFormExample = () => { + const [data, setData] = useState([]); + const [pageCount, setPageCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // Simulate data fetching with URL parameters + useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + + // Get URL parameters + const url = new URL(window.location.href); + const page = Number.parseInt(url.searchParams.get('page') || '0'); + const pageSize = Number.parseInt(url.searchParams.get('pageSize') || '10'); + const sortParam = url.searchParams.get('sort'); + const filtersParam = url.searchParams.get('filters'); + const search = url.searchParams.get('search') || ''; + + // Parse sorting + let sortField = 'name'; + let sortOrder = 'asc'; + + if (sortParam) { + try { + const sortData = JSON.parse(sortParam); + if (sortData.length > 0) { + sortField = sortData[0].id; + sortOrder = sortData[0].desc ? 'desc' : 'asc'; + } + } catch (e) { + console.error('Error parsing sort parameter:', e); + } + } + + // Parse filters + const filters: Record = {}; + + if (filtersParam) { + try { + const filtersData = JSON.parse(filtersParam); + filtersData.forEach((filter: any) => { + filters[filter.id] = filter.value; + }); + } catch (e) { + console.error('Error parsing filters parameter:', e); + } + } + + // Simulate server delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Apply filters + let filteredData = [...users]; + + // Apply search + if (search) { + const searchLower = search.toLowerCase(); + filteredData = filteredData.filter( + (user) => + user.name.toLowerCase().includes(searchLower) || user.email.toLowerCase().includes(searchLower), + ); + } + + // Apply column filters + if (filters.role) { + filteredData = filteredData.filter((user) => filters.role.includes(user.role)); + } + + if (filters.status) { + filteredData = filteredData.filter((user) => filters.status.includes(user.status)); + } + + if (filters.age) { + if (filters.age.min !== undefined) { + filteredData = filteredData.filter((user) => user.age >= filters.age.min); + } + if (filters.age.max !== undefined) { + filteredData = filteredData.filter((user) => user.age <= filters.age.max); + } + } + + if (filters.createdAt) { + if (filters.createdAt.from) { + const fromDate = new Date(filters.createdAt.from).getTime(); + filteredData = filteredData.filter( + (user) => new Date(user.createdAt).getTime() >= fromDate, + ); + } + if (filters.createdAt.to) { + const toDate = new Date(filters.createdAt.to).getTime(); + filteredData = filteredData.filter( + (user) => new Date(user.createdAt).getTime() <= toDate, + ); + } + } + + // Apply sorting + filteredData.sort((a, b) => { + const aValue = a[sortField as keyof User]; + const bValue = b[sortField as keyof User]; + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + + // Apply pagination + const start = page * pageSize; + const paginatedData = filteredData.slice(start, start + pageSize); + + setData(paginatedData); + setPageCount(Math.ceil(filteredData.length / pageSize)); + setIsLoading(false); + }; + + fetchData(); + }, []); + + return ( +
+

Users Table (Nuqs Integration)

+

This example demonstrates integration with nuqs and zod, including:

+
    +
  • URL-based state management with nuqs
  • +
  • Schema validation with zod
  • +
  • Four filter types: text, select, number, and date
  • +
  • Pagination support
  • +
+ +
+ ); +}; + +const meta: Meta = { + title: 'UI/DataTableNuqsForm', + component: DataTableNuqsForm, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; \ No newline at end of file diff --git a/packages/components/package.json b/packages/components/package.json index f342dd27..734c60b4 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -62,6 +62,7 @@ "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", "react-router": "^7.0.0", diff --git a/packages/components/src/ui/data-table/data-table-date-filter.tsx b/packages/components/src/ui/data-table/data-table-date-filter.tsx new file mode 100644 index 00000000..03391f80 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-date-filter.tsx @@ -0,0 +1,157 @@ +import type { Column } from '@tanstack/react-table'; +import { PlusCircle } from 'lucide-react'; +import * as React from 'react'; +import { z } from 'zod'; +import { useQueryState, createParser } from 'nuqs'; +import { format } from 'date-fns'; +import { Calendar as CalendarIcon } from 'lucide-react'; + +import { Badge } from '../badge'; +import { Button } from '../button'; +import { Calendar } from '../calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Separator } from '../separator'; +import { dateFilterSchema } from './data-table-schemas'; + +// Create a parser for date range values +const parseAsDateRange = createParser({ + parse: (value) => { + try { + return JSON.parse(value) as { from?: string; to?: string }; + } catch (e) { + return {}; + } + }, + serialize: (value) => JSON.stringify(value), +}); + +interface DataTableDateFilterProps { + column?: Column; + title?: string; + schema?: z.ZodType; +} + +export function DataTableDateFilter({ + column, + title, + schema = dateFilterSchema, +}: DataTableDateFilterProps) { + const columnId = column?.id || ''; + + // Use nuqs for URL state management + const [dateRange, setDateRange] = useQueryState( + `filter_${columnId}`, + parseAsDateRange.withDefault({}) + ); + + const [fromDate, setFromDate] = React.useState( + dateRange.from ? new Date(dateRange.from) : undefined + ); + const [toDate, setToDate] = React.useState( + dateRange.to ? new Date(dateRange.to) : undefined + ); + + // Set column filter when dateRange changes + React.useEffect(() => { + if (column) { + if (dateRange.from || dateRange.to) { + column.setFilterValue(dateRange); + } else { + column.setFilterValue(undefined); + } + } + }, [column, dateRange]); + + const handleApply = () => { + const newRange: { from?: string; to?: string } = {}; + + if (fromDate) { + newRange.from = fromDate.toISOString(); + } + + if (toDate) { + newRange.to = toDate.toISOString(); + } + + try { + // Validate with zod schema + schema.parse(newRange); + setDateRange(newRange); + } catch (error) { + // If validation fails, don't update + console.error('Validation error:', error); + } + }; + + const handleReset = () => { + setFromDate(undefined); + setToDate(undefined); + setDateRange({}); + }; + + const hasValue = Boolean(dateRange.from || dateRange.to); + + return ( + + + + + +
+
+

Filter by {title}

+
+
+
+ + From +
+ +
+
+
+ + To +
+ +
+
+
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-filter-factory.tsx b/packages/components/src/ui/data-table/data-table-filter-factory.tsx new file mode 100644 index 00000000..0e174103 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-filter-factory.tsx @@ -0,0 +1,70 @@ +import type { Column } from '@tanstack/react-table'; +import * as React from 'react'; +import { z } from 'zod'; + +import { DataTableNuqsFilter } from './data-table-nuqs-filter'; +import { DataTableNumberFilter } from './data-table-number-filter'; +import { DataTableDateFilter } from './data-table-date-filter'; +import { DataTableTextFilter } from './data-table-text-filter'; + +type FilterType = 'select' | 'number' | 'date' | 'text'; + +interface DataTableFilterFactoryProps { + column?: Column; + title?: string; + type: FilterType; + options?: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + schema?: z.ZodType; + placeholder?: string; +} + +export function DataTableFilterFactory({ + column, + title, + type, + options = [], + schema, + placeholder, +}: DataTableFilterFactoryProps) { + switch (type) { + case 'select': + return ( + + ); + case 'number': + return ( + + ); + case 'date': + return ( + + ); + case 'text': + default: + return ( + + ); + } +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-number-filter.tsx b/packages/components/src/ui/data-table/data-table-number-filter.tsx new file mode 100644 index 00000000..f6823002 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-number-filter.tsx @@ -0,0 +1,148 @@ +import type { Column } from '@tanstack/react-table'; +import { PlusCircle } from 'lucide-react'; +import * as React from 'react'; +import { z } from 'zod'; +import { useQueryState, createParser } from 'nuqs'; + +import { Badge } from '../badge'; +import { Button } from '../button'; +import { TextInput } from '../text-input'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Separator } from '../separator'; +import { numberFilterSchema } from './data-table-schemas'; + +// Create a parser for number range values +const parseAsNumberRange = createParser({ + parse: (value) => { + try { + return JSON.parse(value) as { min?: number; max?: number }; + } catch (e) { + return {}; + } + }, + serialize: (value) => JSON.stringify(value), +}); + +interface DataTableNumberFilterProps { + column?: Column; + title?: string; + schema?: z.ZodType; +} + +export function DataTableNumberFilter({ + column, + title, + schema = numberFilterSchema, +}: DataTableNumberFilterProps) { + const columnId = column?.id || ''; + + // Use nuqs for URL state management + const [range, setRange] = useQueryState( + `filter_${columnId}`, + parseAsNumberRange.withDefault({}) + ); + + const [localMin, setLocalMin] = React.useState(range.min?.toString() || ''); + const [localMax, setLocalMax] = React.useState(range.max?.toString() || ''); + + // Set column filter when range changes + React.useEffect(() => { + if (column) { + if (range.min !== undefined || range.max !== undefined) { + column.setFilterValue(range); + } else { + column.setFilterValue(undefined); + } + } + }, [column, range]); + + const handleApply = () => { + const newRange: { min?: number; max?: number } = {}; + + if (localMin !== '') { + const min = Number(localMin); + if (!isNaN(min)) { + newRange.min = min; + } + } + + if (localMax !== '') { + const max = Number(localMax); + if (!isNaN(max)) { + newRange.max = max; + } + } + + try { + // Validate with zod schema + schema.parse(newRange); + setRange(newRange); + } catch (error) { + // If validation fails, don't update + console.error('Validation error:', error); + } + }; + + const handleReset = () => { + setLocalMin(''); + setLocalMax(''); + setRange({}); + }; + + const hasValue = range.min !== undefined || range.max !== undefined; + + return ( + + + + + +
+
+

Filter by {title}

+
+ setLocalMin(e.target.value)} + type="number" + className="h-8" + /> + to + setLocalMax(e.target.value)} + type="number" + className="h-8" + /> +
+
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-nuqs-filter.tsx b/packages/components/src/ui/data-table/data-table-nuqs-filter.tsx new file mode 100644 index 00000000..fcbe8aeb --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-nuqs-filter.tsx @@ -0,0 +1,181 @@ +import type { Column } from '@tanstack/react-table'; +import { Check, PlusCircle } from 'lucide-react'; +import * as React from 'react'; +import { z } from 'zod'; +import { useQueryState, createParser } from 'nuqs'; + +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'; + +// Create a parser for array values +const parseAsStringArray = createParser({ + parse: (value) => { + try { + return JSON.parse(value) as string[]; + } catch (e) { + return []; + } + }, + serialize: (value) => JSON.stringify(value), +}); + +interface DataTableNuqsFilterProps { + column?: Column; + title?: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + schema?: z.ZodType; +} + +export function DataTableNuqsFilter({ + column, + title, + options, + schema, +}: DataTableNuqsFilterProps) { + const facets = column?.getFacetedUniqueValues(); + const columnId = column?.id || ''; + + // Use nuqs for URL state management + const [selectedValues, setSelectedValues] = useQueryState( + `filter_${columnId}`, + parseAsStringArray.withDefault([]) + ); + + // Set column filter when selectedValues change + React.useEffect(() => { + if (column) { + if (selectedValues.length > 0) { + column.setFilterValue(selectedValues); + } else { + column.setFilterValue(undefined); + } + } + }, [column, selectedValues]); + + // Validate values with zod schema if provided + const validateValue = (value: string) => { + if (!schema) return true; + try { + schema.parse(value); + return true; + } catch (error) { + return false; + } + }; + + const handleSelect = (value: string) => { + const isSelected = selectedValues.includes(value); + let newValues: string[]; + + if (isSelected) { + newValues = selectedValues.filter((v) => v !== value); + } else { + if (validateValue(value)) { + newValues = [...selectedValues, value]; + } else { + // If validation fails, don't add the value + return; + } + } + + setSelectedValues(newValues); + }; + + return ( + + + + + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + handleSelect(option.value)} + > +
+ +
+ {option.icon && } + {option.label} + {facets?.get(option.value) && ( + + {facets.get(option.value)} + + )} +
+ ); + })} +
+ {selectedValues.length > 0 && ( + <> + + + setSelectedValues([])} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-nuqs-form.tsx b/packages/components/src/ui/data-table/data-table-nuqs-form.tsx new file mode 100644 index 00000000..90a9867a --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-nuqs-form.tsx @@ -0,0 +1,199 @@ +import { + type ColumnDef, + type ColumnFiltersState, + type SortingState, + type VisibilityState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { z } from 'zod'; +import { useQueryState, parseAsInteger, parseAsString, parseAsJson, createParser } from 'nuqs'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table'; +import { DataTablePagination } from './data-table-pagination'; +import { DataTableNuqsToolbar } from './data-table-nuqs-toolbar'; + +// Create a parser for sorting state +const parseAsSortingState = createParser({ + parse: (value) => { + try { + const parsed = JSON.parse(value); + return parsed as SortingState; + } catch (e) { + return []; + } + }, + serialize: (value) => JSON.stringify(value), +}); + +// Create a parser for column filters state +const parseAsColumnFiltersState = createParser({ + parse: (value) => { + try { + const parsed = JSON.parse(value); + return parsed as ColumnFiltersState; + } catch (e) { + return []; + } + }, + serialize: (value) => JSON.stringify(value), +}); + +interface DataTableNuqsFormProps { + columns: ColumnDef[]; + data: TData[]; + filterableColumns?: { + id: keyof TData; + title: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + schema?: z.ZodType; + }[]; + searchableColumns?: { + id: keyof TData; + title: string; + schema?: z.ZodType; + }[]; + defaultSort?: { + id: string; + desc: boolean; + }; + pageCount?: number; + isLoading?: boolean; +} + +export function DataTableNuqsForm({ + columns, + data, + filterableColumns = [], + searchableColumns = [], + defaultSort, + pageCount, + isLoading = false, +}: DataTableNuqsFormProps) { + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = React.useState({}); + + // Use nuqs for URL state management + const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(0)); + const [pageSize, setPageSize] = useQueryState('pageSize', parseAsInteger.withDefault(10)); + const [sorting, setSorting] = useQueryState( + 'sort', + parseAsSortingState.withDefault(defaultSort ? [defaultSort] : []) + ); + const [columnFilters, setColumnFilters] = useQueryState( + 'filters', + parseAsColumnFiltersState.withDefault([]) + ); + const [search, setSearch] = useQueryState('search', parseAsString.withDefault('')); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination: { + pageIndex: page, + pageSize, + }, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: (updater) => { + const newSorting = typeof updater === 'function' ? updater(sorting) : updater; + setSorting(newSorting); + }, + onColumnFiltersChange: (updater) => { + const newFilters = typeof updater === 'function' ? updater(columnFilters) : updater; + setColumnFilters(newFilters); + }, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: (updater) => { + const newPagination = typeof updater === 'function' + ? updater({ pageIndex: page, pageSize }) + : updater; + setPage(newPagination.pageIndex); + setPageSize(newPagination.pageSize); + }, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + manualPagination: Boolean(pageCount), + pageCount, + }); + + return ( +
+ + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {isLoading ? ( + + +
+
+ Loading... +
+ + + // biome-ignore lint/style/useExplicitLengthCheck: + ) : table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ + +
+ ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-nuqs-toolbar.tsx b/packages/components/src/ui/data-table/data-table-nuqs-toolbar.tsx new file mode 100644 index 00000000..e7e51ae2 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-nuqs-toolbar.tsx @@ -0,0 +1,93 @@ +import type { Table } from '@tanstack/react-table'; +import { X } from 'lucide-react'; +import * as React from 'react'; +import { z } from 'zod'; + +import { Button } from '../button'; +import { TextInput } from '../text-input'; +import { DataTableViewOptions } from './data-table-view-options'; +import { DataTableFilterFactory } from './data-table-filter-factory'; + +interface DataTableNuqsToolbarProps { + table: Table; + filterableColumns?: { + id: keyof TData; + title: string; + type?: 'select' | 'number' | 'date' | 'text'; + options?: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + schema?: z.ZodType; + placeholder?: string; + }[]; + searchableColumns?: { + id: keyof TData; + title: string; + schema?: z.ZodType; + }[]; + search: string; + setSearch: (value: string) => void; +} + +export function DataTableNuqsToolbar({ + table, + filterableColumns = [], + searchableColumns = [], + search, + setSearch, +}: DataTableNuqsToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0 || search !== ''; + + return ( +
+
+ {searchableColumns.length > 0 && + searchableColumns.map( + (column) => + table.getColumn(column.id as string) && ( + + setSearch(event.target.value)} + className="h-8 w-[150px] lg:w-[250px]" + /> + + ), + )} + {filterableColumns.length > 0 && + filterableColumns.map( + (column) => + table.getColumn(column.id as string) && ( + + ), + )} + {isFiltered && ( + + )} +
+ +
+ ); +} \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-schemas.ts b/packages/components/src/ui/data-table/data-table-schemas.ts new file mode 100644 index 00000000..3e04b2b0 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-schemas.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +// Basic filter schemas +export const textFilterSchema = z.string(); + +export const numberFilterSchema = z.object({ + min: z.number().optional(), + max: z.number().optional(), +}); + +export const dateFilterSchema = z.object({ + from: z.string().optional(), + to: z.string().optional(), +}); + +export const selectFilterSchema = z.array(z.string()); + +// Create a schema for a specific column +export const createColumnFilterSchema = (type: 'text' | 'number' | 'date' | 'select') => { + switch (type) { + case 'text': + return textFilterSchema; + case 'number': + return numberFilterSchema; + case 'date': + return dateFilterSchema; + case 'select': + return selectFilterSchema; + default: + return z.any(); + } +}; + +// Create a schema for the entire table +export const createTableFilterSchema = >(columns: { + id: keyof T; + type: 'text' | 'number' | 'date' | 'select'; +}[]) => { + const schemaObj: Record> = {}; + + columns.forEach((column) => { + schemaObj[column.id as string] = createColumnFilterSchema(column.type); + }); + + return z.object(schemaObj); +}; + +// Helper function to validate a filter value against a schema +export const validateFilter = (value: T, schema: z.ZodType): { valid: boolean; value?: T; error?: string } => { + try { + const validatedValue = schema.parse(value); + return { valid: true, value: validatedValue }; + } catch (error) { + if (error instanceof z.ZodError) { + return { valid: false, error: error.errors[0].message }; + } + return { valid: false, error: 'Invalid value' }; + } +}; \ No newline at end of file diff --git a/packages/components/src/ui/data-table/data-table-text-filter.tsx b/packages/components/src/ui/data-table/data-table-text-filter.tsx new file mode 100644 index 00000000..f8facb79 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-text-filter.tsx @@ -0,0 +1,127 @@ +import type { Column } from '@tanstack/react-table'; +import { PlusCircle, X } from 'lucide-react'; +import * as React from 'react'; +import { z } from 'zod'; +import { useQueryState, parseAsString } from 'nuqs'; + +import { Badge } from '../badge'; +import { Button } from '../button'; +import { TextInput } from '../text-input'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Separator } from '../separator'; +import { textFilterSchema } from './data-table-schemas'; + +interface DataTableTextFilterProps { + column?: Column; + title?: string; + placeholder?: string; + schema?: z.ZodType; +} + +export function DataTableTextFilter({ + column, + title, + placeholder = 'Filter...', + schema = textFilterSchema, +}: DataTableTextFilterProps) { + const columnId = column?.id || ''; + + // Use nuqs for URL state management + const [value, setValue] = useQueryState( + `filter_${columnId}`, + parseAsString.withDefault('') + ); + + const [localValue, setLocalValue] = React.useState(value); + + // Set column filter when value changes + React.useEffect(() => { + if (column) { + if (value) { + column.setFilterValue(value); + } else { + column.setFilterValue(undefined); + } + } + }, [column, value]); + + // Update local value when URL value changes + React.useEffect(() => { + setLocalValue(value); + }, [value]); + + const handleApply = () => { + try { + // Validate with zod schema + schema.parse(localValue); + setValue(localValue); + } catch (error) { + // If validation fails, don't update + console.error('Validation error:', error); + } + }; + + const handleReset = () => { + setLocalValue(''); + setValue(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleApply(); + } + }; + + return ( + + + + + +
+
+

Filter by {title}

+
+ setLocalValue(e.target.value)} + onKeyDown={handleKeyDown} + className="h-8" + /> + {localValue && ( + + )} +
+
+
+ + +
+
+
+
+ ); +} \ 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 index 5949bc1a..f9bbc3b4 100644 --- a/packages/components/src/ui/data-table/index.ts +++ b/packages/components/src/ui/data-table/index.ts @@ -1,8 +1,20 @@ export * from './data-table'; export * from './data-table-column-header'; +export * from './data-table-router-form'; +export * from './data-table-nuqs-form'; + export * from './data-table-faceted-filter'; -export * from './data-table-pagination'; +export * from './data-table-nuqs-filter'; +export * from './data-table-text-filter'; +export * from './data-table-number-filter'; +export * from './data-table-date-filter'; +export * from './data-table-filter-factory'; + export * from './data-table-toolbar'; +export * from './data-table-router-toolbar'; +export * from './data-table-nuqs-toolbar'; + +export * from './data-table-pagination'; export * from './data-table-view-options'; -export * from './data-table-router-form'; -export * from './data-table-router-toolbar'; \ No newline at end of file + +export * from './data-table-schemas'; \ No newline at end of file diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 6a0ab8c1..fe5338cb 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -22,6 +22,6 @@ export * from './table'; export * from './data-table'; export * from './badge'; export * from './command'; - +export * from './calendar'; export * from './select'; export * from './separator'; From 6582d677010d1e5d33776c651eb3280235e43603 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:38:53 +0000 Subject: [PATCH 08/32] Implement nuqs and zod filters for DataTableRouterForm --- packages/components/package.json | 1 + .../data-table/data-table-faceted-filter.tsx | 22 +-- .../src/ui/data-table/data-table-hooks.ts | 126 +++++++++++++++ .../ui/data-table/data-table-router-form.tsx | 147 ++++++++++++++---- .../data-table/data-table-router-toolbar.tsx | 35 +++-- .../src/ui/data-table/data-table-schema.ts | 65 ++++++++ .../components/src/ui/data-table/index.ts | 4 +- packages/components/src/ui/utils/debounce.ts | 27 ++++ 8 files changed, 374 insertions(+), 53 deletions(-) create mode 100644 packages/components/src/ui/data-table/data-table-hooks.ts create mode 100644 packages/components/src/ui/data-table/data-table-schema.ts create mode 100644 packages/components/src/ui/utils/debounce.ts diff --git a/packages/components/package.json b/packages/components/package.json index f342dd27..734c60b4 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -62,6 +62,7 @@ "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", "react-router": "^7.0.0", 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 index 54e01da8..6eefda3a 100644 --- a/packages/components/src/ui/data-table/data-table-faceted-filter.tsx +++ b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx @@ -1,6 +1,6 @@ import type { Column } from '@tanstack/react-table'; import { Check, PlusCircle } from 'lucide-react'; -import type * as React from 'react'; +import * as React from 'react'; import { Badge } from '../badge'; import { Button } from '../button'; @@ -25,14 +25,12 @@ interface DataTableFacetedFilterProps { 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[]); @@ -92,12 +90,16 @@ export function DataTableFacetedFilter({
- +
- {option.icon && } + {option.icon && ( + + )} {option.label} {facets?.get(option.value) && ( @@ -124,14 +126,6 @@ export function DataTableFacetedFilter({ - - {formMode && selectedValues.size > 0 && column && ( -
- {Array.from(selectedValues).map((value) => ( - - ))} -
- )} ); } diff --git a/packages/components/src/ui/data-table/data-table-hooks.ts b/packages/components/src/ui/data-table/data-table-hooks.ts new file mode 100644 index 00000000..a5c3fa23 --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-hooks.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { z } from 'zod'; +import { debounce } from '../utils/debounce'; +import { createFilterSchema, type DataTableFilterParams } from './data-table-schema'; + +/** + * Custom hook for managing data table filter state in the URL + */ +export function useDataTableUrlState({ + filterableColumns = [], + searchableColumns = [], + defaultSort, + debounceMs = 300, +}: { + filterableColumns?: { + id: keyof TData; + title: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + }[]; + searchableColumns?: { + id: keyof TData; + title: string; + }[]; + defaultSort?: { + id: string; + desc: boolean; + }; + debounceMs?: number; +}) { + const [searchParams, setSearchParams] = useSearchParams(); + + // Create a schema for the filter parameters based on the column configuration + const filterSchema = useMemo(() => { + return createFilterSchema(filterableColumns, searchableColumns); + }, [filterableColumns, searchableColumns]); + + // Parse the current URL search params using the schema + const parseSearchParams = useCallback(() => { + const params: Record = {}; + + // Extract all search params + for (const [key, value] of searchParams.entries()) { + // Handle array values (like filter arrays) + if (params[key]) { + if (Array.isArray(params[key])) { + params[key].push(value); + } else { + params[key] = [params[key], value]; + } + } else { + params[key] = value; + } + } + + // Parse with the schema, using safe parse to handle validation errors + const result = filterSchema.safeParse(params); + + if (result.success) { + return result.data; + } else { + // If validation fails, return defaults from the schema + const defaults: Record = {}; + + // Extract default values from the schema + Object.entries(filterSchema.shape).forEach(([key, schema]) => { + if ('default' in schema && typeof schema.default === 'function') { + defaults[key] = schema.default(); + } + }); + + return defaults as DataTableFilterParams; + } + }, [searchParams, filterSchema]); + + // Get the current filter state from URL + const filterState = useMemo(() => { + return parseSearchParams(); + }, [parseSearchParams]); + + // Create a debounced version of setSearchParams + const debouncedSetSearchParams = useMemo(() => { + return debounce((newParams: Record) => { + // Remove undefined or null values + const cleanParams = Object.fromEntries( + Object.entries(newParams).filter(([_, value]) => value != null && value !== '') + ); + + setSearchParams(cleanParams); + }, debounceMs); + }, [setSearchParams, debounceMs]); + + // Update URL search params + const updateFilterState = useCallback((updates: Partial) => { + const currentState = parseSearchParams(); + const newState = { ...currentState, ...updates }; + + // Remove null/undefined values and empty strings + Object.keys(newState).forEach(key => { + if (newState[key] === null || newState[key] === undefined || newState[key] === '') { + delete newState[key]; + } + }); + + debouncedSetSearchParams(newState); + }, [parseSearchParams, debouncedSetSearchParams]); + + // Initialize with default sort if provided and not already in URL + useEffect(() => { + if (defaultSort && !searchParams.has('sortField')) { + updateFilterState({ + sortField: defaultSort.id, + sortOrder: defaultSort.desc ? 'desc' : 'asc', + }); + } + }, [defaultSort, searchParams, updateFilterState]); + + return { + filterState, + updateFilterState, + }; +} \ 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 index 26a434f2..0674ab50 100644 --- a/packages/components/src/ui/data-table/data-table-router-form.tsx +++ b/packages/components/src/ui/data-table/data-table-router-form.tsx @@ -13,12 +13,13 @@ import { useReactTable, } from '@tanstack/react-table'; import * as React from 'react'; -import { Form, useNavigation, useSubmit } from 'react-router-dom'; +import { Form, useNavigation } from 'react-router-dom'; -import { useEffect } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table'; import { DataTablePagination } from './data-table-pagination'; import { DataTableRouterToolbar } from './data-table-router-toolbar'; +import { useDataTableUrlState } from './data-table-hooks'; +import { type DataTableFilterParams } from './data-table-schema'; interface DataTableRouterFormProps { columns: ColumnDef[]; @@ -43,6 +44,7 @@ interface DataTableRouterFormProps { pageCount?: number; formAction?: string; formMethod?: 'get' | 'post'; + debounceMs?: number; } export function DataTableRouterForm({ @@ -54,16 +56,118 @@ export function DataTableRouterForm({ pageCount, formAction, formMethod = 'get', + debounceMs = 300, }: DataTableRouterFormProps) { const [rowSelection, setRowSelection] = React.useState({}); const [columnVisibility, setColumnVisibility] = React.useState({}); - const [columnFilters, setColumnFilters] = React.useState([]); - const [sorting, setSorting] = React.useState(defaultSort ? [defaultSort] : []); + + const { filterState, updateFilterState } = useDataTableUrlState({ + filterableColumns, + searchableColumns, + defaultSort, + debounceMs, + }); + + const columnFilters = React.useMemo(() => { + const filters: ColumnFiltersState = []; + + filterableColumns.forEach((column) => { + const columnId = String(column.id); + const filterValues = filterState[columnId]; + + if (filterValues && Array.isArray(filterValues) && filterValues.length > 0) { + filters.push({ + id: columnId, + value: filterValues, + }); + } + }); + + searchableColumns.forEach((column) => { + const columnId = String(column.id); + const searchValue = filterState[`search_${columnId}`]; + + if (searchValue) { + filters.push({ + id: columnId, + value: searchValue, + }); + } + }); + + if (filterState.search) { + filters.push({ + id: 'global', + value: filterState.search, + }); + } + + return filters; + }, [filterState, filterableColumns, searchableColumns]); + + const sorting = React.useMemo(() => { + const sortState: SortingState = []; + + if (filterState.sortField) { + sortState.push({ + id: filterState.sortField, + desc: filterState.sortOrder === 'desc', + }); + } + + return sortState; + }, [filterState]); - const submit = useSubmit(); const navigation = useNavigation(); const isLoading = navigation.state === 'loading' || navigation.state === 'submitting'; + const handleColumnFiltersChange = React.useCallback((filters: ColumnFiltersState) => { + const updates: Partial = {}; + + filterableColumns.forEach((column) => { + updates[String(column.id)] = undefined; + }); + + searchableColumns.forEach((column) => { + updates[`search_${String(column.id)}`] = undefined; + }); + + updates.search = undefined; + + filters.forEach((filter) => { + if (filter.id === 'global') { + updates.search = filter.value as string; + } else if (searchableColumns.some(col => String(col.id) === filter.id)) { + updates[`search_${filter.id}`] = filter.value as string; + } else { + updates[filter.id] = filter.value as string[]; + } + }); + + updateFilterState(updates); + }, [filterableColumns, searchableColumns, updateFilterState]); + + const handleSortingChange = React.useCallback((newSorting: SortingState) => { + if (newSorting.length > 0) { + updateFilterState({ + sortField: newSorting[0].id, + sortOrder: newSorting[0].desc ? 'desc' : 'asc', + }); + } else { + updateFilterState({ + sortField: undefined, + sortOrder: 'asc', + }); + } + }, [updateFilterState]); + + const handlePaginationChange = React.useCallback((pageIndex: number, pageSize: number) => { + updateFilterState({ + page: pageIndex, + pageSize, + }); + }, [updateFilterState]); + const table = useReactTable({ data, columns, @@ -72,12 +176,19 @@ export function DataTableRouterForm({ columnVisibility, rowSelection, columnFilters, + pagination: { + pageIndex: filterState.page, + pageSize: filterState.pageSize, + }, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, + onSortingChange: handleSortingChange, + onColumnFiltersChange: handleColumnFiltersChange, onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: ({ pageIndex, pageSize }) => { + handlePaginationChange(pageIndex, pageSize); + }, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), @@ -88,14 +199,6 @@ export function DataTableRouterForm({ pageCount, }); - // Auto-submit the form when filters change - useEffect(() => { - const formElement = document.getElementById('data-table-router-form') as HTMLFormElement; - if (formElement) { - submit(formElement); - } - }, [sorting, columnFilters, table.getState().pagination, submit]); - return (
@@ -105,18 +208,6 @@ export function DataTableRouterForm({ searchableColumns={searchableColumns} /> - {/* Hidden inputs for sorting */} - {sorting.length > 0 && ( - <> - - - - )} - - {/* Hidden inputs for pagination */} - - -
@@ -163,7 +254,7 @@ export function DataTableRouterForm({ - + ); } diff --git a/packages/components/src/ui/data-table/data-table-router-toolbar.tsx b/packages/components/src/ui/data-table/data-table-router-toolbar.tsx index 852529b2..a2b4978d 100644 --- a/packages/components/src/ui/data-table/data-table-router-toolbar.tsx +++ b/packages/components/src/ui/data-table/data-table-router-toolbar.tsx @@ -1,11 +1,13 @@ import type { Table } from '@tanstack/react-table'; import { X } from 'lucide-react'; import * as React from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Button } from '../button'; import { TextInput } from '../text-input'; import { DataTableFacetedFilter } from './data-table-faceted-filter'; import { DataTableViewOptions } from './data-table-view-options'; +import { debounce } from '../utils/debounce'; interface DataTableRouterToolbarProps { table: Table; @@ -30,6 +32,25 @@ export function DataTableRouterToolbar({ searchableColumns = [], }: DataTableRouterToolbarProps) { const isFiltered = table.getState().columnFilters.length > 0; + const [searchParams] = useSearchParams(); + + const debouncedSearchHandlers = React.useMemo(() => { + const handlers: Record void> = {}; + + searchableColumns.forEach((column) => { + const columnId = column.id as string; + handlers[columnId] = debounce((value: string) => { + table.getColumn(columnId)?.setFilterValue(value); + }, 300); + }); + + return handlers; + }, [searchableColumns, table]); + + const getSearchValue = React.useCallback((columnId: string) => { + const searchKey = `search_${columnId}`; + return searchParams.get(searchKey) || ''; + }, [searchParams]); return (
@@ -40,18 +61,13 @@ export function DataTableRouterToolbar({ table.getColumn(column.id as string) && ( table.getColumn(column.id as string)?.setFilterValue(event.target.value)} + value={getSearchValue(column.id as string)} + onChange={(event) => { + debouncedSearchHandlers[column.id as string](event.target.value); + }} className="h-8 w-[150px] lg:w-[250px]" /> - {/* Hidden input for form submission */} - ), )} @@ -64,7 +80,6 @@ export function DataTableRouterToolbar({ column={table.getColumn(column.id as string)} title={column.title} options={column.options} - formMode={true} /> ), )} diff --git a/packages/components/src/ui/data-table/data-table-schema.ts b/packages/components/src/ui/data-table/data-table-schema.ts new file mode 100644 index 00000000..ecf1322a --- /dev/null +++ b/packages/components/src/ui/data-table/data-table-schema.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +/** + * Schema for data table filter parameters + */ +export const dataTableFilterSchema = z.object({ + // Pagination + page: z.coerce.number().int().min(0).default(0), + pageSize: z.coerce.number().int().min(1).default(10), + + // Sorting + sortField: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).default('asc'), + + // Search + search: z.string().optional(), + + // We'll validate specific filter fields dynamically based on the column configuration +}); + +/** + * Type for data table filter parameters + */ +export type DataTableFilterParams = z.infer; + +/** + * Create a schema for a specific filter configuration + */ +export function createFilterSchema( + filterableColumns: { + id: keyof TData; + options: { + value: string; + }[]; + }[] = [], + searchableColumns: { + id: keyof TData; + }[] = [] +) { + // Start with the base schema + let schema = dataTableFilterSchema; + + // Add filter fields for each filterable column + filterableColumns.forEach((column) => { + const columnId = String(column.id); + const allowedValues = column.options.map((option) => option.value); + + // Add the field to the schema as an optional array of allowed values + schema = schema.extend({ + [columnId]: z.array(z.enum(allowedValues as [string, ...string[]])).optional(), + }); + }); + + // Add search fields for each searchable column + searchableColumns.forEach((column) => { + const columnId = String(column.id); + + // Add the search field to the schema + schema = schema.extend({ + [`search_${columnId}`]: z.string().optional(), + }); + }); + + return schema; +} \ 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 index 5949bc1a..cc65594c 100644 --- a/packages/components/src/ui/data-table/index.ts +++ b/packages/components/src/ui/data-table/index.ts @@ -5,4 +5,6 @@ export * from './data-table-pagination'; export * from './data-table-toolbar'; export * from './data-table-view-options'; export * from './data-table-router-form'; -export * from './data-table-router-toolbar'; \ No newline at end of file +export * from './data-table-router-toolbar'; +export * from './data-table-schema'; +export * from './data-table-hooks'; \ No newline at end of file diff --git a/packages/components/src/ui/utils/debounce.ts b/packages/components/src/ui/utils/debounce.ts new file mode 100644 index 00000000..815e3dec --- /dev/null +++ b/packages/components/src/ui/utils/debounce.ts @@ -0,0 +1,27 @@ +/** + * Creates a debounced function that delays invoking the provided function + * until after the specified wait time has elapsed since the last time it was invoked. + * + * @param func The function to debounce + * @param wait The number of milliseconds to delay + * @returns A debounced version of the provided function + */ +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType | null = null; + + return function(...args: Parameters): void { + const later = () => { + timeout = null; + func(...args); + }; + + if (timeout !== null) { + clearTimeout(timeout); + } + + timeout = setTimeout(later, wait); + }; +} \ No newline at end of file From a10a8232c8012eab767d7c0c1dca2debefe22fa6 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Wed, 2 Apr 2025 00:42:52 -0500 Subject: [PATCH 09/32] Update nuqs to version 2.4.1; integrate nuqs for URL state management in data table components and enhance DataTableRouterForm with improved filtering and pagination features. Refactor data table components for better clarity and consistency, including removal of unused files. --- .vscode/settings.json | 1 + .../src/lib/storybook/react-router-stub.tsx | 49 +++- .../data-table-router-form.stories.tsx | 248 +++++++---------- apps/docs/vite.config.mjs | 6 + packages/components/package.json | 2 +- .../data-table-router-form.tsx | 215 +++++++++++++++ .../data-table-router-parsers.ts | 45 +++ .../data-table-router-toolbar.tsx | 138 ++++++++++ packages/components/src/ui/command.tsx | 4 +- .../data-table/data-table-column-header.tsx | 70 +++-- .../data-table/data-table-faceted-filter.tsx | 88 +++--- .../src/ui/data-table/data-table-hooks.ts | 75 +++-- .../ui/data-table/data-table-pagination.tsx | 70 ++--- .../ui/data-table/data-table-router-form.tsx | 260 ------------------ .../data-table/data-table-router-toolbar.tsx | 96 ------- .../src/ui/data-table/data-table-schema.ts | 64 +++-- .../src/ui/data-table/data-table-toolbar.tsx | 66 +++-- .../ui/data-table/data-table-view-options.tsx | 44 ++- .../src/ui/data-table/data-table.tsx | 98 +------ .../components/src/ui/data-table/index.ts | 6 +- packages/components/src/ui/form.tsx | 2 + yarn.lock | 32 +++ 22 files changed, 852 insertions(+), 827 deletions(-) create mode 100644 packages/components/src/remix-hook-form/data-table-router-form.tsx create mode 100644 packages/components/src/remix-hook-form/data-table-router-parsers.ts create mode 100644 packages/components/src/remix-hook-form/data-table-router-toolbar.tsx delete mode 100644 packages/components/src/ui/data-table/data-table-router-form.tsx delete mode 100644 packages/components/src/ui/data-table/data-table-router-toolbar.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index d07ff312..584d1c44 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "hookform", "isbot", "lucide", + "Nuqs", "shadcn", "sonner" ], diff --git a/apps/docs/src/lib/storybook/react-router-stub.tsx b/apps/docs/src/lib/storybook/react-router-stub.tsx index 6421d7ab..86b0776c 100644 --- a/apps/docs/src/lib/storybook/react-router-stub.tsx +++ b/apps/docs/src/lib/storybook/react-router-stub.tsx @@ -1,4 +1,5 @@ import type { Decorator } from '@storybook/react'; +import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'; import type { ComponentType } from 'react'; import { type ActionFunction, @@ -7,7 +8,8 @@ import { type LoaderFunction, type MetaFunction, type NonIndexRouteObject, - createRoutesStub, + RouterProvider, + createMemoryRouter, } from 'react-router-dom'; export type StubRouteObject = StubIndexRouteObject | StubNonIndexRouteObject; @@ -36,21 +38,50 @@ interface StubIndexRouteObject interface RemixStubOptions { routes: StubRouteObject[]; + initialPath?: string; } export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorator => { - const { routes } = options; - return (Story) => { - // Map routes to include Story component as fallback if no Component provided + const { routes, initialPath = '/' } = options; + // This outer function runs once when Storybook loads the story meta + + return (Story, context) => { + // This inner function runs when the story component actually renders const mappedRoutes = routes.map((route) => ({ ...route, - Component: route.Component ?? (() => ), + Component: route.Component ?? (() => ), })); - // Use more specific type assertion to fix the incompatibility - // @ts-ignore - Types from createRoutesStub are incompatible but the code works at runtime - const RemixStub = createRoutesStub(mappedRoutes); + // Get the base path (without existing query params from options) + const basePath = initialPath.split('?')[0]; + // Get the current search string from the actual browser window, if available + const currentWindowSearch = typeof window !== 'undefined' ? window.location.search : ''; + // Combine them for the initial entry + const actualInitialPath = `${basePath}${currentWindowSearch}`; + + // Create a memory router, initializing it with the path derived from the window's search params + // biome-ignore lint/suspicious/noExplicitAny: + const router = createMemoryRouter(mappedRoutes as any, { + initialEntries: [actualInitialPath], // Use the path combined with window.location.search + }); - return ; + return ( + // NuqsAdapter will now read the initial state from the MemoryRouter, + // which has been initialized using the window's query params. + + + + ); }; }; + +/** + * A decorator that provides URL state management for stories + * Use this when you need URL query parameters in your stories + */ +export const withURLState = (initialPath = '/'): Decorator => { + return withReactRouterStubDecorator({ + routes: [{ path: '/' }], + initialPath, + }); +}; diff --git a/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx index d4c8bc26..80e71753 100644 --- a/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx +++ b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx @@ -1,9 +1,9 @@ +import { DataTableRouterForm } from '@lambdacurry/forms/remix-hook-form/data-table-router-form'; +import { dataTableRouterParsers } from '@lambdacurry/forms/remix-hook-form/data-table-router-parsers'; import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; -import { DataTableRouterForm } from '@lambdacurry/forms/ui/data-table/data-table-router-form'; import type { Meta, StoryObj } from '@storybook/react'; import type { ColumnDef } from '@tanstack/react-table'; -import { useEffect, useState } from 'react'; -import { useLoaderData } from 'react-router'; +import { type ActionFunctionArgs, useLoaderData } from 'react-router'; import { z } from 'zod'; import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; @@ -85,18 +85,10 @@ const columns: ColumnDef[] = [ ]; // Component to display the data table with router form integration -const DataTableRouterFormExample = () => { - const [data, setData] = useState([]); - const [pageCount, setPageCount] = useState(0); +function DataTableRouterFormExample() { const loaderData = useLoaderData(); - - // Update state when loader data changes - useEffect(() => { - if (loaderData) { - setData(loaderData.data); - setPageCount(loaderData.meta.pageCount); - } - }, [loaderData]); + const data = loaderData?.data ?? []; + const pageCount = loaderData?.meta.pageCount ?? 0; return (
@@ -106,18 +98,15 @@ const DataTableRouterFormExample = () => {
  • Form-based filtering with automatic submission
  • Loading state while waiting for data
  • Server-side filtering and pagination
  • -
  • URL-based state management
  • +
  • URL-based state management with nuqs
  • - columns={columns} data={data} pageCount={pageCount} - formAction="/" - formMethod="post" - defaultSort={{ id: 'name', desc: false }} filterableColumns={[ { - id: 'role', + id: 'role' as keyof User, title: 'Role', options: [ { label: 'Admin', value: 'admin' }, @@ -126,7 +115,7 @@ const DataTableRouterFormExample = () => { ], }, { - id: 'status', + id: 'status' as keyof User, title: 'Status', options: [ { label: 'Active', value: 'active' }, @@ -137,156 +126,115 @@ const DataTableRouterFormExample = () => { ]} searchableColumns={[ { - id: 'name', + id: 'name' as keyof User, title: 'Name', }, ]} />
    ); +} + +const handleDataFetch = async ({ request }: ActionFunctionArgs) => { + const url = request.url ? new URL(request.url) : new URL('http://localhost'); + const params = url.searchParams; + + // Use nuqs parsers, providing fallback '' for potentially null values + const page = dataTableRouterParsers.page.parse(params.get('page') ?? ''); + const pageSize = dataTableRouterParsers.pageSize.parse(params.get('pageSize') ?? ''); + const sortField = dataTableRouterParsers.sortField.parse(params.get('sortField') ?? ''); + const sortOrder = dataTableRouterParsers.sortOrder.parse(params.get('sortOrder') ?? ''); + const search = dataTableRouterParsers.search.parse(params.get('search') ?? ''); + const parsedFilters = dataTableRouterParsers.filters.parse(params.get('filters') ?? ''); + + // Apply filters + let filteredData = [...users]; + + // 1. Apply global search filter + if (search) { + const searchLower = search.toLowerCase(); + filteredData = filteredData.filter( + (user) => user.name.toLowerCase().includes(searchLower) || user.email.toLowerCase().includes(searchLower), + ); + } + + // 2. Apply faceted filters from the parsed 'filters' array + if (parsedFilters && parsedFilters.length > 0) { + // Check if parsedFilters is not null + parsedFilters.forEach((filter) => { + if (filter.id in users[0] && Array.isArray(filter.value) && filter.value.length > 0) { + const filterValues = filter.value as string[]; + filteredData = filteredData.filter((user) => { + const userValue = user[filter.id as keyof User]; + return filterValues.includes(userValue); + }); + } else { + console.warn(`Invalid filter encountered: ${JSON.stringify(filter)}`); + } + }); + } + + // 3. Apply sorting + if (sortField && sortOrder && sortField in users[0]) { + filteredData.sort((a, b) => { + const aValue = a[sortField as keyof User]; + const bValue = b[sortField as keyof User]; + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + + // 4. Apply pagination + // Provide defaults again for TS, although parsers guarantee numbers + const safePage = page ?? 0; + const safePageSize = pageSize ?? 10; + const start = safePage * safePageSize; + const paginatedData = filteredData.slice(start, start + safePageSize); + + return { + data: paginatedData, + meta: { + total: filteredData.length, + page: safePage, + pageSize: safePageSize, + pageCount: Math.ceil(filteredData.length / safePageSize), + }, + }; }; -const meta: Meta = { +const meta = { title: 'UI/DataTableRouterForm', component: DataTableRouterForm, parameters: { layout: 'fullscreen', }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => , decorators: [ withReactRouterStubDecorator({ routes: [ { path: '/', Component: DataTableRouterFormExample, - loader: async ({ request }: { request: Request }) => { - // Simulate server delay - await new Promise((resolve) => setTimeout(resolve, 500)); - - // For initial load without URL params, create a base URL - const url = request.url ? new URL(request.url) : new URL('http://localhost'); - - // Set default values if not provided - const page = Number.parseInt(url.searchParams.get('page') || '0'); - const pageSize = Number.parseInt(url.searchParams.get('pageSize') || '10'); - const sortField = url.searchParams.get('sortField') || 'name'; - const sortOrder = url.searchParams.get('sortOrder') || 'asc'; - const roleFilter = url.searchParams.getAll('role'); - const statusFilter = url.searchParams.getAll('status'); - const search = url.searchParams.get('search'); - - // Apply filters - let filteredData = [...users]; - - if (roleFilter.length > 0) { - filteredData = filteredData.filter((user) => roleFilter.includes(user.role)); - } - - if (statusFilter.length > 0) { - filteredData = filteredData.filter((user) => statusFilter.includes(user.status)); - } - - if (search) { - const searchLower = search.toLowerCase(); - filteredData = filteredData.filter( - (user) => - user.name.toLowerCase().includes(searchLower) || user.email.toLowerCase().includes(searchLower), - ); - } - - // Apply sorting - if (sortField && sortOrder) { - filteredData.sort((a, b) => { - const aValue = a[sortField as keyof User]; - const bValue = b[sortField as keyof User]; - - if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; - if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; - return 0; - }); - } - - // Apply pagination - const start = page * pageSize; - const paginatedData = filteredData.slice(start, start + pageSize); - - return { - data: paginatedData, - meta: { - total: filteredData.length, - page, - pageSize, - pageCount: Math.ceil(filteredData.length / pageSize), - }, - }; - }, - action: async ({ request }: { request: Request }) => { - // Simulate server delay - await new Promise((resolve) => setTimeout(resolve, 500)); - - const formData = await request.formData(); - const page = Number.parseInt(formData.get('page')?.toString() || '0'); - const pageSize = Number.parseInt(formData.get('pageSize')?.toString() || '10'); - const sortField = formData.get('sortField')?.toString() || 'name'; - const sortOrder = formData.get('sortOrder')?.toString() || 'asc'; - const roleFilter = formData.getAll('role').map((val: FormDataEntryValue) => val.toString()); - const statusFilter = formData.getAll('status').map((val: FormDataEntryValue) => val.toString()); - const search = formData.get('search')?.toString(); - - // Apply filters - let filteredData = [...users]; - - if (roleFilter.length > 0) { - filteredData = filteredData.filter((user) => roleFilter.includes(user.role)); - } - - if (statusFilter.length > 0) { - filteredData = filteredData.filter((user) => statusFilter.includes(user.status)); - } - - if (search) { - const searchLower = search.toLowerCase(); - filteredData = filteredData.filter( - (user) => - user.name.toLowerCase().includes(searchLower) || user.email.toLowerCase().includes(searchLower), - ); - } - - // Apply sorting - if (sortField && sortOrder) { - filteredData.sort((a, b) => { - const aValue = a[sortField as keyof User]; - const bValue = b[sortField as keyof User]; - - if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; - if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; - return 0; - }); - } - - // Apply pagination - const start = page * pageSize; - const paginatedData = filteredData.slice(start, start + pageSize); - - return { - data: paginatedData, - meta: { - total: filteredData.length, - page, - pageSize, - pageCount: Math.ceil(filteredData.length / pageSize), - }, - }; - }, + loader: handleDataFetch, }, ], }), ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + // biome-ignore lint/suspicious/noExplicitAny: + args: {} as any, + render: () => , + parameters: { + docs: { + description: { + story: 'This is a description of the DataTableRouterForm component.', + }, + }, + }, }; diff --git a/apps/docs/vite.config.mjs b/apps/docs/vite.config.mjs index feff04db..5dacbb17 100644 --- a/apps/docs/vite.config.mjs +++ b/apps/docs/vite.config.mjs @@ -20,4 +20,10 @@ export default defineConfig({ }, }, plugins: [tailwindcss()], + server: { + historyApiFallback: true, + }, + optimizeDeps: { + include: ['nuqs'], + }, }); diff --git a/packages/components/package.json b/packages/components/package.json index 734c60b4..b565e89c 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -62,7 +62,7 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "next-themes": "^0.4.4", - "nuqs": "^1.17.1", + "nuqs": "^2.4.1", "react-day-picker": "8.10.1", "react-hook-form": "^7.53.1", "react-router": "^7.0.0", diff --git a/packages/components/src/remix-hook-form/data-table-router-form.tsx b/packages/components/src/remix-hook-form/data-table-router-form.tsx new file mode 100644 index 00000000..c93428f1 --- /dev/null +++ b/packages/components/src/remix-hook-form/data-table-router-form.tsx @@ -0,0 +1,215 @@ +import { + type ColumnDef, + type ColumnFilter, + type VisibilityState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { useQueryStates } from 'nuqs'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigation } from 'react-router-dom'; +import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; + +import { DataTablePagination } from '../ui/data-table/data-table-pagination'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; +import { DataTableRouterToolbar, type DataTableRouterToolbarProps } from './data-table-router-toolbar'; + +// Import the nuqs parsers and the inferred type +import { type DataTableRouterState, type FilterValue, dataTableRouterParsers } from './data-table-router-parsers'; + +// Schema for form data validation and type safety +const dataTableSchema = z.object({ + search: z.string().optional(), + filters: z.array(z.object({ id: z.string(), value: z.any() })).optional(), + page: z.number().min(0).optional(), + pageSize: z.number().min(1).optional(), + sortField: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), +}); + +type DataTableFormData = z.infer; + +export interface DataTableRouterFormProps { + columns: ColumnDef[]; + data: TData[]; + filterableColumns?: DataTableRouterToolbarProps['filterableColumns']; + searchableColumns?: DataTableRouterToolbarProps['searchableColumns']; + pageCount?: number; + defaultStateValues?: Partial; +} + +export function DataTableRouterForm({ + columns, + data, + filterableColumns = [], + searchableColumns = [], + pageCount, + defaultStateValues, +}: DataTableRouterFormProps) { + const navigation = useNavigation(); + const isLoading = navigation.state === 'loading'; + + // --- nuqs state management --- + // Use nuqs to manage URL state. Debounce options can be set here per parser if needed. + const [urlState, setUrlState] = useQueryStates(dataTableRouterParsers, { + // Default nuqs options (shallow routing, replace history, no scroll) + history: 'replace', // Default + shallow: false, // we want to re-run the loader when the url changes + // scroll: false, // Default + // Configure debounce globally if needed (though nuqs batches by default) + // throttleMs: 300, + }); + // --- End nuqs state management --- + + // Initialize RHF to *reflect* the nuqs state + const methods = useRemixForm({ + // Use the nuqs inferred type + // No resolver needed if Zod isn't primary validation driver here + defaultValues: urlState, // Initialize with current URL state from nuqs + }); + + // Sync RHF state if urlState changes (e.g., back/forward, external link) + useEffect(() => { + // Only reset if the urlState differs from current RHF values + if (JSON.stringify(urlState) !== JSON.stringify(methods.getValues())) { + methods.reset(urlState); + } + }, [urlState, methods]); + + // Local UI state (column visibility, row selection) + const [columnVisibility, setColumnVisibility] = useState({}); + const [rowSelection, setRowSelection] = useState({}); + + // Table instance uses RHF state (which mirrors nuqs/URL state) + const table = useReactTable({ + data, + columns, + state: { + sorting: [{ id: urlState.sortField, desc: urlState.sortOrder === 'desc' }], + columnFilters: urlState.filters as ColumnFilter[], + pagination: { pageIndex: urlState.page, pageSize: urlState.pageSize }, + columnVisibility, + rowSelection, + }, + manualPagination: true, + manualSorting: true, + manualFiltering: true, + pageCount, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + + // Define callbacks inline + onSortingChange: (updater) => { + const currentSorting = table.getState().sorting; + const sorting = typeof updater === 'function' ? updater(currentSorting) : updater; + setUrlState({ + sortField: sorting[0]?.id ?? '', + sortOrder: sorting[0]?.desc ? 'desc' : 'asc', + page: 0, + }); + }, + onColumnFiltersChange: (updater) => { + const currentFilters = table.getState().columnFilters; + const filters = typeof updater === 'function' ? updater(currentFilters) : updater; + setUrlState({ + filters: filters as FilterValue[], + page: 0, + }); + }, + }); + + // Pagination handler updates nuqs state + const handlePaginationChange = useCallback( + (pageIndex: number, newPageSize: number) => { + setUrlState({ page: pageIndex, pageSize: newPageSize }); + }, + [setUrlState], + ); + + // Derive default values directly from parsers for reset + const standardStateValues: DataTableRouterState = { + search: '', + filters: [], + page: 0, + pageSize: 10, + sortField: '', + sortOrder: 'asc', + ...defaultStateValues, + }; + + // Handle pagination props separately + const paginationProps = { + pageCount: pageCount || 0, + onPaginationChange: handlePaginationChange, + }; + + return ( + +
    + + table={table} + filterableColumns={filterableColumns} + searchableColumns={searchableColumns} + setUrlState={setUrlState} + defaultStateValues={standardStateValues} + /> + + {/* Table Rendering */} +
    +
    + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {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. + + + )} + +
    +
    + + +
    + + ); +} diff --git a/packages/components/src/remix-hook-form/data-table-router-parsers.ts b/packages/components/src/remix-hook-form/data-table-router-parsers.ts new file mode 100644 index 00000000..3f2fac30 --- /dev/null +++ b/packages/components/src/remix-hook-form/data-table-router-parsers.ts @@ -0,0 +1,45 @@ +import { parseAsInteger, parseAsJson, parseAsString } from 'nuqs'; + +// Define and export the shape of a single filter +export interface FilterValue { + // Export the interface + id: string; + value: unknown; // Keep unknown for flexibility, JSON handles serialization +} + +// Runtime parser for FilterValue[] +const parseFilterValueArray = (value: unknown): FilterValue[] => { + if (!Array.isArray(value)) throw new Error('Expected array'); + return value.map((item) => { + if ( + typeof item !== 'object' || + item === null || + !('id' in item) || + typeof item.id !== 'string' || + !('value' in item) + ) { + throw new Error('Invalid filter value'); + } + return { id: item.id, value: item.value }; + }); +}; + +export const dataTableRouterParsers = { + search: parseAsString.withDefault('').withOptions({ clearOnDefault: true }), + filters: parseAsJson(parseFilterValueArray).withDefault([]).withOptions({ clearOnDefault: true }), + page: parseAsInteger.withDefault(0), + pageSize: parseAsInteger.withDefault(10), + sortField: parseAsString.withDefault(''), // Provide a sensible default if possible + // Use parseAsString for sortOrder, rely on component logic for valid values + sortOrder: parseAsString.withDefault('asc'), +}; + +// Export the inferred type for convenience +export type DataTableRouterState = { + search: string | null; + filters: FilterValue[] | null; + page: number; + pageSize: number; + sortField: string; + sortOrder: string; +}; diff --git a/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx b/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx new file mode 100644 index 00000000..60a9cd10 --- /dev/null +++ b/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx @@ -0,0 +1,138 @@ +import { Cross2Icon } from '@radix-ui/react-icons'; +import type { Table } from '@tanstack/react-table'; +import type { ChangeEvent, ComponentType } from 'react'; +import { useCallback } from 'react'; +import { useRemixFormContext } from 'remix-hook-form'; +import { cn } from '../ui'; +import { Button } from '../ui/button'; +import { DataTableFacetedFilter } from '../ui/data-table/data-table-faceted-filter'; +import { DataTableViewOptions } from '../ui/data-table/data-table-view-options'; +import type { DataTableRouterState, FilterValue } from './data-table-router-parsers'; +import { TextField } from './text-field'; + +export interface DataTableRouterToolbarProps { + className?: string; + table: Table; + filterableColumns?: { + id: keyof TData; + title: string; + options: { + label: string; + value: string; + icon?: ComponentType<{ className?: string }>; + }[]; + }[]; + searchableColumns?: { + id: keyof TData; + title: string; + }[]; + setUrlState: (newState: Partial) => void; + defaultStateValues: DataTableRouterState; +} + +export function DataTableRouterToolbar({ + table, + filterableColumns = [], + searchableColumns = [], + className, + setUrlState, + defaultStateValues, +}: DataTableRouterToolbarProps) { + const { watch } = useRemixFormContext(); + + const watchedSearch = watch('search'); + const watchedFilters = watch('filters'); + + const handleSearchChange = useCallback( + (event: ChangeEvent) => { + setUrlState({ search: event.target.value || null, page: 0 }); + }, + [setUrlState], + ); + + const handleFilterUpdate = useCallback( + (columnId: string, value: unknown) => { + const currentFilters = watchedFilters || []; + let newFilters: FilterValue[]; + const existingFilterIndex = currentFilters.findIndex((f: FilterValue) => f.id === columnId); + + if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) { + newFilters = currentFilters.filter((f: FilterValue) => f.id !== columnId); + } else if (existingFilterIndex > -1) { + newFilters = [ + ...currentFilters.slice(0, existingFilterIndex), + { id: columnId, value }, + ...currentFilters.slice(existingFilterIndex + 1), + ]; + } else { + newFilters = [...currentFilters, { id: columnId, value }]; + } + setUrlState({ filters: newFilters.length > 0 ? newFilters : null, page: 0 }); + }, + [setUrlState, watchedFilters], + ); + + const handleReset = useCallback(() => { + setUrlState({ + ...defaultStateValues, + search: null, + filters: null, + }); + }, [setUrlState, defaultStateValues]); + + const isFiltered = Boolean(watchedSearch) || (watchedFilters?.length || 0) > 0; + + return ( +
    +
    +
    + {searchableColumns.length > 0 && ( + c.title).join(', ')}...`} + onChange={handleSearchChange} + className="w-[150px] lg:w-[250px]" + suffix={ + watchedSearch ? ( + + ) : null + } + /> + )} + + {filterableColumns.map((column) => { + const tableColumn = table.getColumn(String(column.id)); + if (!tableColumn) return null; + const currentFilterValue = watchedFilters?.find((f: FilterValue) => f.id === String(column.id))?.value; + return ( + handleFilterUpdate(String(column.id), value)} + /> + ); + })} + + {isFiltered && ( + + )} +
    + col.getCanHide())} /> +
    +
    + ); +} diff --git a/packages/components/src/ui/command.tsx b/packages/components/src/ui/command.tsx index 06f66182..80686eae 100644 --- a/packages/components/src/ui/command.tsx +++ b/packages/components/src/ui/command.tsx @@ -35,7 +35,7 @@ const CommandInput = ({ className, ...props }: React.ComponentPropsWithoutRef ) => ( extends React.HTMLAttributes { +interface DataTableColumnHeaderProps { column: Column; title: string; } -export function DataTableColumnHeader({ - column, - title, - className, -}: DataTableColumnHeaderProps) { +export function DataTableColumnHeader({ column, title }: DataTableColumnHeaderProps) { + const [sort, setSort] = useQueryState('sortField', parseAsString); + const [order, setOrder] = useQueryState('sortOrder', parseAsString.withDefault('asc')); + const isSorted = sort === column.id; + + const handleSort = async () => { + if (isSorted) { + if (order === 'asc') { + await setOrder('desc'); + column.toggleSorting(true); + } else { + await setSort(null); + await setOrder('asc'); + column.toggleSorting(false); + } + } else { + await setSort(column.id); + await setOrder('asc'); + column.toggleSorting(false); + } + }; + if (!column.getCanSort()) { - return
    {title}
    ; + return
    {title}
    ; } return ( -
    +
    - - column.toggleSorting(false)}> - - Asc + + + Sort - column.toggleSorting(true)}> - - Desc + + column.toggleVisibility(false)}> + + Hide - {column.getCanHide() && ( - <> - - column.toggleVisibility(false)}> - - Hide - - - )}
    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 index 6eefda3a..640fee3d 100644 --- a/packages/components/src/ui/data-table/data-table-faceted-filter.tsx +++ b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx @@ -1,7 +1,7 @@ +import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'; import type { Column } from '@tanstack/react-table'; -import { Check, PlusCircle } from 'lucide-react'; -import * as React from 'react'; - +import type * as React from 'react'; +import { useEffect, useState } from 'react'; import { Badge } from '../badge'; import { Button } from '../button'; import { @@ -17,31 +17,69 @@ import { Popover, PopoverContent, PopoverTrigger } from '../popover'; import { Separator } from '../separator'; import { cn } from '../utils'; -interface DataTableFacetedFilterProps { - column?: Column; +interface DataTableFacetedFilterProps { + column?: Column; title?: string; options: { label: string; value: string; icon?: React.ComponentType<{ className?: string }>; }[]; + initialValue?: string[]; + onValueChange?: (value: string[] | undefined) => void; } -export function DataTableFacetedFilter({ +export function DataTableFacetedFilter({ column, title, options, -}: DataTableFacetedFilterProps) { + initialValue, + onValueChange, +}: DataTableFacetedFilterProps) { const facets = column?.getFacetedUniqueValues(); - const selectedValues = new Set(column?.getFilterValue() as string[]); + const [selectedValues, setSelectedValues] = useState>( + () => new Set(initialValue || (column?.getFilterValue() as string[])), + ); + + // Sync with external changes + useEffect(() => { + setSelectedValues(new Set(initialValue || (column?.getFilterValue() as string[]))); + }, [initialValue, column]); + + const handleValueChange = (value: string) => { + setSelectedValues((current) => { + const next = new Set(current); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + const filterValues = Array.from(next); + if (onValueChange) { + onValueChange(filterValues.length ? filterValues : undefined); + } else { + column?.setFilterValue(filterValues.length ? filterValues : undefined); + } + return next; + }); + }; + + const handleClear = () => { + setSelectedValues(new Set()); + if (onValueChange) { + onValueChange(undefined); + } else { + column?.setFilterValue(undefined); + } + }; return ( - - )} -
    - -
    - ); -} diff --git a/packages/components/src/ui/data-table/data-table-schema.ts b/packages/components/src/ui/data-table/data-table-schema.ts index ecf1322a..afa7da73 100644 --- a/packages/components/src/ui/data-table/data-table-schema.ts +++ b/packages/components/src/ui/data-table/data-table-schema.ts @@ -3,25 +3,27 @@ import { z } from 'zod'; /** * Schema for data table filter parameters */ -export const dataTableFilterSchema = z.object({ - // Pagination - page: z.coerce.number().int().min(0).default(0), - pageSize: z.coerce.number().int().min(1).default(10), - - // Sorting - sortField: z.string().optional(), - sortOrder: z.enum(['asc', 'desc']).default('asc'), - - // Search - search: z.string().optional(), - - // We'll validate specific filter fields dynamically based on the column configuration -}); +export const dataTableFilterSchema = z + .object({ + // Pagination + page: z.coerce.number().int().min(0).default(0), + pageSize: z.coerce.number().int().min(1).default(10), + + // Sorting + sortField: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).default('asc'), + + // Search + dataTableSearch: z.string().optional(), + }) + .passthrough(); // Allow additional properties /** * Type for data table filter parameters */ -export type DataTableFilterParams = z.infer; +export type DataTableFilterParams = z.infer & { + [key: string]: string | string[] | number | undefined; +}; /** * Create a schema for a specific filter configuration @@ -35,31 +37,33 @@ export function createFilterSchema( }[] = [], searchableColumns: { id: keyof TData; - }[] = [] + }[] = [], ) { - // Start with the base schema - let schema = dataTableFilterSchema; + // Create a shape object for the extended schema + const extendedShape: Record> = {}; // Add filter fields for each filterable column filterableColumns.forEach((column) => { const columnId = String(column.id); const allowedValues = column.options.map((option) => option.value); - - // Add the field to the schema as an optional array of allowed values - schema = schema.extend({ - [columnId]: z.array(z.enum(allowedValues as [string, ...string[]])).optional(), - }); + + // Add the field to the shape + extendedShape[columnId] = z.array(z.enum(allowedValues as [string, ...string[]])).optional(); }); // Add search fields for each searchable column searchableColumns.forEach((column) => { const columnId = String(column.id); - - // Add the search field to the schema - schema = schema.extend({ - [`search_${columnId}`]: z.string().optional(), - }); + + // Add the search field to the shape + extendedShape[`search_${columnId}`] = z.string().optional(); }); - return schema; -} \ No newline at end of file + // Merge the base schema with the extended shape + return z + .object({ + ...dataTableFilterSchema.shape, + ...extendedShape, + }) + .passthrough(); +} diff --git a/packages/components/src/ui/data-table/data-table-toolbar.tsx b/packages/components/src/ui/data-table/data-table-toolbar.tsx index 3b1b76bf..f479787e 100644 --- a/packages/components/src/ui/data-table/data-table-toolbar.tsx +++ b/packages/components/src/ui/data-table/data-table-toolbar.tsx @@ -1,5 +1,6 @@ +import { Cross2Icon } from '@radix-ui/react-icons'; import type { Table } from '@tanstack/react-table'; -import { X } from 'lucide-react'; +import { parseAsString, useQueryState } from 'nuqs'; import type * as React from 'react'; import { Button } from '../button'; @@ -29,44 +30,49 @@ export function DataTableToolbar({ filterableColumns = [], searchableColumns = [], }: DataTableToolbarProps) { - const isFiltered = table.getState().columnFilters.length > 0; + const [globalFilter, setGlobalFilter] = useQueryState('search', parseAsString); + + const resetFilters = async () => { + await setGlobalFilter(null); + table.resetColumnFilters(); + }; return (
    - {searchableColumns.length > 0 && - searchableColumns.map( - (column) => - table.getColumn(column.id as string) && ( - table.getColumn(column.id as string)?.setFilterValue(event.target.value)} - className="h-8 w-[150px] lg:w-[250px]" - /> - ), - )} + {searchableColumns.length > 0 && ( + ) => { + await setGlobalFilter(event.target.value || null); + searchableColumns.forEach((column) => { + table.getColumn(column.id as string)?.setFilterValue(event.target.value); + }); + }} + className="h-10 w-[150px] lg:w-[250px]" + /> + )} {filterableColumns.length > 0 && - filterableColumns.map( - (column) => - table.getColumn(column.id as string) && ( - - ), - )} - {isFiltered && ( - )}
    - +
    ); } diff --git a/packages/components/src/ui/data-table/data-table-view-options.tsx b/packages/components/src/ui/data-table/data-table-view-options.tsx index 635aeb83..253afadf 100644 --- a/packages/components/src/ui/data-table/data-table-view-options.tsx +++ b/packages/components/src/ui/data-table/data-table-view-options.tsx @@ -1,6 +1,6 @@ -import type { Table } from '@tanstack/react-table'; -import { Settings2 } from 'lucide-react'; - +import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; +import { MixerHorizontalIcon } from '@radix-ui/react-icons'; +import type { Column } from '@tanstack/react-table'; import { Button } from '../button'; import { DropdownMenu, @@ -8,40 +8,38 @@ import { DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuTrigger, } from '../dropdown-menu'; interface DataTableViewOptionsProps { - table: Table; + columns: Column[]; } -export function DataTableViewOptions({ table }: DataTableViewOptionsProps) { +export function DataTableViewOptions({ columns }: DataTableViewOptionsProps) { + const hideableColumns = columns.filter((column) => column.getCanHide()); + return ( - Toggle columns - {table - .getAllColumns() - .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()) - .map((column) => { - return ( - column.toggleVisibility(!!value)} - > - {column.id} - - ); - })} + {hideableColumns.map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.id} + + ); + })} ); diff --git a/packages/components/src/ui/data-table/data-table.tsx b/packages/components/src/ui/data-table/data-table.tsx index 2baa528e..d474d986 100644 --- a/packages/components/src/ui/data-table/data-table.tsx +++ b/packages/components/src/ui/data-table/data-table.tsx @@ -1,98 +1,24 @@ -import { - type ColumnDef, - type ColumnFiltersState, - type SortingState, - type VisibilityState, - flexRender, - getCoreRowModel, - getFacetedRowModel, - getFacetedUniqueValues, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table'; -import * as React from 'react'; - +import { type Table as TableType, flexRender } from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table'; import { DataTablePagination } from './data-table-pagination'; -import { DataTableToolbar } from './data-table-toolbar'; -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - filterableColumns?: { - id: keyof TData; - title: string; - options: { - label: string; - value: string; - icon?: React.ComponentType<{ className?: string }>; - }[]; - }[]; - searchableColumns?: { - id: keyof TData; - title: string; - }[]; +interface DataTableProps { + table: TableType; + columns: number; pagination?: boolean; onPaginationChange?: (pageIndex: number, pageSize: number) => void; - onSortingChange?: (sorting: SortingState) => void; - onFilterChange?: (filters: ColumnFiltersState) => void; + pageCount?: number; } -export function DataTable({ +export function DataTable({ + table, columns, - data, - filterableColumns = [], - searchableColumns = [], - pagination = true, + pagination, onPaginationChange, - onSortingChange, - onFilterChange, -}: DataTableProps) { - const [rowSelection, setRowSelection] = React.useState({}); - const [columnVisibility, setColumnVisibility] = React.useState({}); - const [columnFilters, setColumnFilters] = React.useState([]); - const [sorting, setSorting] = React.useState([]); - - // Handle external state changes - React.useEffect(() => { - if (onFilterChange) { - onFilterChange(columnFilters); - } - }, [columnFilters, onFilterChange]); - - React.useEffect(() => { - if (onSortingChange) { - onSortingChange(sorting); - } - }, [sorting, onSortingChange]); - - const table = useReactTable({ - data, - columns, - state: { - sorting, - columnVisibility, - rowSelection, - columnFilters, - }, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onColumnVisibilityChange: setColumnVisibility, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFacetedRowModel: getFacetedRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), - }); - + pageCount = 1, +}: DataTableProps) { return (
    -
    @@ -119,7 +45,7 @@ export function DataTable({ )) ) : ( - + No results. @@ -127,7 +53,7 @@ export function DataTable({
    - {pagination && } + {pagination && }
    ); } diff --git a/packages/components/src/ui/data-table/index.ts b/packages/components/src/ui/data-table/index.ts index cc65594c..2076174e 100644 --- a/packages/components/src/ui/data-table/index.ts +++ b/packages/components/src/ui/data-table/index.ts @@ -4,7 +4,7 @@ export * from './data-table-faceted-filter'; export * from './data-table-pagination'; export * from './data-table-toolbar'; export * from './data-table-view-options'; -export * from './data-table-router-form'; -export * from './data-table-router-toolbar'; +export * from '../../remix-hook-form/data-table-router-form'; +export * from '../../remix-hook-form/data-table-router-toolbar'; export * from './data-table-schema'; -export * from './data-table-hooks'; \ No newline at end of file +export * from './data-table-hooks'; diff --git a/packages/components/src/ui/form.tsx b/packages/components/src/ui/form.tsx index e203fbda..6d1f4ae9 100644 --- a/packages/components/src/ui/form.tsx +++ b/packages/components/src/ui/form.tsx @@ -3,6 +3,7 @@ import { Slot } from '@radix-ui/react-slot'; import * as React from 'react'; import { Controller, type ControllerProps, type FieldPath, type FieldValues } from 'react-hook-form'; import { Label } from './label'; +import type { InputProps } from './text-input'; import { cn } from './utils'; export interface FieldComponents { @@ -10,6 +11,7 @@ export interface FieldComponents { FormDescription: React.ComponentType; FormLabel: React.ComponentType; FormMessage: React.ComponentType; + Input?: React.ComponentType; } export type FormFieldContextValue< diff --git a/yarn.lock b/yarn.lock index 65f78ed2..08421344 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1806,6 +1806,7 @@ __metadata: input-otp: "npm:^1.4.1" lucide-react: "npm:^0.468.0" next-themes: "npm:^0.4.4" + nuqs: "npm:^2.4.1" react: "npm:^19.0.0" react-day-picker: "npm:8.10.1" react-hook-form: "npm:^7.53.1" @@ -8721,6 +8722,13 @@ __metadata: languageName: node linkType: hard +"mitt@npm:^3.0.1": + version: 3.0.1 + resolution: "mitt@npm:3.0.1" + checksum: 10c0/3ab4fdecf3be8c5255536faa07064d05caa3dd332bd318ff02e04621f7b3069ca1de9106cfe8e7ced675abfc2bec2ce4c4ef321c4a1bb1fb29df8ae090741913 + languageName: node + linkType: hard + "mkdirp@npm:^1.0.4": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" @@ -8943,6 +8951,30 @@ __metadata: languageName: node linkType: hard +"nuqs@npm:^2.4.1": + version: 2.4.1 + resolution: "nuqs@npm:2.4.1" + dependencies: + mitt: "npm:^3.0.1" + peerDependencies: + "@remix-run/react": ">=2" + next: ">=14.2.0" + react: ">=18.2.0 || ^19.0.0-0" + react-router: ^6 || ^7 + react-router-dom: ^6 || ^7 + peerDependenciesMeta: + "@remix-run/react": + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + checksum: 10c0/128faf503ca7d7373c7a82e6c0e96640b27014295f832778b7d01c9e9d2621c3825570d3530624dd4a2636e91587a8768d77d7595b5b0045f5d0c42a98d5076f + languageName: node + linkType: hard + "nyc@npm:^15.1.0": version: 15.1.0 resolution: "nyc@npm:15.1.0" From 64b88e8af4235bfa33f8f03bd6ace91ac8f5b09d Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Wed, 2 Apr 2025 00:44:41 -0500 Subject: [PATCH 10/32] Update story title for DataTableRouterForm to follow naming conventions and improve clarity. --- .../docs/src/remix-hook-form/data-table-router-form.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx index 80e71753..94997f4f 100644 --- a/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx +++ b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx @@ -204,7 +204,7 @@ const handleDataFetch = async ({ request }: ActionFunctionArgs) => { }; const meta = { - title: 'UI/DataTableRouterForm', + title: 'RemixHookForm/Data Table', component: DataTableRouterForm, parameters: { layout: 'fullscreen', From b93512e15d1408fcafdad8485f580ea433563ff9 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Wed, 2 Apr 2025 00:49:12 -0500 Subject: [PATCH 11/32] merge conflicts --- packages/components/package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/components/package.json b/packages/components/package.json index 9e530d2a..b565e89c 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -62,11 +62,7 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "next-themes": "^0.4.4", -<<<<<<< HEAD "nuqs": "^2.4.1", -======= - "nuqs": "^1.17.1", ->>>>>>> 546f30594f1d45d1262195ab9e092058199e48ed "react-day-picker": "8.10.1", "react-hook-form": "^7.53.1", "react-router": "^7.0.0", From decae8a03158bf0e2ee1f0051c75886ce7ea0a12 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Wed, 2 Apr 2025 00:55:41 -0500 Subject: [PATCH 12/32] merge conflicts --- packages/components/src/ui/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index fe5338cb..2b5ecaa9 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -22,6 +22,5 @@ export * from './table'; export * from './data-table'; export * from './badge'; export * from './command'; -export * from './calendar'; export * from './select'; export * from './separator'; From 643eb298a035ce89951cf656d16f71660652d828 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Wed, 2 Apr 2025 17:03:29 -0500 Subject: [PATCH 13/32] Update package.json and turbo.json scripts; add new serve and prerelease commands, and adjust build outputs. Update dependencies in yarn.lock for improved functionality and performance. --- apps/docs/package.json | 10 +- package.json | 5 +- turbo.json | 11 +- yarn.lock | 346 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 348 insertions(+), 24 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index 5bd6c758..d019a7f2 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,9 +4,10 @@ "scripts": { "dev": "storybook dev -p 6006", "build": "storybook build", - "build-storybook": "storybook build", "storybook": "storybook dev -p 6006", - "test": "test-storybook" + "serve": "http-server ./storybook-static -p 6006 -s", + "test": "start-server-and-test serve http://127.0.0.1:6006 'test-storybook --url http://127.0.0.1:6006'", + "test:local": "test-storybook" }, "dependencies": { "@lambdacurry/forms": "*", @@ -28,12 +29,15 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "autoprefixer": "^10.4.20", + "http-server": "^14.1.1", "react": "^19.0.0", "react-router": "^7.0.0", "react-router-dom": "^7.0.0", + "start-server-and-test": "^2.0.11", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", "vite": "^6.2.2", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "wait-on": "^8.0.3" } } diff --git a/package.json b/package.json index 2b7d0d02..21c89cbc 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "start": "yarn dev", "dev": "turbo run dev", "build": "turbo run build", - "build-storybook": "turbo run build-storybook", + "serve": "turbo run serve", "test": "turbo run test", "clean": "find . -name '.turbo' -type d -prune -exec rm -rf {} + && find . -name 'node_modules' -type d -prune -exec rm -rf {} + && find . -name 'yarn.lock' -type f -delete", "format-and-lint": "biome check .", "format-and-lint:fix": "biome check . --write", - "release": "turbo run build && changeset publish" + "prerelease": "turbo run build", + "release": "changeset publish" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/turbo.json b/turbo.json index 5b5dc8d6..7bd297d1 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + "outputs": ["dist/**", "storybook-static/**"] }, "//#format-and-lint": {}, "//#format-and-lint:fix": { @@ -18,10 +18,13 @@ "cache": false, "persistent": true }, - "test": { - "cache": true + "serve": { + "cache": false, + "persistent": true, + "dependsOn": ["build"] }, - "build-storybook": { + "test": { + "dependsOn": ["build"], "cache": true } } diff --git a/yarn.lock b/yarn.lock index 08421344..b81516ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1759,14 +1759,17 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^6.21.0" "@typescript-eslint/parser": "npm:^6.21.0" autoprefixer: "npm:^10.4.20" + http-server: "npm:^14.1.1" react: "npm:^19.0.0" react-router: "npm:^7.0.0" react-router-dom: "npm:^7.0.0" + start-server-and-test: "npm:^2.0.11" storybook: "npm:^8.6.7" tailwindcss: "npm:^4.0.0" typescript: "npm:^5.7.2" vite: "npm:^6.2.2" vite-tsconfig-paths: "npm:^5.1.4" + wait-on: "npm:^8.0.3" languageName: unknown linkType: soft @@ -4878,7 +4881,7 @@ __metadata: languageName: node linkType: hard -"arg@npm:^5.0.1": +"arg@npm:^5.0.1, arg@npm:^5.0.2": version: 5.0.2 resolution: "arg@npm:5.0.2" checksum: 10c0/ccaf86f4e05d342af6666c569f844bec426595c567d32a8289715087825c2ca7edd8a3d204e4d2fb2aa4602e09a57d0c13ea8c9eea75aac3dbb4af5514e6800e @@ -4961,6 +4964,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.6": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10c0/36484bb15ceddf07078688d95e27076379cc2f87b10c03b6dd8a83e89475a3c8df5848859dd06a4c95af1e4c16fc973de0171a77f18ea00be899aca2a4f85e70 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -5006,6 +5016,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.8.2": + version: 1.8.4 + resolution: "axios@npm:1.8.4" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/450993c2ba975ffccaf0d480b68839a3b2435a5469a71fa2fb0b8a55cdb2c2ae47e609360b9c1e2b2534b73dfd69e2733a1cf9f8215bee0bcd729b72f801b0ce + languageName: node + linkType: hard + "babel-dead-code-elimination@npm:^1.0.6": version: 1.0.9 resolution: "babel-dead-code-elimination@npm:1.0.9" @@ -5104,6 +5125,15 @@ __metadata: languageName: node linkType: hard +"basic-auth@npm:^2.0.1": + version: 2.0.1 + resolution: "basic-auth@npm:2.0.1" + dependencies: + safe-buffer: "npm:5.1.2" + checksum: 10c0/05f56db3a0fc31c89c86b605231e32ee143fb6ae38dc60616bc0970ae6a0f034172def99e69d3aed0e2c9e7cac84e2d63bc51a0b5ff6ab5fc8808cc8b29923c1 + languageName: node + linkType: hard + "better-opn@npm:^3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" @@ -5122,6 +5152,13 @@ __metadata: languageName: node linkType: hard +"bluebird@npm:3.7.2": + version: 3.7.2 + resolution: "bluebird@npm:3.7.2" + checksum: 10c0/680de03adc54ff925eaa6c7bb9a47a0690e8b5de60f4792604aae8ed618c65e6b63a7893b57ca924beaf53eee69c5af4f8314148c08124c550fe1df1add897d2 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -5320,7 +5357,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0, chalk@npm:^4.1.0": +"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -5365,6 +5402,13 @@ __metadata: languageName: node linkType: hard +"check-more-types@npm:2.24.0": + version: 2.24.0 + resolution: "check-more-types@npm:2.24.0" + checksum: 10c0/93fda2c32eb5f6cd1161a84a2f4107c0e00b40a851748516791dd9a0992b91bdf504e3bf6bf7673ce603ae620042e11ed4084d16d6d92b36818abc9c2e725520 + languageName: node + linkType: hard + "chokidar@npm:^4.0.0": version: 4.0.3 resolution: "chokidar@npm:4.0.3" @@ -5573,6 +5617,13 @@ __metadata: languageName: node linkType: hard +"corser@npm:^2.0.1": + version: 2.0.1 + resolution: "corser@npm:2.0.1" + checksum: 10c0/1f319a752a560342dd22d936e5a4c158bfcbc332524ef5b05a7277236dad8b0b2868fd5cf818559f29954ec4d777d82e797fccd76601fcfe431610e4143c8acc + languageName: node + linkType: hard + "create-jest@npm:^29.7.0": version: 29.7.0 resolution: "create-jest@npm:29.7.0" @@ -5639,7 +5690,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.4, debug@npm:^4.4.0": +"debug@npm:4, debug@npm:4.4.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.4, debug@npm:^4.3.6, debug@npm:^4.4.0": version: 4.4.0 resolution: "debug@npm:4.4.0" dependencies: @@ -5892,6 +5943,13 @@ __metadata: languageName: node linkType: hard +"duplexer@npm:~0.1.1": + version: 0.1.2 + resolution: "duplexer@npm:0.1.2" + checksum: 10c0/c57bcd4bdf7e623abab2df43a7b5b23d18152154529d166c1e0da6bee341d84c432d157d7e97b32fecb1bf3a8b8857dd85ed81a915789f550637ed25b8e64fc2 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -6299,7 +6357,29 @@ __metadata: languageName: node linkType: hard -"execa@npm:^5.0.0": +"event-stream@npm:=3.3.4": + version: 3.3.4 + resolution: "event-stream@npm:3.3.4" + dependencies: + duplexer: "npm:~0.1.1" + from: "npm:~0" + map-stream: "npm:~0.1.0" + pause-stream: "npm:0.0.11" + split: "npm:0.3" + stream-combiner: "npm:~0.0.4" + through: "npm:~2.3.1" + checksum: 10c0/c3ec4e1efc27ab3e73a98923f0a2fa9a19051b87068fea2f3d53d2e4e8c5cfdadf8c8a115b17f3d90b16a46432d396bad91b6e8d0cceb3e449be717a03b75209 + languageName: node + linkType: hard + +"eventemitter3@npm:^4.0.0": + version: 4.0.7 + resolution: "eventemitter3@npm:4.0.7" + checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b + languageName: node + linkType: hard + +"execa@npm:5.1.1, execa@npm:^5.0.0": version: 5.1.1 resolution: "execa@npm:5.1.1" dependencies: @@ -6508,7 +6588,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.6": +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.6": version: 1.15.9 resolution: "follow-redirects@npm:1.15.9" peerDependenciesMeta: @@ -6575,6 +6655,13 @@ __metadata: languageName: node linkType: hard +"from@npm:~0": + version: 0.1.7 + resolution: "from@npm:0.1.7" + checksum: 10c0/3aab5aea8fe8e1f12a5dee7f390d46a93431ce691b6222dcd5701c5d34378e51ca59b44967da1105a0f90fcdf5d7629d963d51e7ccd79827d19693bdcfb688d4 + languageName: node + linkType: hard + "fromentries@npm:^1.2.0": version: 1.3.2 resolution: "fromentries@npm:1.3.2" @@ -6961,6 +7048,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^3.0.0": + version: 3.0.0 + resolution: "html-encoding-sniffer@npm:3.0.0" + dependencies: + whatwg-encoding: "npm:^2.0.0" + checksum: 10c0/b17b3b0fb5d061d8eb15121c3b0b536376c3e295ecaf09ba48dd69c6b6c957839db124fe1e2b3f11329753a4ee01aa7dedf63b7677999e86da17fbbdd82c5386 + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -6999,6 +7095,40 @@ __metadata: languageName: node linkType: hard +"http-proxy@npm:^1.18.1": + version: 1.18.1 + resolution: "http-proxy@npm:1.18.1" + dependencies: + eventemitter3: "npm:^4.0.0" + follow-redirects: "npm:^1.0.0" + requires-port: "npm:^1.0.0" + checksum: 10c0/148dfa700a03fb421e383aaaf88ac1d94521dfc34072f6c59770528c65250983c2e4ec996f2f03aa9f3fe46cd1270a593126068319311e3e8d9e610a37533e94 + languageName: node + linkType: hard + +"http-server@npm:^14.1.1": + version: 14.1.1 + resolution: "http-server@npm:14.1.1" + dependencies: + basic-auth: "npm:^2.0.1" + chalk: "npm:^4.1.2" + corser: "npm:^2.0.1" + he: "npm:^1.2.0" + html-encoding-sniffer: "npm:^3.0.0" + http-proxy: "npm:^1.18.1" + mime: "npm:^1.6.0" + minimist: "npm:^1.2.6" + opener: "npm:^1.5.1" + portfinder: "npm:^1.0.28" + secure-compare: "npm:3.0.1" + union: "npm:~0.5.0" + url-join: "npm:^4.0.1" + bin: + http-server: bin/http-server + checksum: 10c0/c5770ddd722dd520ce0af25efee6bfb7c6300ff4e934636d4eec83fa995739e64de2e699e89e7a795b3a1894bcc37bec226617c1023600aacd7871fd8d6ffe6d + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.1": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" @@ -7023,6 +7153,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 + languageName: node + linkType: hard + "iconv-lite@npm:^0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" @@ -7032,15 +7171,6 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.2": - version: 0.6.3 - resolution: "iconv-lite@npm:0.6.3" - dependencies: - safer-buffer: "npm:>= 2.1.2 < 3.0.0" - checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 - languageName: node - linkType: hard - "ignore@npm:^5.2.0, ignore@npm:^5.2.4": version: 5.3.2 resolution: "ignore@npm:5.3.2" @@ -8075,7 +8205,7 @@ __metadata: languageName: node linkType: hard -"joi@npm:^17.11.0": +"joi@npm:^17.11.0, joi@npm:^17.13.3": version: 17.13.3 resolution: "joi@npm:17.13.3" dependencies: @@ -8215,6 +8345,13 @@ __metadata: languageName: node linkType: hard +"lazy-ass@npm:1.6.0": + version: 1.6.0 + resolution: "lazy-ass@npm:1.6.0" + checksum: 10c0/4af6cb9a333fbc811268c745f9173fba0f99ecb817cc9c0fae5dbf986b797b730ff525504128f6623b91aba32b02124553a34b0d14de3762b637b74d7233f3bd + languageName: node + linkType: hard + "leven@npm:^3.1.0": version: 3.1.0 resolution: "leven@npm:3.1.0" @@ -8523,6 +8660,13 @@ __metadata: languageName: node linkType: hard +"map-stream@npm:~0.1.0": + version: 0.1.0 + resolution: "map-stream@npm:0.1.0" + checksum: 10c0/7dd6debe511c1b55d9da75e1efa65a28b1252a2d8357938d2e49b412713c478efbaefb0cdf0ee0533540c3bf733e8f9f71e1a15aa0fe74bf71b64e75bf1576bd + languageName: node + linkType: hard + "math-intrinsics@npm:^1.0.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -8579,6 +8723,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:^1.6.0": + version: 1.6.0 + resolution: "mime@npm:1.6.0" + bin: + mime: cli.js + checksum: 10c0/b92cd0adc44888c7135a185bfd0dddc42c32606401c72896a842ae15da71eb88858f17669af41e498b463cd7eb998f7b48939a25b08374c7924a9c8a6f8a81b0 + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0" @@ -9079,6 +9232,15 @@ __metadata: languageName: node linkType: hard +"opener@npm:^1.5.1": + version: 1.5.2 + resolution: "opener@npm:1.5.2" + bin: + opener: bin/opener-bin.js + checksum: 10c0/dd56256ab0cf796585617bc28e06e058adf09211781e70b264c76a1dbe16e90f868c974e5bf5309c93469157c7d14b89c35dc53fe7293b0e40b4d2f92073bc79 + languageName: node + linkType: hard + "os-homedir@npm:^1.0.1": version: 1.0.2 resolution: "os-homedir@npm:1.0.2" @@ -9296,6 +9458,15 @@ __metadata: languageName: node linkType: hard +"pause-stream@npm:0.0.11": + version: 0.0.11 + resolution: "pause-stream@npm:0.0.11" + dependencies: + through: "npm:~2.3" + checksum: 10c0/86f12c64cdaaa8e45ebaca4e39a478e1442db8b4beabc280b545bfaf79c0e2f33c51efb554aace5c069cc441c7b924ba484837b345eaa4ba6fc940d62f826802 + languageName: node + linkType: hard + "picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -9384,6 +9555,16 @@ __metadata: languageName: node linkType: hard +"portfinder@npm:^1.0.28": + version: 1.0.35 + resolution: "portfinder@npm:1.0.35" + dependencies: + async: "npm:^3.2.6" + debug: "npm:^4.3.6" + checksum: 10c0/bdc3653bf7f8e6f83464812fba8cb26abdb1a7d3d4977199f6edd857a1697c829d5ac4342b626590619efbbddc9916a1847145812d32bb0d2457ee6e895f9082 + languageName: node + linkType: hard + "possible-typed-array-names@npm:^1.0.0": version: 1.0.0 resolution: "possible-typed-array-names@npm:1.0.0" @@ -9515,6 +9696,17 @@ __metadata: languageName: node linkType: hard +"ps-tree@npm:1.2.0": + version: 1.2.0 + resolution: "ps-tree@npm:1.2.0" + dependencies: + event-stream: "npm:=3.3.4" + bin: + ps-tree: ./bin/ps-tree.js + checksum: 10c0/9d1c159e0890db5aa05f84d125193c2190a6c4ecd457596fd25e7611f8f747292a846459dcc0244e27d45529d4cea6d1010c3a2a087fad02624d12fdb7d97c22 + languageName: node + linkType: hard + "punycode@npm:^2.1.0": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -9529,6 +9721,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.4.0": + version: 6.14.0 + resolution: "qs@npm:6.14.0" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -9835,6 +10036,13 @@ __metadata: languageName: node linkType: hard +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -10092,6 +10300,22 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:^7.8.2": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45 + languageName: node + linkType: hard + +"safe-buffer@npm:5.1.2": + version: 5.1.2 + resolution: "safe-buffer@npm:5.1.2" + checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21 + languageName: node + linkType: hard + "safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -10124,6 +10348,13 @@ __metadata: languageName: node linkType: hard +"secure-compare@npm:3.0.1": + version: 3.0.1 + resolution: "secure-compare@npm:3.0.1" + checksum: 10c0/af3102f3f555d917c8ffff7a5f6f00f70195708f4faf82d48794485c9f3cb365cee0dd4da6b4e53e8964f172970bce6069b6101ba3ce8c309bff54f460d1f650 + languageName: node + linkType: hard + "semver@npm:^6.0.0, semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -10434,6 +10665,15 @@ __metadata: languageName: node linkType: hard +"split@npm:0.3": + version: 0.3.3 + resolution: "split@npm:0.3.3" + dependencies: + through: "npm:2" + checksum: 10c0/88c09b1b4de84953bf5d6c153123a1fbb20addfea9381f70d27b4eb6b2bfbadf25d313f8f5d3fd727d5679b97bfe54da04766b91010f131635bf49e51d5db3fc + languageName: node + linkType: hard + "sprintf-js@npm:^1.1.3": version: 1.1.3 resolution: "sprintf-js@npm:1.1.3" @@ -10466,6 +10706,26 @@ __metadata: languageName: node linkType: hard +"start-server-and-test@npm:^2.0.11": + version: 2.0.11 + resolution: "start-server-and-test@npm:2.0.11" + dependencies: + arg: "npm:^5.0.2" + bluebird: "npm:3.7.2" + check-more-types: "npm:2.24.0" + debug: "npm:4.4.0" + execa: "npm:5.1.1" + lazy-ass: "npm:1.6.0" + ps-tree: "npm:1.2.0" + wait-on: "npm:8.0.3" + bin: + server-test: src/bin/start.js + start-server-and-test: src/bin/start.js + start-test: src/bin/start.js + checksum: 10c0/d85ada5f31f43503134e3d190a9deeae01fcbd4489226610a40527f6dd23ebb0081305aa1880dfeadd93c56a167b5bbae86ec1e538209502e5f848516744ccab + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.0.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0" @@ -10494,6 +10754,15 @@ __metadata: languageName: node linkType: hard +"stream-combiner@npm:~0.0.4": + version: 0.0.4 + resolution: "stream-combiner@npm:0.0.4" + dependencies: + duplexer: "npm:~0.1.1" + checksum: 10c0/8075a94c0eb0f20450a8236cb99d4ce3ea6e6a4b36d8baa7440b1a08cde6ffd227debadffaecd80993bd334282875d0e927ab5b88484625e01970dd251004ff5 + languageName: node + linkType: hard + "stream-slice@npm:^0.1.2": version: 0.1.2 resolution: "stream-slice@npm:0.1.2" @@ -10719,6 +10988,13 @@ __metadata: languageName: node linkType: hard +"through@npm:2, through@npm:~2.3, through@npm:~2.3.1": + version: 2.3.8 + resolution: "through@npm:2.3.8" + checksum: 10c0/4b09f3774099de0d4df26d95c5821a62faee32c7e96fb1f4ebd54a2d7c11c57fe88b0a0d49cf375de5fee5ae6bf4eb56dbbf29d07366864e2ee805349970d3cc + languageName: node + linkType: hard + "tiny-invariant@npm:^1.3.1, tiny-invariant@npm:^1.3.3": version: 1.3.3 resolution: "tiny-invariant@npm:1.3.3" @@ -10998,6 +11274,15 @@ __metadata: languageName: node linkType: hard +"union@npm:~0.5.0": + version: 0.5.0 + resolution: "union@npm:0.5.0" + dependencies: + qs: "npm:^6.4.0" + checksum: 10c0/9ac158d99991063180e56f408f5991e808fa07594713439c098116da09215c154672ee8c832e16a6b39b037609c08bcaff8ff07c1e3e46c3cc622897972af2aa + languageName: node + linkType: hard + "unique-filename@npm:^4.0.0": version: 4.0.0 resolution: "unique-filename@npm:4.0.0" @@ -11063,6 +11348,13 @@ __metadata: languageName: node linkType: hard +"url-join@npm:^4.0.1": + version: 4.0.1 + resolution: "url-join@npm:4.0.1" + checksum: 10c0/ac65e2c7c562d7b49b68edddcf55385d3e922bc1dd5d90419ea40b53b6de1607d1e45ceb71efb9d60da02c681d13c6cb3a1aa8b13fc0c989dfc219df97ee992d + languageName: node + linkType: hard + "use-callback-ref@npm:^1.3.3": version: 1.3.3 resolution: "use-callback-ref@npm:1.3.3" @@ -11328,6 +11620,21 @@ __metadata: languageName: node linkType: hard +"wait-on@npm:8.0.3, wait-on@npm:^8.0.3": + version: 8.0.3 + resolution: "wait-on@npm:8.0.3" + dependencies: + axios: "npm:^1.8.2" + joi: "npm:^17.13.3" + lodash: "npm:^4.17.21" + minimist: "npm:^1.2.8" + rxjs: "npm:^7.8.2" + bin: + wait-on: bin/wait-on + checksum: 10c0/7f14086c3bb6fc055207ab591d2faefc045f718aa7c959353a54af05cf08c53c25d9af80d4d5f6934a169cc0d97ebd1dcf024b13583d09b9a935c36bd745bd7b + languageName: node + linkType: hard + "wait-on@npm:^7.0.0": version: 7.2.0 resolution: "wait-on@npm:7.2.0" @@ -11372,6 +11679,15 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^2.0.0": + version: 2.0.0 + resolution: "whatwg-encoding@npm:2.0.0" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10c0/91b90a49f312dc751496fd23a7e68981e62f33afe938b97281ad766235c4872fc4e66319f925c5e9001502b3040dd25a33b02a9c693b73a4cbbfdc4ad10c3e3e + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.0.2": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" From 4bbad97fd2a6f218310c36d1289904a6cb021fb9 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Fri, 4 Apr 2025 11:32:56 -0500 Subject: [PATCH 14/32] Refactor custom components in form stories to use functional components instead of forwardRefs. Update story examples for better clarity and consistency across checkbox, dropdown, radio group, switch, text field, and textarea components. --- .../checkbox-custom.stories.tsx | 290 ++++++------------ .../dropdown-menu-select.stories.tsx | 13 +- .../radio-group-custom.stories.tsx | 59 ++-- .../remix-hook-form/radio-group.stories.tsx | 5 +- .../remix-hook-form/switch-custom.stories.tsx | 46 +-- .../text-field-custom.stories.tsx | 106 +++---- .../remix-hook-form/text-field.stories.tsx | 13 +- .../textarea-custom.stories.tsx | 88 +++--- 8 files changed, 239 insertions(+), 381 deletions(-) diff --git a/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx b/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx index 94506165..08da48f5 100644 --- a/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx +++ b/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx @@ -5,7 +5,7 @@ import { Button } from '@lambdacurry/forms/ui/button'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import type { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, within } from '@storybook/test'; -import * as React from 'react'; +import type * as React from 'react'; import type { ActionFunctionArgs } from 'react-router'; import { useFetcher } from 'react-router'; import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; @@ -21,62 +21,44 @@ const formSchema = z.object({ type FormData = z.infer; // Custom checkbox component -const PurpleCheckbox = React.forwardRef< - HTMLButtonElement, - React.ComponentPropsWithoutRef ->((props, ref) => ( +const PurpleCheckbox = (props: React.ComponentPropsWithoutRef) => ( {props.children} -)); +); PurpleCheckbox.displayName = 'PurpleCheckbox'; // Custom indicator -const PurpleIndicator = React.forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->((props, ref) => ( - +const PurpleIndicator = (props: React.ComponentPropsWithoutRef) => ( + -)); +); PurpleIndicator.displayName = 'PurpleIndicator'; // Custom form label component -const CustomLabel = React.forwardRef>( - ({ className, htmlFor, ...props }, ref) => ( -