Data Table Filters
blockdata-table-actionssource: ok · refreshed
Data Table Row Actions
Row, bulk, and filter-scoped actions declared next to their Drizzle handlers and rendered from JSON: the list endpoint advertises what can be done to each row, the UI renders buttons, one POST applies it in a transaction.
npx shadcn add "https://logs.run/r/data-table-actions.json"free
Where it goes
data-table
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@tanstack/react-query@^5.101.4drizzle-orm@^0.45.2zod@^4.3.6lucide-react@^0.469.0sonner@^1.7.2
Registry dependencies
https://data-table.openstatus.dev/r/data-table.jsonhttps://data-table.openstatus.dev/r/data-table-drizzle.jsonhttps://data-table.openstatus.dev/r/data-table-query.jsonbuttondropdown-menualert-dialog
Files9 · source cached 10/2/2026, 1:53:05 AM
src/lib/actions/index.ts
export {
defineActions,
type ActionDefinitionBase,
type DefinedActions,
type DefineActionsOptions,
} from "./define-actions";
export {
ROW_ACTIONS_KEY,
type ActionDescriptor,
type ActionError,
type ActionErrorCode,
type ActionRequest,
type ActionResponse,
type ActionScope,
type ActionVariant,
type WithRowActions,
} from "./types";
export {
isSafeActionHref,
sanitizeActionDescriptors,
validateActionDescriptor,
type ActionRejection,
type ActionValidationOptions,
} from "./validate";
src/lib/actions/define-actions.ts
import type { Filters } from "@/lib/filters";
import {
ROW_ACTIONS_KEY,
type ActionDescriptor,
type ActionScope,
type ActionVariant,
type WithRowActions,
} from "./types";
/**
* The declarative half of an action — everything except how it executes.
*
* `createActionHandler` (Drizzle) adds the `handler`; this module never looks
* at it, which is what keeps `annotate` and `descriptors` importable anywhere.
*/
export type ActionDefinitionBase<
TRow = Record<string, unknown>,
TValues = Record<string, unknown>,
> = {
label: string;
/** Defaults to `["row", "bulk"]`. */
scope?: ActionScope[];
variant?: ActionVariant;
/**
* Confirmation copy. `{count}` is replaced with the affected row count and
* `{one|other}` picks a form by it: `"Delete {count} {log|logs}?"`.
*/
confirm?: string;
/**
* Availability, as filter values — `{ status: ["dead"] }` — in exactly the
* shape the list endpoint reads from its search params.
*
* One declaration, two engines: `filters.matches` evaluates it per row to
* compute `_actions`, and the SQL engine compiles it into the handler's WHERE
* guard. Keys must be filterable columns; anything else throws at
* construction rather than silently matching every row.
*
* Typed by the `Filters` the actions are defined against: from a table
* schema definition, `TValues` is `FilterValues<typeof definition>`, so a
* key that is not a filterable column or a value the column cannot filter
* on (`{ level: ["fatal"] }` against `col.enum(LEVELS)`) is a compile
* error before it is a runtime one.
*/
when?: Partial<TValues>;
/**
* JS-only escape hatch for availability the filter semantics cannot express
* (a computed field, a non-filterable column). It only shapes `_actions`;
* there is no SQL counterpart, so the handler must guard itself.
*/
available?: (row: TRow) => boolean;
};
export type DefineActionsOptions = {
/** `href` is `${basePath}/${id}`. */
basePath: string;
/** Published on `bulk` descriptors as `maxIds`. */
maxIds?: number;
};
export type DefinedActions<TDef extends ActionDefinitionBase<never>> = {
/** The declarations, with defaults applied. Keyed by id. */
definitions: ReadonlyMap<string, TDef & { scope: ActionScope[] }>;
/** Public metadata for the wire. Same order as declared. */
descriptors: ActionDescriptor[];
/** Stamp every row with the ids it currently qualifies for. */
annotate<TRow>(rows: readonly TRow[]): WithRowActions<TRow>[];
/** The ids one row qualifies for. */
actionsFor(row: unknown): string[];
};
const DEFAULT_SCOPE: ActionScope[] = ["row", "bulk"];
/**
* An id is a URL segment and an audit-log key. Keep it boring. The leading
* letter is not taste: `Object.entries` hoists integer-like keys (`"1"`,
* `"42"`) to the front in numeric order, which would silently reorder
* `descriptors` and the menus built from them.
*/
const ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
/**
* The pick-list. Adding a field to `ActionDescriptor` fails to compile here
* until it is either projected or deliberately excluded — the failure mode
* this guards against is `{ ...definition }` leaking a handler, a secret, or a
* `when` clause the server never meant to publish.
*/
const PUBLIC_KEYS = {
id: true,
label: true,
scope: true,
variant: true,
confirm: true,
href: true,
maxIds: true,
} as const satisfies Record<keyof ActionDescriptor, true>;
type _PublicKeysExhaustive = [
Exclude<keyof ActionDescriptor, keyof typeof PUBLIC_KEYS>,
] extends [never]
? true
: never;
const _publicKeysExhaustive: _PublicKeysExhaustive = true;
void _publicKeysExhaustive;
function toDescriptor(
id: string,
definition: ActionDefinitionBase<never> & { scope: ActionScope[] },
options: DefineActionsOptions,
): ActionDescriptor {
const descriptor: ActionDescriptor = {
id,
label: definition.label,
scope: [...definition.scope],
href: `${options.basePath}/${id}`,
};
// Optional keys are only present when set, so the JSON stays canonical.
if (definition.variant !== undefined) descriptor.variant = definition.variant;
if (definition.confirm !== undefined) descriptor.confirm = definition.confirm;
// Only a bulk request can carry more than one id, so only there does the
// limit mean anything to the client.
if (options.maxIds !== undefined && definition.scope.includes("bulk")) {
descriptor.maxIds = options.maxIds;
}
return descriptor;
}
/**
* Validate a `when` clause against the declared filter semantics.
*
* `filters.plan` silently drops unknown keys and inactive values, which is
* right for search params and wrong for an availability guard: a typo would
* turn "only dead rows" into "every row". So every key must be a spec, and
* every value must plan to an op.
*/
function assertWhen(
id: string,
when: Record<string, unknown>,
filters: Filters,
): void {
for (const key of Object.keys(when)) {
if (!filters.spec(key)) {
throw new Error(
`[defineActions] Action "${id}": when.${JSON.stringify(key)} is not a filterable column. ` +
`Filterable: ${filters.specs.map((spec) => spec.key).join(", ") || "(none)"}`,
);
}
const ops = filters.plan({ [key]: when[key] });
if (ops.length === 0) {
throw new Error(
`[defineActions] Action "${id}": when.${JSON.stringify(key)} is not an active filter value ` +
`(got ${JSON.stringify(when[key])}). An empty guard would match every row.`,
);
}
}
}
/**
* Turn action declarations into the two things the rest of the system needs:
* public descriptors for the wire, and a per-row availability stamp.
*
* Generic over the definition type so the Drizzle handler can carry its
* `handler` through without this module depending on Drizzle, and over the
* filter values so `when` is checked against the columns `filters` declares.
*/
export function defineActions<
TDef extends ActionDefinitionBase<never, TValues>,
TValues extends Record<string, unknown> = Record<string, unknown>,
>(
filters: Filters<TValues>,
actions: Record<string, TDef>,
options: DefineActionsOptions,
): DefinedActions<TDef> {
const basePath = options.basePath.replace(/\/+$/, "");
if (basePath.length === 0) {
throw new Error(`[defineActions] basePath must be a non-empty path`);
}
if (
options.maxIds !== undefined &&
(!Number.isInteger(options.maxIds) || options.maxIds < 1)
) {
throw new Error(
`[defineActions] maxIds must be a positive integer, got ${String(options.maxIds)}`,
);
}
const resolved: DefineActionsOptions = { ...options, basePath };
const definitions = new Map<string, TDef & { scope: ActionScope[] }>();
const descriptors: ActionDescriptor[] = [];
for (const [id, definition] of Object.entries(actions)) {
if (!ID_PATTERN.test(id)) {
throw new Error(
`[defineActions] Action id ${JSON.stringify(id)} must match ${ID_PATTERN} — it becomes a URL segment.`,
);
}
if (!definition.label) {
throw new Error(`[defineActions] Action "${id}" needs a label`);
}
const scope = Array.from(new Set(definition.scope ?? DEFAULT_SCOPE));
if (scope.length === 0) {
throw new Error(`[defineActions] Action "${id}" declares an empty scope`);
}
if (definition.when) assertWhen(id, definition.when, filters);
const withDefaults = { ...definition, scope };
definitions.set(id, withDefaults);
descriptors.push(toDescriptor(id, withDefaults, resolved));
}
const actionsFor = (row: unknown): string[] => {
const ids: string[] = [];
for (const [id, definition] of definitions) {
if (definition.when && !filters.matches(definition.when, row)) continue;
if (definition.available && !definition.available(row as never)) continue;
ids.push(id);
}
return ids;
};
return {
definitions,
descriptors,
actionsFor,
annotate: (rows) =>
rows.map((row) => ({ ...row, [ROW_ACTIONS_KEY]: actionsFor(row) })),
};
}
src/lib/drizzle/actions.ts
import {
defineActions,
type ActionDefinitionBase,
type ActionDescriptor,
type ActionErrorCode,
type ActionRequest,
type ActionResponse,
type WithRowActions,
} from "@/lib/actions";
import type { Filters } from "@/lib/filters";
import { and, count, inArray, sql, type SQL } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import { z } from "zod";
import { buildWhereConditions } from "./filters";
import type { ColumnMapping, DrizzleDB } from "./types";
/**
* What a handler receives.
*
* `where` is the authority: the requested set (ids, or a filter) intersected
* with the action's `when` guard. A handler that uses it cannot touch a row
* the action does not apply to, no matter what the client sent.
*/
export type ActionContext = {
where: SQL;
/** Present for `scope: "ids"`. */
ids?: string[];
/**
* Present for `scope: "filter"`. Pruned to keys the filter semantics know;
* the values are exactly what the client sent — never `filters.coerce`d, so
* a slider range is not clamped to its declared bounds. See `execute`.
*/
filter?: Record<string, unknown>;
/** Whoever the route authenticated. Never read from the request body. */
actor?: string;
/** Client-generated; at-least-once, see `ActionRequest`. */
cmdId: string;
};
export type DrizzleActionDefinition<
TRow = Record<string, unknown>,
TValues = Record<string, unknown>,
> = ActionDefinitionBase<TRow, TValues> & {
/**
* Runs inside one transaction and returns how many rows it applied to.
*
* Actions enqueue; they don't execute. A replay flips `status` to
* `pending` and lets the worker do the work — that is why one short
* transaction is enough and why nothing here does I/O.
*
* ```ts
* handler: async (ctx, tx) => {
* const rows = await tx
* .update(outbox)
* .set({ status: "pending", attempt: 0 })
* .where(ctx.where)
* .returning({ id: outbox.id });
* return rows.length;
* }
* ```
*/
handler: (ctx: ActionContext, tx: DrizzleDB) => Promise<number>;
};
export type ActionAuditEvent = {
action: string;
actor?: string;
cmdId: string;
scope: ActionRequest["scope"];
ids?: string[];
filter?: Record<string, unknown>;
applied: number;
at: Date;
};
export type ActionHandlerConfig<
TRow = Record<string, unknown>,
TValues = Record<string, unknown>,
> = {
db: DrizzleDB;
table: PgTable;
/**
* The same `defineFilters(...)` the list handler uses. Built from the table
* schema definition, it also types every action's `when` guard.
*/
filters: Filters<TValues>;
columnMapping: ColumnMapping;
/** The schema key that identifies a row. Must be in `columnMapping`. */
idColumn: string;
actions: Record<string, DrizzleActionDefinition<TRow, TValues>>;
/** `href` is `${basePath}/${id}` — the route that calls `execute`. */
basePath: string;
/**
* Upper bound on `ids.length`; larger requests are `invalid_request`. Also
* published on `bulk` descriptors so the client can stop short of it.
*/
maxIds?: number;
/**
* The shape of one id, when the column is stricter than "non-empty string"
* — `z.uuid()` for a `uuid` column. Without it a malformed id reaches
* Postgres, whose cast error surfaces as a 500 rather than a 400.
*
* Parsed asynchronously, so an async refinement is allowed here.
*/
idSchema?: z.ZodType<string>;
/**
* Called once per applied command, after the transaction committed. If it
* throws, the mutation has already happened — the error propagates so the
* route can decide, but nothing is rolled back.
*/
audit?: (event: ActionAuditEvent) => void | Promise<void>;
};
const STATUS_BY_CODE: Record<ActionErrorCode, number> = {
unknown_action: 404,
scope_not_allowed: 400,
invalid_request: 400,
count_mismatch: 409,
forbidden: 403,
failed: 500,
};
/** A typed failure the route maps to an HTTP response. */
export class ActionHandlerError extends Error {
readonly code: ActionErrorCode;
readonly status: number;
/** Present on `count_mismatch`. */
readonly actual?: number;
constructor(code: ActionErrorCode, message: string, actual?: number) {
super(message);
this.name = "ActionHandlerError";
this.code = code;
this.status = STATUS_BY_CODE[code];
if (actual !== undefined) this.actual = actual;
}
/** The JSON body for the wire. */
toJSON(): { error: ActionErrorCode; actual?: number } {
return this.actual === undefined
? { error: this.code }
: { error: this.code, actual: this.actual };
}
}
function createRequestSchema(idSchema: z.ZodType<string>) {
return z.discriminatedUnion("scope", [
z.object({
scope: z.literal("ids"),
ids: z.array(idSchema).min(1),
cmd_id: z.string().min(1),
}),
z.object({
scope: z.literal("filter"),
filter: z.record(z.string(), z.unknown()),
expected_count: z.number().int().nonnegative().optional(),
cmd_id: z.string().min(1),
}),
]);
}
/** Postgres SQLSTATE for "could not serialize access". */
const SERIALIZATION_FAILURE = "40001";
/** How often a serialization failure is retried before it propagates. */
const MAX_ATTEMPTS = 3;
/**
* Drivers report SQLSTATE as `code`, and Drizzle wraps the driver error in a
* `DrizzleQueryError` whose `cause` is the original — so walk the chain.
*/
export function isSerializationFailure(error: unknown): boolean {
let current: unknown = error;
for (
let depth = 0;
depth < 5 && typeof current === "object" && current;
depth++
) {
if ((current as { code?: unknown }).code === SERIALIZATION_FAILURE) {
return true;
}
current = (current as { cause?: unknown }).cause;
}
return false;
}
export type ActionHandler<TRow = Record<string, unknown>> = {
/** Public metadata for `meta.actions`. */
descriptors: ActionDescriptor[];
/** Stamp fetched rows with `_actions`. */
annotate<T extends TRow>(rows: readonly T[]): WithRowActions<T>[];
/**
* Run one action. Throws `ActionHandlerError` for every contract violation;
* anything else that escapes is the handler's own failure, already rolled
* back.
*/
execute(
actionId: string,
body: unknown,
options?: { actor?: string },
): Promise<ActionResponse>;
};
/**
* The write-side sibling of `createDrizzleHandler`.
*
* Shares its `filters` and `columnMapping`, so an action's `when` guard and a
* filter-scoped request compile through the very same `buildWhereConditions`
* the list endpoint uses — the set the user saw is the set the action hits.
*/
export function createActionHandler<
TRow = Record<string, unknown>,
TValues extends Record<string, unknown> = Record<string, unknown>,
>(config: ActionHandlerConfig<TRow, TValues>): ActionHandler<TRow> {
const {
db,
table,
filters,
columnMapping,
idColumn,
basePath,
maxIds = 1000,
idSchema = z.string().min(1),
audit,
} = config;
const requestSchema = createRequestSchema(idSchema);
const idCol = columnMapping[idColumn];
if (!idCol) {
throw new Error(
`[createActionHandler] idColumn "${idColumn}" not found in columnMapping`,
);
}
// Mirror `createDrizzleHandler`: `buildWhereConditions` skips a key that is
// missing from the mapping. For a `when` guard that would mean "every row";
// for a filter-scoped request, "more rows than the user saw". Fail here.
const unmapped = filters.specs
.map((spec) => spec.key)
.filter((key) => !columnMapping[key]);
if (unmapped.length > 0) {
throw new Error(
`[createActionHandler] These filterable columns are missing from columnMapping:\n` +
unmapped.map((key) => ` - ${key}`).join("\n"),
);
}
const defined = defineActions<
DrizzleActionDefinition<TRow, TValues>,
TValues
>(filters, config.actions, { basePath, maxIds });
// `when` guards never change after construction, so compile them once.
const guards = new Map<string, SQL[]>();
for (const [id, definition] of defined.definitions) {
guards.set(
id,
definition.when
? buildWhereConditions(filters, definition.when, columnMapping)
: [],
);
}
async function execute(
actionId: string,
body: unknown,
options: { actor?: string } = {},
): Promise<ActionResponse> {
const definition = defined.definitions.get(actionId);
if (!definition) {
throw new ActionHandlerError("unknown_action", `No action "${actionId}"`);
}
// Async so a caller's `idSchema` may carry an async refinement; a sync
// schema parses just the same through it.
const parsed = await requestSchema.safeParseAsync(body);
if (!parsed.success) {
throw new ActionHandlerError(
"invalid_request",
`Invalid request body: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`,
);
}
const request = parsed.data;
const guard = guards.get(actionId) ?? [];
let where: SQL;
/** The set the client counted — the filter alone, before the guard. */
let shown: SQL | undefined;
let ids: string[] | undefined;
let filter: Record<string, unknown> | undefined;
if (request.scope === "ids") {
const allowed =
definition.scope.includes("row") || definition.scope.includes("bulk");
if (!allowed) {
throw new ActionHandlerError(
"scope_not_allowed",
`Action "${actionId}" does not accept ids (scope: ${definition.scope.join(", ")})`,
);
}
if (!definition.scope.includes("bulk") && request.ids.length > 1) {
throw new ActionHandlerError(
"scope_not_allowed",
`Action "${actionId}" accepts one id at a time (scope: ${definition.scope.join(", ")})`,
);
}
if (request.ids.length > maxIds) {
throw new ActionHandlerError(
"invalid_request",
`Too many ids (${request.ids.length} > ${maxIds}); use scope "filter"`,
);
}
ids = Array.from(new Set(request.ids));
where = and(inArray(idCol, ids), ...guard)!;
} else {
if (!definition.scope.includes("filter")) {
throw new ActionHandlerError(
"scope_not_allowed",
`Action "${actionId}" does not accept a filter (scope: ${definition.scope.join(", ")})`,
);
}
// The same interpretation as the list endpoint: `filters.plan` (inside
// `buildWhereConditions`) drops unknown keys and inactive values and
// never clamps. `filters.coerce` would clamp a slider to its declared
// bounds — and a range the user dragged past those bounds must hit the
// rows they saw, not a narrower set. Unknown keys are dropped from the
// recorded filter as well.
filter = Object.fromEntries(
Object.entries(request.filter).filter(
([key]) => filters.spec(key) !== undefined,
),
);
const visible = buildWhereConditions(filters, filter, columnMapping);
// An empty filter is "every row". Legal — `expected_count` is the
// client's safety net — but the handler still gets a real SQL.
shown = and(...visible) ?? sql`true`;
where = and(...visible, ...guard) ?? sql`true`;
}
const ctx: ActionContext = {
where,
cmdId: request.cmd_id,
...(ids ? { ids } : {}),
...(filter ? { filter } : {}),
...(options.actor !== undefined ? { actor: options.actor } : {}),
};
const expectedCount =
request.scope === "filter" ? request.expected_count : undefined;
const run = () =>
db.transaction(
async (tx) => {
if (expectedCount !== undefined && shown !== undefined) {
// Drift is measured on the set the client was shown, not on the
// guarded set: a guard makes `applied` smaller by design, and that
// is not "the set changed".
const [row] = await tx
.select({ n: count() })
.from(table)
.where(shown);
const actual = Number(row?.n ?? 0);
if (actual !== expectedCount) {
throw new ActionHandlerError(
"count_mismatch",
`Expected ${expectedCount} rows, found ${actual}`,
actual,
);
}
}
const result = await definition.handler(ctx, tx);
if (!Number.isInteger(result) || result < 0) {
throw new Error(
`[createActionHandler] Action "${actionId}" handler must return the applied row count, got ${String(result)}`,
);
}
return result;
},
// The count is only a promise if the mutation sees the same rows.
// Under READ COMMITTED each statement takes its own snapshot, so a row
// committed in between is counted by neither and updated anyway.
// REPEATABLE READ pins one snapshot for the whole transaction: a
// concurrent insert is invisible to both statements, and a concurrent
// change to a counted row fails with 40001 instead of being applied
// over — which is retried below against a fresh snapshot, where the
// recount catches it as `count_mismatch`.
expectedCount !== undefined
? { isolationLevel: "repeatable read" }
: undefined,
);
let applied: number;
for (let attempt = 1; ; attempt++) {
try {
applied = await run();
break;
} catch (error) {
if (attempt >= MAX_ATTEMPTS || !isSerializationFailure(error)) {
throw error;
}
}
}
if (audit) {
await audit({
action: actionId,
cmdId: request.cmd_id,
scope: request.scope,
applied,
at: new Date(),
...(ids ? { ids } : {}),
...(filter ? { filter } : {}),
...(options.actor !== undefined ? { actor: options.actor } : {}),
});
}
return { applied };
}
return {
descriptors: defined.descriptors,
annotate: (rows) => defined.annotate(rows),
execute,
};
}
src/components/data-table/data-table-actions/index.ts
export { DataTableActionsBar } from "./bar";
export { createActionsColumn, DataTableActionsCell } from "./column";
export {
DataTableActionsConfirmDialog,
describeCommand,
type ConfirmingCommandView,
} from "./confirm-dialog";
export {
DataTableActionsProvider,
useDataTableActions,
type ActionRequestInput,
type AppliedEvent,
type DataTableActionsContextValue,
type DataTableActionsProviderProps,
type TriggerMeta,
} from "./provider";
export {
ActionRequestError,
actionsForScope,
interpolate,
newCommandId,
partitionRows,
postAction,
rowActionsOf,
rowScopedActions,
} from "./utils";
src/components/data-table/data-table-actions/provider.tsx
"use client";
import type {
ActionDescriptor,
ActionRequest,
ActionResponse,
} from "@/lib/actions/types";
import { sanitizeActionDescriptors } from "@/lib/actions/validate";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { toast } from "sonner";
import {
DataTableActionsConfirmDialog,
type ConfirmingCommandView,
} from "./confirm-dialog";
import {
ActionRequestError,
newCommandId,
postAction,
rowActionsOf,
} from "./utils";
type DistributiveOmit<T, K extends keyof T> = T extends unknown
? Omit<T, K>
: never;
/** A request minus the `cmd_id` the provider stamps on at send time. */
export type ActionRequestInput = DistributiveOmit<ActionRequest, "cmd_id">;
export type TriggerMeta = {
/** How many rows the user was shown. */
count: number;
/** Selected rows the action does not apply to. */
skipped?: number;
/** Runs after a 2xx — e.g. clear the selection the action consumed. */
onApplied?: () => void;
};
export type AppliedEvent = {
action: ActionDescriptor;
request: ActionRequest;
response: ActionResponse;
};
export type DataTableActionsContextValue<TData = unknown> = {
actions: ActionDescriptor[];
getRowId: (row: TData) => string;
getRowActions: (row: TData) => string[];
/**
* A human name for the row, for the actions trigger's accessible label.
* `undefined` when the host did not supply one — the trigger then falls
* back to static text rather than reading out the internal row id.
*/
getRowLabel?: (row: TData) => string;
/**
* Start an action. Opens the confirmation if the descriptor asks for one,
* otherwise sends immediately. A `count_mismatch` reopens the dialog with
* the server's number and offers to apply anyway.
*/
trigger: (
action: ActionDescriptor,
request: ActionRequestInput,
meta: TriggerMeta,
) => void;
isPending: boolean;
};
const DataTableActionsContext =
React.createContext<DataTableActionsContextValue<never> | null>(null);
export function useDataTableActions<TData = unknown>() {
const context = React.useContext(DataTableActionsContext);
if (!context) {
throw new Error(
"useDataTableActions must be used within a DataTableActionsProvider",
);
}
return context as unknown as DataTableActionsContextValue<TData>;
}
/** What the dialog shows, plus what to send once the user says yes. */
type ConfirmingCommand = ConfirmingCommandView & {
request: ActionRequestInput;
onApplied?: () => void;
};
export type DataTableActionsProviderProps<TData> = {
/** From `meta.actions`. `undefined` while loading renders no buttons. */
actions?: ActionDescriptor[];
getRowId: (row: TData) => string;
/** Defaults to the server's `_actions` stamp. */
getRowActions?: (row: TData) => string[];
/**
* Names the row for screen readers ("Actions for <label>"). Pass something
* the user can recognize — a title, an email — not the id the wire uses.
* Omitted, every trigger reads "Row actions".
*/
getRowLabel?: (row: TData) => string;
/**
* Origins the actions endpoint may live on, beyond the page's own.
*
* Descriptors are server-authored and choose the URL this provider POSTs row
* ids to. Relative hrefs need nothing here; an absolute one is only sent if
* its origin is the page's own or listed here. Anything else is dropped
* before it can be rendered — see `sanitizeActionDescriptors`.
*/
allowedActionOrigins?: readonly string[];
/**
* The first element of the table's query key. Every page under it is
* invalidated after a successful action, so rows that no longer match
* leave the view.
*/
queryKeyPrefix?: string;
onApplied?: (event: AppliedEvent) => void;
/** Injected in tests. */
fetcher?: typeof fetch;
children: React.ReactNode;
};
export function DataTableActionsProvider<TData>({
actions,
getRowId,
getRowActions,
getRowLabel,
allowedActionOrigins,
queryKeyPrefix,
onApplied,
fetcher,
children,
}: DataTableActionsProviderProps<TData>) {
const queryClient = useQueryClient();
// The descriptors are validated once, here, rather than at click time: a
// button whose `href` we would refuse to POST to should never be drawn.
const safeActions = React.useMemo(() => {
const { actions: kept, rejected } = sanitizeActionDescriptors(actions, {
allowedOrigins: allowedActionOrigins,
});
if (rejected.length > 0) {
console.warn(
"[data-table-actions] dropped unsafe or malformed descriptors:",
rejected.map(({ id, reason }) => `${id} (${reason})`).join(", "),
);
}
return kept;
}, [actions, allowedActionOrigins]);
// Awaiting the user's answer; distinct from `isPending` (request on the wire).
const [confirming, setConfirming] = React.useState<ConfirmingCommand | null>(
null,
);
// The `cmd_id` the dialog last submitted. Only that request may close the
// dialog: an unconfirmed action settling must not tear down a confirmation
// the user opened while it was still in flight.
const submittedFromDialog = React.useRef<string | null>(null);
const mutation = useMutation({
mutationFn: (variables: {
action: ActionDescriptor;
request: ActionRequest;
meta: TriggerMeta;
}) => postAction(variables.action.href, variables.request, fetcher),
onSuccess: (response, { action, request, meta }) => {
if (request.scope === "filter") {
// The client counted the filter-only set; the server applied within
// the action's guard. A smaller number is the guard at work, not a
// partial failure.
toast.success(
`${action.label}: applied to ${response.applied} of ${meta.count} matching`,
);
} else if (response.applied < request.ids.length) {
// Only eligible ids were sent, so a shortfall means rows changed
// between fetch and click.
toast.warning(
`${action.label}: applied to ${response.applied} of ${request.ids.length}`,
);
} else {
toast.success(`${action.label}: applied to ${response.applied}`);
}
if (queryKeyPrefix) {
void queryClient.invalidateQueries({ queryKey: [queryKeyPrefix] });
}
// The confirmation stays open, with its buttons disabled, until the
// request it submitted settles — so the user sees the outcome land. Any
// other action settling leaves it alone.
if (submittedFromDialog.current === request.cmd_id) {
submittedFromDialog.current = null;
setConfirming(null);
}
meta.onApplied?.();
onApplied?.({ action, request, response });
},
onError: (error, { action, request, meta }) => {
if (
error instanceof ActionRequestError &&
error.code === "count_mismatch" &&
request.scope === "filter"
) {
const { cmd_id: _cmdId, ...input } = request;
// A fresh dialog, nothing submitted from it yet.
submittedFromDialog.current = null;
setConfirming({
action,
request: input,
count: meta.count,
skipped: meta.skipped ?? 0,
scope: "filter",
actual: error.actual ?? 0,
onApplied: meta.onApplied,
});
return;
}
if (submittedFromDialog.current === request.cmd_id) {
submittedFromDialog.current = null;
setConfirming(null);
}
toast.error(`${action.label} failed: ${error.message}`);
},
});
// `mutate` is referentially stable; the result object is not, and depending
// on it would rebuild `trigger` — and the context value — on every render.
const { mutate, isPending } = mutation;
const send = React.useCallback(
(
action: ActionDescriptor,
request: ActionRequestInput,
meta: TriggerMeta,
options?: { fromDialog?: boolean },
) => {
const cmdId = newCommandId();
if (options?.fromDialog) submittedFromDialog.current = cmdId;
mutate({
action,
request: { ...request, cmd_id: cmdId } as ActionRequest,
meta,
});
},
[mutate],
);
const trigger = React.useCallback<
DataTableActionsContextValue<TData>["trigger"]
>(
(action, request, meta) => {
if (action.confirm) {
setConfirming({
action,
request,
count: meta.count,
skipped: meta.skipped ?? 0,
scope: request.scope,
onApplied: meta.onApplied,
});
return;
}
send(action, request, meta);
},
[send],
);
const confirm = React.useCallback(() => {
if (!confirming) return;
const { action, request, count, skipped, actual, onApplied } = confirming;
if (actual !== undefined && request.scope === "filter") {
// The user accepted the server's number: drop the optimistic check.
const { expected_count: _expected, ...rest } = request;
send(
action,
rest,
{ count: actual, skipped, onApplied },
{
fromDialog: true,
},
);
return;
}
send(action, request, { count, skipped, onApplied }, { fromDialog: true });
}, [confirming, send]);
const cancel = React.useCallback(() => {
submittedFromDialog.current = null;
setConfirming(null);
}, []);
const value = React.useMemo<DataTableActionsContextValue<TData>>(
() => ({
actions: safeActions,
getRowId,
getRowActions: getRowActions ?? rowActionsOf,
getRowLabel,
trigger,
isPending,
}),
[safeActions, getRowId, getRowActions, getRowLabel, trigger, isPending],
);
return (
<DataTableActionsContext.Provider
value={value as unknown as DataTableActionsContextValue<never>}
>
{children}
<DataTableActionsConfirmDialog
command={confirming}
inFlight={isPending}
onConfirm={confirm}
onCancel={cancel}
/>
</DataTableActionsContext.Provider>
);
}
src/components/data-table/data-table-actions/confirm-dialog.tsx
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { ActionDescriptor } from "@/lib/actions/types";
import { LoaderCircle } from "lucide-react";
import * as React from "react";
import { interpolate } from "./utils";
/** The command awaiting the user's answer, as the dialog sees it. */
export type ConfirmingCommandView = {
action: ActionDescriptor;
/** How many rows the user was shown. */
count: number;
/** Selected rows the action does not apply to. */
skipped: number;
/** `filter` scope applies to every matching row, not a selection. */
scope: "ids" | "filter";
/** Set when the server answered `count_mismatch`. */
actual?: number;
};
export function describeCommand(command: ConfirmingCommandView): {
title: string;
description: string | null;
} {
const { action, count, skipped, scope, actual } = command;
if (actual !== undefined) {
return {
title: `${action.label}: the matching set changed`,
description: `You were shown ${count} ${plural(count, "row")}; the server now counts ${actual}. Apply to ${actual} ${plural(actual, "row")} anyway?`,
};
}
const title = interpolate(
action.confirm ?? `${action.label} {count} ${plural(count, "row")}?`,
{ count },
);
const parts: string[] = [];
if (scope === "filter") {
parts.push("Applies to every row matching the current filters.");
}
if (skipped > 0) {
parts.push(
`${skipped} selected ${plural(skipped, "row")} ${skipped === 1 ? "does" : "do"} not qualify and will be skipped.`,
);
}
return { title, description: parts.length > 0 ? parts.join(" ") : null };
}
function plural(n: number, noun: string): string {
return n === 1 ? noun : `${noun}s`;
}
export function DataTableActionsConfirmDialog({
command,
inFlight = false,
onConfirm,
onCancel,
}: {
/** Open while non-null. */
command: ConfirmingCommandView | null;
/**
* The confirmed request is on the wire. The dialog stays open with both
* buttons disabled until it settles — the owner closes it by clearing
* `command`, or swaps in a `count_mismatch` view.
*/
inFlight?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
// Keep the last command on screen while the dialog animates out: `command`
// is cleared before the exit transition ends, and an empty title flickers.
const [view, setView] = React.useState(command);
if (command !== null && command !== view) setView(command);
const copy = view ? describeCommand(view) : null;
const destructive = view?.action.variant === "destructive";
return (
<AlertDialog
open={command !== null}
onOpenChange={(open) => {
// Escape and outside clicks don't abandon a request already sent.
if (!open && !inFlight) onCancel();
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>{copy?.title}</AlertDialogTitle>
{copy?.description ? (
<AlertDialogDescription>{copy.description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={inFlight}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? "destructive" : "default"}
disabled={inFlight}
aria-busy={inFlight || undefined}
onClick={(event) => {
// Radix closes on click; the owner closes once the request lands.
event.preventDefault();
onConfirm();
}}
>
{inFlight ? (
<LoaderCircle aria-hidden className="size-4 animate-spin" />
) : null}
{view?.actual !== undefined
? "Apply anyway"
: (view?.action.label ?? "Confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
src/components/data-table/data-table-actions/bar.tsx
"use client";
import { DataTableContext } from "@/components/data-table/data-table-provider";
import { Button } from "@/components/ui/button";
import * as React from "react";
import { useDataTableActions } from "./provider";
import { actionsForScope, partitionRows } from "./utils";
/**
* Bulk buttons for a selection. Drop it into `DataTableFloatingBar`:
*
* ```tsx
* <DataTableFloatingBar>
* {({ rows }) => <DataTableActionsBar rows={rows} />}
* </DataTableFloatingBar>
* ```
*
* Structurally typed on `{ original }` so it accepts TanStack rows without
* depending on the table.
*/
export function DataTableActionsBar<TData>({
rows,
}: {
rows: readonly { original: TData }[];
}) {
const { actions, getRowId, getRowActions, trigger, isPending } =
useDataTableActions<TData>();
// Optional on purpose: inside `DataTableFloatingBar` the table is there and
// the selection is cleared once the action landed — the rows it named may
// have left the view, and a bar saying "3 selected" over nothing is wrong.
const table = React.useContext(DataTableContext)?.table;
const bulk = actionsForScope(actions, "bulk");
if (bulk.length === 0) return null;
return (
<>
{bulk.map((action) => {
const { eligible, skipped } = partitionRows(
rows,
action.id,
getRowActions,
);
// The server publishes its `maxIds`; past it the request is refused
// as `invalid_request`, so refuse it here with a reason instead.
const overLimit =
action.maxIds !== undefined && eligible.length > action.maxIds;
// Why the button is dead, in the order the user hits them. A disabled
// button swallows pointer events, so the tooltip lives on the wrapper
// — on the button it would never show.
const reason = overLimit
? `${action.label} applies to at most ${action.maxIds?.toLocaleString()} rows at a time`
: eligible.length === 0
? `Select a row this action applies to`
: isPending
? "Another action is still running"
: undefined;
return (
<span
key={action.id}
className="inline-flex"
title={reason}
data-reason={reason}
>
<Button
size="sm"
variant={
action.variant === "destructive" ? "destructive" : "outline"
}
disabled={reason !== undefined}
data-action={action.id}
data-eligible={eligible.length}
data-skipped={skipped}
data-over-limit={overLimit ? "" : undefined}
onClick={() =>
trigger(
action,
{
scope: "ids",
ids: eligible.map((row) => getRowId(row.original)),
},
{
count: eligible.length,
skipped,
onApplied: () => table?.resetRowSelection(),
},
)
}
>
{action.label}
<span className="tabular-nums opacity-70">{eligible.length}</span>
</Button>
</span>
);
})}
</>
);
}
src/components/data-table/data-table-actions/column.tsx
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { DataTableFeatures } from "@/lib/table/features";
import type { ColumnDef, RowData } from "@tanstack/react-table";
import { MoreHorizontal } from "lucide-react";
import { useDataTableActions } from "./provider";
import { rowScopedActions } from "./utils";
/** The cell: a menu of the row-scoped actions this row is stamped with. */
export function DataTableActionsCell<TData>({
row,
}: {
row: { original: TData };
}) {
const { actions, getRowId, getRowActions, getRowLabel, trigger, isPending } =
useDataTableActions<TData>();
const available = rowScopedActions(actions, getRowActions(row.original));
if (available.length === 0) return null;
const id = getRowId(row.original);
// Never the row id: it is the internal key the wire uses, which for a
// composite or opaque key reads as noise. Hosts name the row themselves.
const label = getRowLabel?.(row.original);
return (
<div
className="flex items-center justify-center"
// The row itself opens the sheet on click and on Enter. Both bubble up
// from the trigger — and, through React's tree, from the portalled menu
// items — so both stop here. Only the keys that open the row are
// stopped; everything else keeps bubbling to the row, the table, and
// any app-level shortcut listening above them.
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.stopPropagation();
}
}}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="size-5"
aria-label={label ? `Actions for ${label}` : "Row actions"}
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{available.map((action) => (
<DropdownMenuItem
key={action.id}
// One action at a time, as in the bulk bar: a second trigger
// while one is on the wire races the first for the shared
// confirmation dialog.
disabled={isPending}
variant={
action.variant === "destructive" ? "destructive" : "default"
}
onSelect={() =>
trigger(action, { scope: "ids", ids: [id] }, { count: 1 })
}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
/**
* A column that renders `DataTableActionsCell`. Append it to the generated
* columns; it needs a `DataTableActionsProvider` above the table. The column
* is hideable — pair it with a `columnVisibility` default of `{ [id]: false }`
* to keep it hidden until enabled from the view options.
*/
export function createActionsColumn<TData extends RowData>(
options: { id?: string; size?: number } = {},
): ColumnDef<DataTableFeatures, TData> {
const { id = "actions", size = 40 } = options;
return {
id,
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => <DataTableActionsCell row={row} />,
enableSorting: false,
enableHiding: true,
enableResizing: false,
size,
minSize: size,
maxSize: size,
meta: { label: "Actions", kind: "actions" },
};
}
src/components/data-table/data-table-actions/utils.ts
import {
ROW_ACTIONS_KEY,
type ActionDescriptor,
type ActionErrorCode,
type ActionRequest,
type ActionResponse,
type ActionScope,
} from "@/lib/actions/types";
/**
* Replace `{name}` placeholders. Unknown names are left as-is.
*
* `{one|other}` picks a form by `vars.count` — `"Delete {count} {log|logs}?"`
* reads "Delete 1 log?" and "Delete 3 logs?". Descriptors travel as JSON, so
* this is the only way copy can pluralise without a function.
*
* Whitespace around the forms is formatting, not copy: `{log | logs}` is the
* same marker written with room to breathe, so the picked form is trimmed
* rather than leaking a stray space into the sentence.
*/
export function interpolate(
template: string,
vars: Record<string, string | number>,
): string {
return template.replace(
/\{(\w+)\}|\{([^{}|]*)\|([^{}|]*)\}/g,
(match, name: string | undefined, one: string, other: string) => {
if (name !== undefined) {
return Object.hasOwn(vars, name) ? String(vars[name]) : match;
}
return Number(vars.count) === 1 ? one.trim() : other.trim();
},
);
}
/** Read the server's `_actions` stamp off a row. Missing means "none". */
export function rowActionsOf(row: unknown): string[] {
if (typeof row !== "object" || row === null) return [];
const value = (row as Record<string, unknown>)[ROW_ACTIONS_KEY];
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
}
export function actionsForScope(
actions: readonly ActionDescriptor[],
scope: ActionScope,
): ActionDescriptor[] {
return actions.filter((action) => action.scope.includes(scope));
}
/** Row-scoped actions the row is stamped with, in descriptor order. */
export function rowScopedActions(
actions: readonly ActionDescriptor[],
rowActionIds: readonly string[],
): ActionDescriptor[] {
return actionsForScope(actions, "row").filter((action) =>
rowActionIds.includes(action.id),
);
}
/**
* Split a selection into the rows an action applies to and the rest.
*
* A bulk button stays enabled while *any* selected row qualifies — an
* intersection would make bulk useless on a mixed list — and the request
* carries only the eligible ids, with the skipped count surfaced in the
* confirmation.
*/
export function partitionRows<TRow extends { original: unknown }>(
rows: readonly TRow[],
actionId: string,
getRowActions: (row: TRow["original"]) => string[],
): { eligible: TRow[]; skipped: number } {
const eligible = rows.filter((row) =>
getRowActions(row.original).includes(actionId),
);
return { eligible, skipped: rows.length - eligible.length };
}
export function newCommandId(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === "function") return `cmd_${c.randomUUID()}`;
return `cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}
/** The server said no. `code` mirrors `ActionError.error`. */
export class ActionRequestError extends Error {
readonly code: ActionErrorCode;
readonly status: number;
/** Present on `count_mismatch`. */
readonly actual?: number;
constructor(code: ActionErrorCode, status: number, actual?: number) {
super(code.replace(/_/g, " "));
this.name = "ActionRequestError";
this.code = code;
this.status = status;
if (actual !== undefined) this.actual = actual;
}
}
const KNOWN_CODES: ReadonlySet<string> = new Set<ActionErrorCode>([
"unknown_action",
"scope_not_allowed",
"invalid_request",
"count_mismatch",
"forbidden",
"failed",
]);
/** One POST. Throws `ActionRequestError` for any non-2xx. */
export async function postAction(
href: string,
request: ActionRequest,
fetcher: typeof fetch = fetch,
): Promise<ActionResponse> {
const response = await fetcher(href, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
});
let json: unknown = null;
try {
json = await response.json();
} catch {
// A non-JSON body is handled by the status check below.
}
if (!response.ok) {
const body = (json ?? {}) as { error?: unknown; actual?: unknown };
const code =
typeof body.error === "string" && KNOWN_CODES.has(body.error)
? (body.error as ActionErrorCode)
: "failed";
const actual = typeof body.actual === "number" ? body.actual : undefined;
throw new ActionRequestError(code, response.status, actual);
}
// A 2xx without a numeric `applied` is not "applied to 0" — it is a route
// that does not speak the contract (204, an HTML page from a proxy, …).
const body = (json ?? {}) as { applied?: unknown };
if (typeof body.applied !== "number") {
throw new ActionRequestError("failed", response.status);
}
return { applied: body.applied };
}