feat(front) : mapping des erreurs de validation 422 par champ (ERP-101)
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

- 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
This commit is contained in:
2026-06-04 08:24:39 +02:00
parent e85d46a17b
commit 502d1a216b
15 changed files with 910 additions and 212 deletions
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFormErrors } from '../useFormErrors'
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
/**
* Tests du composable `useFormErrors` — pendant front de la regle « le back
* renvoie toutes les violations 422 d'un coup » (ERP-101). Centralise l'etat
* d'erreurs par champ (`Record<propertyPath, message>`) et la dispatch d'une
* erreur API : 422 mappee inline, sinon toast de fallback.
*/
describe('useFormErrors', () => {
beforeEach(() => {
mockToastError.mockReset()
})
/** Fabrique une erreur ofetch avec status + payload. */
function fetchError(status: number, data: unknown) {
return { response: { status, _data: data } }
}
it('demarre sans erreur', () => {
const { errors, hasErrors } = useFormErrors()
expect(errors).toEqual({})
expect(hasErrors.value).toBe(false)
})
it('setServerErrors mappe les violations par champ et retourne true', () => {
const { errors, hasErrors, setServerErrors } = useFormErrors()
const mapped = setServerErrors({
violations: [
{ propertyPath: 'companyName', message: 'Obligatoire.' },
{ propertyPath: 'siren', message: 'Deja utilise.' },
],
})
expect(mapped).toBe(true)
expect(errors).toEqual({ companyName: 'Obligatoire.', siren: 'Deja utilise.' })
expect(hasErrors.value).toBe(true)
})
it('setServerErrors retourne false et ne touche rien sans violation', () => {
const { errors, setServerErrors } = useFormErrors()
expect(setServerErrors({})).toBe(false)
expect(errors).toEqual({})
})
it('setError / clearError / clearErrors manipulent l\'etat finement', () => {
const { errors, setError, clearError, clearErrors } = useFormErrors()
setError('iban', 'IBAN invalide.')
expect(errors.iban).toBe('IBAN invalide.')
clearError('iban')
expect(errors.iban).toBeUndefined()
setError('a', 'x')
setError('b', 'y')
clearErrors()
expect(errors).toEqual({})
})
it('handleApiError : 422 avec violations → mappe inline, pas de toast, retourne true', () => {
const { errors, handleApiError } = useFormErrors()
const handled = handleApiError(
fetchError(422, { violations: [{ propertyPath: 'email', message: 'Invalide.' }] }),
)
expect(handled).toBe(true)
expect(errors.email).toBe('Invalide.')
expect(mockToastError).not.toHaveBeenCalled()
})
it('handleApiError : erreur non-422 → toast de fallback, retourne false', () => {
const { errors, handleApiError } = useFormErrors()
const handled = handleApiError(
fetchError(500, { 'hydra:description': 'Erreur serveur.' }),
{ fallbackMessage: 'Oups.' },
)
expect(handled).toBe(false)
expect(errors).toEqual({})
expect(mockToastError).toHaveBeenCalledTimes(1)
expect(mockToastError.mock.calls[0][0]).toMatchObject({ message: 'Erreur serveur.' })
})
it('handleApiError : 422 sans violation mappable → toast de fallback, retourne false', () => {
const { handleApiError } = useFormErrors()
const handled = handleApiError(fetchError(422, { 'hydra:description': 'Donnees invalides.' }))
expect(handled).toBe(false)
expect(mockToastError).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,112 @@
/**
* Composable d'erreurs de formulaire — convention de mapping erreur→champ pour
* tous les forms du projet (ERP-101).
*
* Le back renvoie TOUTES les violations d'une 422 d'un coup (un `propertyPath`
* + `message` par champ fautif). Ce composable centralise leur affichage
* inline : il tient un `Record<propertyPath, message>` reactif que le template
* branche directement sur la prop `:error` des composants `Malio*` (le nom du
* champ cote front = le `propertyPath` cote back, donc aucun mapping manuel).
*
* Chaque appel cree son propre etat (refs internes a la fonction) — un form =
* une instance, pas de singleton partage.
*
* Convention d'usage : les appels API qui veulent un retour inline doivent
* passer `{ toast: false }` a `useApi` (sinon le toast natif masque le mapping
* fin), puis router l'erreur via `handleApiError`. Pour les collections (1
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
*/
import { computed, reactive } from 'vue'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
/**
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
* + payload) pour eviter de typer toute la lib.
*/
interface ApiFetchError {
response?: {
status?: number
_data?: unknown
}
}
/** Options de `handleApiError`. */
interface HandleApiErrorOptions {
/** Message de toast si l'erreur n'est pas une 422 exploitable. */
fallbackMessage?: string
}
export function useFormErrors() {
const toast = useToast()
// Etat d'erreurs indexe par propertyPath. Reactif : muter une cle suffit a
// rafraichir la prop `:error` du champ correspondant.
const errors = reactive<Record<string, string>>({})
const hasErrors = computed(() => Object.keys(errors).length > 0)
/** Pose une erreur sur un champ. */
function setError(field: string, message: string): void {
errors[field] = message
}
/** Retire l'erreur d'un champ (no-op si absente). */
function clearError(field: string): void {
delete errors[field]
}
/** Vide toutes les erreurs (a appeler en debut de submit). */
function clearErrors(): void {
for (const key of Object.keys(errors)) {
delete errors[key]
}
}
/**
* Mappe les violations 422 d'un payload sur les champs. Retourne true des
* qu'au moins une violation a ete posee, false sinon (payload sans
* violation exploitable).
*/
function setServerErrors(data: unknown): boolean {
const mapped = mapViolationsToRecord(data)
const keys = Object.keys(mapped)
if (keys.length === 0) return false
for (const key of keys) {
errors[key] = mapped[key]
}
return true
}
/**
* Route une erreur API : 422 avec violations exploitables → mapping inline
* (pas de toast, l'erreur s'affiche sous le champ) ; sinon → toast de
* fallback (message serveur extrait, ou `fallbackMessage`).
*
* Retourne true si l'erreur a ete mappee inline, false si fallback toast.
*/
function handleApiError(e: unknown, opts: HandleApiErrorOptions = {}): boolean {
const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 422 && setServerErrors(data)) {
return true
}
const message
= extractApiErrorMessage(data)
|| opts.fallbackMessage
|| 'Une erreur est survenue.'
toast.error({ title: 'Erreur', message })
return false
}
return {
errors,
hasErrors,
setError,
clearError,
clearErrors,
setServerErrors,
handleApiError,
}
}