Skip to content
Closed
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
19 changes: 16 additions & 3 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 @@ -86,9 +86,20 @@ const columns: ColumnDef<User>[] = [

// Component to display the data table with router form integration
function DataTableRouterFormExample() {
const loaderData = useLoaderData<DataResponse>();
const data = loaderData?.data ?? [];
const pageCount = loaderData?.meta.pageCount ?? 0;
// Try to use the loader data, but provide fallback for testing environments
let data: User[] = [];
let pageCount = 0;

try {
const loaderData = useLoaderData<DataResponse>();
data = loaderData?.data ?? [];
pageCount = loaderData?.meta.pageCount ?? 0;
} catch (error) {
console.warn('React Router loader data not available. Using fallback data for testing.');
// Use a subset of the data for testing
data = users.slice(0, 10);
pageCount = Math.ceil(users.length / 10);
}

return (
<div className="container mx-auto py-10">
Expand All @@ -104,6 +115,8 @@ function DataTableRouterFormExample() {
columns={columns}
data={data}
pageCount={pageCount}
// For testing environments, disable router integration
disableRouterIntegration={process.env.NODE_ENV === 'test'}
filterableColumns={[
{
id: 'role' as keyof User,
Expand Down
214 changes: 141 additions & 73 deletions packages/components/src/remix-hook-form/data-table-router-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export interface DataTableRouterFormProps<TData, TValue> {
searchableColumns?: DataTableRouterToolbarProps<TData>['searchableColumns'];
pageCount?: number;
defaultStateValues?: Partial<DataTableRouterState>;
/**
* For testing environments, you can disable the router integration
* This will make the component work without React Router context
*/
disableRouterIntegration?: boolean;
}

export function DataTableRouterForm<TData, TValue>({
Expand All @@ -52,33 +57,89 @@ export function DataTableRouterForm<TData, TValue>({
searchableColumns = [],
pageCount,
defaultStateValues,
disableRouterIntegration = false,
}: DataTableRouterFormProps<TData, TValue>) {
const navigation = useNavigation();
const isLoading = navigation.state === 'loading';
// Use try/catch to handle missing React Router context
let navigation;
let isLoading = false;

try {
navigation = useNavigation();
isLoading = navigation?.state === 'loading';
} catch (error) {
// If React Router context is missing, we'll use default values
console.warn('React Router context not found. Navigation state will not be available.');
navigation = null;
isLoading = false;
}

// --- 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 try/catch to handle missing nuqs context
let urlState = {} as DataTableRouterState;
let setUrlState = () => {};

try {
// Only use nuqs if router integration is enabled
if (!disableRouterIntegration) {
const [state, setState] = 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,
});
urlState = state;
setUrlState = setState;
}
} catch (error) {
// If nuqs context is missing, we'll use default values
console.warn('nuqs context not found. URL state management will not be available.');

// Derive default values directly from parsers for reset
const standardStateValues: DataTableRouterState = {
search: '',
filters: [],
page: 0,
pageSize: 10,
sortField: '',
sortOrder: 'asc',
...defaultStateValues,
};

urlState = standardStateValues;
setUrlState = () => {
console.warn('setUrlState called but nuqs is not available');
};
}

// Initialize RHF to *reflect* the nuqs 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
});
let methods;

try {
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
});
} catch (error) {
// If RemixFormProvider context is missing, we'll use a mock
console.warn('RemixFormProvider context not found. Form state will not be available.');
methods = {
getValues: () => urlState,
reset: () => {},
handleSubmit: () => () => {},
watch: () => '',
formState: { errors: {} },
register: () => ({}),
control: { _formValues: urlState },
};
}

// Sync RHF state if urlState changes (e.g., back/forward, external link)
useEffect(() => {
// Only reset if the urlState differs from current RHF values
if (JSON.stringify(urlState) !== JSON.stringify(methods.getValues())) {
// Only reset if the urlState differs from current RHF values and methods is available
if (methods && methods.reset && JSON.stringify(urlState) !== JSON.stringify(methods.getValues())) {
methods.reset(urlState);
}
}, [urlState, methods]);
Expand All @@ -92,9 +153,9 @@ export function DataTableRouterForm<TData, TValue>({
data,
columns,
state: {
sorting: [{ id: urlState.sortField, desc: urlState.sortOrder === 'desc' }],
columnFilters: urlState.filters as ColumnFilter[],
pagination: { pageIndex: urlState.page, pageSize: urlState.pageSize },
sorting: [{ id: urlState.sortField || '', desc: urlState.sortOrder === 'desc' }],
columnFilters: (urlState.filters || []) as ColumnFilter[],
pagination: { pageIndex: urlState.page || 0, pageSize: urlState.pageSize || 10 },
columnVisibility,
rowSelection,
},
Expand Down Expand Up @@ -157,59 +218,66 @@ export function DataTableRouterForm<TData, TValue>({
onPaginationChange: handlePaginationChange,
};

return (
<RemixFormProvider {...methods}>
<div className="space-y-4">
<DataTableRouterToolbar<TData>
table={table}
filterableColumns={filterableColumns}
searchableColumns={searchableColumns}
setUrlState={setUrlState}
defaultStateValues={standardStateValues}
/>

{/* Table Rendering */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
// Wrap with RemixFormProvider only if methods is valid
const content = (
<div className="space-y-4">
<DataTableRouterToolbar<TData>
table={table}
filterableColumns={filterableColumns}
searchableColumns={searchableColumns}
setUrlState={setUrlState}
defaultStateValues={standardStateValues}
/>

{/* Table Rendering */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Loading...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Loading...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>

<DataTablePagination {...paginationProps} />
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</RemixFormProvider>

<DataTablePagination {...paginationProps} />
</div>
);

// Only wrap with RemixFormProvider if methods is valid and has required methods
if (methods && methods.handleSubmit) {
return <RemixFormProvider {...methods}>{content}</RemixFormProvider>;
}

// Fallback for testing environments
return content;
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,18 @@ export function DataTableRouterToolbar<TData>({
setUrlState,
defaultStateValues,
}: DataTableRouterToolbarProps<TData>) {
const { watch } = useRemixFormContext<DataTableRouterState>();

const watchedSearch = watch('search');
const watchedFilters = watch('filters');
let watchedSearch = '';
let watchedFilters: FilterValue[] = [];

try {
const { watch } = useRemixFormContext<DataTableRouterState>();
watchedSearch = watch('search') || '';
watchedFilters = watch('filters') || [];
} catch (error) {
console.warn('RemixFormProvider context not found in DataTableRouterToolbar. Form state will not be available.');
watchedSearch = defaultStateValues.search || '';
watchedFilters = defaultStateValues.filters || [];
}

const handleSearchChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
Expand Down
Loading