Data Table
Schema-driven TanStack Table with toolbar, filters, URL state, inline editing, batch actions, and JSON Logic — install from the private registry with Bearer token auth.
Private Registry
This component requires a Bearer token from your f-ui account.
It is not part of the public registry.json.
See Installation below for setup.
The data table module provides a single <DataTable> container backed by the useDataTable() master hook, a declarative TableSchema (via defineTableSchema), and a ListQueryAdapter data contract. Composable parts include the filter sidebar + chip bar + command palette, pagination, column manager, density toggle, row & batch actions, and hooks such as useTableSearchParams (URL state via nuqs). Use createInMemoryListAdapter for in-memory data or write a custom adapter for any list-shaped backend (REST, JSON-Logic /query, RPC, etc.). Full architecture and API details live in the README shipped with the source tree.
Installation
1. Configure components.json
Add the @f-ui-private registry with Bearer token auth:
{
"registries": {
"@f-ui": "https://ui.isaacfei.com/r/{name}.json",
"@f-ui-private": {
"url": "https://ui.isaacfei.com/api/private/r/{name}.json",
"headers": {
"Authorization": "Bearer ${REGISTRY_TOKEN}"
}
}
}
}2. Set Your Token
Generate a token from your f-ui tokens page, then set it as an environment variable:
# .env.local or shell export
REGISTRY_TOKEN=your_token_here3. Install
REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-private/data-tableREGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-private/data-tableREGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-private/data-tableREGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-private/data-table4. Testing
Verify your token works with a curl request:
curl -H "Authorization: Bearer $REGISTRY_TOKEN" \
https://ui.isaacfei.com/api/private/r/data-table.json5. CI/CD (GitHub Actions)
- name: Install private components
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: npx shadcn@latest add @f-ui-private/data-tableContributors / Self-Hosting
If you are developing in this repo, you can also build locally with pnpm registry:build:private and install from .registry-private/r/data-table.json.
Prerequisites / Peers
- nuqs — URL state for filters, sort, and pagination params. Wrap your app with
NuqsAdapterfrom your router's nuqs adapter. Only required if you useuseTableSearchParams; the in-memoryuseLocalTableParamsvariant has no router dependency. - @tanstack/react-table — table core (declared in the registry item).
- @tanstack/react-query — required for adapter mode (the container reads any
ListQueryAdapterthrough TanStack Query). Wrap your app with<QueryClientProvider>. In-memory mode (passingdata+paginationprops directly) does not require a query client. - shadcn components resolved via
registryDependencies(button, select, dialog, sheet, pagination, context-menu, etc.).
URL ⇄ wire naming boundary
The data-table uses camelCase for URL parameters (?page=2&pageSize=50&sortBy=createdAt&sortOrder=desc) and JSON-Logic var references. Adapters are responsible for translating to whatever wire format their backend expects — snake_case for REST, GraphQL field names, etc. URL state stays camelCase regardless of how each adapter chooses to talk to its server.
Read-Only
10 columns with sorting, density control, pagination, and column pinning (Order # pinned left).
"use client";
import { useMemo } from "react";
import { parseAsInteger } from "nuqs";
import { createInMemoryListAdapter } from "@/components/f-ui/data-table/adapters/in-memory-list-adapter";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
CATEGORY_VARIANTS,
} from "./demo-data";
export function DataTableReadonlyDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
},
status: {
kind: "badge",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
},
priority: {
kind: "badge",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
},
quantity: {
kind: "number",
label: "Qty",
sortKey: "quantity",
decimals: 0,
size: 80,
},
orderDate: {
kind: "date",
label: "Order Date",
sortKey: "orderDate",
size: 120,
},
shippedDate: {
kind: "date",
label: "Shipped",
sortKey: "shippedDate",
size: 120,
},
shipped: {
kind: "boolean",
label: "Shipped?",
trueLabel: "Yes",
falseLabel: "No",
size: 90,
},
category: {
kind: "badge",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 110,
},
},
{},
),
[],
);
const adapter = useMemo(
() => createInMemoryListAdapter<Order>({ getItems: () => SEED_ORDERS }),
[],
);
const { params, setParams } = useTableSearchParams(schema, {
urlParamPrefix: "dt_ro_",
extraParsers: {
pageSize: parseAsInteger.withDefault(10),
},
});
return (
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
adapter={adapter}
tableCode="docs-dt-readonly"
params={params}
onParamsChange={setParams}
showDensityControl
/>
);
}Filters & URL State
Filter panel with 6 control types (search, select, multi-select, number-range, date-range). Filter state syncs to the URL via nuqs.
Filter state syncs to the URL via nuqs. Active filters: 0
"use client";
import { useMemo } from "react";
import { createInMemoryListAdapter } from "@/components/f-ui/data-table/adapters/in-memory-list-adapter";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
CATEGORY_VARIANTS,
} from "./demo-data";
export function DataTableFiltersDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
filter: {
dataType: "text",
paramKey: "customer",
logicVar: "customer",
placeholder: "Search customer…",
},
},
status: {
kind: "badge",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
filter: {
dataType: "multi-select",
paramKey: "status",
logicVar: "status",
options: Object.entries(STATUS_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
priority: {
kind: "badge",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
filter: {
dataType: "select",
paramKey: "priority",
logicVar: "priority",
options: Object.entries(PRIORITY_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
filter: {
dataType: "number",
paramKey: "amount",
logicVar: "amount",
defaultOperator: "between",
},
},
quantity: {
kind: "number",
label: "Qty",
sortKey: "quantity",
decimals: 0,
size: 80,
},
orderDate: {
kind: "date",
label: "Order Date",
sortKey: "orderDate",
size: 130,
filter: {
dataType: "date",
paramKey: "orderDate",
logicVar: "orderDate",
},
},
shipped: {
kind: "boolean",
label: "Shipped?",
trueLabel: "Yes",
falseLabel: "No",
size: 90,
},
category: {
kind: "badge",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 110,
filter: {
dataType: "multi-select",
paramKey: "category",
logicVar: "category",
options: Object.entries(CATEGORY_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
},
{
filterGroups: [
{ label: "Order", fields: ["customer", "status", "priority"] },
{ label: "Details", fields: ["amount", "orderDate", "category"] },
],
},
),
[],
);
const { params, setParams, activeFilterCount } = useTableSearchParams(
schema,
{ urlParamPrefix: "dt_fl_" },
);
const adapter = useMemo(
() => createInMemoryListAdapter<Order>({ getItems: () => SEED_ORDERS }),
[],
);
return (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
Filter state syncs to the URL via nuqs. Active filters:{" "}
<span className="text-foreground font-medium tabular-nums">
{activeFilterCount}
</span>
</p>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
adapter={adapter}
getRowId={(r) => r.id}
tableCode="docs-dt-filters"
params={params}
onParamsChange={setParams}
enableFilterPanel
showDensityControl
/>
</div>
);
}Filter Bar (bazza-style)
The bar shows one chip per active filter: a read-only field label, then operator, value (when needed), and remove (×). To filter on a different column, remove the chip and use + Filter. It is enabled by default (enableFilterBar) and can be tuned via filterBarProps:
variant="full"(default) — chips + add button + clear allvariant="chips-only"— chips + clear all only; pair with the Panel so adds happen there
<DataTable
schema={schema}
data={rows}
pagination={pagination}
enableFilterBar
filterBarProps={{ variant: "chips-only" }}
/>Filter Panel (Zoho-style)
A 280px sticky-header / sticky-footer left sidebar. Field rows expand to type-specific value controls. Apply batches changes inside the panel and flushes them in one shot. Edits made elsewhere (chip bar, quick filters, criteria, preset apply) commit immediately to the same applied state.
<DataTable
schema={schema}
data={rows}
pagination={pagination}
enableFilterPanel
filterPanelProps={{
title: "Filter Deals By",
sections: [
{
id: "fields",
label: "Field Filters",
content: { kind: "fields", fieldKeys: "*" },
},
],
}}
/>Saved Filters (presets)
A dropdown rendered in the panel header lists user-saved presets and a footer Save filter button captures the current panel draft—staged rows you have edited but not yet applied—together with any panel criteria state, as a new preset. The dropdown supports inline rename and delete via a per-row menu. Persistence falls back to localStorage keyed by tableCode when no remote callbacks are provided.
<DataTable
schema={schema}
data={rows}
pagination={pagination}
enableFilterPanel
initialFilterPresets={[
{
id: "preset-shipped",
name: "Shipped orders",
filterModel: { shipped: { op: "is_true" } },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]}
filterPanelProps={{
title: "Filter Orders By",
enableSavedFilters: true, // defaults to true once any preset exists
}}
onFilterPresetSave={async (preset) => {
await fetch("/api/presets", { method: "POST", body: JSON.stringify(preset) });
}}
onFilterPresetDelete={async (id) => {
await fetch(`/api/presets/${id}`, { method: "DELETE" });
}}
/>Breaking change in PR 5
useDataTableFilters().savePreset(name, options?) no longer accepts a
visibility argument. Saving is per-user only (Q4); presets are persisted to
localStorage (or your onSave callback) without a sharing flag. The
signature is now savePreset(name) on the public hook (criteria, when
present, are captured automatically). If you used savePreset(name, "shared")
in your app code, drop the second argument — there is no behavior to preserve.
Filter panel (Apply & Clear)
The sidebar uses a panel-local draft: changes are staged until Apply Filter commits them to the table filter state (and URL when enabled). Clear removes all active filters—field rows, custom criteria, and quick filters—and applies that immediately (Zoho-style one-shot clear), without a separate apply step for the reset.
<DataTable
schema={schema}
data={rows}
pagination={pagination}
enableFilterPanel
filterPanelProps={{
title: "Filter Orders By",
sections: [
{
id: "quick",
label: "Quick Filters",
defaultOpen: true,
content: {
kind: "quick-filters",
items: [
{
id: "shipped",
label: "Shipped only",
expression: { "==": [{ var: "shipped" }, true] },
},
],
},
},
{
id: "fields",
label: "Field Filters",
content: { kind: "fields", fieldKeys: "*" },
},
],
}}
/>Persistence (collapsed/open)
The filter panel’s collapsed vs open state is persisted to localStorage by default when <DataTable> supplies a stable tableCode. The storage key is f-ui:filter-panel-collapsed:{tableCode}. Pass filterPanelCollapsePersistence on filterPanelProps to replace local storage with an async load / save pair (for example a backend preference API).
<DataTable
schema={schema}
data={rows}
pagination={pagination}
tableCode="orders"
enableFilterPanel
filterPanelProps={{
defaultCollapsed: false,
// persistFilterPanelCollapsed: false, // opt out — memory only
// filterPanelCollapseStorageKey: "f-ui:filter-panel-collapsed:orders-secondary",
// filterPanelCollapsePersistence: {
// load: () => fetch("/api/prefs/filter-panel").then((r) => r.json()),
// save: (collapsed) =>
// fetch("/api/prefs/filter-panel", {
// method: "PUT",
// body: JSON.stringify({ collapsed }),
// }).then(() => undefined),
// },
}}
/>Command palette
The field-picker command palette is opt-in: set enableCommandPalette to mount the dialog and register the keyboard shortcut (commandPaletteHotkey: "cmdf" | "slash" | "none"; default "cmdf"). The toolbar does not include an “Add filter” button; use the filter bar’s add control, the filter panel, or the shortcut.
<DataTable
schema={schema}
data={rows}
pagination={pagination}
enableCommandPalette
commandPaletteHotkey="cmdf"
/>The useDataTableFilters hook still exposes a criteria slot and APIs for advanced or custom UIs (merged into JsonLogic with field filters). There is no built-in criteria builder in the filter panel.
filters.criteria;
filters.setCriteriaConnector("or");
filters.addCriteriaRow();
filters.updateCriteriaRow(id, { paramKey: "amount", value: { op: "gt", value: "100" } });
filters.removeCriteriaRow(id);
filters.clearCriteria();Quick filters are toggled from the panel's quick-filters section:
filters.quickFilterIds; // ReadonlySet<string>
filters.setQuickFilter("shipped", { "==": [{ var: "shipped" }, true] });
filters.setQuickFilter("shipped", null); // disable
filters.clearQuickFilters();Standalone usage with useDataTableFilters
The headless hook can power custom layouts that aren't wrapped in <DataTable>. Both filter surfaces accept a filters prop and reuse the same state:
import { useDataTableFilters } from "@/components/f-ui/data-table/hooks/use-data-table-filters";
import { DataTableFilterBar } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-bar";
import { DataTableFilterPanel } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-panel";
const filters = useDataTableFilters({
fields,
onAppliedChange: (applied, jsonLogic) => refetch(jsonLogic),
});
return (
<>
<DataTableFilterPanel filters={filters} />
<DataTableFilterBar filters={filters} variant="chips-only" />
</>
);Row & Batch Actions
Row actions menu (three-dot dropdown), right-click context menu, batch action bar with selection checkboxes. "Delete" shows a destructive confirmation dialog.
Select rows for batch actions. Use the kebab menu in the trailing column for per-row actions.
"use client";
import { useMemo } from "react";
import {
EyeIcon,
PencilIcon,
Trash2Icon,
TruckIcon,
XCircleIcon,
} from "lucide-react";
import { toast } from "sonner";
import { createInMemoryListAdapter } from "@/components/f-ui/data-table/adapters/in-memory-list-adapter";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import type {
BatchAction,
RowAction,
} from "@/components/f-ui/data-table/schema/types";
import {
type Order,
PRIORITY_VARIANTS,
SEED_ORDERS,
STATUS_VARIANTS,
} from "./demo-data";
export function DataTableActionsDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
},
status: {
kind: "badge",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
},
priority: {
kind: "badge",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
},
orderDate: {
kind: "date",
label: "Order Date",
sortKey: "orderDate",
size: 120,
},
},
{},
),
[],
);
const { params, setParams } = useTableSearchParams(schema, {
urlParamPrefix: "dt_ac_",
});
const adapter = useMemo(
() => createInMemoryListAdapter<Order>({ getItems: () => SEED_ORDERS }),
[],
);
const rowActions = useMemo<RowAction<Order>[]>(
() => [
{
id: "view",
label: "View details",
icon: <EyeIcon className="size-4" />,
onClick: (row) => toast.info(`View: ${row.orderNumber}`),
},
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-4" />,
onClick: (row) => toast.success(`Edit: ${row.orderNumber}`),
},
{
id: "cancel",
label: "Cancel order",
icon: <XCircleIcon className="size-4" />,
variant: "destructive",
onClick: (row) => toast.warning(`Cancel: ${row.orderNumber}`),
disabled: (row) =>
row.status === "cancelled" || row.status === "delivered",
},
],
[],
);
const batchActions = useMemo<BatchAction<Order>[]>(
() => [
{
id: "ship",
label: "Mark shipped",
icon: <TruckIcon className="size-4" />,
onClick: (selection) => {
if (selection.mode === "rows") {
toast.success(`Shipping ${selection.rows.length} order(s)`);
}
},
disabled: (rows) => {
const allShipped = rows.every((r) => r.shipped);
return allShipped ? "All selected orders are already shipped" : false;
},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-4" />,
variant: "destructive",
confirmBeforeRun: true,
onClick: (selection) => {
if (selection.mode === "rows") {
toast.success(`Deleted ${selection.rows.length} order(s)`);
}
},
},
],
[],
);
return (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
Select rows for batch actions. Use the kebab menu in the trailing
column for per-row actions.
</p>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
adapter={adapter}
getRowId={(r) => r.id}
tableCode="docs-dt-actions"
params={params}
onParamsChange={setParams}
rowActions={rowActions}
batchActions={batchActions}
enableRowSelection
showDensityControl
/>
</div>
);
}Inline Editing
The data-table-parts/edit-cells/ and data-table-parts/table-fields/ files
ship as building blocks for per-cell editing. The <DataTable> container
currently exposes them through composition; a higher-level inline-edit API
plus a RowMutationAdapter contract are tracked separately and not part of the
default container surface today.
Column Manager
Toggle column visibility, drag-reorder, and pin columns using the column manager button in the toolbar.
Use the columns button in the toolbar to toggle visibility, reorder, and pin columns.
"use client";
import { useMemo } from "react";
import { createInMemoryListAdapter } from "@/components/f-ui/data-table/adapters/in-memory-list-adapter";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
CATEGORY_VARIANTS,
} from "./demo-data";
export function DataTableColumnManagerDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
},
status: {
kind: "badge",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
},
priority: {
kind: "badge",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
},
quantity: {
kind: "number",
label: "Qty",
sortKey: "quantity",
decimals: 0,
size: 80,
},
orderDate: {
kind: "date",
label: "Order Date",
sortKey: "orderDate",
size: 120,
},
shippedDate: {
kind: "date",
label: "Shipped",
sortKey: "shippedDate",
size: 120,
},
shipped: {
kind: "boolean",
label: "Shipped?",
trueLabel: "Yes",
falseLabel: "No",
size: 90,
},
category: {
kind: "badge",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 110,
},
notes: { kind: "text", label: "Notes", size: 200 },
},
{},
),
[],
);
const adapter = useMemo(
() => createInMemoryListAdapter<Order>({ getItems: () => SEED_ORDERS }),
[],
);
return (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
Use the columns button in the toolbar to toggle visibility, reorder, and
pin columns.
</p>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
adapter={adapter}
getRowId={(r) => r.id}
tableCode="docs-dt-columns"
showDensityControl
enableColumnManager
disablePagination
/>
</div>
);
}Server-Shaped + JSON Logic
buildJsonLogicFilter converts URL params into a JSON Logic rule for POST { filter } list endpoints. The preview panel shows the live output.
null (no active filters)
"use client";
import { useCallback, useMemo, useState } from "react";
import { parseAsInteger } from "nuqs";
import { useQueryClient } from "@tanstack/react-query";
import { createInMemoryListAdapter } from "@/components/f-ui/data-table/adapters/in-memory-list-adapter";
import type {
JsonLogicRule,
ListQueryAdapter,
} from "@/components/f-ui/data-table/adapters/list-query-contract";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
CATEGORY_VARIANTS,
} from "./demo-data";
export function DataTableServerJsonLogicDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
filter: {
dataType: "text",
paramKey: "customer",
logicVar: "customer",
},
},
status: {
kind: "badge",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
filter: {
dataType: "multi-select",
paramKey: "status",
logicVar: "status",
options: Object.entries(STATUS_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
filter: {
dataType: "number",
paramKey: "amount",
logicVar: "amount",
defaultOperator: "between",
},
},
category: {
kind: "badge",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 110,
filter: {
dataType: "multi-select",
paramKey: "category",
logicVar: "category",
options: Object.entries(CATEGORY_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
},
{
filterGroups: [
{
label: "Filters",
fields: ["customer", "status", "amount", "category"],
},
],
},
),
[],
);
const { params, setParams } = useTableSearchParams(schema, {
urlParamPrefix: "dt_jl_",
extraParsers: {
pageSize: parseAsInteger.withDefault(10),
},
});
const baseAdapter = useMemo(
() => createInMemoryListAdapter<Order>({ getItems: () => SEED_ORDERS }),
[],
);
const [latestFilter, setLatestFilter] = useState<JsonLogicRule | null>(null);
// Pedagogical shim: capture each adapter call's `request.filter` into local
// state so the debug panel below can render the live JSON-Logic payload.
const adapter = useCallback<ListQueryAdapter<Order>>(
async (request) => {
setLatestFilter(request.filter ?? null);
return baseAdapter(request);
},
[baseAdapter],
);
const queryClient = useQueryClient();
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="border-input bg-background hover:bg-muted/50 rounded-md border px-2 py-1 text-xs"
onClick={() => {
queryClient.invalidateQueries({
queryKey: ["data-table", "docs-dt-json-logic"],
});
}}
>
Refetch
</button>
</div>
<div className="border-input bg-muted/30 rounded-md border p-3 font-mono text-[11px] leading-relaxed">
<div className="text-muted-foreground mb-1">
adapter request.filter →
</div>
<pre className="text-foreground max-h-32 overflow-auto whitespace-pre-wrap break-all">
{latestFilter == null
? "null (no active filters)"
: JSON.stringify(latestFilter, null, 2)}
</pre>
</div>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
adapter={adapter}
getRowId={(r) => r.id}
tableCode="docs-dt-json-logic"
params={params}
onParamsChange={setParams}
enableFilterPanel
showDensityControl
/>
</div>
);
}Related Registry Items
- Date Picker / Date Range Picker —
DatePicker/DateRangePickerfor filter date fields. - Currency Input — money columns and
currency-formathelpers. - Empty Value Placeholder — empty cells use this component internally.