Éditeur de commande
Choisir un client et modifier des lignes imbriquées.
Conservez les données et la configuration produit du démarrage. Copiez les fichiers propres aux commandes ci-dessous. La recette combine un sélecteur de client et une collection de lignes ; chaque ligne peut contenir des allocations par entrepôt.
Données et validation
import { z } from "zod";
export const customers = [
{ id: "customer-1", name: "Atelier North" },
{ id: "customer-2", name: "Studio South" },
];
const lineSchema = z.object({
id: z.string(),
productId: z.string().min(1),
quantity: z.number().int().positive(),
allocations: z.array(
z.object({
id: z.string(),
warehouse: z.string().min(1),
quantity: z.number().int().positive(),
})
),
});
export const orderSchema = z.object({
reference: z.string().trim().min(1),
customerId: z.string().min(1),
lines: z.array(lineSchema).min(1),
});
export interface Order extends Record<string, unknown> {
customerId: string;
id: string;
lines: z.infer<typeof lineSchema>[];
reference: string;
}
export const orders: Order[] = [
{
id: "order-1",
reference: "SO-1001",
customerId: "customer-1",
lines: [{ id: "line-1", productId: "desk", quantity: 2, allocations: [] }],
},
];
export function updateOrder(id: string, patch: Record<string, unknown>) {
const row = orders.find((order) => order.id === id);
if (!row) {
return { success: false, error: "Order no longer exists." };
}
const parsed = orderSchema.safeParse({ ...row, ...patch });
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message ?? "Invalid order.",
};
}
if (!customers.some((customer) => customer.id === parsed.data.customerId)) {
return { success: false, error: "Unknown customer." };
}
Object.assign(row, parsed.data);
return { success: true, data: structuredClone(row) };
}order-config.ts
import { defineTableConfig } from "@/components/ui/yayaw-table/config/helpers";
import { productConfig } from "./product-config";
export const orderConfig = defineTableConfig({
id: "orders",
translations: { namespace: "orders", keys: {} },
columns: {
definitions: [
{ id: "reference", header: "Reference", type: "text" },
{ id: "customerId", header: "Customer", type: "text" },
],
mandatory: ["reference"],
visible: ["reference", "customerId"],
order: ["reference", "customerId"],
},
table: { ...productConfig.table, allowEdit: true, rowClickMode: "edit" },
form: { editFormType: "orders" },
});
export const customerConfig = defineTableConfig({
id: "customers",
translations: { namespace: "customers", keys: {} },
columns: {
definitions: [{ id: "name", header: "Customer", type: "text" }],
mandatory: ["name"],
visible: ["name"],
order: ["name"],
},
table: {
...productConfig.table,
enableRowSelection: true,
enableMultiRowSelection: false,
syncUrl: false,
},
});import { defineTableConfig } from "@/components/ui/yayaw-table-vue/config";
import { productConfig } from "./product-config";
export const orderConfig = defineTableConfig({
id: "orders",
translations: { namespace: "orders", keys: {} },
columns: {
definitions: [
{ id: "reference", header: "Reference", type: "text" },
{ id: "customerId", header: "Customer", type: "text" },
],
mandatory: ["reference"],
visible: ["reference", "customerId"],
order: ["reference", "customerId"],
},
table: { ...productConfig.table, allowEdit: true },
form: { editFormType: "orders" },
});
export const customerConfig = defineTableConfig({
id: "customers",
translations: { namespace: "customers", keys: {} },
columns: {
definitions: [{ id: "name", header: "Customer", type: "text" }],
mandatory: ["name"],
visible: ["name"],
order: ["name"],
},
table: {
...productConfig.table,
enableRowSelection: true,
enableMultiRowSelection: false,
syncUrl: false,
},
});order-form.ts
import type { FormConfig } from "@/components/ui/yayaw-table/components/forms/types";
import { customers, orderSchema } from "../shared/orders";
import { customerConfig } from "./order-config";
import { renderProductSelect } from "./product-select";
export const orderForm: FormConfig = {
id: "orders",
title: "Edit order",
presentation: "modal",
width: "64rem",
schema: orderSchema,
fields: [
{ name: "reference", label: "Reference", type: "text", bulkEdit: false },
{
name: "customerId",
label: "Customer",
type: "tablePicker",
tablePicker: {
tableType: "customers",
config: customerConfig,
data: customers,
getRowId: (row) => String(row.id),
multiple: false,
syncUrl: false,
maxHeight: "18rem",
},
},
{
name: "lines",
label: "Order lines",
type: "collection",
collectionMode: "dialog",
addLabel: "Add line",
getItemKey: (line) => String(line.id),
createItem: () => ({
id: crypto.randomUUID(),
productId: "",
quantity: 1,
allocations: [],
}),
columns: [
{ id: "productId", header: "Product" },
{ id: "quantity", header: "Quantity" },
],
validateItem: (line) =>
Number(line.quantity) > 0 && line.productId
? []
: ["Choose a product and a positive quantity."],
validateItems: (lines) =>
lines.length ? [] : ["Add at least one line."],
itemFields: [
{
name: "productId",
label: "Product",
type: "custom",
renderField: renderProductSelect,
},
{
name: "quantity",
label: "Quantity",
type: "number",
min: 1,
step: 1,
},
{
name: "allocations",
label: "Warehouse allocations",
type: "collection",
collectionMode: "inline",
getItemKey: (allocation) => String(allocation.id),
createItem: () => ({
id: crypto.randomUUID(),
warehouse: "",
quantity: 1,
}),
itemFields: [
{ name: "warehouse", label: "Warehouse", type: "text" },
{ name: "quantity", label: "Quantity", type: "number", min: 1 },
],
},
],
},
],
};import type { FormConfig } from "@/components/ui/yayaw-table-vue/types";
import { customers, orderSchema } from "../shared/orders";
import { products } from "../shared/products";
import { customerConfig } from "./order-config";
export const orderForm: FormConfig = {
id: "orders",
title: "Edit order",
presentation: "modal",
width: "64rem",
schema: orderSchema,
fields: [
{ name: "reference", label: "Reference", type: "text", bulkEdit: false },
{
name: "customerId",
label: "Customer",
type: "tablePicker",
tablePicker: {
tableType: "customers",
config: customerConfig,
data: customers,
getRowId: (row) => String(row.id),
multiple: false,
syncUrl: false,
maxHeight: "18rem",
},
},
{
name: "lines",
label: "Order lines",
type: "collection",
collectionMode: "dialog",
addLabel: "Add line",
getItemKey: (line) => String(line.id),
createItem: () => ({
id: crypto.randomUUID(),
productId: "",
quantity: 1,
allocations: [],
}),
columns: [
{ id: "productId", header: "Product" },
{ id: "quantity", header: "Quantity" },
],
validateItem: (line) =>
Number(line.quantity) > 0 && line.productId
? []
: ["Choose a product and a positive quantity."],
validateItems: (lines) =>
lines.length ? [] : ["Add at least one line."],
itemFields: [
{
name: "productId",
label: "Product",
type: "select",
options: products.map((product) => ({
value: product.id,
label: product.name,
})),
},
{
name: "quantity",
label: "Quantity",
type: "number",
min: 1,
step: 1,
},
{
name: "allocations",
label: "Warehouse allocations",
type: "collection",
collectionMode: "inline",
getItemKey: (allocation) => String(allocation.id),
createItem: () => ({
id: crypto.randomUUID(),
warehouse: "",
quantity: 1,
}),
itemFields: [
{ name: "warehouse", label: "Warehouse", type: "text" },
{ name: "quantity", label: "Quantity", type: "number", min: 1 },
],
},
],
},
],
};get-order-form.ts
import type {
FieldValues,
FormConfig,
FormConfigContext,
} from "@/components/ui/yayaw-table/components/forms/types";
import { isolatedTablePicker } from "./isolated-table-picker";
import { orderForm } from "./order-form";
/** The lookup resolves the order catalogue for the registry's generic form boundary. */
export function getFormConfig<T extends FieldValues>(
formType: string,
context?: FormConfigContext<T>
): FormConfig<T> | undefined {
if (formType !== "orders") {
return;
}
if (!context) {
throw new Error("The order form requires the table's form context.");
}
return {
...orderForm,
fields: orderForm.fields.map((field) =>
field.type === "tablePicker" ? isolatedTablePicker(field, context) : field
),
} as FormConfig<T>;
}Le registre React vérifié partage les atomes de formulaire entre les instances. Isolez le sélecteur natif avec Jotai pour éviter que son tableau imbriqué ouvre un second formulaire. L’API du champ continue d’écrire l’identifiant client dans la commande parente.
import { Provider } from "jotai";
import { TablePickerField } from "@/components/ui/yayaw-table/components/forms/fields/table-picker-field";
import type {
BaseFieldProps,
CustomFieldDefinition,
FormConfigContext,
TablePickerFieldDefinition,
} from "@/components/ui/yayaw-table/components/forms/types";
/** Scope the nested table's form atoms while keeping the parent's field API. */
export function isolatedTablePicker(
definition: TablePickerFieldDefinition,
context: FormConfigContext
): CustomFieldDefinition {
return {
...definition,
type: "custom",
renderField: ({ field }: BaseFieldProps) => (
<Provider>
<TablePickerField
context={context}
field={definition}
fieldApi={field}
/>
</Provider>
),
};
}Le champ produit montre un contrôle natif personnalisé dans le dialogue de collection. Le focus et la sélection restent dans cette modale imbriquée ; un menu rendu dans un portail demande un conteneur appartenant à cette modale, et non au formulaire de commande parent.
import type { BaseFieldProps } from "@/components/ui/yayaw-table/components/forms/types";
import { products } from "../shared/products";
/** A native control keeps focus inside the nested collection dialog. */
export function renderProductSelect({ field }: BaseFieldProps) {
return (
<label className="grid gap-2 text-sm">
Product
<select
className="h-9 rounded-md border bg-background px-3"
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
value={String(field.state.value ?? "")}
>
<option value="">Choose a product</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.name}
</option>
))}
</select>
</label>
);
}import { orderForm } from "./order-form";
export const getFormConfig = (formType: string) =>
formType === "orders" ? orderForm : undefined;order-actions.ts
import type { TableActions } from "@/components/ui/yayaw-table/providers/table-provider";
import { orders, updateOrder } from "../shared/orders";
import { listRecords } from "./list-records";
const orderActions: TableActions = {
list: (params) => listRecords(orders, params),
update: (id, patch) => Promise.resolve(updateOrder(id, patch)),
};
export const getOrderActions = (tableType: string) =>
tableType === "orders" ? orderActions : undefined;import type { TableActions } from "@/components/ui/yayaw-table-vue/types";
import { orders, updateOrder } from "../shared/orders";
import { listRecords } from "./list-records";
const orderActions: TableActions = {
list: (params) => listRecords(orders, params),
update: (id, patch) => Promise.resolve(updateOrder(id, patch)),
};
export const getOrderActions = (tableType: string) =>
tableType === "orders" ? orderActions : undefined;Composant d’entrée
Données de démonstration. Les modifications restent dans cet aperçu.
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NuqsAdapter } from "nuqs/adapters/react";
import { useState } from "react";
import { Toaster } from "sonner";
import { DataTable } from "@/components/ui/yayaw-table";
import { getFormConfig } from "./get-order-form";
import { getOrderActions } from "./order-actions";
import { orderConfig } from "./order-config";
import type { ExamplePresentation } from "./presentation";
const getConfig = () => orderConfig;
export default function OrderRecipe(presentation: ExamplePresentation = {}) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<NuqsAdapter>
<DataTable
{...presentation}
getFormConfig={getFormConfig}
getRowId={(row) => String(row.id)}
getTableActions={getOrderActions}
getTableConfig={getConfig}
tableId="recipe-order"
tableType="orders"
title={presentation.title ?? "Order"}
/>
<Toaster />
</NuqsAdapter>
</QueryClientProvider>
);
}Données de démonstration. Les modifications restent dans cet aperçu.
<script setup lang="ts">
import type { ExamplePresentation } from "./presentation";
defineProps<ExamplePresentation>();
import { YayawDataTable } from "@/components/ui/yayaw-table-vue";
import { getFormConfig } from "./get-order-form";
import { getOrderActions } from "./order-actions";
import { orderConfig } from "./order-config";
const getRowId = (row: Record<string, unknown>) => String(row.id);
</script>
<template>
<YayawDataTable v-bind="$props" table-type="orders" table-id="recipe-order" :title="title ?? 'Order'"
:config="orderConfig" :get-table-actions="getOrderActions" :get-row-id="getRowId"
:get-form-config="getFormConfig" />
</template>Vérifier le résultat
Ouvrez SO-1001 en édition.
Choisissez Studio South dans le sélecteur client.
Ajoutez une ligne, choisissez un produit et une quantité de 2.
Ajoutez une allocation d’entrepôt puis enregistrez ligne et commande.
Rouvrez : client et valeurs imbriquées sont conservés.
Testez une quantité de 0 et une collection vide : la validation refuse.
Annulez un dialogue d’élément et vérifiez que le formulaire parent reste intact.
Utilisez le playground existant pour explorer les contrôles de la bibliothèque.