Skip to content

Commit b256fa8

Browse files
authored
Merge pull request #51 from lambda-curry/codegen-replace-nuqs-with-react-router
Replace nuqs with React Router 7 features in data table component
2 parents f0c0fd5 + 222fc4b commit b256fa8

16 files changed

Lines changed: 527 additions & 280 deletions

apps/docs/src/lib/storybook/react-router-stub.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Decorator } from '@storybook/react';
2-
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
32
import type { ComponentType } from 'react';
43
import {
54
type ActionFunction,
@@ -54,8 +53,13 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat
5453

5554
// Get the base path (without existing query params from options)
5655
const basePath = initialPath.split('?')[0];
56+
5757
// Get the current search string from the actual browser window, if available
58-
const currentWindowSearch = typeof window !== 'undefined' ? window.location.search : '';
58+
// If not available, use a default search string with parameters needed for the data table
59+
const currentWindowSearch = typeof window !== 'undefined'
60+
? window.location.search
61+
: '?page=0&pageSize=10';
62+
5963
// Combine them for the initial entry
6064
const actualInitialPath = `${basePath}${currentWindowSearch}`;
6165

@@ -65,13 +69,7 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat
6569
initialEntries: [actualInitialPath], // Use the path combined with window.location.search
6670
});
6771

68-
return (
69-
// NuqsAdapter will now read the initial state from the MemoryRouter,
70-
// which has been initialized using the window's query params.
71-
<NuqsAdapter>
72-
<RouterProvider router={router} />
73-
</NuqsAdapter>
74-
);
72+
return <RouterProvider router={router} />;
7573
};
7674
};
7775

apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { dataTableRouterParsers } from '@lambdacurry/forms/remix-hook-form/data-
33
import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header';
44
import type { Meta, StoryObj } from '@storybook/react';
55
import type { ColumnDef } from '@tanstack/react-table';
6-
import { type ActionFunctionArgs, useLoaderData } from 'react-router';
6+
import { type LoaderFunctionArgs, useLoaderData } from 'react-router';
77
import { z } from 'zod';
88
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
99

@@ -87,9 +87,13 @@ const columns: ColumnDef<User>[] = [
8787
// Component to display the data table with router form integration
8888
function DataTableRouterFormExample() {
8989
const loaderData = useLoaderData<DataResponse>();
90+
91+
// Ensure we have data even if loaderData is undefined
9092
const data = loaderData?.data ?? [];
9193
const pageCount = loaderData?.meta.pageCount ?? 0;
9294

95+
console.log('DataTableRouterFormExample - loaderData:', loaderData);
96+
9397
return (
9498
<div className="container mx-auto py-10">
9599
<h1 className="text-2xl font-bold mb-4">Users Table (React Router Form Integration)</h1>
@@ -98,7 +102,7 @@ function DataTableRouterFormExample() {
98102
<li>Form-based filtering with automatic submission</li>
99103
<li>Loading state while waiting for data</li>
100104
<li>Server-side filtering and pagination</li>
101-
<li>URL-based state management with nuqs</li>
105+
<li>URL-based state management with React Router</li>
102106
</ul>
103107
<DataTableRouterForm<User, keyof User>
104108
columns={columns}
@@ -135,17 +139,27 @@ function DataTableRouterFormExample() {
135139
);
136140
}
137141

138-
const handleDataFetch = async ({ request }: ActionFunctionArgs) => {
139-
const url = request.url ? new URL(request.url) : new URL('http://localhost');
142+
// Loader function to handle data fetching based on URL parameters
143+
const handleDataFetch = async ({ request }: LoaderFunctionArgs) => {
144+
// Add a small delay to simulate network latency
145+
await new Promise((resolve) => setTimeout(resolve, 300));
146+
147+
// Ensure we have a valid URL object
148+
const url = request?.url ? new URL(request.url) : new URL('http://localhost?page=0&pageSize=10');
140149
const params = url.searchParams;
141150

142-
// Use nuqs parsers, providing fallback '' for potentially null values
143-
const page = dataTableRouterParsers.page.parse(params.get('page') ?? '');
144-
const pageSize = dataTableRouterParsers.pageSize.parse(params.get('pageSize') ?? '');
145-
const sortField = dataTableRouterParsers.sortField.parse(params.get('sortField') ?? '');
146-
const sortOrder = dataTableRouterParsers.sortOrder.parse(params.get('sortOrder') ?? '');
147-
const search = dataTableRouterParsers.search.parse(params.get('search') ?? '');
148-
const parsedFilters = dataTableRouterParsers.filters.parse(params.get('filters') ?? '');
151+
console.log('handleDataFetch - URL:', url.toString());
152+
console.log('handleDataFetch - Search Params:', Object.fromEntries(params.entries()));
153+
154+
// Use our custom parsers to parse URL search parameters
155+
const page = dataTableRouterParsers.page.parse(params.get('page'));
156+
const pageSize = dataTableRouterParsers.pageSize.parse(params.get('pageSize'));
157+
const sortField = dataTableRouterParsers.sortField.parse(params.get('sortField'));
158+
const sortOrder = dataTableRouterParsers.sortOrder.parse(params.get('sortOrder'));
159+
const search = dataTableRouterParsers.search.parse(params.get('search'));
160+
const parsedFilters = dataTableRouterParsers.filters.parse(params.get('filters'));
161+
162+
console.log('handleDataFetch - Parsed Parameters:', { page, pageSize, sortField, sortOrder, search, parsedFilters });
149163

150164
// Apply filters
151165
let filteredData = [...users];
@@ -186,12 +200,16 @@ const handleDataFetch = async ({ request }: ActionFunctionArgs) => {
186200
}
187201

188202
// 4. Apply pagination
189-
// Provide defaults again for TS, although parsers guarantee numbers
190-
const safePage = page ?? 0;
191-
const safePageSize = pageSize ?? 10;
203+
// Determine safe values for page and pageSize using defaultValue when params are missing
204+
const safePage = params.has('page') ? page : dataTableRouterParsers.page.defaultValue;
205+
const safePageSize = params.has('pageSize') ? pageSize : dataTableRouterParsers.pageSize.defaultValue;
192206
const start = safePage * safePageSize;
193207
const paginatedData = filteredData.slice(start, start + safePageSize);
194208

209+
// Log the data being returned for debugging
210+
console.log(`Returning ${paginatedData.length} items, page ${safePage}, total ${filteredData.length}`);
211+
212+
// Return the data response
195213
return {
196214
data: paginatedData,
197215
meta: {

apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { zodResolver } from '@hookform/resolvers/zod';
22
import { DropdownMenuSelect } from '@lambdacurry/forms/remix-hook-form/dropdown-menu-select';
33
import { Button } from '@lambdacurry/forms/ui/button';
4-
import { FormMessage } from '@lambdacurry/forms/ui/form';
4+
import { DropdownMenuSelectItem } from '@lambdacurry/forms/ui/dropdown-menu-select-field';
55
import type { Meta, StoryObj } from '@storybook/react';
6+
import { expect, screen, userEvent, within } from '@storybook/test';
67
import { type ActionFunctionArgs, Form, useFetcher } from 'react-router';
78
import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form';
89
import { z } from 'zod';
@@ -40,7 +41,7 @@ const ControlledDropdownMenuSelectExample = () => {
4041
onValid: (data) => {
4142
fetcher.submit(
4243
createFormData({
43-
selectedFruit: data.fruit,
44+
fruit: data.fruit,
4445
}),
4546
{
4647
method: 'post',
@@ -55,8 +56,13 @@ const ControlledDropdownMenuSelectExample = () => {
5556
<RemixFormProvider {...methods}>
5657
<Form onSubmit={methods.handleSubmit}>
5758
<div className="space-y-4">
58-
<DropdownMenuSelect name="fruit" label="Select a fruit" options={AVAILABLE_FRUITS} />
59-
<FormMessage error={methods.formState.errors.fruit?.message} />
59+
<DropdownMenuSelect name="fruit" label="Select a fruit">
60+
{AVAILABLE_FRUITS.map((fruit) => (
61+
<DropdownMenuSelectItem key={fruit.value} value={fruit.value}>
62+
{fruit.label}
63+
</DropdownMenuSelectItem>
64+
))}
65+
</DropdownMenuSelect>
6066
<Button type="submit" className="mt-4">
6167
Submit
6268
</Button>
@@ -113,22 +119,22 @@ export const Default: Story = {
113119
},
114120
},
115121
},
116-
// play: async ({ canvasElement }) => {
117-
// const canvas = within(canvasElement);
122+
play: async ({ canvasElement }) => {
123+
const canvas = within(canvasElement);
118124

119-
// // Open the dropdown
120-
// const dropdownButton = canvas.getByRole('combobox');
121-
// await userEvent.click(dropdownButton);
125+
// Open the dropdown
126+
const dropdownButton = canvas.getByRole('button', { name: 'Select an option' });
127+
await userEvent.click(dropdownButton);
122128

123-
// // Select an option
124-
// const option = canvas.getByRole('option', { name: 'Banana' });
125-
// await userEvent.click(option);
129+
// Select an option (portal renders outside the canvas)
130+
const option = screen.getByRole('menuitem', { name: 'Banana' });
131+
await userEvent.click(option);
126132

127-
// // Submit the form
128-
// const submitButton = canvas.getByRole('button', { name: 'Submit' });
129-
// await userEvent.click(submitButton);
133+
// Submit the form
134+
const submitButton = canvas.getByRole('button', { name: 'Submit' });
135+
await userEvent.click(submitButton);
130136

131-
// // Check if the selected option is displayed
132-
// await expect(await canvas.findByText('Banana')).toBeInTheDocument();
133-
// },
137+
// Check if the selected option is displayed
138+
await expect(await canvas.findByText('Banana')).toBeInTheDocument();
139+
},
134140
};

apps/docs/vite.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ export default defineConfig({
2424
historyApiFallback: true,
2525
},
2626
optimizeDeps: {
27-
include: ['nuqs'],
27+
include: [],
2828
},
2929
});

packages/components/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@
6262
"input-otp": "^1.4.1",
6363
"lucide-react": "^0.468.0",
6464
"next-themes": "^0.4.4",
65-
"nuqs": "^2.4.1",
6665
"react-day-picker": "8.10.1",
6766
"react-hook-form": "^7.53.1",
6867
"react-router": "^7.0.0",

packages/components/src/remix-hook-form/data-table-router-form.tsx

Lines changed: 32 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import {
1111
getSortedRowModel,
1212
useReactTable,
1313
} from '@tanstack/react-table';
14-
import { useQueryStates } from 'nuqs';
15-
import { useCallback, useEffect, useState } from 'react';
14+
import { useCallback, useEffect, useMemo, useState } from 'react';
1615
import { useNavigation } from 'react-router-dom';
1716
import { RemixFormProvider, useRemixForm } from 'remix-hook-form';
1817
import { z } from 'zod';
@@ -21,8 +20,9 @@ import { DataTablePagination } from '../ui/data-table/data-table-pagination';
2120
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
2221
import { DataTableRouterToolbar, type DataTableRouterToolbarProps } from './data-table-router-toolbar';
2322

24-
// Import the nuqs parsers and the inferred type
25-
import { type DataTableRouterState, type FilterValue, dataTableRouterParsers } from './data-table-router-parsers';
23+
// Import the parsers and the inferred type
24+
import type { DataTableRouterState, FilterValue } from './data-table-router-parsers';
25+
import { getDefaultDataTableState, useDataTableUrlState } from './use-data-table-url-state';
2626

2727
// Schema for form data validation and type safety
2828
const dataTableSchema = z.object({
@@ -56,23 +56,13 @@ export function DataTableRouterForm<TData, TValue>({
5656
const navigation = useNavigation();
5757
const isLoading = navigation.state === 'loading';
5858

59-
// --- nuqs state management ---
60-
// Use nuqs to manage URL state. Debounce options can be set here per parser if needed.
61-
const [urlState, setUrlState] = useQueryStates(dataTableRouterParsers, {
62-
// Default nuqs options (shallow routing, replace history, no scroll)
63-
history: 'replace', // Default
64-
shallow: false, // we want to re-run the loader when the url changes
65-
// scroll: false, // Default
66-
// Configure debounce globally if needed (though nuqs batches by default)
67-
// throttleMs: 300,
68-
});
69-
// --- End nuqs state management ---
59+
// Use our custom hook for URL state management
60+
const { urlState, setUrlState } = useDataTableUrlState();
7061

71-
// Initialize RHF to *reflect* the nuqs state
62+
// Initialize RHF to *reflect* the URL state
7263
const methods = useRemixForm<DataTableRouterState>({
73-
// Use the nuqs inferred type
7464
// No resolver needed if Zod isn't primary validation driver here
75-
defaultValues: urlState, // Initialize with current URL state from nuqs
65+
defaultValues: urlState, // Initialize with current URL state
7666
});
7767

7868
// Sync RHF state if urlState changes (e.g., back/forward, external link)
@@ -87,7 +77,7 @@ export function DataTableRouterForm<TData, TValue>({
8777
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
8878
const [rowSelection, setRowSelection] = useState({});
8979

90-
// Table instance uses RHF state (which mirrors nuqs/URL state)
80+
// Table instance uses RHF state (which mirrors URL state)
9181
const table = useReactTable({
9282
data,
9383
columns,
@@ -132,24 +122,25 @@ export function DataTableRouterForm<TData, TValue>({
132122
},
133123
});
134124

135-
// Pagination handler updates nuqs state
125+
// Determine default pageSize and visible columns for skeleton loader
126+
const defaultDataTableState = getDefaultDataTableState(defaultStateValues);
127+
const visibleColumns = table.getVisibleFlatColumns();
128+
// Generate stable IDs for skeleton rows based on current pageSize or fallback
129+
const skeletonRowIds = useMemo(() => {
130+
const count = urlState.pageSize > 0 ? urlState.pageSize : defaultDataTableState.pageSize;
131+
return Array.from({ length: count }, () => window.crypto.randomUUID());
132+
}, [urlState.pageSize, defaultDataTableState.pageSize]);
133+
134+
// Pagination handler updates URL state
136135
const handlePaginationChange = useCallback(
137136
(pageIndex: number, newPageSize: number) => {
138137
setUrlState({ page: pageIndex, pageSize: newPageSize });
139138
},
140139
[setUrlState],
141140
);
142141

143-
// Derive default values directly from parsers for reset
144-
const standardStateValues: DataTableRouterState = {
145-
search: '',
146-
filters: [],
147-
page: 0,
148-
pageSize: 10,
149-
sortField: '',
150-
sortOrder: 'asc',
151-
...defaultStateValues,
152-
};
142+
// Get default state values using our utility function
143+
const standardStateValues = getDefaultDataTableState(defaultStateValues);
153144

154145
// Handle pagination props separately
155146
const paginationProps = {
@@ -184,14 +175,19 @@ export function DataTableRouterForm<TData, TValue>({
184175
</TableHeader>
185176
<TableBody>
186177
{isLoading ? (
187-
<TableRow>
188-
<TableCell colSpan={columns.length} className="h-24 text-center">
189-
Loading...
190-
</TableCell>
191-
</TableRow>
178+
// Skeleton rows matching pageSize with zebra background
179+
skeletonRowIds.map((rowId) => (
180+
<TableRow key={rowId} className="even:bg-gray-50">
181+
{visibleColumns.map((column) => (
182+
<TableCell key={column.id} className="py-2">
183+
<div className="h-6 my-1.5 bg-gray-200 rounded animate-pulse w-full" />
184+
</TableCell>
185+
))}
186+
</TableRow>
187+
))
192188
) : table.getRowModel().rows?.length ? (
193189
table.getRowModel().rows.map((row) => (
194-
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
190+
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'} className="even:bg-gray-50">
195191
{row.getVisibleCells().map((cell) => (
196192
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
197193
))}

0 commit comments

Comments
 (0)