Yayaw
Documentation
Recettes

É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

orders.ts
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

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,
  },
});
order-config.ts
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

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 },
          ],
        },
      ],
    },
  ],
};
order-form.ts
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

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.

isolated-table-picker.tsx
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.

product-select.tsx
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>
  );
}
get-order-form.ts
import { orderForm } from "./order-form";
export const getFormConfig = (formType: string) =>
  formType === "orders" ? orderForm : undefined;

order-actions.ts

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;
order-actions.ts
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

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Vérifier le résultat

  1. Ouvrez SO-1001 en édition.

  2. Choisissez Studio South dans le sélecteur client.

  3. Ajoutez une ligne, choisissez un produit et une quantité de 2.

  4. Ajoutez une allocation d’entrepôt puis enregistrez ligne et commande.

  5. Rouvrez : client et valeurs imbriquées sont conservés.

  6. Testez une quantité de 0 et une collection vide : la validation refuse.

  7. 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.