data-table-filters
blockdata-table-example-infinitesource: ok · hit
Example: infinite table
A complete /example route to see the table running before wiring your own data: a table schema, a mock API that filters, computes facets and cursor-paginates in memory with the same semantics as the Drizzle handler, and the infinite table with a timeline chart, URL state, command palette, row sheet and a hand-written timing phases column. Delete app/example when you have your own.
npx shadcn add "https://data-table.openstatus.dev/r/data-table-example-infinite.json"free
Where it goes
statsdata-table
Interaction
animated-countercursor-follow
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
https://data-table.openstatus.dev/r/data-table.jsonhttps://data-table.openstatus.dev/r/data-table-schema.jsonhttps://data-table.openstatus.dev/r/data-table-query.jsonhttps://data-table.openstatus.dev/r/data-table-nuqs.jsonhttps://data-table.openstatus.dev/r/data-table-sheet.jsonhttps://data-table.openstatus.dev/r/data-table-cell.jsonhttps://data-table.openstatus.dev/r/data-table-filter-command.jsonhttps://data-table.openstatus.dev/r/data-table-chart.jsonhover-carddropdown-menubutton
Files11 · source cached 10/8/2026, 1:40:34 PM
src/examples/infinite/layout.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import * as React from "react";
/**
* The two providers the table needs, scoped to this route so nothing in the
* root layout has to change. Move them up to `app/layout.tsx` once more than
* one route uses them.
*/
export default function Layout({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<NuqsAdapter>{children}</NuqsAdapter>
</QueryClientProvider>
);
}
src/examples/infinite/page.tsx
import type { SearchParams as RawSearchParams } from "nuqs";
import { Client } from "./client";
import { searchParamsCache } from "./schema";
/**
* Parses the URL on the server so the first render already has the filters,
* the sort and the cursor — no flash of an unfiltered table.
*/
export default async function Page({
searchParams,
}: {
searchParams: Promise<RawSearchParams>;
}) {
const search = await searchParamsCache.parse(searchParams);
return <Client initialState={search} />;
}
src/examples/infinite/client.tsx
"use client";
import {
TimelineChart,
type TimelineChartSeries,
} from "@/components/data-table/data-table-chart/timeline-chart";
import { DataTableFilterCommand } from "@/components/data-table/data-table-filter-command";
import { DataTableInfinite } from "@/components/data-table/data-table-infinite";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { DataTableRefreshButton } from "@/components/data-table/data-table-refresh-button";
import { MemoizedDataTableSheetContent } from "@/components/data-table/data-table-sheet/data-table-sheet-content";
import { DataTableSheetDetails } from "@/components/data-table/data-table-sheet/data-table-sheet-details";
import type { SheetField } from "@/components/data-table/types";
import {
applyFacets,
getFacetedMinMaxValues,
getFacetedUniqueValues,
getMetaPage,
refreshDataTableQuery,
} from "@/lib/data-table";
import { isActive } from "@/lib/filters";
import { useNuqsAdapter } from "@/lib/store/adapters/nuqs";
import { useFilterState } from "@/lib/store/hooks/useFilterState";
import { DataTableStoreProvider } from "@/lib/store/provider/DataTableStoreProvider";
import {
generateColumns,
generateFilterFields,
generateSheetFields,
getDefaultColumnVisibility,
} from "@/lib/table-schema";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { Footer } from "./footer";
import { dataOptions } from "./query-options";
import { filterSchema, type FilterState, type SearchParams } from "./schema";
import {
getLevelRowClassName,
tableSchema,
type ColumnSchema,
} from "./table-schema";
import { timingPhasesColumn } from "./timing-phases";
// Everything the table renders is generated from the schema, once. The one
// exception is the timing bar, which spans five columns and is appended by hand.
const columns = [
...generateColumns<ColumnSchema>(tableSchema.definition),
timingPhasesColumn<ColumnSchema>(),
];
const filterFields = generateFilterFields<ColumnSchema>(tableSchema.definition);
const sheetFields = generateSheetFields<ColumnSchema>(tableSchema.definition);
const defaultColumnVisibility = getDefaultColumnVisibility(
tableSchema.definition,
);
/** The chart's stacked series, bottom-up. Colours come from the core block's CSS variables. */
const CHART_SERIES: TimelineChartSeries[] = [
{ key: "error", label: "Error" },
{ key: "warning", label: "Warning" },
{ key: "info", label: "Info" },
];
// Module-level so its identity is stable: the memoized rows compare it.
const getRowClassName = (row: { original: ColumnSchema }) =>
getLevelRowClassName(row.original.level);
/** URL state that is not a column filter. */
const STATE_KEYS = new Set(["sort", "uuid", "size", "direction", "cursor"]);
export function Client({ initialState }: { initialState: SearchParams }) {
const adapter = useNuqsAdapter(filterSchema.definition, {
id: "example",
initialState,
});
return (
<DataTableStoreProvider adapter={adapter}>
<Table />
</DataTableStoreProvider>
);
}
function Table() {
const search = useFilterState<FilterState>();
const queryClient = useQueryClient();
const { data, isFetching, isLoading, fetchNextPage, hasNextPage } =
useInfiniteQuery(dataOptions(search));
// Not the bare `refetch`: that would restart from the first cached page
// param. The helper resets the cache to a fresh first page (now, with meta).
const refresh = React.useCallback(
() => refreshDataTableQuery(queryClient, dataOptions(search)),
[queryClient, search],
);
const flatData = React.useMemo(
() => data?.pages?.flatMap((page) => page.data ?? []) ?? [],
[data?.pages],
);
// Facets and counts come from the page that carries meta — the first one,
// since pagination requests skip it.
const meta = getMetaPage(data)?.meta;
const facets = meta?.facets;
const defaultColumnFilters = Object.entries(search)
.filter(([key, value]) => !STATE_KEYS.has(key) && isActive(value))
.map(([id, value]) => ({ id, value }));
const dynamicFilterFields = React.useMemo(
() => applyFacets(filterFields, facets),
[facets],
);
return (
<DataTableInfinite
columns={columns}
data={flatData}
totalRows={meta?.totalRowCount}
filterRows={meta?.filterRowCount}
totalRowsFetched={flatData.length}
defaultColumnFilters={defaultColumnFilters}
defaultColumnSorting={search.sort ? [search.sort] : undefined}
defaultRowSelection={search.uuid ? { [search.uuid]: true } : undefined}
defaultColumnVisibility={defaultColumnVisibility}
filterFields={dynamicFilterFields}
isFetching={isFetching}
isLoading={isLoading}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
getRowId={(row) => row.uuid}
getRowClassName={getRowClassName}
getFacetedUniqueValues={getFacetedUniqueValues(facets)}
getFacetedMinMaxValues={getFacetedMinMaxValues(facets)}
chartSlot={
<TimelineChart
data={meta?.chartData ?? []}
columnId="date"
series={CHART_SERIES}
className="-mb-2"
/>
}
toolbarActions={<DataTableRefreshButton onClick={refresh} />}
footerSlot={<Footer />}
commandSlot={
<DataTableFilterCommand
schema={filterSchema.definition}
tableId="example"
/>
}
sheetSlot={
<SheetSlot
sheetFields={sheetFields}
totalRows={meta?.totalRowCount ?? 0}
filterRows={meta?.filterRowCount ?? 0}
totalRowsFetched={flatData.length}
/>
}
tableId="example"
/>
);
}
function SheetSlot({
sheetFields: fields,
totalRows,
filterRows,
totalRowsFetched,
}: {
sheetFields: SheetField<ColumnSchema>[];
totalRows: number;
filterRows: number;
totalRowsFetched: number;
}) {
const { table, rowSelection, isLoading, filterFields } = useDataTable<
ColumnSchema,
unknown
>();
const selectedRowKey = Object.keys(rowSelection)?.[0];
const selectedRow = React.useMemo(() => {
if (isLoading && !selectedRowKey) return undefined;
return table
.getCoreRowModel()
.flatRows.find((row) => row.id === selectedRowKey);
}, [selectedRowKey, isLoading, table]);
return (
<DataTableSheetDetails
title={
selectedRow
? `${selectedRow.original.method} ${selectedRow.original.pathname}`
: undefined
}
titleClassName="font-mono"
>
<MemoizedDataTableSheetContent
table={table}
data={selectedRow?.original}
filterFields={filterFields}
fields={fields}
metadata={{ totalRows, filterRows, totalRowsFetched }}
/>
</DataTableSheetDetails>
);
}
src/examples/infinite/schema.ts
import {
createNuqsSearchParams,
type inferParserType,
} from "@/lib/store/adapters/nuqs/server";
import { field } from "@/lib/store/schema";
import { generateFilterSchema } from "@/lib/table-schema";
import { tableSchema } from "./table-schema";
const DIRECTIONS = ["prev", "next"] as const;
/**
* Everything that lives in the URL: one filter field per filterable column,
* generated from the table schema, plus the state that is not a filter —
* the sort, the selected row, and the cursor pagination.
*/
export const filterSchema = generateFilterSchema(tableSchema.definition, {
sort: field.sort(),
uuid: field.string(),
size: field.number().default(40),
direction: field.stringLiteral(DIRECTIONS).default("next"),
cursor: field.timestamp(),
});
export type FilterState = typeof filterSchema._type;
export const { searchParamsCache, searchParamsSerializer, searchParamsParser } =
createNuqsSearchParams(filterSchema.definition);
export type SearchParams = inferParserType<typeof searchParamsParser>;
src/examples/infinite/table-schema.ts
import {
col,
createTableSchema,
type InferTableType,
} from "@/lib/table-schema";
import { SheetTimingPhases } from "./timing-phases";
export const LEVELS = ["error", "warning", "info"] as const;
export const METHODS = ["GET", "POST", "PUT", "DELETE"] as const;
export const REGIONS = ["ams", "fra", "iad", "syd"] as const;
export const STATUS_CODES = [200, 201, 400, 404, 500];
/**
* A faint tint of the level's colour on the rows worth noticing, stepping up
* on hover, focus, and for the row that is open in the sheet or checked — the
* table marks that row `data-[state=selected]` without a select column, and
* `data-detail` / `data-checked` with one. `info` is nearly every row, so it
* stays plain.
*/
export function getLevelRowClassName(level: string): string {
switch (level) {
case "warning":
return [
"bg-warning/5 hover:bg-warning/10 focus-visible:bg-warning/10 data-[state=selected]:bg-warning/20 data-detail:bg-warning/20 data-checked:bg-warning/20",
"dark:bg-warning/10 dark:hover:bg-warning/20 dark:focus-visible:bg-warning/20 dark:data-[state=selected]:bg-warning/30 dark:data-detail:bg-warning/30 dark:data-checked:bg-warning/30",
].join(" ");
case "error":
return [
"bg-error/5 hover:bg-error/10 focus-visible:bg-error/10 data-[state=selected]:bg-error/20 data-detail:bg-error/20 data-checked:bg-error/20",
"dark:bg-error/10 dark:hover:bg-error/20 dark:focus-visible:bg-error/20 dark:data-[state=selected]:bg-error/30 dark:data-detail:bg-error/30 dark:data-checked:bg-error/30",
].join(" ");
default:
return "";
}
}
/**
* The one definition the whole example reads from. The columns, the filter
* sidebar, the command palette, the row sheet and the mock API's filter
* semantics are all generated from it, so a column added here shows up on
* every surface.
*/
export const tableSchema = createTableSchema({
level: col.presets
.logLevel(LEVELS)
.display("level-indicator")
.hideHeader()
.size(37)
.sheet(),
date: col.presets
.timestamp()
.label("Date")
// The mock API returns rows newest-first and does not sort; the header
// must not promise what the endpoint cannot deliver.
.sortable(false)
.defaultOpen()
.commandDisabled()
// The one unsized column: it absorbs the table's leftover width.
.minSize(200)
.sheet(),
status: col.presets.httpStatus(STATUS_CODES).size(70).sheet(),
method: col.presets.httpMethod(METHODS).size(80).sheet(),
pathname: col.presets.pathname().size(220).sheet(),
region: col
.enum(REGIONS)
.label("Region")
.display("badge")
.filterable("checkbox", {
options: REGIONS.map((region) => ({ label: region, value: region })),
})
.size(90)
.sheet(),
latency: col.presets
.latency("ms")
.label("Latency")
.sortable(false)
.size(110)
.sheet(),
// The five phases behind `timingPhasesColumn`: hidden from the table by
// default (toggle them in the column settings), each filterable by range,
// and shown together as one sheet section hung off the first.
"timing.dns": col.presets
.duration("ms")
.label("DNS")
.size(110)
.hidden()
.sheet({
label: "Timing Phases",
component: SheetTimingPhases,
className: "flex-col items-start w-full gap-1",
}),
"timing.connection": col.presets
.duration("ms")
.label("Connection")
.size(110)
.hidden(),
"timing.tls": col.presets.duration("ms").label("TLS").size(110).hidden(),
"timing.ttfb": col.presets.duration("ms").label("TTFB").size(110).hidden(),
"timing.transfer": col.presets
.duration("ms")
.label("Transfer")
.size(110)
.hidden(),
uuid: col.presets.traceId().label("Request ID").hidden().sheet(),
});
export type ColumnSchema = InferTableType<typeof tableSchema.definition>;
src/examples/infinite/timing.ts
/**
* The request's timing phases, in the order they happen. Each is a flat,
* dotted column on the row (`"timing.dns"`), the shape the table schema,
* the filters and the mock API all read.
*/
export const TIMING_PHASES = [
"timing.dns",
"timing.connection",
"timing.tls",
"timing.ttfb",
"timing.transfer",
] as const;
export type TimingPhase = (typeof TIMING_PHASES)[number];
export type Timing = Record<TimingPhase, number>;
/**
* What `timingPhasesColumn` and `SheetTimingPhases` read off a row: the
* total, and whichever phases the row carries. Any row type with these keys
* fits, which is how the docs site's own demos share the components.
*/
export type TimingRow = { latency: number } & Partial<
Record<TimingPhase, number>
>;
const PHASES: Record<TimingPhase, { label: string; color: string }> = {
"timing.dns": { label: "DNS", color: "bg-emerald-500" },
"timing.connection": { label: "Connection", color: "bg-cyan-500" },
"timing.tls": { label: "TLS", color: "bg-blue-500" },
"timing.ttfb": { label: "TTFB", color: "bg-violet-500" },
"timing.transfer": { label: "Transfer", color: "bg-purple-500" },
};
export function getTimingLabel(phase: TimingPhase): string {
return PHASES[phase].label;
}
export function getTimingColor(phase: TimingPhase): string {
return PHASES[phase].color;
}
/**
* One phase's share of the latency, as a percentage for a bar's `width`.
* A row with no latency has no shares, not `NaN` ones.
*/
export function getTimingShare(
timing: Timing,
phase: TimingPhase,
latency: number,
): number {
return latency > 0 ? (timing[phase] / latency) * 100 : 0;
}
/** Each phase's share of the latency, formatted for display (`"12.5%"`, `"<1%"`). */
export function getTimingPercentage(
timing: Timing,
latency: number,
): Record<TimingPhase, string> {
const out = {} as Record<TimingPhase, string>;
for (const phase of TIMING_PHASES) {
const share = getTimingShare(timing, phase, latency);
out[phase] = share > 0 && share < 1 ? "<1%" : `${share.toFixed(1)}%`;
}
return out;
}
/** The phases of a row, read off its flat dotted keys. */
export function pickTiming(row: Partial<Record<TimingPhase, number>>): Timing {
const out = {} as Timing;
for (const phase of TIMING_PHASES) out[phase] = row[phase] ?? 0;
return out;
}
src/examples/infinite/timing-phases.tsx
"use client";
import { HOVER_CARD_DELAY } from "@/components/data-table/ui-compat";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { formatMilliseconds } from "@/lib/format";
import type { DataTableFeatures } from "@/lib/table/features";
import { cn } from "@/lib/utils";
import type { ColumnDef } from "@tanstack/react-table";
import {
getTimingColor,
getTimingLabel,
getTimingPercentage,
getTimingShare,
pickTiming,
TIMING_PHASES,
type Timing,
type TimingRow,
} from "./timing";
/** One stacked bar, each phase as wide as its share of the latency. */
function TimingBar({
timing,
latency,
className,
}: {
timing: Timing;
latency: number;
className?: string;
}) {
return (
<div className={cn("flex", className)}>
{TIMING_PHASES.map((phase) => (
<div
key={phase}
className={cn(getTimingColor(phase), "h-4")}
style={{ width: `${getTimingShare(timing, phase, latency)}%` }}
/>
))}
</div>
);
}
/**
* A column that spans five schema columns, so it cannot be generated from
* one of them: append `timingPhasesColumn<ColumnSchema>()` to
* `generateColumns(...)` by hand. The bar reads the phases off `row.original`,
* whose flat dotted keys are the schema's.
*
* A factory rather than one def so the column takes the table's own row type:
* any row carrying `latency` and the phases fits, which is how the docs site's
* demos share it.
*/
export function timingPhasesColumn<TData extends TimingRow>(): ColumnDef<
DataTableFeatures,
TData
> {
return {
id: "timing",
header: () => <div className="whitespace-nowrap">Timing Phases</div>,
cell: ({ row }) => {
const timing = pickTiming(row.original);
const latency = row.original.latency;
const percentage = getTimingPercentage(timing, latency);
return (
// The props are spread, and the trigger has no `asChild`, on purpose:
// this file ships as `registry:file`, which the shadcn CLI copies
// verbatim — its Base UI codemod (`asChild` → `render`) never runs on
// it, and a literal `openDelay` fails to typecheck on Base UI's
// preview card. So the trigger renders its own element, with the
// open-state class in both libraries' spelling.
<HoverCard {...HOVER_CARD_DELAY}>
<HoverCardTrigger className="block opacity-70 hover:opacity-100 data-open:opacity-100 data-[state=open]:opacity-100">
<TimingBar timing={timing} latency={latency} />
</HoverCardTrigger>
<HoverCardContent
side="bottom"
align="end"
className="z-10 w-auto p-2"
>
<div className="flex flex-col gap-1">
{TIMING_PHASES.map((phase) => (
<div key={phase} className="grid grid-cols-2 gap-4 text-xs">
<div className="flex items-center gap-2">
<div
className={cn(
getTimingColor(phase),
"h-2 w-2 rounded-full",
)}
/>
<div className="text-accent-foreground font-mono uppercase">
{getTimingLabel(phase)}
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="text-muted-foreground font-mono">
{percentage[phase]}
</div>
<div className="font-mono">
{formatMilliseconds(timing[phase])}
<span className="text-muted-foreground">ms</span>
</div>
</div>
</div>
))}
</div>
</HoverCardContent>
</HoverCard>
);
},
enableResizing: false,
size: 130,
minSize: 130,
// Pins the width: the bars have no intrinsic size, so without a fixed width
// the column would collapse once another column claims the leftover width.
maxSize: 130,
meta: { label: "Timing Phases" },
};
}
/**
* The row sheet's "Timing Phases" section. The sheet renders a field's
* `component` with the whole row as props, hence the loose parameter type:
* `SheetConfig.component` is typed `(row: unknown) => …`.
*/
export function SheetTimingPhases(props: unknown) {
const row = props as TimingRow;
const timing = pickTiming(row);
const percentage = getTimingPercentage(timing, row.latency);
return (
<div className="w-full space-y-1 text-left">
{TIMING_PHASES.map((phase) => (
<div
key={phase}
className="grid grid-cols-3 items-center justify-between gap-2 text-xs"
>
<div className="text-foreground truncate font-mono uppercase">
{getTimingLabel(phase)}
</div>
<div className="col-span-2 flex gap-2">
<div className="text-muted-foreground mr-8 font-mono">
{percentage[phase]}
</div>
<div className="flex flex-1 items-center justify-end gap-2">
<div className="font-mono">
{formatMilliseconds(timing[phase])}
<span className="text-muted-foreground">ms</span>
</div>
</div>
<div
className={cn(getTimingColor(phase), "h-4")}
style={{
width: `${getTimingShare(timing, phase, row.latency)}%`,
}}
/>
</div>
</div>
))}
</div>
);
}
src/examples/infinite/footer.tsx
"use client";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Github, Keyboard } from "lucide-react";
/**
* The chords `useHotKey` registers for the example. The hook only fires with
* `⌘`/`Ctrl` held — that includes `Escape` — so every one of these is a chord
* and can be replayed from the menu.
*/
export const SHORTCUTS = [
{ key: "k", label: "Toggle command input" },
{ key: "b", label: "Toggle filter controls" },
{ key: "u", label: "Reset column order and visibility" },
{ key: "Escape", label: "Reset table filters" },
] as const;
export type Shortcut = (typeof SHORTCUTS)[number];
/** Plain keys the table and the row sheet respond to. Listed, not replayed. */
export const NAVIGATION = [
{ keys: "↑ ↓", label: "Previous / next row (sheet open)" },
{ keys: "Enter", label: "Open the focused row" },
{ keys: "Esc", label: "Close the row sheet" },
] as const;
/**
* Replays a chord as the keydown `useHotKey` listens for, so picking it from
* the menu does the same thing as pressing it.
*/
export function triggerShortcut(key: Shortcut["key"]) {
document.dispatchEvent(
new KeyboardEvent("keydown", { key, metaKey: true, bubbles: true }),
);
}
export function formatShortcut(key: Shortcut["key"]) {
return `⌘${key === "Escape" ? "Esc" : key.toUpperCase()}`;
}
/**
* The sidebar footer: a GitHub link and the shortcuts list, with a
* "Powered by openstatus" line. Delete it along with the rest of
* `app/example` — or keep whatever is useful in your own footer slot.
*/
export function Footer() {
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
Powered by{" "}
<a
href="https://openstatus.dev"
target="_blank"
rel="noreferrer"
className="text-foreground decoration-border hover:decoration-foreground underline underline-offset-4"
>
openstatus
</a>
</p>
<div className="flex items-center gap-1">
{/* No `asChild` here, on purpose: this file ships as `registry:file`,
which the shadcn CLI copies verbatim, so its Base UI codemod
(`asChild` → `render`) never runs on it. The link and the menu
trigger take the button styles directly instead. */}
<a
href="https://github.com/openstatusHQ/data-table-filters"
target="_blank"
rel="noreferrer"
className={buttonVariants({ variant: "ghost", size: "icon-xs" })}
>
<Github />
<span className="sr-only">View on GitHub</span>
</a>
<ShortcutsMenu />
</div>
</div>
);
}
function ShortcutsMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger
className={buttonVariants({ variant: "ghost", size: "icon-xs" })}
>
<Keyboard />
<span className="sr-only">Keyboard shortcuts</span>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-72" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>Shortcuts</DropdownMenuLabel>
{SHORTCUTS.map((shortcut) => (
<DropdownMenuItem
key={shortcut.key}
onClick={() => triggerShortcut(shortcut.key)}
>
{shortcut.label}
<DropdownMenuShortcut>
{formatShortcut(shortcut.key)}
</DropdownMenuShortcut>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Navigation</DropdownMenuLabel>
{NAVIGATION.map((item) => (
<DropdownMenuItem key={item.keys} disabled>
{item.label}
<DropdownMenuShortcut>{item.keys}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
src/examples/infinite/data.ts
import { LEVELS, METHODS, REGIONS, type ColumnSchema } from "./table-schema";
import { TIMING_PHASES, type Timing } from "./timing";
const ROWS = 5000;
const DAYS = 7;
const PATHNAMES = [
"/api/v1/orders",
"/api/v1/products",
"/api/v1/customers",
"/checkout",
"/cart",
"/search",
"/account/settings",
"/health",
];
/** A small seeded generator, so every server process serves the same rows. */
function mulberry32(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pick<T>(random: () => number, values: readonly T[]): T {
return values[Math.floor(random() * values.length)];
}
function hex(random: () => number, length: number): string {
let out = "";
for (let index = 0; index < length; index++) {
out += Math.floor(random() * 16).toString(16);
}
return out;
}
function statusFor(random: () => number): number {
const roll = random();
if (roll < 0.85) return 200;
if (roll < 0.9) return 201;
if (roll < 0.94) return 400;
if (roll < 0.97) return 404;
return 500;
}
/** Rough share of a request each phase takes; jittered per row below. */
const TIMING_WEIGHTS = [0.05, 0.1, 0.15, 0.5, 0.2] as const;
/**
* Split the latency over the phases so they add up to it exactly: every
* phase but the last is rounded, and the last takes whatever is left.
*/
function timingFor(random: () => number, latency: number): Timing {
const weights = TIMING_WEIGHTS.map(
(weight) => weight * (0.6 + random() * 0.8),
);
const total = weights.reduce((sum, weight) => sum + weight, 0);
const timing = {} as Timing;
let remaining = latency;
TIMING_PHASES.forEach((phase, index) => {
const last = index === TIMING_PHASES.length - 1;
const value = last
? remaining
: Math.round((latency * weights[index]) / total);
timing[phase] = Math.max(0, value);
remaining -= value;
});
return timing;
}
function levelFor(status: number): (typeof LEVELS)[number] {
if (status >= 500) return "error";
if (status >= 400) return "warning";
return "info";
}
/**
* Rows spread over the last `DAYS` days, newest first — the order a timestamp
* cursor expects. The values are seeded, so a refresh shows the same table;
* only the timestamps move with the clock.
*/
export function createRows(now: number = Date.now()): ColumnSchema[] {
const random = mulberry32(42);
const span = DAYS * 24 * 60 * 60 * 1000;
const step = span / ROWS;
const rows: ColumnSchema[] = [];
for (let index = 0; index < ROWS; index++) {
const status = statusFor(random);
const base = 40 + random() * 200;
const slow = random() < 0.05 ? random() * 3000 : 0;
const latency = Math.round(base + slow);
rows.push({
uuid: hex(random, 32),
// Jitter stays under one step, so the order is strictly descending.
date: new Date(now - Math.floor(index * step + random() * step * 0.4)),
level: levelFor(status),
status,
method: pick(random, METHODS),
pathname: pick(random, PATHNAMES),
region: pick(random, REGIONS),
latency,
...timingFor(random, latency),
});
}
return rows;
}
export const rows = createRows();
src/examples/infinite/query-options.ts
import { createDataTableQueryOptions } from "@/lib/data-table";
import { searchParamsSerializer, type SearchParams } from "./schema";
import type { ColumnSchema } from "./table-schema";
const options = createDataTableQueryOptions<
ColumnSchema[],
Record<string, unknown>
>({
queryKeyPrefix: "example",
apiEndpoint: "/example/api",
searchParamsSerializer: searchParamsSerializer as (
search: Record<string, unknown>,
) => string,
// The route honours `_meta=false`; the client reads meta via `getMetaPage`.
skipMetaOnPagination: true,
});
export const dataOptions = (search: SearchParams) =>
options(search as unknown as Record<string, unknown>);
src/examples/infinite/api/route.ts
import type {
FacetMetadataSchema,
InfiniteQueryResponse,
} from "@/lib/data-table";
import { bucketChartData } from "@/lib/data-table/chart-data";
import { defineFilters } from "@/lib/filters";
import SuperJSON from "superjson";
import { rows } from "../data";
import { searchParamsCache, type SearchParams } from "../schema";
import { LEVELS, tableSchema, type ColumnSchema } from "../table-schema";
export const dynamic = "force-dynamic";
/**
* The same filter semantics the sidebar, the command palette and the Drizzle
* handler use, applied to an array. When the rows move to Postgres, replace
* this file with `createDrizzleHandler` from the drizzle block — the client
* does not change.
*/
const filters = defineFilters(tableSchema.definition);
const sliderKeys = filters.specs
.filter((spec) => spec.type === "slider")
.map((spec) => spec.key);
const dateKeys = filters.specs
.filter((spec) => spec.type === "timerange")
.map((spec) => spec.key);
const facetKeys = filters.specs
.filter((spec) => spec.type !== "timerange" && spec.type !== "slider")
.map((spec) => spec.key);
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const search = searchParamsCache.parse(Object.fromEntries(url.searchParams));
const values = search as Record<string, unknown>;
// Three passes, matching the Drizzle handler: the date range first, then
// every filter except the sliders, then the sliders. A slider's min and max
// facet is computed over the second pass, so the range stays visible after
// the user narrows it.
const ranged = filters.apply(rows, values, { only: dateKeys });
const withoutSliders = filters.apply(ranged, values, {
exclude: [...sliderKeys, ...dateKeys],
});
const filtered = filters.apply(withoutSliders, values, { only: sliderKeys });
const page = pageRows(sortRows(filtered, search.sort), search);
// Pagination requests send `_meta=false`: the client keeps the meta from the
// first page, and it cannot change while the filters are fixed.
const skipMeta = url.searchParams.get("_meta") === "false";
const facets = skipMeta
? {}
: {
...facetsOf(withoutSliders, sliderKeys),
...facetsOf(filtered, facetKeys),
};
// One point per time bucket with a count per level, over the date filter
// when one is set and the rows' own span otherwise.
const chartData = skipMeta
? []
: bucketChartData(filtered, {
timestamp: (row) => row.date,
series: (row) => row.level,
keys: LEVELS,
range: search.date,
});
const response: InfiniteQueryResponse<ColumnSchema[]> = {
data: page,
meta: {
totalRowCount: rows.length,
filterRowCount: filtered.length,
chartData,
facets,
},
prevCursor: page.length > 0 ? page[0].date.getTime() : null,
nextCursor: page.length > 0 ? page[page.length - 1].date.getTime() : null,
};
return Response.json(SuperJSON.stringify(response));
}
function compare(left: unknown, right: unknown): number {
const a = left instanceof Date ? left.getTime() : left;
const b = right instanceof Date ? right.getTime() : right;
if (typeof a === "number" && typeof b === "number") return a - b;
return String(a).localeCompare(String(b));
}
/**
* Newest first, the order a timestamp cursor addresses. The caller's sort
* only decides between rows sharing a timestamp, and `uuid` descending
* settles the rest — the same composition and directions as the Drizzle
* handler (cursor desc, sort, tiebreak desc), so swapping it in does not
* reshuffle tied rows.
* Sorting by the requested column alone would make every page after the
* first re-read or skip rows, because the cursor is still a timestamp.
*/
function sortRows(
data: ColumnSchema[],
sort: SearchParams["sort"],
): ColumnSchema[] {
const key = sort?.id as keyof ColumnSchema | undefined;
const direction = sort?.desc ? -1 : 1;
return [...data].sort(
(a, b) =>
compare(b.date, a.date) ||
(key ? compare(a[key], b[key]) * direction : 0) ||
compare(b.uuid, a.uuid),
);
}
/**
* One page, addressed by a timestamp cursor. `next` reads rows older than the
* cursor; `prev` reads everything newer, which is what a live refresh asks
* for. Rows sharing the last timestamp travel together so none are skipped.
*/
function pageRows(
data: ColumnSchema[],
search: Pick<SearchParams, "cursor" | "size" | "direction">,
): ColumnSchema[] {
const cursor = search.cursor?.getTime() ?? Date.now();
// A `size` of zero or less would send back an empty first page forever.
const size = search.size && search.size > 0 ? search.size : 40;
if (search.direction === "prev") {
return data.filter((row) => row.date.getTime() > cursor);
}
const page: ColumnSchema[] = [];
for (const row of data) {
const time = row.date.getTime();
if (time >= cursor) continue;
const last = page[page.length - 1];
if (page.length < size || (last && last.date.getTime() === time)) {
page.push(row);
} else {
break;
}
}
return page;
}
/** Value counts per key, plus min and max for the numeric ones. */
function facetsOf(
data: ColumnSchema[],
keys: string[],
): Record<string, FacetMetadataSchema> {
const facets: Record<string, FacetMetadataSchema> = {};
for (const key of keys) {
const counts = new Map<unknown, number>();
let min: number | undefined;
let max: number | undefined;
for (const row of data) {
const value = row[key as keyof ColumnSchema];
if (value === null || value === undefined) continue;
counts.set(value, (counts.get(value) ?? 0) + 1);
if (typeof value === "number") {
min = min === undefined ? value : Math.min(min, value);
max = max === undefined ? value : Math.max(max, value);
}
}
facets[key] = {
rows: [...counts].map(([value, total]) => ({ value, total })),
total: data.length,
min,
max,
};
}
return facets;
}