YaYaw TableYaYaw Table

Construire des formulaires create/edit avec éditeurs de tableaux natifs, validation et collections imbriquées

Formulaires et champs collection

YaYaw Table peut rendre des formulaires de création, d'édition et d'édition de masse à partir d'une configuration. Vous fournissez un FormConfig via getFormConfig, et la table ouvre le formulaire correspondant dans le drawer ou la modal intégrée quand l'utilisateur crée ou modifie une ligne.

La plupart des champs correspondent à une valeur primitive : text, number, select, switch, textarea, url, value-type ou custom. Le champ collection est différent : il édite un tableau d'objets avec une interface structurée. Il est conçu pour les données de type JSON qui forceraient sinon chaque application consommatrice à construire son propre mini éditeur.

Ce que permettent les champs collection

Utilisez type: "collection" quand une valeur de formulaire est un tableau et que les utilisateurs doivent gérer ses éléments visuellement.

Cas adaptés :

  • Menus de navigation stockés en JSON, avec liens, groupes, toggles et liens imbriqués.
  • Listes de fonctionnalités où chaque fonctionnalité a un label, une icône, un état actif et une description.
  • Règles de prix répétées, FAQ, méthodes de contact, éléments de galerie ou blocs de contenu localisés.
  • Tout champ “tableau d'enregistrements” où une simple textarea JSON serait trop risquée pour les utilisateurs.

L'interface collection intégrée fournit :

  • Une vue de type table avec des colonnes configurables.
  • Un état vide clair quand il n'y a pas encore d'élément.
  • Un bouton d'ajout unique ou plusieurs actions d'ajout, comme “Add link”, “Add group” et “Add theme toggle”.
  • Des actions de ligne pour éditer, supprimer, monter et descendre.
  • Une dialog interne d'édition d'élément avec Cancel et Save.
  • Des erreurs par ligne via validateItem.
  • Des erreurs globales via validateItems.
  • Le blocage du submit quand la collection est invalide.
  • La compatibilité avec la validation Zod du même formulaire.
  • L'édition de collections imbriquées via le composant exporté CollectionEditor.

Le champ collection est générique. YaYaw Table n'a pas besoin de connaître la signification métier d'un lien de menu, d'une fonctionnalité ou d'un élément de FAQ. Vous définissez les formes d'items avec createItem / createActions, vous rendez les contrôles métier avec renderItemForm, et vous imposez les règles avec les validateurs.

Modèle mental

La valeur du formulaire reste toujours la source de vérité.

  1. CollectionField lit la valeur courante depuis TanStack Form.
  2. Si cette valeur n'est pas un tableau, elle est normalisée en [].
  3. Chaque ajout, édition, suppression ou réordonnancement crée un nouveau tableau.
  4. Les modifications d'items créent de nouveaux objets au lieu de muter l'item existant.
  5. Le nouveau tableau est écrit via fieldApi.handleChange.
  6. Au submit, YaYaw Table valide la même valeur avec les validateurs collection et le schéma Zod configuré.

Il n'y a donc pas d'état JSON interne opaque à synchroniser avec votre application. Si la valeur du formulaire est { items_json: [...] }, l'éditeur collection est simplement une interface plus sûre pour ce tableau.

Exemple rapide

Cet exemple crée un petit éditeur de fonctionnalités avec ajout, édition, suppression, réordonnancement, validation par ligne et blocage du submit.

import { defineFormConfig } from "@/components/ui/yayaw-table/components/forms";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { z } from "zod";

const FeatureSchema = z.object({
  features: z.array(
    z.object({
      label: z.string().min(1),
      enabled: z.boolean(),
    })
  ),
});

export const featureForm = defineFormConfig({
  id: "features",
  schema: FeatureSchema,
  defaultValues: {
    features: [],
  },
  fields: [
    {
      type: "collection",
      name: "features",
      label: "Features",
      description: "Manage the product feature list.",
      addLabel: "Add feature",
      itemLabel: "feature",
      emptyLabel: "No features yet.",
      columns: [
        { id: "label", header: "Label" },
        {
          id: "enabled",
          header: "Enabled",
          render: (item) => (item.enabled ? "Yes" : "No"),
        },
      ],
      createItem: () => ({ label: "", enabled: true }),
      renderItemForm: ({ item, onChange, disabled }) => (
        <div className="space-y-3">
          <Input
            disabled={disabled}
            onChange={(event) =>
              onChange({ ...item, label: event.target.value })
            }
            value={String(item.label ?? "")}
          />
          <Switch
            checked={item.enabled === true}
            disabled={disabled}
            onCheckedChange={(enabled) => onChange({ ...item, enabled })}
          />
        </div>
      ),
      validateItem: (item) =>
        typeof item.label === "string" && item.label.length > 0
          ? []
          : ["Label is required"],
      validateItems: (items) =>
        items.length > 0 ? [] : ["Add at least one feature"],
    },
  ],
});

Référence API

CollectionFieldDefinition<TFormValues> étend la définition de champ classique avec les options d'un éditeur de tableau.

PropriétéRôle
type: "collection"Sélectionne le champ collection natif.
nameChemin du formulaire pour la valeur tableau.
label / descriptionTexte affiché au-dessus de l'éditeur.
disabledDésactive les actions collection et les contrôles du formulaire d'item.
addLabelLabel du bouton d'ajout par défaut.
itemLabelNom lisible de l'item utilisé dans les compteurs, titres de dialog et messages de validation.
emptyLabelTexte optionnel de l'état vide.
columnsColonnes de résumé. Une colonne peut lire item[column.id] ou fournir un render personnalisé.
createItem(items)Crée le nouvel item par défaut à partir des items courants.
createActionsListe optionnelle d'actions d'ajout nommées pour plusieurs types d'items.
getItemKey(item, index)Clé React stable optionnelle. Utilisez les IDs quand les items en ont.
renderItemFormRend le contenu de la dialog pour créer ou éditer un item.
validateItemRetourne les erreurs d'une ligne.
validateItemsRetourne les erreurs globales du tableau.
labelsSurcharge les labels intégrés comme Actions, Cancel, Save, Edit, Delete, Move up, Move down.
labelKeysClés de traduction pour ces mêmes labels intégrés.

renderItemForm reçoit un item contrôlé :

renderItemForm: (props: {
  item: Record<string, unknown>;
  index: number | null;
  disabled?: boolean;
  onChange: (item: Record<string, unknown>) => void;
}) => ReactNode;

Appelez onChange({ ...item, field: nextValue }) dès qu'un contrôle change. Ne mutez pas item directement.

Plusieurs types d'items

Utilisez createActions quand les utilisateurs doivent créer plusieurs formes d'items dans le même tableau.

const createLink = () => ({
  type: "link",
  label: "",
  href: "",
  placement: "primary",
  variant: "default",
  description: "",
  external: false,
});

const createGroup = () => ({
  type: "group",
  label: "",
  description: "",
  placement: "primary",
  items: [],
});

{
  type: "collection",
  name: "items_json",
  label: "Menu items",
  addLabel: "Add item",
  itemLabel: "menu item",
  columns: [
    { id: "label", header: "Label" },
    { id: "type", header: "Type" },
    { id: "placement", header: "Placement" },
  ],
  createItem: createLink,
  createActions: [
    { label: "Add link", createItem: createLink },
    { label: "Add group", createItem: createGroup },
    {
      label: "Add theme toggle",
      createItem: () => ({ type: "themeToggle", placement: "utility" }),
    },
    {
      label: "Add language toggle",
      createItem: () => ({ type: "languageToggle", placement: "utility" }),
    },
  ],
  renderItemForm: ({ item, onChange }) => {
    if (item.type === "group") {
      return <GroupItemForm item={item} onChange={onChange} />;
    }

    if (item.type === "link") {
      return <LinkItemForm item={item} onChange={onChange} />;
    }

    return <ToggleItemForm item={item} onChange={onChange} />;
  },
}

C'est suffisant pour modéliser un menu où les items top-level peuvent être des liens, des groupes, des toggles de thème ou des toggles de langue. Le champ collection orchestre seulement l'éditeur ; vos formulaires d'items décident des contrôles nécessaires à chaque type métier.

Collections imbriquées

Les collections imbriquées sont supportées avec le composant bas niveau CollectionEditor à l'intérieur d'un formulaire d'item. C'est utile pour les groupes de menu à un niveau : la collection principale édite les items de menu, et un item group contient une collection imbriquée pour group.items.

import { CollectionEditor } from "@/components/ui/yayaw-table/components/forms";
import { Input } from "@/components/ui/input";

function GroupItemForm({
  item,
  onChange,
}: {
  item: Record<string, unknown>;
  onChange: (item: Record<string, unknown>) => void;
}) {
  return (
    <div className="space-y-4">
      <Input
        onChange={(event) => onChange({ ...item, label: event.target.value })}
        value={String(item.label ?? "")}
      />
      <CollectionEditor
        addLabel="Add nested link"
        columns={[
          { id: "label", header: "Label" },
          { id: "href", header: "Href" },
        ]}
        createItem={() => ({
          type: "link",
          label: "",
          href: "",
          placement: "primary",
          variant: "default",
          external: false,
        })}
        emptyLabel="No nested links yet."
        itemLabel="nested link"
        label="Nested links"
        onChange={(items) => onChange({ ...item, items })}
        renderItemForm={({ item: nestedItem, onChange: onNestedChange }) => (
          <div className="space-y-3">
            <Input
              onChange={(event) =>
                onNestedChange({
                  ...nestedItem,
                  label: event.target.value,
                })
              }
              value={String(nestedItem.label ?? "")}
            />
            <Input
              onChange={(event) =>
                onNestedChange({
                  ...nestedItem,
                  href: event.target.value,
                })
              }
              value={String(nestedItem.href ?? "")}
            />
          </div>
        )}
        validateItem={(nestedItem) =>
          nestedItem.type === "link" ? [] : ["Only links are allowed"]
        }
        value={item.items}
      />
    </div>
  );
}

Le détail important est le onChange imbriqué : il écrit le prochain tableau imbriqué dans l'item parent avec onChange({ ...item, items }).

Couches de validation

Les collections ont trois couches de validation qui peuvent fonctionner ensemble :

  1. validateItem(item, index) retourne les erreurs d'une ligne. Ces erreurs sont visibles sous la ligne et dans la dialog d'item.
  2. validateItems(items) retourne les erreurs du tableau entier, par exemple “Add at least one item”.
  3. Le schema du formulaire valide toujours la valeur finale avec Zod.

validateItem et validateItems sont branchés à TanStack Form comme validateurs du champ. Si l'un des deux retourne des erreurs, form.handleSubmit() n'appelle pas l'action de submit.

Utilisez les validateurs collection pour les messages métier et UI, et gardez Zod comme contrat structurel final. Par exemple :

validateItem: (item) => {
  if (item.type === "link" && !item.href) {
    return ["Href is required"];
  }

  if (item.type === "group") {
    const nestedItems = Array.isArray(item.items) ? item.items : [];
    return nestedItems.every(
      (nestedItem) =>
        typeof nestedItem === "object" &&
        nestedItem !== null &&
        "href" in nestedItem
    )
      ? []
      : ["Group items must be links"];
  }

  return [];
},
validateItems: (items) =>
  items.length > 0 ? [] : ["Add at least one menu item"],

Formulaires en modal et drawer

Les champs collection fonctionnent dans les mêmes surfaces CatalogueForm que les autres champs. Ils peuvent être utilisés dans le drawer latéral par défaut ou dans une modal plus large :

defineTableConfig({
  form: {
    layout: {
      mode: "modal",
      width: "80vw",
    },
  },
});

Utilisez une modal quand les formulaires d'items contiennent plusieurs contrôles, des collections imbriquées ou des colonnes de résumé larges.

Traduction et labels

Les champs collection utilisent des labels anglais par défaut pour les actions intégrées :

  • Actions
  • Cancel
  • Save
  • Edit
  • Delete
  • Move up
  • Move down

Vous pouvez les surcharger champ par champ avec labels :

{
  type: "collection",
  labels: {
    actions: "Actions de ligne",
    save: "Enregistrer l'item",
    cancel: "Annuler l'édition",
  },
  // ...
}

Ou utiliser labelKeys si votre application résout les labels depuis les traductions :

{
  type: "collection",
  labelKeys: {
    actions: "menu.fields.items.actions",
    save: "menu.fields.items.save",
    cancel: "menu.fields.items.cancel",
  },
  // ...
}

Quand garder custom

Gardez type: "custom" quand le champ n'est pas un éditeur de tableau, quand il demande une mise en page complètement différente, ou quand il intègre un composant métier qui possède déjà son UX.

Utilisez type: "collection" quand la donnée reste un tableau d'objets et que l'application doit seulement définir :

  • Comment créer de nouveaux items.
  • Quelles colonnes résument les items.
  • Quels contrôles afficher dans le formulaire d'item.
  • Quelles règles rendent un item ou le tableau entier invalide.

Cette séparation garde le comportement générique ajouter/éditer/supprimer/réordonner dans YaYaw Table, tout en laissant les champs métier dans l'application consommatrice.

Pièges fréquents

  • Muter item ou items en place. Créez toujours un nouvel objet ou tableau avant d'appeler onChange.
  • Oublier un getItemKey stable quand les items ont des IDs durables. Le fallback par index fonctionne, mais les IDs gardent une identité de ligne plus claire.
  • Mettre les règles métier uniquement dans Zod. Utilisez aussi validateItem / validateItems quand l'utilisateur a besoin d'un feedback par ligne avant le submit.
  • Mettre un arbre métier profondément imbriqué dans un seul éditeur. Les collections imbriquées sont surtout adaptées à une sous-liste claire, comme les liens d'un groupe.
  • Reconstruire une collection avec type: "custom" alors que le comportement collection intégré couvre déjà le workflow tableau.

Docs liées

On this page