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é.
CollectionFieldlit la valeur courante depuis TanStack Form.- Si cette valeur n'est pas un tableau, elle est normalisée en
[]. - Chaque ajout, édition, suppression ou réordonnancement crée un nouveau tableau.
- Les modifications d'items créent de nouveaux objets au lieu de muter l'item existant.
- Le nouveau tableau est écrit via
fieldApi.handleChange. - 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. |
name | Chemin du formulaire pour la valeur tableau. |
label / description | Texte affiché au-dessus de l'éditeur. |
disabled | Désactive les actions collection et les contrôles du formulaire d'item. |
addLabel | Label du bouton d'ajout par défaut. |
itemLabel | Nom lisible de l'item utilisé dans les compteurs, titres de dialog et messages de validation. |
emptyLabel | Texte optionnel de l'état vide. |
columns | Colonnes 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. |
createActions | Liste 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. |
renderItemForm | Rend le contenu de la dialog pour créer ou éditer un item. |
validateItem | Retourne les erreurs d'une ligne. |
validateItems | Retourne les erreurs globales du tableau. |
labels | Surcharge les labels intégrés comme Actions, Cancel, Save, Edit, Delete, Move up, Move down. |
labelKeys | Clé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 :
validateItem(item, index)retourne les erreurs d'une ligne. Ces erreurs sont visibles sous la ligne et dans la dialog d'item.validateItems(items)retourne les erreurs du tableau entier, par exemple “Add at least one item”.- Le
schemadu 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 :
ActionsCancelSaveEditDeleteMove upMove 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
itemouitemsen place. Créez toujours un nouvel objet ou tableau avant d'appeleronChange. - Oublier un
getItemKeystable 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/validateItemsquand 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
- Provider & Setup pour
getFormConfig. - DataTable Reference pour la prop
getFormConfig. - Translations pour la configuration partagée des traductions table et formulaire.