- composable useFormErrors + util mapViolationsToRecord (shared) - formulaire Client (new + edit) : erreurs inline par champ (scalaires) et par ligne pour les collections (contacts / adresses / RIB) - blocs ClientContactBlock / ClientAddressBlock : prop errors - migration de useCategoryForm sur useFormErrors - convention documentee dans .claude/rules/frontend.md
5.4 KiB
ERP-101 — Mapping des erreurs de validation par champ (convention forms)
Statut : design validé — implémentation TDD en cours Branche :
feat/ERP-101-form-field-validation-mappingDate : 2026-06-03
Problème
Quand le back renvoie une 422 (violations API Platform), il renvoie toutes les
violations d'un coup (un propertyPath + message par champ fautif). Aujourd'hui, seul
le drawer Catégorie (useCategoryForm) exploite ce détail pour afficher l'erreur sous
le champ concerné ; il le fait via un if/else manuel par champ, non réutilisable.
Le formulaire Client (≈ 20 champs sur 5 submits, dont 3 collections) ne mappe rien : une 422 multi-champs ⇒ un seul toast global. On veut un retour par champ, et surtout une convention unique réutilisée par tous les modules.
Décisions
- Primitif générique plutôt que composable par form :
useFormErrors()partagé. - Périmètre complet sur Client : champs scalaires et collections (erreur par ligne).
Architecture — 3 briques
1. mapViolationsToRecord(data) — frontend/shared/utils/api.ts
Util pur, fondation réutilisée partout. Transforme un payload 422 en
Record<propertyPath, message>. S'appuie sur extractApiViolations (déjà existant,
gère les formats violations et hydra:violations).
export function mapViolationsToRecord(data: unknown): Record<string, string> {
const out: Record<string, string> = {}
for (const v of extractApiViolations(data)) {
if (v.propertyPath) out[v.propertyPath] = v.message
}
return out
}
2. useFormErrors() — frontend/shared/composables/useFormErrors.ts
API que tous les forms scalaires consomment.
const { errors, hasErrors, setServerErrors, setError, clearError, clearErrors, handleApiError } = useFormErrors()
errors:reactive<Record<string, string>>indexé parpropertyPath.setServerErrors(data):mapViolationsToRecord→ rempliterrors. Retournetruesi au moins une violation a été mappée.setError(field, msg)/clearError(field)/clearErrors(): manipulation fine.hasErrors:computedbooléen.handleApiError(e, opts?): dispatch standard depuis une erreur ofetch — 422 →setServerErrors(mapping inline, pas de toast) ; autre → toast générique de fallback (message extrait viaextractApiErrorMessage).
Côté template, le nom du champ est le propertyPath :
<MalioInputText v-model="main.companyName" :error="errors.companyName" />
<MalioInputText v-model="accounting.siren" :error="errors.siren" />
L'unicité SIREN (RG-1.15) remonte en 422
UniqueEntityavecpropertyPath: "siren"→ mappée automatiquement. Pas de cas 409 spécial (contrairement à Catégorie).
3. Collections — erreurs par ligne
Chaque ligne (contact / adresse / RIB) est persistée par son propre appel API, donc
le back renvoie un 422 relatif à la sous-entité (propertyPath: "email", "iban"…).
- Le parent tient, par collection, un tableau d'erreurs aligné sur l'index de ligne :
const contactErrors = ref<Record<string, string>[]>([]). - Au submit de la ligne
i:catch→contactErrors.value[i] = mapViolationsToRecord(data). - On
clearErrorsla collection au début de chaque passe de submit. - Les blocs reçoivent une prop
:errors(Record<string, string>) et bindent:error="errors?.email"sur chaque champ Malio.
Fichiers touchés
| Fichier | Action |
|---|---|
shared/utils/api.ts |
+ mapViolationsToRecord |
shared/composables/useFormErrors.ts |
nouveau composable |
modules/commercial/pages/clients/new.vue |
scalaires (Main/Info/Compta) + erreurs par ligne |
modules/commercial/pages/clients/[id]/edit.vue |
idem |
modules/commercial/components/ClientContactBlock.vue |
+ prop :errors, bind :error |
modules/commercial/components/ClientAddressBlock.vue |
+ prop :errors, bind :error |
| RIB (inline dans new/edit) | bind :error par ligne |
Tests (Vitest — règle « pas d'E2E »)
mapViolationsToRecord: formatsviolations/hydra:violations, payload vide,propertyPathmanquant.useFormErrors:setServerErrorsmappe et retournetrue/falsesans violation,clearErrors, fallback toast sur non-422.
Convention posée pour tous les forms
À reporter dans .claude/rules/frontend.md une fois le pattern stabilisé :
Tout form qui veut un retour d'erreur par champ : appels API en
{ toast: false }+useFormErrorspour les champs scalaires (422 inline),mapViolationsToRecordpar ligne pour les collections.useCategoryFormmigrera suruseFormErrors.
Fait dans la foulée (post-ERP-101 initial)
useCategoryFormmigré suruseFormErrors:errorsdevient lereactivedu composable (drawer adapté :form.errors.nameau lieu deform.errors.value.name, bloc_globalretiré → erreur transverse en toast). 28 tests verts.- Convention reportée dans
.claude/rules/frontend.md(section « Validation des formulaires — useFormErrors obligatoire »).
Hors scope ERP-101 (suivi : ticket ERP-107)
- Langue / présence des messages de validation côté back : le
messageaffiché est celui renvoyé par le serveur. Audit des contraintes Symfony (présence d'unmessageFR, contraintes manquantes, violations sanspropertyPath) tracké dans ERP-107.