f-ui
Components

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_here

3. Install

REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-private/data-table
REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-private/data-table
REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-private/data-table
REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-private/data-table

4. Testing

Verify your token works with a curl request:

curl -H "Authorization: Bearer $REGISTRY_TOKEN" \
  https://ui.isaacfei.com/api/private/r/data-table.json

5. CI/CD (GitHub Actions)

- name: Install private components
  env:
    REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
  run: npx shadcn@latest add @f-ui-private/data-table

Contributors / 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 NuqsAdapter from your router's nuqs adapter. Only required if you use useTableSearchParams; the in-memory useLocalTableParams variant 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 ListQueryAdapter through TanStack Query). Wrap your app with <QueryClientProvider>. In-memory mode (passing data + pagination props 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).

Loading…
Page 1 of 1
"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

Loading…
Page 1 of 1
"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 all
  • variant="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.

Loading…
Page 1 of 1
"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.

Loading…
"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.

adapter request.filter →
null (no active filters)
Loading…
Page 1 of 1
"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>
  );
}

On this page