Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions apps/docs/src/lib/storybook/react-router-stub.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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}`;

Expand All @@ -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.
<NuqsAdapter>
<RouterProvider router={router} />
</NuqsAdapter>
);
return <RouterProvider router={router} />;
};
};

Expand Down
46 changes: 32 additions & 14 deletions apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -87,9 +87,13 @@ const columns: ColumnDef<User>[] = [
// Component to display the data table with router form integration
function DataTableRouterFormExample() {
const loaderData = useLoaderData<DataResponse>();

// 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 (
<div className="container mx-auto py-10">
<h1 className="text-2xl font-bold mb-4">Users Table (React Router Form Integration)</h1>
Expand All @@ -98,7 +102,7 @@ function DataTableRouterFormExample() {
<li>Form-based filtering with automatic submission</li>
<li>Loading state while waiting for data</li>
<li>Server-side filtering and pagination</li>
<li>URL-based state management with nuqs</li>
<li>URL-based state management with React Router</li>
</ul>
<DataTableRouterForm<User, keyof User>
columns={columns}
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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: {
Expand Down
42 changes: 24 additions & 18 deletions apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,7 +41,7 @@ const ControlledDropdownMenuSelectExample = () => {
onValid: (data) => {
fetcher.submit(
createFormData({
selectedFruit: data.fruit,
fruit: data.fruit,
}),
{
method: 'post',
Expand All @@ -55,8 +56,13 @@ const ControlledDropdownMenuSelectExample = () => {
<RemixFormProvider {...methods}>
<Form onSubmit={methods.handleSubmit}>
<div className="space-y-4">
<DropdownMenuSelect name="fruit" label="Select a fruit" options={AVAILABLE_FRUITS} />
<FormMessage error={methods.formState.errors.fruit?.message} />
<DropdownMenuSelect name="fruit" label="Select a fruit">
{AVAILABLE_FRUITS.map((fruit) => (
<DropdownMenuSelectItem key={fruit.value} value={fruit.value}>
{fruit.label}
</DropdownMenuSelectItem>
))}
</DropdownMenuSelect>
<Button type="submit" className="mt-4">
Submit
</Button>
Expand Down Expand Up @@ -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();
},
};
2 changes: 1 addition & 1 deletion apps/docs/vite.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ export default defineConfig({
historyApiFallback: true,
},
optimizeDeps: {
include: ['nuqs'],
include: [],
},
});
1 change: 0 additions & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 32 additions & 36 deletions packages/components/src/remix-hook-form/data-table-router-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -56,23 +56,13 @@ export function DataTableRouterForm<TData, TValue>({
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<DataTableRouterState>({
// 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)
Expand All @@ -87,7 +77,7 @@ export function DataTableRouterForm<TData, TValue>({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
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,
Expand Down Expand Up @@ -132,24 +122,25 @@ export function DataTableRouterForm<TData, TValue>({
},
});

// 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 });
},
[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 = {
Expand Down Expand Up @@ -184,14 +175,19 @@ export function DataTableRouterForm<TData, TValue>({
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Loading...
</TableCell>
</TableRow>
// Skeleton rows matching pageSize with zebra background
skeletonRowIds.map((rowId) => (
<TableRow key={rowId} className="even:bg-gray-50">
{visibleColumns.map((column) => (
<TableCell key={column.id} className="py-2">
<div className="h-6 my-1.5 bg-gray-200 rounded animate-pulse w-full" />
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'} className="even:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
Expand Down
Loading
Loading