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
@@ -44,7 +44,8 @@
:options="categoryOptions"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:disabled="readonly"
:readonly="readonly"
:required="true"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
@@ -52,7 +53,8 @@
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:disabled="readonly"
:readonly="readonly"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
@@ -61,6 +63,8 @@
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
@@ -71,8 +75,10 @@
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:disabled="readonly"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
@@ -80,6 +86,8 @@
:model-value="model.city"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
@@ -99,6 +107,8 @@
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
@@ -108,6 +118,8 @@
:model-value="model.street"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
@@ -117,6 +129,7 @@
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
@@ -139,7 +152,7 @@
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:disabled="readonly"
:readonly="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
@@ -151,6 +164,7 @@
:label="t('commercial.clients.form.address.billingEmail')"
:required="true"
:readonly="readonly"
:error="errors?.billingEmail"
@update:model-value="(v: string) => update('billingEmail', v)"
/>
</div>
@@ -183,6 +197,8 @@ const props = defineProps<{
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
@@ -16,24 +16,28 @@
:model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')"
:readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')"
:readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
<MalioInputEmail
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
:readonly="readonly"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
@@ -41,6 +45,7 @@
:label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@@ -52,6 +57,7 @@
:label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
@@ -73,6 +79,8 @@ const props = defineProps<{
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
@@ -74,3 +74,59 @@ describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
expect(values).toContain('8 Boulevard du Port')
})
})
/**
* Stub MalioInputText qui re-expose `label` + `error` recus : permet de cibler
* un champ par son libelle et de verifier l'erreur 422 propagee (ERP-101).
*/
const MalioInputTextProbe = defineComponent({
name: 'MalioInputTextProbe',
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', {
'data-testid': 'addr-text',
'data-label': props.label,
'data-error': props.error,
})
},
})
describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
function mountWithErrors(errors: Record<string, string>) {
return mount(ClientAddressBlock, {
props: {
modelValue: emptyAddress(),
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
errors,
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioInputText: MalioInputTextProbe,
},
},
})
}
it('affiche l\'erreur serveur sur le champ code postal via la prop errors', () => {
const wrapper = mountWithErrors({ postalCode: 'Code postal invalide.' })
const field = wrapper.findAll('[data-testid="addr-text"]').find(
el => el.attributes('data-label') === 'commercial.clients.form.address.postalCode',
)
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
})
})
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyContact } from '~/modules/commercial/types/clientForm'
import ClientContactBlock from '../ClientContactBlock.vue'
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
/**
* Stub d'un champ Malio qui re-expose la prop `error` recue dans un attribut
* data-* : permet de verifier que le bloc propage bien `:errors[champ]` sur le
* bon champ (ERP-101 — mapping erreur 422 par champ, par ligne de collection).
*/
function errorProbe(testid: string) {
return defineComponent({
name: `Probe-${testid}`,
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
},
})
}
function mountBlock(errors?: Record<string, string>) {
return mount(ClientContactBlock, {
props: {
modelValue: emptyContact(),
title: 'Contact 1',
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputPhone: true,
MalioInputText: errorProbe('contact-text'),
MalioInputEmail: errorProbe('contact-email'),
},
},
})
}
describe('ClientContactBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
const wrapper = mountBlock({ email: 'Adresse e-mail invalide.' })
const email = wrapper.find('[data-testid="contact-email"]')
expect(email.attributes('data-error')).toBe('Adresse e-mail invalide.')
})
it('laisse les champs sans erreur quand errors est absent', () => {
const wrapper = mountBlock()
const email = wrapper.find('[data-testid="contact-email"]')
expect(email.attributes('data-error')).toBe('')
})
})