Recettes
Six parcours complets avec un socle de code commun et consultable.
Les quatre parcours guidés sont Catalogue → Stock → Pipeline → Administration. Commande et Fiche prolongent ces bases. Les extraits spécifiques utilisent les onglets React/Vue synchronisés ; les données et validations partagées sont identiques. Copiez l’adaptateur view-actions.ts depuis Vues enregistrées lorsque vous utilisez editable-actions.ts.
Partez du démarrage React ou Vue. Conservez shared/products.ts, product-config.ts et list-records.ts ; les recettes ajoutent les fichiers ci-dessous. Ces exemples fonctionnent en mémoire dans le navigateur pour observer le comportement avant de brancher un backend. Recharger réinitialise modifications, vues et activité.
Choisir un parcours
| Recette | Résultat |
|---|---|
| Catalogue | Recherche, filtres, galerie et CSV filtré |
| Stock | Validation en cellule et totaux |
| Pipeline | Changements de statut en Kanban |
| Commande | Relation client et lignes imbriquées |
| Administration | Sélection entre pages et édition groupée par champ |
| Fiche | Consultation, historique, annulation et vues |
Stockage des modifications
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();Adaptateur des actions
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;Éditeur de produit commun
Copiez product-form.ts et get-form-config.ts depuis Formulaires. Chaque entrée React inclut un QueryClient et un adaptateur Nuqs pour fonctionner seule. Dans une application existante, utilisez ses providers partagés. Les recettes Vue utilisent la racine VueQueryPlugin du démarrage : remplacez le composant Products importé par la recette choisie et montez un Toaster de vue-sonner dans cette racine.
Brancher un vrai backend
Remplacez le stockage en mémoire par des actions authentifiées ou des endpoints HTTP. Validez les modifications partielles, autorisez chaque identifiant et champ, écrivez l’activité dans la transaction de modification et invalidez le client partagé. N’utilisez pas des tableaux de module comme stockage serveur. Le playground existant permet l’exploration interactive ; la documentation présente des sources copiables.