Files
Starseed/docs/superpowers/specs/2026-06-03-form-field-validation-mapping-design.md
T
tristan 502d1a216b
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m40s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m10s
feat(front) : mapping des erreurs de validation 422 par champ (ERP-101)
- 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
2026-06-04 08:24:39 +02:00

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-mapping Date : 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

  1. Primitif générique plutôt que composable par form : useFormErrors() partagé.
  2. 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é par propertyPath.
  • setServerErrors(data) : mapViolationsToRecord → remplit errors. Retourne true si au moins une violation a été mappée.
  • setError(field, msg) / clearError(field) / clearErrors() : manipulation fine.
  • hasErrors : computed booléen.
  • handleApiError(e, opts?) : dispatch standard depuis une erreur ofetch — 422setServerErrors (mapping inline, pas de toast) ; autre → toast générique de fallback (message extrait via extractApiErrorMessage).

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 UniqueEntity avec propertyPath: "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 : catchcontactErrors.value[i] = mapViolationsToRecord(data).
  • On clearErrors la 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 : formats violations / hydra:violations, payload vide, propertyPath manquant.
  • useFormErrors : setServerErrors mappe et retourne true / false sans 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 } + useFormErrors pour les champs scalaires (422 inline), mapViolationsToRecord par ligne pour les collections. useCategoryForm migrera sur useFormErrors.

Fait dans la foulée (post-ERP-101 initial)

  • useCategoryForm migré sur useFormErrors : errors devient le reactive du composable (drawer adapté : form.errors.name au lieu de form.errors.value.name, bloc _global retiré → 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 message affiché est celui renvoyé par le serveur. Audit des contraintes Symfony (présence d'un message FR, contraintes manquantes, violations sans propertyPath) tracké dans ERP-107.