feat(front) : mapping des erreurs de validation 422 par champ (ERP-101) (#58)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## 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: #58 Reviewed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #58.
This commit is contained in:
@@ -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<propertyPath, message>`. S'appuie sur `extractApiViolations` (déjà existant,
|
||||
gère les formats `violations` et `hydra:violations`).
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
```ts
|
||||
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 —
|
||||
**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
|
||||
<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` : `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<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**.
|
||||
Reference in New Issue
Block a user