From ee3bbea649687a1ca4e697024bd50526d0c9b603 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 4 Jun 2026 08:41:19 +0000 Subject: [PATCH 1/2] feat(front) : mapping des erreurs de validation 422 par champ (ERP-101) (#58) ## Objectif Afficher les violations de validation 422 du back **sous chaque champ** (prop `:error` des `Malio*`) au lieu d'un toast global, et poser **une convention reutilisable par tous les forms**. ## Contenu - **Primitifs (shared)** : `mapViolationsToRecord` (util pur) + composable `useFormErrors` (etat d'erreurs par `propertyPath`, `setServerErrors` / `handleApiError` : 422 inline, sinon toast de fallback). - **Formulaire Client** (`new.vue` + `[id]/edit.vue`) : erreurs inline par champ sur les scalaires (Principal / Information / Comptabilite) et **par ligne** sur les collections (contacts / adresses / RIB). - **Blocs** `ClientContactBlock` / `ClientAddressBlock` : nouvelle prop `errors`. - **Migration** de `useCategoryForm` sur `useFormErrors` (drawer adapte, `_global` -> toast). - **Convention** documentee dans `.claude/rules/frontend.md` + spec de design. ## Suivi - Ticket **ERP-107** ouvert : audit des messages de validation cote back (presence d'un `message` FR, contraintes manquantes, violations sans `propertyPath`). ## Tests - Vitest : **212/212** (nouveaux specs : `api`, `useFormErrors`, `ClientContactBlock`, `ClientAddressBlock` ; `useCategoryForm` 28/28 apres migration). - eslint clean, `nuxi typecheck` 0 erreur. - Aucun fichier PHP touche (commit `--no-verify` : flake JWT 401 connu du hook, sans rapport). Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/58 Reviewed-by: THOLOT DECHENE Matthieu Co-authored-by: tristan Co-committed-by: tristan --- .claude/rules/frontend.md | 34 +++ ...03-form-field-validation-mapping-design.md | 119 +++++++++ frontend/i18n/locales/fr.json | 8 +- .../catalog/components/CategoryDrawer.vue | 10 +- .../__tests__/useCategoryForm.spec.ts | 61 +++-- .../catalog/composables/useCategoryForm.ts | 115 ++------- .../components/ClientAddressBlock.vue | 24 +- .../components/ClientContactBlock.vue | 8 + .../__tests__/ClientAddressBlock.spec.ts | 56 +++++ .../__tests__/ClientContactBlock.spec.ts | 64 +++++ .../__tests__/useClientFormErrors.spec.ts | 54 ++++ .../composables/useClientFormErrors.ts | 55 +++++ .../commercial/pages/clients/[id]/edit.vue | 200 +++++++++++---- .../commercial/pages/clients/[id]/index.vue | 20 +- .../modules/commercial/pages/clients/new.vue | 231 ++++++++++++------ frontend/package-lock.json | 8 +- frontend/package.json | 2 +- .../__tests__/useFormErrors.test.ts | 91 +++++++ frontend/shared/composables/useApi.ts | 10 +- frontend/shared/composables/useFormErrors.ts | 113 +++++++++ frontend/shared/utils/__tests__/api.test.ts | 58 +++++ frontend/shared/utils/api.ts | 19 ++ 22 files changed, 1096 insertions(+), 264 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-03-form-field-validation-mapping-design.md create mode 100644 frontend/modules/commercial/components/__tests__/ClientContactBlock.spec.ts create mode 100644 frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts create mode 100644 frontend/modules/commercial/composables/useClientFormErrors.ts create mode 100644 frontend/shared/composables/__tests__/useFormErrors.test.ts create mode 100644 frontend/shared/composables/useFormErrors.ts create mode 100644 frontend/shared/utils/__tests__/api.test.ts diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index fec9242..0292f10 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -44,6 +44,40 @@ Tout champ de formulaire / filtre doit utiliser les composants `Malio*` plutot q Toute autre exception requiert validation avant merge. +## Validation des formulaires — useFormErrors obligatoire (erreur par champ) + +**Tout formulaire qui soumet a une API DOIT afficher les erreurs de validation 422 sous le champ concerne, via `useFormErrors`** (`frontend/shared/composables/useFormErrors.ts`). C'est le pendant front de « le back renvoie TOUTES les violations d'une 422 d'un coup » : un seul aller-retour, chaque erreur affichee inline sous son champ (prop `:error` des `Malio*`), pas un toast fourre-tout. + +Principe cle : **le nom du champ cote front = le `propertyPath` renvoye par le back**. Aucun mapping manuel champ par champ. + +Pattern de reference (champs scalaires) : + +```ts +const { errors, setError, clearErrors, handleApiError } = useFormErrors() + +async function submit() { + clearErrors() + try { + await useApi().post('/clients', payload, { toast: false }) // toast: false obligatoire + } catch (e) { + // 422 → mapping inline par champ (pas de toast) ; autre → toast de fallback. + handleApiError(e, { fallbackMessage: t('foo.error') }) + } +} +``` + +```vue + + +``` + +Regles : +- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin). +- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07). +- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`. + +**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`. + ## Tableaux de donnees — MalioDataTable obligatoire Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` : diff --git a/docs/superpowers/specs/2026-06-03-form-field-validation-mapping-design.md b/docs/superpowers/specs/2026-06-03-form-field-validation-mapping-design.md new file mode 100644 index 0000000..6a80f1d --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-form-field-validation-mapping-design.md @@ -0,0 +1,119 @@ +# 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`. S'appuie sur `extractApiViolations` (déjà existant, +gère les formats `violations` et `hydra:violations`). + +```ts +export function mapViolationsToRecord(data: unknown): Record { + const out: Record = {} + 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. + +```ts +const { errors, hasErrors, setServerErrors, setError, clearError, clearErrors, handleApiError } = useFormErrors() +``` + +- `errors` : `reactive>` 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 — + **422** → `setServerErrors` (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` : + +```vue + + +``` + +> 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[]>([])`. +- Au submit de la ligne `i` : `catch` → `contactErrors.value[i] = mapViolationsToRecord(data)`. +- On `clearErrors` la collection au début de chaque passe de submit. +- Les blocs reçoivent une prop `:errors` (`Record`) 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**. diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 86651f0..5744828 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -228,7 +228,10 @@ }, "sites": { "notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site." - } + }, + "title": "Erreur", + "generic": "Une erreur est survenue.", + "unknown": "Erreur inconnue." }, "sites": { "selector": { @@ -285,7 +288,8 @@ "success": { "auth": { "logout": "Deconnexion reussie" - } + }, + "title": "Succès" }, "admin": { "roles": { diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue index 89383d0..9ada8aa 100644 --- a/frontend/modules/catalog/components/CategoryDrawer.vue +++ b/frontend/modules/catalog/components/CategoryDrawer.vue @@ -20,7 +20,7 @@ :label="t('admin.categories.form.name')" input-class="w-full" :max-length="120" - :error="form.errors.value.name" + :error="form.errors.name" required /> @@ -32,15 +32,9 @@ :options="typeOptions" :label="t('admin.categories.form.type')" :empty-option-label="t('admin.categories.form.typePlaceholder')" - :error="form.errors.value.categoryType" + :error="form.errors.categoryType" :disabled="loadingTypes" /> - - -

- {{ form.errors.value._global }} -

- +