diff --git a/apps/docs/src/lib/storybook/react-router-stub.tsx b/apps/docs/src/lib/storybook/react-router-stub.tsx index 86b0776c..901b3f02 100644 --- a/apps/docs/src/lib/storybook/react-router-stub.tsx +++ b/apps/docs/src/lib/storybook/react-router-stub.tsx @@ -1,5 +1,4 @@ import type { Decorator } from '@storybook/react'; -import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'; import type { ComponentType } from 'react'; import { type ActionFunction, @@ -54,8 +53,13 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat // 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 : ''; + // If not available, use a default search string with parameters needed for the data table + const currentWindowSearch = typeof window !== 'undefined' + ? window.location.search + : '?page=0&pageSize=10'; + // Combine them for the initial entry const actualInitialPath = `${basePath}${currentWindowSearch}`; @@ -65,13 +69,7 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat initialEntries: [actualInitialPath], // Use the path combined with window.location.search }); - return ( - // NuqsAdapter will now read the initial state from the MemoryRouter, - // which has been initialized using the window's query params. - - - - ); + return ; }; }; 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 94997f4f..d24c5b72 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 @@ -3,7 +3,7 @@ import { dataTableRouterParsers } from '@lambdacurry/forms/remix-hook-form/data- import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; import type { Meta, StoryObj } from '@storybook/react'; import type { ColumnDef } from '@tanstack/react-table'; -import { type ActionFunctionArgs, useLoaderData } from 'react-router'; +import { type LoaderFunctionArgs, useLoaderData } from 'react-router'; import { z } from 'zod'; import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; @@ -87,9 +87,13 @@ const columns: ColumnDef[] = [ // Component to display the data table with router form integration function DataTableRouterFormExample() { const loaderData = useLoaderData(); + + // Ensure we have data even if loaderData is undefined const data = loaderData?.data ?? []; const pageCount = loaderData?.meta.pageCount ?? 0; + console.log('DataTableRouterFormExample - loaderData:', loaderData); + return (

Users Table (React Router Form Integration)

@@ -98,7 +102,7 @@ function DataTableRouterFormExample() {
  • Form-based filtering with automatic submission
  • Loading state while waiting for data
  • Server-side filtering and pagination
  • -
  • URL-based state management with nuqs
  • +
  • URL-based state management with React Router
  • columns={columns} @@ -135,17 +139,27 @@ function DataTableRouterFormExample() { ); } -const handleDataFetch = async ({ request }: ActionFunctionArgs) => { - const url = request.url ? new URL(request.url) : new URL('http://localhost'); +// Loader function to handle data fetching based on URL parameters +const handleDataFetch = async ({ request }: LoaderFunctionArgs) => { + // Add a small delay to simulate network latency + await new Promise((resolve) => setTimeout(resolve, 300)); + + // Ensure we have a valid URL object + const url = request?.url ? new URL(request.url) : new URL('http://localhost?page=0&pageSize=10'); 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') ?? ''); + console.log('handleDataFetch - URL:', url.toString()); + console.log('handleDataFetch - Search Params:', Object.fromEntries(params.entries())); + + // Use our custom parsers to parse URL search parameters + 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')); + + console.log('handleDataFetch - Parsed Parameters:', { page, pageSize, sortField, sortOrder, search, parsedFilters }); // Apply filters let filteredData = [...users]; @@ -186,12 +200,16 @@ const handleDataFetch = async ({ request }: ActionFunctionArgs) => { } // 4. Apply pagination - // Provide defaults again for TS, although parsers guarantee numbers - const safePage = page ?? 0; - const safePageSize = pageSize ?? 10; + // Determine safe values for page and pageSize using defaultValue when params are missing + const safePage = params.has('page') ? page : dataTableRouterParsers.page.defaultValue; + const safePageSize = params.has('pageSize') ? pageSize : dataTableRouterParsers.pageSize.defaultValue; const start = safePage * safePageSize; const paginatedData = filteredData.slice(start, start + safePageSize); + // Log the data being returned for debugging + console.log(`Returning ${paginatedData.length} items, page ${safePage}, total ${filteredData.length}`); + + // Return the data response return { data: paginatedData, meta: { diff --git a/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx b/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx index 5d338ae0..3d6d5b45 100644 --- a/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx +++ b/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx @@ -1,8 +1,9 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { DropdownMenuSelect } from '@lambdacurry/forms/remix-hook-form/dropdown-menu-select'; import { Button } from '@lambdacurry/forms/ui/button'; -import { FormMessage } from '@lambdacurry/forms/ui/form'; +import { DropdownMenuSelectItem } from '@lambdacurry/forms/ui/dropdown-menu-select-field'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect, screen, userEvent, within } from '@storybook/test'; import { type ActionFunctionArgs, Form, useFetcher } from 'react-router'; import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form'; import { z } from 'zod'; @@ -40,7 +41,7 @@ const ControlledDropdownMenuSelectExample = () => { onValid: (data) => { fetcher.submit( createFormData({ - selectedFruit: data.fruit, + fruit: data.fruit, }), { method: 'post', @@ -55,8 +56,13 @@ const ControlledDropdownMenuSelectExample = () => {
    - - + + {AVAILABLE_FRUITS.map((fruit) => ( + + {fruit.label} + + ))} + @@ -113,22 +119,22 @@ export const Default: Story = { }, }, }, - // play: async ({ canvasElement }) => { - // const canvas = within(canvasElement); + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); - // // Open the dropdown - // const dropdownButton = canvas.getByRole('combobox'); - // await userEvent.click(dropdownButton); + // Open the dropdown + const dropdownButton = canvas.getByRole('button', { name: 'Select an option' }); + await userEvent.click(dropdownButton); - // // Select an option - // const option = canvas.getByRole('option', { name: 'Banana' }); - // await userEvent.click(option); + // Select an option (portal renders outside the canvas) + const option = screen.getByRole('menuitem', { name: 'Banana' }); + await userEvent.click(option); - // // Submit the form - // const submitButton = canvas.getByRole('button', { name: 'Submit' }); - // await userEvent.click(submitButton); + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); - // // Check if the selected option is displayed - // await expect(await canvas.findByText('Banana')).toBeInTheDocument(); - // }, + // Check if the selected option is displayed + await expect(await canvas.findByText('Banana')).toBeInTheDocument(); + }, }; diff --git a/apps/docs/vite.config.mjs b/apps/docs/vite.config.mjs index 5dacbb17..d5447bcc 100644 --- a/apps/docs/vite.config.mjs +++ b/apps/docs/vite.config.mjs @@ -24,6 +24,6 @@ export default defineConfig({ historyApiFallback: true, }, optimizeDeps: { - include: ['nuqs'], + include: [], }, }); diff --git a/packages/components/package.json b/packages/components/package.json index 9b8146e6..3ba0899c 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -62,7 +62,6 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "next-themes": "^0.4.4", - "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 index c93428f1..9535d103 100644 --- a/packages/components/src/remix-hook-form/data-table-router-form.tsx +++ b/packages/components/src/remix-hook-form/data-table-router-form.tsx @@ -11,8 +11,7 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { useQueryStates } from 'nuqs'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigation } from 'react-router-dom'; import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; import { z } from 'zod'; @@ -21,8 +20,9 @@ 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'; +// Import the parsers and the inferred type +import type { DataTableRouterState, FilterValue } from './data-table-router-parsers'; +import { getDefaultDataTableState, useDataTableUrlState } from './use-data-table-url-state'; // Schema for form data validation and type safety const dataTableSchema = z.object({ @@ -56,23 +56,13 @@ export function DataTableRouterForm({ 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 --- + // Use our custom hook for URL state management + const { urlState, setUrlState } = useDataTableUrlState(); - // Initialize RHF to *reflect* the nuqs state + // Initialize RHF to *reflect* the URL 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 + defaultValues: urlState, // Initialize with current URL state }); // Sync RHF state if urlState changes (e.g., back/forward, external link) @@ -87,7 +77,7 @@ export function DataTableRouterForm({ const [columnVisibility, setColumnVisibility] = useState({}); const [rowSelection, setRowSelection] = useState({}); - // Table instance uses RHF state (which mirrors nuqs/URL state) + // Table instance uses RHF state (which mirrors URL state) const table = useReactTable({ data, columns, @@ -132,7 +122,16 @@ export function DataTableRouterForm({ }, }); - // Pagination handler updates nuqs state + // Determine default pageSize and visible columns for skeleton loader + const defaultDataTableState = getDefaultDataTableState(defaultStateValues); + const visibleColumns = table.getVisibleFlatColumns(); + // Generate stable IDs for skeleton rows based on current pageSize or fallback + const skeletonRowIds = useMemo(() => { + const count = urlState.pageSize > 0 ? urlState.pageSize : defaultDataTableState.pageSize; + return Array.from({ length: count }, () => window.crypto.randomUUID()); + }, [urlState.pageSize, defaultDataTableState.pageSize]); + + // Pagination handler updates URL state const handlePaginationChange = useCallback( (pageIndex: number, newPageSize: number) => { setUrlState({ page: pageIndex, pageSize: newPageSize }); @@ -140,16 +139,8 @@ export function DataTableRouterForm({ [setUrlState], ); - // Derive default values directly from parsers for reset - const standardStateValues: DataTableRouterState = { - search: '', - filters: [], - page: 0, - pageSize: 10, - sortField: '', - sortOrder: 'asc', - ...defaultStateValues, - }; + // Get default state values using our utility function + const standardStateValues = getDefaultDataTableState(defaultStateValues); // Handle pagination props separately const paginationProps = { @@ -184,14 +175,19 @@ export function DataTableRouterForm({ {isLoading ? ( - - - Loading... - - + // Skeleton rows matching pageSize with zebra background + skeletonRowIds.map((rowId) => ( + + {visibleColumns.map((column) => ( + +
    + + ))} + + )) ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - + {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} 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 index 3f2fac30..cce39518 100644 --- a/packages/components/src/remix-hook-form/data-table-router-parsers.ts +++ b/packages/components/src/remix-hook-form/data-table-router-parsers.ts @@ -1,4 +1,3 @@ -import { parseAsInteger, parseAsJson, parseAsString } from 'nuqs'; // Define and export the shape of a single filter export interface FilterValue { @@ -24,20 +23,84 @@ const parseFilterValueArray = (value: unknown): FilterValue[] => { }); }; +// Custom parsers to replace nuqs parsers +export const parseAsString = { + parse: (value: string | null): string => { + return value || ''; + }, + serialize: (value: string | null): string | null => { + return value === '' ? null : value; + }, +}; + +export const parseAsInteger = { + parse: (value: string | null): number => { + if (!value) return 0; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? 0 : parsed; + }, + serialize: (value: number | null): string | null => { + return value === 0 ? null : value?.toString() || null; + }, +}; + +export const parseAsJson = (validator: (value: unknown) => T) => ({ + parse: (value: string | null): T | null => { + if (!value) return null; + try { + const parsed = JSON.parse(value); + return validator(parsed); + } catch (e) { + console.error('Error parsing JSON:', e); + return null; + } + }, + serialize: (value: T | null): string | null => { + if (value === null) return null; + return JSON.stringify(value); + }, +}); + +// Export the parsers with default values 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'), + search: { + parse: parseAsString.parse, + serialize: parseAsString.serialize, + defaultValue: '', + }, + filters: { + parse: (value: string | null) => parseAsJson(parseFilterValueArray).parse(value) || [], + serialize: (value: FilterValue[] | null) => { + return value && value.length > 0 ? parseAsJson(parseFilterValueArray).serialize(value) : null; + }, + defaultValue: [] as FilterValue[], + }, + page: { + parse: parseAsInteger.parse, + serialize: parseAsInteger.serialize, + defaultValue: 0, + }, + pageSize: { + parse: parseAsInteger.parse, + serialize: parseAsInteger.serialize, + defaultValue: 10, + }, + sortField: { + parse: parseAsString.parse, + serialize: parseAsString.serialize, + defaultValue: '', + }, + sortOrder: { + parse: parseAsString.parse, + serialize: parseAsString.serialize, + defaultValue: 'asc', + }, }; // Export the inferred type for convenience export type DataTableRouterState = { - search: string | null; - filters: FilterValue[] | null; + search: string; + filters: FilterValue[]; page: number; pageSize: number; sortField: 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 index 60a9cd10..ec6f964d 100644 --- a/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx +++ b/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx @@ -1,32 +1,36 @@ 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 { type ChangeEvent, 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 DataTableFilterOption { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; +} + +export interface DataTableFilterableColumn { + id: keyof TData | string; + title: string; + options: DataTableFilterOption[]; +} + +export interface DataTableSearchableColumn { + id: keyof TData | string; + title: string; +} + 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; + filterableColumns?: DataTableFilterableColumn[]; + searchableColumns?: DataTableSearchableColumn[]; + setUrlState: (state: Partial) => void; defaultStateValues: DataTableRouterState; } @@ -34,40 +38,37 @@ export function DataTableRouterToolbar({ table, filterableColumns = [], searchableColumns = [], - className, setUrlState, defaultStateValues, }: DataTableRouterToolbarProps) { - const { watch } = useRemixFormContext(); - - const watchedSearch = watch('search'); - const watchedFilters = watch('filters'); + const { watch } = useRemixFormContext(); + const watchedFilters = (watch('filters') || []) as FilterValue[]; + const watchedSearch = watch('search') || ''; const handleSearchChange = useCallback( (event: ChangeEvent) => { - setUrlState({ search: event.target.value || null, page: 0 }); + setUrlState({ search: event.target.value || '', page: 0 }); }, [setUrlState], ); - const handleFilterUpdate = useCallback( - (columnId: string, value: unknown) => { - const currentFilters = watchedFilters || []; + const handleFilterChange = useCallback( + (columnId: string, value: string[]) => { + const currentFilters = [...watchedFilters]; + const existingFilterIndex = currentFilters.findIndex((filter: FilterValue) => filter.id === columnId); 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), - ]; + + if (value.length === 0 && existingFilterIndex !== -1) { + newFilters = currentFilters.filter((_, i) => i !== existingFilterIndex); + } else if (value.length === 0) { + newFilters = currentFilters; + } else if (existingFilterIndex !== -1) { + newFilters = [...currentFilters]; + newFilters[existingFilterIndex] = { id: columnId, value }; } else { newFilters = [...currentFilters, { id: columnId, value }]; } - setUrlState({ filters: newFilters.length > 0 ? newFilters : null, page: 0 }); + setUrlState({ filters: newFilters, page: 0 }); }, [setUrlState, watchedFilters], ); @@ -75,30 +76,32 @@ export function DataTableRouterToolbar({ const handleReset = useCallback(() => { setUrlState({ ...defaultStateValues, - search: null, - filters: null, + search: '', + filters: [], }); }, [setUrlState, defaultStateValues]); - const isFiltered = Boolean(watchedSearch) || (watchedFilters?.length || 0) > 0; + const hasFiltersOrSearch = watchedFilters.length > 0 || watchedSearch.length > 0; return ( -
    -
    +
    + {/* Search */} + {searchableColumns.length > 0 && (
    - {searchableColumns.length > 0 && ( +
    c.title).join(', ')}...`} + placeholder={`Search ${searchableColumns.map((column) => column.title).join(', ')}...`} + value={watchedSearch} onChange={handleSearchChange} - className="w-[150px] lg:w-[250px]" + className="w-full" suffix={ watchedSearch ? ( - )} +
    - col.getCanHide())} /> + )} + + {/* Filters */} +
    + {filterableColumns.length > 0 && ( +
    + {filterableColumns.map((column) => { + // Find the current filter value for this column + const currentFilter = watchedFilters.find((filter: FilterValue) => filter.id === column.id); + const selectedValues = (currentFilter?.value as string[]) || []; + + return ( + handleFilterChange(String(column.id), values)} + /> + ); + })} +
    + )} + + {/* Reset Button */} + {hasFiltersOrSearch && ( + + )} + + {/* View Options */} +
    ); diff --git a/packages/components/src/remix-hook-form/index.ts b/packages/components/src/remix-hook-form/index.ts index 797cd611..1256860d 100644 --- a/packages/components/src/remix-hook-form/index.ts +++ b/packages/components/src/remix-hook-form/index.ts @@ -1,13 +1,14 @@ export * from './checkbox'; export * from './form'; -export * from './input'; -export * from './label'; -export * from './select'; +export * from './date-picker'; +export * from './dropdown-menu-select'; export * from './text-field'; export * from './radio-group'; export * from './radio-group-item'; export * from './switch'; export * from './textarea'; +export * from './otp-input'; export * from './data-table-router-form'; export * from './data-table-router-parsers'; export * from './data-table-router-toolbar'; +export * from './use-data-table-url-state'; diff --git a/packages/components/src/remix-hook-form/use-data-table-url-state.ts b/packages/components/src/remix-hook-form/use-data-table-url-state.ts new file mode 100644 index 00000000..56d3562b --- /dev/null +++ b/packages/components/src/remix-hook-form/use-data-table-url-state.ts @@ -0,0 +1,89 @@ +import { useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { type DataTableRouterState, dataTableRouterParsers } from './data-table-router-parsers'; + +/** + * Custom hook for managing URL state in data tables + * + * This hook provides a clean interface for working with URL search parameters + * in data table components, replacing the functionality previously provided by nuqs. + * + * @returns An object containing the current URL state and a function to update it + */ +export function useDataTableUrlState() { + const [searchParams, setSearchParams] = useSearchParams(); + + // Parse URL search parameters using our custom parsers + const urlState: DataTableRouterState = { + search: dataTableRouterParsers.search.parse(searchParams.get('search')), + filters: dataTableRouterParsers.filters.parse(searchParams.get('filters')), + page: dataTableRouterParsers.page.parse(searchParams.get('page')), + pageSize: dataTableRouterParsers.pageSize.parse(searchParams.get('pageSize')), + sortField: dataTableRouterParsers.sortField.parse(searchParams.get('sortField')), + sortOrder: dataTableRouterParsers.sortOrder.parse(searchParams.get('sortOrder')), + }; + + // Function to update URL search parameters + const setUrlState = useCallback( + (newState: Partial) => { + const updatedState = { ...urlState, ...newState }; + const newParams = new URLSearchParams(); + + // Only add parameters that are not default values + if (updatedState.search !== dataTableRouterParsers.search.defaultValue) { + const serialized = dataTableRouterParsers.search.serialize(updatedState.search); + if (serialized !== null) newParams.set('search', serialized); + } + + if (updatedState.filters.length > 0) { + const serialized = dataTableRouterParsers.filters.serialize(updatedState.filters); + if (serialized !== null) newParams.set('filters', serialized); + } + + if (updatedState.page !== dataTableRouterParsers.page.defaultValue) { + const serialized = dataTableRouterParsers.page.serialize(updatedState.page); + if (serialized !== null) newParams.set('page', serialized); + } + + if (updatedState.pageSize !== dataTableRouterParsers.pageSize.defaultValue) { + const serialized = dataTableRouterParsers.pageSize.serialize(updatedState.pageSize); + if (serialized !== null) newParams.set('pageSize', serialized); + } + + if (updatedState.sortField !== dataTableRouterParsers.sortField.defaultValue) { + const serialized = dataTableRouterParsers.sortField.serialize(updatedState.sortField); + if (serialized !== null) newParams.set('sortField', serialized); + } + + if (updatedState.sortOrder !== dataTableRouterParsers.sortOrder.defaultValue) { + const serialized = dataTableRouterParsers.sortOrder.serialize(updatedState.sortOrder); + if (serialized !== null) newParams.set('sortOrder', serialized); + } + + // Update the URL with the new search parameters + setSearchParams(newParams, { replace: true }); + }, + [urlState, setSearchParams] + ); + + // Return the current URL state and the function to update it + return { urlState, setUrlState }; +} + +/** + * Get the default state values for a data table + * + * @param defaultStateValues Optional custom default values to override the standard ones + * @returns A DataTableRouterState object with default values + */ +export function getDefaultDataTableState(defaultStateValues?: Partial): DataTableRouterState { + return { + search: dataTableRouterParsers.search.defaultValue, + filters: dataTableRouterParsers.filters.defaultValue, + page: dataTableRouterParsers.page.defaultValue, + pageSize: dataTableRouterParsers.pageSize.defaultValue, + sortField: dataTableRouterParsers.sortField.defaultValue, + sortOrder: dataTableRouterParsers.sortOrder.defaultValue, + ...defaultStateValues, + }; +} 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 96095895..dd951a44 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,6 +1,6 @@ import type { Column } from '@tanstack/react-table'; import { ArrowDown, ArrowUp, ArrowUpDown, EyeOff } from 'lucide-react'; -import { parseAsString, useQueryState } from 'nuqs'; +import { useSearchParams } from 'react-router-dom'; import { Button } from '../button'; import { @@ -17,25 +17,30 @@ interface DataTableColumnHeaderProps { } export function DataTableColumnHeader({ column, title }: DataTableColumnHeaderProps) { - const [sort, setSort] = useQueryState('sortField', parseAsString); - const [order, setOrder] = useQueryState('sortOrder', parseAsString.withDefault('asc')); + const [searchParams, setSearchParams] = useSearchParams(); + const sort = searchParams.get('sortField'); + const order = searchParams.get('sortOrder') || 'asc'; const isSorted = sort === column.id; - const handleSort = async () => { + const handleSort = () => { + const newParams = new URLSearchParams(searchParams); + if (isSorted) { if (order === 'asc') { - await setOrder('desc'); + newParams.set('sortOrder', 'desc'); column.toggleSorting(true); } else { - await setSort(null); - await setOrder('asc'); + newParams.delete('sortField'); + newParams.set('sortOrder', 'asc'); column.toggleSorting(false); } } else { - await setSort(column.id); - await setOrder('asc'); + newParams.set('sortField', column.id); + newParams.set('sortOrder', 'asc'); column.toggleSorting(false); } + + setSearchParams(newParams, { replace: true }); }; if (!column.getCanSort()) { 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 640fee3d..7428b6eb 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,29 +25,27 @@ interface DataTableFacetedFilterProps { value: string; icon?: React.ComponentType<{ className?: string }>; }[]; - initialValue?: string[]; - onValueChange?: (value: string[] | undefined) => void; + selectedValues?: string[]; + onValuesChange?: (values: string[]) => void; } export function DataTableFacetedFilter({ column, title, options, - initialValue, - onValueChange, + selectedValues = [], + onValuesChange, }: DataTableFacetedFilterProps) { const facets = column?.getFacetedUniqueValues(); - const [selectedValues, setSelectedValues] = useState>( - () => new Set(initialValue || (column?.getFilterValue() as string[])), - ); + const [selected, setSelected] = useState>(new Set(selectedValues)); // Sync with external changes useEffect(() => { - setSelectedValues(new Set(initialValue || (column?.getFilterValue() as string[]))); - }, [initialValue, column]); + setSelected(new Set(selectedValues || (column?.getFilterValue() as string[]))); + }, [selectedValues, column]); const handleValueChange = (value: string) => { - setSelectedValues((current) => { + setSelected((current) => { const next = new Set(current); if (next.has(value)) { next.delete(value); @@ -55,21 +53,23 @@ export function DataTableFacetedFilter({ next.add(value); } const filterValues = Array.from(next); - if (onValueChange) { - onValueChange(filterValues.length ? filterValues : undefined); - } else { - column?.setFilterValue(filterValues.length ? filterValues : undefined); + + if (onValuesChange) { + onValuesChange(filterValues); + } else if (column) { + column.setFilterValue(filterValues.length ? filterValues : undefined); } + return next; }); }; const handleClear = () => { - setSelectedValues(new Set()); - if (onValueChange) { - onValueChange(undefined); - } else { - column?.setFilterValue(undefined); + setSelected(new Set()); + if (onValuesChange) { + onValuesChange([]); + } else if (column) { + column.setFilterValue(undefined); } }; @@ -79,20 +79,20 @@ export function DataTableFacetedFilter({