refactor(front) : suites review ERP-101 — i18n libelles toast + factorisation useClientFormErrors
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m49s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m9s

- libelles de toast generiques passes en i18n (errors.title/generic/unknown,
  success.title) dans useFormErrors, useApi et useCategoryForm
- nouveau composable useClientFormErrors : factorise l'etat d'erreurs
  (3 useFormErrors scalaires + 3 tableaux par ligne + mapRowError) partage
  entre clients/new.vue et [id]/edit.vue
- mapRowError retourne un booleen et ne toaste plus : chaque page garde son
  fallback (toast generique en creation, showError en edition)
This commit is contained in:
2026-06-04 10:36:17 +02:00
parent ec0855d870
commit 97fe0b39de
10 changed files with 188 additions and 87 deletions
+6 -2
View File
@@ -228,7 +228,10 @@
}, },
"sites": { "sites": {
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site." "notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
} },
"title": "Erreur",
"generic": "Une erreur est survenue.",
"unknown": "Erreur inconnue."
}, },
"sites": { "sites": {
"selector": { "selector": {
@@ -285,7 +288,8 @@
"success": { "success": {
"auth": { "auth": {
"logout": "Deconnexion reussie" "logout": "Deconnexion reussie"
} },
"title": "Succès"
}, },
"admin": { "admin": {
"roles": { "roles": {
@@ -220,7 +220,7 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès', title: 'success.title',
message: 'admin.categories.toast.created', message: 'admin.categories.toast.created',
}) })
}) })
@@ -302,7 +302,7 @@ describe('useCategoryForm', () => {
// Pas d'erreur inline par champ : l'erreur transverse part en toast. // Pas d'erreur inline par champ : l'erreur transverse part en toast.
expect(form.errors).toEqual({}) expect(form.errors).toEqual({})
expect(mockToastError).toHaveBeenCalledWith({ expect(mockToastError).toHaveBeenCalledWith({
title: 'Erreur', title: 'errors.title',
message: 'Boom server', message: 'Boom server',
}) })
}) })
@@ -378,7 +378,7 @@ describe('useCategoryForm', () => {
await form.submitUpdate(42) await form.submitUpdate(42)
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès', title: 'success.title',
message: 'admin.categories.toast.updated', message: 'admin.categories.toast.updated',
}) })
}) })
@@ -409,7 +409,7 @@ describe('useCategoryForm', () => {
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false }) expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
expect(ok).toBe(true) expect(ok).toBe(true)
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès', title: 'success.title',
message: 'admin.categories.toast.deleted', message: 'admin.categories.toast.deleted',
}) })
}) })
@@ -129,11 +129,11 @@ export function useCategoryForm() {
name: attemptedName, name: attemptedName,
}) })
formErrors.setError('name', duplicateMessage) formErrors.setError('name', duplicateMessage)
toast.error({ title: 'Erreur', message: duplicateMessage }) toast.error({ title: t('errors.title'), message: duplicateMessage })
return true return true
} }
return formErrors.handleApiError(e, { fallbackMessage: 'Une erreur est survenue.' }) return formErrors.handleApiError(e, { fallbackMessage: t('errors.generic') })
} }
/** /**
@@ -150,7 +150,7 @@ export function useCategoryForm() {
toast: false, toast: false,
}) })
toast.success({ toast.success({
title: 'Succès', title: t('success.title'),
message: t('admin.categories.toast.created'), message: t('admin.categories.toast.created'),
}) })
return created return created
@@ -189,7 +189,7 @@ export function useCategoryForm() {
toast: false, toast: false,
}) })
toast.success({ toast.success({
title: 'Succès', title: t('success.title'),
message: t('admin.categories.toast.updated'), message: t('admin.categories.toast.updated'),
}) })
return updated return updated
@@ -215,7 +215,7 @@ export function useCategoryForm() {
try { try {
await api.delete(`/categories/${id}`, {}, { toast: false }) await api.delete(`/categories/${id}`, {}, { toast: false })
toast.success({ toast.success({
title: 'Succès', title: t('success.title'),
message: t('admin.categories.toast.deleted'), message: t('admin.categories.toast.deleted'),
}) })
return true return true
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useClientFormErrors } from '../useClientFormErrors'
// useFormErrors (auto-import) expose l'implementation reelle ; elle consomme
// useToast + useI18n, stubbes ici.
vi.stubGlobal('useToast', () => ({ error: vi.fn(), success: vi.fn() }))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useFormErrors', useFormErrors)
/**
* Tests du composable partage `useClientFormErrors` — factorisation du cablage
* d'erreurs des ecrans client (creation/edition), suggestion de revue ERP-101.
* `mapRowError` ne toaste plus : il retourne un booleen et chaque page garde son
* propre fallback (toast.error en creation, showError en edition).
*/
describe('useClientFormErrors', () => {
it('expose les 3 etats scalaires (vides) et les 3 tableaux d\'erreurs par ligne', () => {
const f = useClientFormErrors()
expect(f.mainErrors.errors).toEqual({})
expect(f.informationErrors.errors).toEqual({})
expect(f.accountingErrors.errors).toEqual({})
expect(f.contactErrors.value).toEqual([])
expect(f.addressErrors.value).toEqual([])
expect(f.ribErrors.value).toEqual([])
})
it('mapRowError mappe une 422 sur target[index] et retourne true', () => {
const f = useClientFormErrors()
const error = {
response: {
status: 422,
_data: { violations: [{ propertyPath: 'email', message: 'Adresse invalide.' }] },
},
}
const mapped = f.mapRowError(error, f.contactErrors, 0)
expect(mapped).toBe(true)
expect(f.contactErrors.value[0]).toEqual({ email: 'Adresse invalide.' })
})
it('mapRowError retourne false et ne touche pas la cible pour une erreur non-422', () => {
const f = useClientFormErrors()
const error = { response: { status: 500, _data: {} } }
expect(f.mapRowError(error, f.ribErrors, 0)).toBe(false)
expect(f.ribErrors.value[0]).toBeUndefined()
})
it('mapRowError retourne false pour une 422 sans violation exploitable', () => {
const f = useClientFormErrors()
const error = { response: { status: 422, _data: { 'hydra:description': 'Donnees invalides.' } } }
expect(f.mapRowError(error, f.addressErrors, 0)).toBe(false)
expect(f.addressErrors.value[0]).toBeUndefined()
})
})
@@ -0,0 +1,55 @@
/**
* Composable d'erreurs partage des ecrans client (creation + edition, M1
* Commercial). Factorise le cablage identique entre `clients/new.vue` et
* `clients/[id]/edit.vue` (suggestion de revue ERP-101) :
* - un `useFormErrors` par groupe scalaire (Principal / Information /
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
* adresses / RIB), aligne sur l'index du `v-for`.
*
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`
* (toast generique en creation, `showError` en edition) sans imposer un
* comportement commun.
*/
import { ref, type Ref } from 'vue'
import { mapViolationsToRecord } from '~/shared/utils/api'
export function useClientFormErrors() {
const mainErrors = useFormErrors()
const informationErrors = useFormErrors()
const accountingErrors = useFormErrors()
const contactErrors = ref<Record<string, string>[]>([])
const addressErrors = ref<Record<string, string>[]>([])
const ribErrors = ref<Record<string, string>[]>([])
/**
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
* 422 avec violations exploitables → erreurs inline sous les champs de la
* ligne + retourne true. Sinon → ne touche pas la cible et retourne false
* (le caller decide du fallback toast).
*/
function mapRowError(
error: unknown,
target: Ref<Record<string, string>[]>,
index: number,
): boolean {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
return true
}
return false
}
return {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
}
}
@@ -379,9 +379,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, type Ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
@@ -424,7 +425,7 @@ import {
type ContactFormDraft, type ContactFormDraft,
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -615,34 +616,20 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
} }
// ── Erreurs de validation par champ (ERP-101) ─────────────────────────────── // ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Un `useFormErrors` par groupe scalaire (submit independant) + un tableau // Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
// d'erreurs par ligne pour chaque collection (aligne sur l'index visible). // un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
const mainErrors = useFormErrors() // chaque collection (aligne sur l'index visible). `mapRowError` mappe une 422
const informationErrors = useFormErrors() // inline et retourne true ; il ne toaste pas, le fallback `showError` reste
const accountingErrors = useFormErrors() // local a l'edition (cf. catch des submits de collection).
const contactErrors = ref<Record<string, string>[]>([]) const {
const addressErrors = ref<Record<string, string>[]>([]) mainErrors,
const ribErrors = ref<Record<string, string>[]>([]) informationErrors,
accountingErrors,
/** contactErrors,
* Mappe l'erreur d'une ligne de collection sur le tableau d'erreurs cible (par addressErrors,
* index). 422 exploitable → erreurs inline sous les champs de la ligne ; sinon ribErrors,
* → toast de fallback. Renvoie true si mappee inline. mapRowError,
*/ } = useClientFormErrors()
function mapRowError(
error: unknown,
target: Ref<Record<string, string>[]>,
index: number,
): boolean {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
return true
}
showError(error)
return false
}
// ── Bloc principal ─────────────────────────────────────────────────────────── // ── Bloc principal ───────────────────────────────────────────────────────────
const isMainValid = computed(() => { const isMainValid = computed(() => {
@@ -775,7 +762,9 @@ async function submitContacts(): Promise<void> {
} }
catch (error) { catch (error) {
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe. // 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe.
mapRowError(error, contactErrors, index) if (!mapRowError(error, contactErrors, index)) {
showError(error)
}
return return
} }
} }
@@ -852,7 +841,9 @@ async function submitAddresses(): Promise<void> {
} }
} }
catch (error) { catch (error) {
mapRowError(error, addressErrors, index) if (!mapRowError(error, addressErrors, index)) {
showError(error)
}
return return
} }
} }
@@ -949,7 +940,9 @@ async function submitAccounting(): Promise<void> {
} }
} }
catch (error) { catch (error) {
mapRowError(error, ribErrors, index) if (!mapRowError(error, ribErrors, index)) {
showError(error)
}
return return
} }
} }
@@ -376,8 +376,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch, type Ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
buildClientFormTabKeys, buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS, CLIENT_FORM_PLACEHOLDER_TABS,
@@ -395,7 +396,7 @@ import {
type ContactFormDraft, type ContactFormDraft,
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -428,36 +429,20 @@ function apiErrorMessage(error: unknown): string {
} }
// ── Erreurs de validation par champ (ERP-101) ─────────────────────────────── // ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Un `useFormErrors` par groupe scalaire (submit independant) : les violations // Etat d'erreurs factorise entre creation et edition (cf. useClientFormErrors) :
// 422 du serveur s'affichent sous le champ concerne (prop `:error`) au lieu d'un // un `useFormErrors` par groupe scalaire (Principal / Information / Comptabilite)
// simple toast. Les collections (contacts/adresses/RIB) portent une erreur PAR // + un tableau d'erreurs par ligne pour chaque collection (contacts/adresses/RIB).
// LIGNE via des tableaux alignes sur l'index, peuples par `mapRowError`. // `mapRowError` mappe une 422 inline et retourne true ; il ne toaste pas, le
const mainErrors = useFormErrors() // fallback reste local a la creation (cf. catch des submits de collection).
const informationErrors = useFormErrors() const {
const accountingErrors = useFormErrors() mainErrors,
const contactErrors = ref<Record<string, string>[]>([]) informationErrors,
const addressErrors = ref<Record<string, string>[]>([]) accountingErrors,
const ribErrors = ref<Record<string, string>[]>([]) contactErrors,
addressErrors,
/** ribErrors,
* Mappe l'erreur d'une ligne de collection sur le tableau d'erreurs cible (par mapRowError,
* index). 422 avec violations exploitables → erreurs inline sous les champs de } = useClientFormErrors()
* la ligne ; sinon → toast de fallback. Renvoie true si mappee inline.
*/
function mapRowError(
error: unknown,
target: Ref<Record<string, string>[]>,
index: number,
): boolean {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
return true
}
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
return false
}
useHead({ title: t('commercial.clients.form.title') }) useHead({ title: t('commercial.clients.form.title') })
@@ -724,7 +709,9 @@ async function submitContacts(): Promise<void> {
catch (error) { catch (error) {
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe // 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe
// a la premiere ligne en echec (les suivantes ne sont pas tentees). // a la premiere ligne en echec (les suivantes ne sont pas tentees).
mapRowError(error, contactErrors, index) if (!mapRowError(error, contactErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return return
} }
} }
@@ -830,7 +817,9 @@ async function submitAddresses(): Promise<void> {
} }
} }
catch (error) { catch (error) {
mapRowError(error, addressErrors, index) if (!mapRowError(error, addressErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return return
} }
} }
@@ -941,7 +930,9 @@ async function submitAccounting(): Promise<void> {
} }
} }
catch (error) { catch (error) {
mapRowError(error, ribErrors, index) if (!mapRowError(error, ribErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return return
} }
} }
@@ -3,6 +3,8 @@ import { useFormErrors } from '../useFormErrors'
const mockToastError = vi.hoisted(() => vi.fn()) const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() })) vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
// useI18n stub : renvoie la cle telle quelle (pour asserter dessus).
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
/** /**
* Tests du composable `useFormErrors` — pendant front de la regle « le back * Tests du composable `useFormErrors` — pendant front de la regle « le back
@@ -76,7 +78,8 @@ describe('useFormErrors', () => {
expect(handled).toBe(false) expect(handled).toBe(false)
expect(errors).toEqual({}) expect(errors).toEqual({})
expect(mockToastError).toHaveBeenCalledTimes(1) expect(mockToastError).toHaveBeenCalledTimes(1)
expect(mockToastError.mock.calls[0][0]).toMatchObject({ message: 'Erreur serveur.' }) // Titre via i18n (cle renvoyee telle quelle par le stub).
expect(mockToastError.mock.calls[0][0]).toMatchObject({ title: 'errors.title', message: 'Erreur serveur.' })
}) })
it('handleApiError : 422 sans violation mappable → toast de fallback, retourne false', () => { it('handleApiError : 422 sans violation mappable → toast de fallback, retourne false', () => {
+5 -5
View File
@@ -44,7 +44,7 @@ export function useApi(): ApiClient {
const data = responseData ?? (error as FetchError)?.data const data = responseData ?? (error as FetchError)?.data
const msg = extractApiErrorMessage(data) const msg = extractApiErrorMessage(data)
if (msg) return msg if (msg) return msg
return (error as FetchError)?.message ?? 'Erreur inconnue.' return (error as FetchError)?.message ?? t('errors.unknown')
} }
const methodErrorKeys: Record<string, string> = { const methodErrorKeys: Record<string, string> = {
@@ -76,7 +76,7 @@ export function useApi(): ApiClient {
if (successMessage) { if (successMessage) {
toast.success({ toast.success({
title: 'Succes', title: t('success.title'),
message: successMessage message: successMessage
}) })
} }
@@ -98,10 +98,10 @@ export function useApi(): ApiClient {
apiOptions?.toastErrorMessage || apiOptions?.toastErrorMessage ||
errorMessage || errorMessage ||
extractedMessage || extractedMessage ||
'Une erreur est survenue.' t('errors.generic')
toast.error({ toast.error({
title: apiOptions?.toastTitle ?? 'Erreur', title: apiOptions?.toastTitle ?? t('errors.title'),
message message
}) })
} }
@@ -139,7 +139,7 @@ export function useApi(): ApiClient {
'Une erreur est survenue.' 'Une erreur est survenue.'
toast.error({ toast.error({
title: apiOptions?.toastTitle ?? 'Erreur', title: apiOptions?.toastTitle ?? t('errors.title'),
message message
}) })
} }
+3 -2
View File
@@ -38,6 +38,7 @@ interface HandleApiErrorOptions {
export function useFormErrors() { export function useFormErrors() {
const toast = useToast() const toast = useToast()
const { t } = useI18n()
// Etat d'erreurs indexe par propertyPath. Reactif : muter une cle suffit a // Etat d'erreurs indexe par propertyPath. Reactif : muter une cle suffit a
// rafraichir la prop `:error` du champ correspondant. // rafraichir la prop `:error` du champ correspondant.
@@ -95,8 +96,8 @@ export function useFormErrors() {
const message const message
= extractApiErrorMessage(data) = extractApiErrorMessage(data)
|| opts.fallbackMessage || opts.fallbackMessage
|| 'Une erreur est survenue.' || t('errors.generic')
toast.error({ title: 'Erreur', message }) toast.error({ title: t('errors.title'), message })
return false return false
} }