Recipes
Six complete table workflows with shared, inspectable example code.
The four guided paths are Catalog → Stock → Pipeline → Administration. Order and Record extend those foundations. Framework-specific snippets use synchronized React/Vue tabs; shared data and validation files are identical in both editions. Copy the view-actions.ts adapter from Saved views when using editable-actions.ts.
Start with the matching React or Vue quick start. Keep its shared/products.ts, product-config.ts and list-records.ts; the recipes add the files shown below. These examples run in browser memory so you can inspect the behavior before connecting a backend. Reloading resets edits, views and activity.
Choose a workflow
| Recipe | What you build |
|---|---|
| Catalog | Search, facets, gallery and filtered CSV |
| Stock | Inline validation and footer totals |
| Pipeline | Kanban status changes |
| Order | Customer relation and nested line items |
| Administration | Cross-page selection and field-aware bulk updates |
| Record | Consultation, edit history, undo and saved views |
Mutation store
import { z } from "zod";
import { type Product, products } from "./products";
const productPatch = z
.object({
name: z.string().trim().min(1),
price: z.number().finite().min(0),
stock: z.number().int().min(0),
status: z.enum(["draft", "active", "archived"]),
active: z.boolean(),
tags: z.array(z.string()),
})
.partial()
.strict();
export interface Activity {
action: string;
actor: { name: string };
at: string;
changes: { field: string; before: unknown; after: unknown }[];
id: string;
reversible?: boolean;
reverts?: string;
}
/** Browser-local learning data. Replace this store with authenticated server actions. */
export function createProductStore() {
const rows: Product[] = structuredClone(products);
const history = new Map<string, Activity[]>();
const activity = (id: string) => history.get(id) ?? [];
const update = (id: string, input: Record<string, unknown>) => {
const row = rows.find((item) => item.id === id);
if (!row) {
return { success: false, error: "Product no longer exists." };
}
// Undefined transport fields are omitted from a patch; explicit unknown values still fail.
const patch = Object.fromEntries(
Object.entries(input).filter(([, value]) => value !== undefined)
);
const parsed = productPatch.safeParse(patch);
if (!parsed.success) {
return {
success: false,
error: "Check the highlighted fields.",
fieldErrors: Object.fromEntries(
parsed.error.issues.map((issue) => [
String(issue.path[0]),
issue.message,
])
),
};
}
const changes = Object.entries(parsed.data)
.filter(
([field, value]) => JSON.stringify(row[field]) !== JSON.stringify(value)
)
.map(([field, after]) => ({
field,
before: structuredClone(row[field]),
after: structuredClone(after),
}));
if (changes.length) {
const at = new Date().toISOString();
history.set(id, [
...activity(id),
{
id: crypto.randomUUID(),
actor: { name: "Demo editor" },
at,
action: "Updated product",
changes,
},
]);
Object.assign(row, parsed.data, { updatedAt: at });
}
return { success: true, data: structuredClone(row) };
};
const bulkUpdate = (ids: string[], patch: Record<string, unknown>) => {
const failedIds: string[] = [];
const data: Product[] = [];
for (const id of new Set(ids)) {
const result = update(id, patch);
if (result.success && result.data) {
data.push(result.data);
} else {
failedIds.push(id);
}
}
return {
success: failedIds.length === 0,
data,
failedIds,
...(failedIds.length
? { error: "Some products could not be updated." }
: {}),
};
};
const revert = (id: string, eventId: string) => {
const events = activity(id);
const entry = events.find((event) => event.id === eventId);
const row = rows.find((item) => item.id === id);
if (
!(entry && row) ||
entry.reverts ||
events.some((event) => event.reverts === eventId)
) {
return { success: false, error: "This change is no longer reversible." };
}
// Check current values again at the write boundary, even when the UI enables Undo.
if (
entry.changes.some(
(change) =>
JSON.stringify(row[change.field]) !== JSON.stringify(change.after)
)
) {
return {
success: false,
error: "A newer change must be reviewed first.",
};
}
const at = new Date().toISOString();
const changes = entry.changes.map((change) => ({
field: change.field,
before: change.after,
after: change.before,
}));
for (const change of changes) {
row[change.field] = structuredClone(change.after);
}
row.updatedAt = at;
history.set(id, [
...events,
{
id: crypto.randomUUID(),
actor: { name: "Demo editor" },
at,
action: "Undid product change",
reverts: eventId,
reversible: false,
changes,
},
]);
return { success: true };
};
return { rows, update, bulkUpdate, activity, revert };
}
export const productStore = createProductStore();Action adapter
import type { TableActions } from "@/components/ui/yayaw-table/providers/table-provider";
import { productStore } from "../shared/store";
import { listRecords } from "./list-records";
import { viewActions } from "./view-actions";
export const editableActions: TableActions = {
views: viewActions,
list: (params) => listRecords(productStore.rows, params),
update: (id, patch) => Promise.resolve(productStore.update(id, patch)),
bulkUpdate: (ids, patch) =>
Promise.resolve(productStore.bulkUpdate(ids, patch)),
};
export const getEditableActions = (tableType: string) =>
tableType === "products" ? editableActions : undefined;import type { TableActions } from "@/components/ui/yayaw-table-vue/types";
import { productStore } from "../shared/store";
import { listRecords } from "./list-records";
import { viewActions } from "./view-actions";
export const editableActions: TableActions = {
views: viewActions,
list: (params) => listRecords(productStore.rows, params),
update: (id, patch) => Promise.resolve(productStore.update(id, patch)),
bulkUpdate: (ids, patch) =>
Promise.resolve(productStore.bulkUpdate(ids, patch)),
};
export const getEditableActions = (tableType: string) =>
tableType === "products" ? editableActions : undefined;Shared product editor
Copy product-form.ts and get-form-config.ts from Forms. React recipes include an isolated QueryClient and Nuqs adapter so each entry can run alone. In an existing application, keep its shared providers instead. Vue recipes use the VueQueryPlugin root from the quick start; replace its imported Products component with the selected recipe and mount one Toaster from vue-sonner at that root.
Connect a real backend
Replace the in-memory store with authenticated actions or HTTP endpoints. Validate incoming patches, authorize every ID and field, write audit events in the same transaction as the change, and invalidate the shared query client. Do not use module-level arrays as server storage. The existing playground provides interactive exploration; this documentation keeps examples as copyable source.