feat(front) : mapping des erreurs de validation 422 par champ (ERP-101) (#58)
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:
2026-06-04 08:41:19 +00:00
committed by Autin
parent e85d46a17b
commit ee3bbea649
22 changed files with 1096 additions and 264 deletions
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useCategoryForm } from '../useCategoryForm'
// Stubs des auto-imports Nuxt consommes par le composable.
@@ -21,6 +22,9 @@ vi.stubGlobal('useToast', () => ({
success: mockToastSuccess,
error: mockToastError,
}))
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
// (elle consomme useToast, deja stubbe ci-dessus) pour tester l'integration.
vi.stubGlobal('useFormErrors', useFormErrors)
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
// Quand le composable passe des params (ex: doublon), on les serialise pour
// pouvoir verifier que l'interpolation a bien recu le bon nom.
@@ -61,7 +65,7 @@ describe('useCategoryForm', () => {
expect(form.name.value).toBe('Vis')
expect(form.categoryTypeId.value).toBe(1)
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
expect(form.errors).toEqual({})
})
it('vide le formulaire en mode creation (null)', () => {
@@ -105,7 +109,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
expect(form.errors.name).toBe('admin.categories.validation.nameRequired')
})
it('signale erreur si name est whitespace-only (trim → vide)', () => {
@@ -116,7 +120,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
expect(form.errors.name).toBe('admin.categories.validation.nameRequired')
})
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
@@ -127,7 +131,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
@@ -138,7 +142,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
@@ -149,7 +153,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
})
it('passe quand name et categoryType sont valides', () => {
@@ -160,19 +164,22 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(true)
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
expect(form.errors).toEqual({})
})
it('reinitialise les erreurs avant chaque validation', () => {
const form = useCategoryForm()
// Erreur prealable.
form.errors.value._global = 'erreur ancienne'
form.name.value = 'Vis'
// Erreur prealable : une validation en echec peuple errors.name.
form.name.value = ''
form.categoryTypeId.value = 1
form.validate()
expect(form.errors.name).toBeTruthy()
// Seconde validation avec des valeurs valides : errors repart vide.
form.name.value = 'Vis'
form.validate()
expect(form.errors.value._global).toBe('')
expect(form.errors).toEqual({})
})
})
@@ -213,7 +220,7 @@ describe('useCategoryForm', () => {
await form.submitCreate()
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès',
title: 'success.title',
message: 'admin.categories.toast.created',
})
})
@@ -231,8 +238,8 @@ describe('useCategoryForm', () => {
expect(result).toBeNull()
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
// les params i18n (stub serialise les params).
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Vis"')
expect(form.errors.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.name).toContain('"name":"Vis"')
expect(mockToastError).toHaveBeenCalledTimes(1)
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
expect(toastArg.message).toContain('Vis')
@@ -256,7 +263,7 @@ describe('useCategoryForm', () => {
const result = await form.submitCreate()
expect(result).toBeNull()
expect(form.errors.value.name).toBe('name should not be blank.')
expect(form.errors.name).toBe('name should not be blank.')
// Pas de toast quand on a mappe les violations : l erreur est
// affichee inline sous le champ concerne.
expect(mockToastError).not.toHaveBeenCalled()
@@ -279,10 +286,10 @@ describe('useCategoryForm', () => {
await form.submitCreate()
expect(form.errors.value.categoryType).toBe('Type invalide.')
expect(form.errors.categoryType).toBe('Type invalide.')
})
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
mockPost.mockRejectedValueOnce({
response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
})
@@ -292,9 +299,10 @@ describe('useCategoryForm', () => {
await form.submitCreate()
expect(form.errors.value._global).toBe('Boom server')
// Pas d'erreur inline par champ : l'erreur transverse part en toast.
expect(form.errors).toEqual({})
expect(mockToastError).toHaveBeenCalledWith({
title: 'Erreur',
title: 'errors.title',
message: 'Boom server',
})
})
@@ -370,7 +378,7 @@ describe('useCategoryForm', () => {
await form.submitUpdate(42)
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès',
title: 'success.title',
message: 'admin.categories.toast.updated',
})
})
@@ -386,8 +394,8 @@ describe('useCategoryForm', () => {
const result = await form.submitUpdate(42)
expect(result).toBeNull()
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Doublon"')
expect(form.errors.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.name).toContain('"name":"Doublon"')
})
})
@@ -401,7 +409,7 @@ describe('useCategoryForm', () => {
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
expect(ok).toBe(true)
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès',
title: 'success.title',
message: 'admin.categories.toast.deleted',
})
})
@@ -415,7 +423,6 @@ describe('useCategoryForm', () => {
const ok = await form.submitDelete(42)
expect(ok).toBe(false)
expect(form.errors.value._global).toBe('down')
expect(mockToastError).toHaveBeenCalled()
})
})
@@ -424,15 +431,15 @@ describe('useCategoryForm', () => {
it('vide le formulaire et les erreurs', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'edit'
form.errors.value._global = 'erreur'
form.name.value = ''
form.validate() // peuple errors.name
form.submitting.value = true
form.reset()
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
expect(form.errors).toEqual({})
expect(form.submitting.value).toBe(false)
})
})