From 2f065e24d02fa3295b6fccdcc957ebd0d8e5b29a Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Wed, 16 Apr 2025 04:56:01 +0000 Subject: [PATCH 01/10] Replace nuqs with React Router 7 features in data table component --- .../src/lib/storybook/react-router-stub.tsx | 9 +- .../data-table-router-form.stories.tsx | 16 ++-- packages/components/package.json | 75 +++++++-------- .../data-table-router-form.tsx | 94 ++++++++++++++----- .../data-table-router-parsers.ts | 83 ++++++++++++++-- .../data-table-router-toolbar.tsx | 10 +- 6 files changed, 188 insertions(+), 99 deletions(-) diff --git a/apps/docs/src/lib/storybook/react-router-stub.tsx b/apps/docs/src/lib/storybook/react-router-stub.tsx index 86b0776c..0c40f214 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, @@ -65,13 +64,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..798e839d 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 @@ -98,7 +98,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} @@ -139,13 +139,13 @@ 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') ?? ''); + // 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')); // Apply filters let filteredData = [...users]; diff --git a/packages/components/package.json b/packages/components/package.json index 9b8146e6..93a4b323 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -6,33 +6,32 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./ui": { + "import": "./dist/ui/index.js", + "types": "./dist/ui/index.d.ts" + }, + "./ui/*": { + "import": "./dist/ui/*/index.js", + "types": "./dist/ui/*/index.d.ts" }, "./remix-hook-form": { - "import": { - "types": "./dist/remix-hook-form/index.d.ts", - "default": "./dist/remix-hook-form/index.js" - } + "import": "./dist/remix-hook-form/index.js", + "types": "./dist/remix-hook-form/index.d.ts" }, - "./ui": { - "import": { - "types": "./dist/ui/index.d.ts", - "default": "./dist/ui/index.js" - } + "./remix-hook-form/*": { + "import": "./dist/remix-hook-form/*/index.js", + "types": "./dist/remix-hook-form/*/index.d.ts" } }, - "files": [ - "dist" - ], "scripts": { - "prepublishOnly": "yarn run build", - "build": "vite build", + "build": "tsup", + "dev": "tsup --watch", "lint": "biome check .", - "lint:fix": "biome check --apply .", - "type-check": "tsc --noEmit" + "format": "biome format --write .", + "typecheck": "tsc --noEmit" }, "peerDependencies": { "react": "^19.0.0", @@ -40,13 +39,11 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.1", - "@radix-ui/react-alert-dialog": "^1.1.4", - "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.4", - "@radix-ui/react-dropdown-menu": "^2.1.4", - "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", @@ -62,7 +59,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", @@ -71,23 +67,16 @@ "sonner": "^1.7.1", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", - "zod": "^3.24.1" + "zod": "^3.22.4" }, "devDependencies": { - "@react-router/dev": "^7.0.0", - "@react-router/node": "^7.0.0", - "@types/glob": "^8.1.0", - "@types/react": "^19.0.0", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", - "glob": "^11.0.0", + "@types/node": "^20.11.30", + "@types/react": "^18.2.67", + "@types/react-dom": "^18.2.22", + "biome-config": "workspace:*", "react": "^19.0.0", - "tailwindcss": "^4.0.0", - "typescript": "^5.7.2", - "vite": "^5.4.11", - "vite-plugin-dts": "^4.4.0", - "vite-tsconfig-paths": "^5.1.4" + "react-dom": "^19.0.0", + "tsup": "^8.0.2", + "typescript": "^5.4.2" } } 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..9d0ceecc 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,9 +11,8 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { useQueryStates } from 'nuqs'; import { useCallback, useEffect, useState } from 'react'; -import { useNavigation } from 'react-router-dom'; +import { useNavigation, useSearchParams } from 'react-router-dom'; import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; import { z } from 'zod'; @@ -21,7 +20,7 @@ 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 the parsers and the inferred type import { type DataTableRouterState, type FilterValue, dataTableRouterParsers } from './data-table-router-parsers'; // Schema for form data validation and type safety @@ -56,23 +55,68 @@ 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 --- + // --- React Router state management --- + // Use React Router's useSearchParams hook to manage URL state + 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] + ); + // --- End React Router state management --- - // 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 +131,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 +176,7 @@ export function DataTableRouterForm({ }, }); - // Pagination handler updates nuqs state + // Pagination handler updates URL state const handlePaginationChange = useCallback( (pageIndex: number, newPageSize: number) => { setUrlState({ page: pageIndex, pageSize: newPageSize }); @@ -142,12 +186,12 @@ export function DataTableRouterForm({ // Derive default values directly from parsers for reset const standardStateValues: DataTableRouterState = { - search: '', - filters: [], - page: 0, - pageSize: 10, - sortField: '', - sortOrder: 'asc', + 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/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..c4e231e6 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 @@ -45,7 +45,7 @@ export function DataTableRouterToolbar({ const handleSearchChange = useCallback( (event: ChangeEvent) => { - setUrlState({ search: event.target.value || null, page: 0 }); + setUrlState({ search: event.target.value || '', page: 0 }); }, [setUrlState], ); @@ -67,7 +67,7 @@ export function DataTableRouterToolbar({ } else { newFilters = [...currentFilters, { id: columnId, value }]; } - setUrlState({ filters: newFilters.length > 0 ? newFilters : null, page: 0 }); + setUrlState({ filters: newFilters, page: 0 }); }, [setUrlState, watchedFilters], ); @@ -75,8 +75,8 @@ export function DataTableRouterToolbar({ const handleReset = useCallback(() => { setUrlState({ ...defaultStateValues, - search: null, - filters: null, + search: '', + filters: [], }); }, [setUrlState, defaultStateValues]); @@ -98,7 +98,7 @@ export function DataTableRouterToolbar({ variant="ghost" size="icon" className="h-8 w-8 -mr-2" - onClick={() => setUrlState({ search: null, page: 0 })} + onClick={() => setUrlState({ search: '', page: 0 })} > Clear search From fa05b58ab4512b390f2ed1d9ca41d692df361140 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Wed, 16 Apr 2025 05:05:37 +0000 Subject: [PATCH 02/10] Address PR feedback: restore original package.json and create custom hook for URL state management --- packages/components/package.json | 74 ++++--- .../data-table-router-form.tsx | 76 +------ .../data-table-router-toolbar.tsx | 197 ++++++++++-------- .../components/src/remix-hook-form/index.ts | 1 + .../use-data-table-url-state.ts | 89 ++++++++ 5 files changed, 247 insertions(+), 190 deletions(-) create mode 100644 packages/components/src/remix-hook-form/use-data-table-url-state.ts diff --git a/packages/components/package.json b/packages/components/package.json index 93a4b323..3ba0899c 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -6,32 +6,33 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./ui": { - "import": "./dist/ui/index.js", - "types": "./dist/ui/index.d.ts" - }, - "./ui/*": { - "import": "./dist/ui/*/index.js", - "types": "./dist/ui/*/index.d.ts" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } }, "./remix-hook-form": { - "import": "./dist/remix-hook-form/index.js", - "types": "./dist/remix-hook-form/index.d.ts" + "import": { + "types": "./dist/remix-hook-form/index.d.ts", + "default": "./dist/remix-hook-form/index.js" + } }, - "./remix-hook-form/*": { - "import": "./dist/remix-hook-form/*/index.js", - "types": "./dist/remix-hook-form/*/index.d.ts" + "./ui": { + "import": { + "types": "./dist/ui/index.d.ts", + "default": "./dist/ui/index.js" + } } }, + "files": [ + "dist" + ], "scripts": { - "build": "tsup", - "dev": "tsup --watch", + "prepublishOnly": "yarn run build", + "build": "vite build", "lint": "biome check .", - "format": "biome format --write .", - "typecheck": "tsc --noEmit" + "lint:fix": "biome check --apply .", + "type-check": "tsc --noEmit" }, "peerDependencies": { "react": "^19.0.0", @@ -39,11 +40,13 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.1", - "@radix-ui/react-checkbox": "^1.0.4", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-icons": "^1.3.0", - "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-alert-dialog": "^1.1.4", + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", @@ -67,16 +70,23 @@ "sonner": "^1.7.1", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", - "zod": "^3.22.4" + "zod": "^3.24.1" }, "devDependencies": { - "@types/node": "^20.11.30", - "@types/react": "^18.2.67", - "@types/react-dom": "^18.2.22", - "biome-config": "workspace:*", + "@react-router/dev": "^7.0.0", + "@react-router/node": "^7.0.0", + "@types/glob": "^8.1.0", + "@types/react": "^19.0.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "glob": "^11.0.0", "react": "^19.0.0", - "react-dom": "^19.0.0", - "tsup": "^8.0.2", - "typescript": "^5.4.2" + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vite-plugin-dts": "^4.4.0", + "vite-tsconfig-paths": "^5.1.4" } } 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 9d0ceecc..e951623e 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 @@ -12,7 +12,7 @@ import { useReactTable, } from '@tanstack/react-table'; import { useCallback, useEffect, useState } from 'react'; -import { useNavigation, useSearchParams } from 'react-router-dom'; +import { useNavigation } from 'react-router-dom'; import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; import { z } from 'zod'; @@ -21,7 +21,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '. import { DataTableRouterToolbar, type DataTableRouterToolbarProps } from './data-table-router-toolbar'; // Import the parsers and the inferred type -import { type DataTableRouterState, type FilterValue, dataTableRouterParsers } from './data-table-router-parsers'; +import { type DataTableRouterState, type 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({ @@ -55,63 +56,8 @@ export function DataTableRouterForm({ const navigation = useNavigation(); const isLoading = navigation.state === 'loading'; - // --- React Router state management --- - // Use React Router's useSearchParams hook to manage URL state - 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] - ); - // --- End React Router state management --- + // Use our custom hook for URL state management + const { urlState, setUrlState } = useDataTableUrlState(); // Initialize RHF to *reflect* the URL state const methods = useRemixForm({ @@ -184,16 +130,8 @@ export function DataTableRouterForm({ [setUrlState], ); - // Derive default values directly from parsers for reset - const standardStateValues: DataTableRouterState = { - 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, - }; + // Get default state values using our utility function + const standardStateValues = getDefaultDataTableState(defaultStateValues); // Handle pagination props separately const paginationProps = { 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 c4e231e6..9df08207 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 { useRemixFormContext } from 'remix-hook-form'; -import { cn } from '../ui'; +import { Cross2Icon, MixerHorizontalIcon, PlusIcon } from '@radix-ui/react-icons'; +import { type Table } from '@tanstack/react-table'; +import { type ChangeEvent, useCallback } from 'react'; +import { useFormContext } from 'remix-hook-form'; + import { Button } from '../ui/button'; +import { Input } from '../ui/input'; 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'; +import { type DataTableRouterState } from './data-table-router-parsers'; + +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,14 +38,12 @@ export function DataTableRouterToolbar({ table, filterableColumns = [], searchableColumns = [], - className, setUrlState, defaultStateValues, }: DataTableRouterToolbarProps) { - const { watch } = useRemixFormContext(); - - const watchedSearch = watch('search'); - const watchedFilters = watch('filters'); + const { watch } = useFormContext(); + const watchedFilters = watch('filters') || []; + const watchedSearch = watch('search') || ''; const handleSearchChange = useCallback( (event: ChangeEvent) => { @@ -50,22 +52,27 @@ export function DataTableRouterToolbar({ [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), - ]; + const handleFilterChange = useCallback( + (columnId: string, value: string[]) => { + const currentFilters = [...watchedFilters]; + const existingFilterIndex = currentFilters.findIndex((filter) => filter.id === columnId); + let newFilters; + + if (value.length === 0) { + // Remove filter if no values selected + if (existingFilterIndex !== -1) { + newFilters = currentFilters.filter((_, index) => index !== existingFilterIndex); + } else { + newFilters = currentFilters; + } } else { - newFilters = [...currentFilters, { id: columnId, value }]; + // Add or update filter + if (existingFilterIndex !== -1) { + newFilters = [...currentFilters]; + newFilters[existingFilterIndex] = { id: columnId, value }; + } else { + newFilters = [...currentFilters, { id: columnId, value }]; + } } setUrlState({ filters: newFilters, page: 0 }); }, @@ -80,58 +87,70 @@ export function DataTableRouterToolbar({ }); }, [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(', ')}...`} +
    + column.title).join(', ')}...`} + value={watchedSearch} onChange={handleSearchChange} - className="w-[150px] lg:w-[250px]" - suffix={ - watchedSearch ? ( - - ) : null - } + className="h-8 w-full" /> - )} - - {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 && ( - - )} + {watchedSearch && ( +
    + +
    + )} +
    - col.getCanHide())} /> + )} + + {/* Filters */} +
    + {filterableColumns.length > 0 && ( +
    + {filterableColumns.map((column) => { + // Find the current filter value for this column + const currentFilter = watchedFilters.find((filter) => filter.id === column.id); + const selectedValues = (currentFilter?.value as string[]) || []; + + return ( + handleFilterChange(column.id as string, 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..94be2d73 100644 --- a/packages/components/src/remix-hook-form/index.ts +++ b/packages/components/src/remix-hook-form/index.ts @@ -11,3 +11,4 @@ export * from './textarea'; 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, + }; +} From 30d609fba61f223330545921854c88dad63a9ec4 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Wed, 16 Apr 2025 00:21:47 -0500 Subject: [PATCH 03/10] yarn lock update --- yarn.lock | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5506f0e4..c16c963a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1809,7 +1809,6 @@ __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" @@ -8875,13 +8874,6 @@ __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" @@ -9104,30 +9096,6 @@ __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 835242a99d11433bac2d0e76e584192a3e9d403c Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Wed, 16 Apr 2025 05:35:12 +0000 Subject: [PATCH 04/10] Replace nuqs with React Router 7 features in all components --- .../data-table-router-toolbar.tsx | 50 +++++++++--------- .../components/src/remix-hook-form/index.ts | 6 +-- .../data-table/data-table-column-header.tsx | 23 ++++---- .../data-table/data-table-faceted-filter.tsx | 52 +++++++++---------- .../ui/data-table/data-table-pagination.tsx | 40 +++++++------- .../src/ui/data-table/data-table-toolbar.tsx | 34 ++++++++---- 6 files changed, 110 insertions(+), 95 deletions(-) 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 9df08207..030db9fb 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,13 +1,13 @@ import { Cross2Icon, MixerHorizontalIcon, PlusIcon } from '@radix-ui/react-icons'; import { type Table } from '@tanstack/react-table'; import { type ChangeEvent, useCallback } from 'react'; -import { useFormContext } from 'remix-hook-form'; +import { useRemixFormContext } from 'remix-hook-form'; import { Button } from '../ui/button'; -import { Input } from '../ui/input'; +import { TextField } from './text-field'; import { DataTableFacetedFilter } from '../ui/data-table/data-table-faceted-filter'; import { DataTableViewOptions } from '../ui/data-table/data-table-view-options'; -import { type DataTableRouterState } from './data-table-router-parsers'; +import { type DataTableRouterState, type FilterValue } from './data-table-router-parsers'; export interface DataTableFilterOption { label: string; @@ -41,8 +41,8 @@ export function DataTableRouterToolbar({ setUrlState, defaultStateValues, }: DataTableRouterToolbarProps) { - const { watch } = useFormContext(); - const watchedFilters = watch('filters') || []; + const { watch } = useRemixFormContext(); + const watchedFilters = (watch('filters') || []) as FilterValue[]; const watchedSearch = watch('search') || ''; const handleSearchChange = useCallback( @@ -55,7 +55,7 @@ export function DataTableRouterToolbar({ const handleFilterChange = useCallback( (columnId: string, value: string[]) => { const currentFilters = [...watchedFilters]; - const existingFilterIndex = currentFilters.findIndex((filter) => filter.id === columnId); + const existingFilterIndex = currentFilters.findIndex((filter: FilterValue) => filter.id === columnId); let newFilters; if (value.length === 0) { @@ -95,25 +95,26 @@ export function DataTableRouterToolbar({ {searchableColumns.length > 0 && (
    - column.title).join(', ')}...`} value={watchedSearch} onChange={handleSearchChange} className="h-8 w-full" + suffix={ + watchedSearch ? ( + + ) : null + } /> - {watchedSearch && ( -
    - -
    - )}
    )} @@ -124,17 +125,16 @@ export function DataTableRouterToolbar({
    {filterableColumns.map((column) => { // Find the current filter value for this column - const currentFilter = watchedFilters.find((filter) => filter.id === column.id); + const currentFilter = watchedFilters.find((filter: FilterValue) => filter.id === column.id); const selectedValues = (currentFilter?.value as string[]) || []; return ( handleFilterChange(column.id as string, values)} + onValuesChange={(values) => handleFilterChange(String(column.id), values)} /> ); })} @@ -150,7 +150,7 @@ export function DataTableRouterToolbar({ )} {/* View Options */} - +
    ); diff --git a/packages/components/src/remix-hook-form/index.ts b/packages/components/src/remix-hook-form/index.ts index 94be2d73..1256860d 100644 --- a/packages/components/src/remix-hook-form/index.ts +++ b/packages/components/src/remix-hook-form/index.ts @@ -1,13 +1,13 @@ 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'; 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({ @@ -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/packages/components/src/ui/dropdown-menu-select-field.tsx b/packages/components/src/ui/dropdown-menu-select-field.tsx index 07246cbe..8ca4fb28 100644 --- a/packages/components/src/ui/dropdown-menu-select-field.tsx +++ b/packages/components/src/ui/dropdown-menu-select-field.tsx @@ -1,9 +1,15 @@ // biome-ignore lint/style/noNamespaceImport: from Radix import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import type * as React from 'react'; +import { createContext, useContext, useState } from 'react'; import type { Control, FieldPath, FieldValues } from 'react-hook-form'; import { Button } from './button'; import { DropdownMenuContent } from './dropdown-menu'; +import { + DropdownMenuCheckboxItem as BaseDropdownMenuCheckboxItem, + DropdownMenuItem as BaseDropdownMenuItem, + DropdownMenuRadioItem as BaseDropdownMenuRadioItem, +} from './dropdown-menu'; import { type FieldComponents, FormControl, @@ -44,11 +50,13 @@ export function DropdownMenuSelectField< components, ...props }: DropdownMenuSelectProps) { + const [open, setOpen] = useState(false); + return ( ( + render={({ field, fieldState, formState }) => ( {label && ( @@ -56,13 +64,21 @@ export function DropdownMenuSelectField< )} - - - - - {children} + + + + + + {children} + {description && {description}} @@ -74,3 +90,66 @@ export function DropdownMenuSelectField< } DropdownMenuSelectField.displayName = 'DropdownMenuSelect'; + +// Context to wire menu items to form field +interface DropdownMenuSelectContextValue { + onValueChange: (value: T) => void; + value: T; +} +const DropdownMenuSelectContext = createContext | null>(null); + +/** Hook to access select context in item wrappers */ +export function useDropdownMenuSelectContext() { + const ctx = useContext(DropdownMenuSelectContext); + if (!ctx) { + throw new Error('useDropdownMenuSelectContext must be used within DropdownMenuSelectField'); + } + return ctx as { onValueChange: (value: T) => void; value: T }; +} + +/** Single-select menu item */ +export function DropdownMenuSelectItem({ + value, + children, + ...props +}: { value: string; children: React.ReactNode } & React.ComponentProps) { + const { onValueChange } = useDropdownMenuSelectContext(); + return ( + onValueChange(value)}> + {children} + + ); +} + +/** Multi-select checkbox menu item */ +export function DropdownMenuSelectCheckboxItem({ + value, + children, + ...props +}: { value: string; children: React.ReactNode } & React.ComponentProps) { + const { onValueChange, value: selected } = useDropdownMenuSelectContext(); + const isChecked = Array.isArray(selected) && selected.includes(value); + const handleChange = () => { + const newValue = isChecked ? selected.filter((v) => v !== value) : [...(selected || []), value]; + onValueChange(newValue); + }; + return ( + + {children} + + ); +} + +/** Radio-select menu item */ +export function DropdownMenuSelectRadioItem({ + value: itemValue, + children, + ...props +}: { value: string; children: React.ReactNode } & React.ComponentProps) { + const { onValueChange } = useDropdownMenuSelectContext(); + return ( + onValueChange(itemValue)}> + {children} + + ); +}