From 9ed42e9d912c9a15e970835816d255f37e8dcc38 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 29 May 2026 10:46:24 +0200 Subject: [PATCH] refactor(catalog) : address Matthieu review on composables (constant + shared helpers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCategoriesAdmin : extract `HYDRA_NO_PAGINATION = 999` to a named constant (was duplicated between fetchAll and fetchTypes) + comment the post-M0 server-pagination debt. - useCategoryForm + useApi + shared/utils/api : drop the local copy of `extractErrorMessage` in useCategoryForm (it was duplicating the one in useApi), and centralize Hydra error / violation extraction in `shared/utils/api.ts` via two new helpers : - extractApiErrorMessage(data) : tries hydra:description, detail, description, message, error, title, hydra:title — used by both useApi.onResponseError and useCategoryForm.handleApiError. - extractApiViolations(data) : returns the ApiPlatform 422 violations as a typed array (handles `violations` and `hydra:violations`), letting each caller map them onto its own fields. useCategoryForm now uses this helper instead of an inline loop, ready for the next form drawer to reuse. handleApiError keeps a manual fallback toast on non-409/422 errors : the native useApi toast is disabled by design (`toast: false`) to allow fine-grained 409/422 mapping, so the catch-all branch must re-emit one or a 500 would be silent. No behavior change — 43/43 Vitest tests still pass. --- .../catalog/composables/useCategoriesAdmin.ts | 12 ++- .../catalog/composables/useCategoryForm.ts | 75 +++++++------------ frontend/shared/composables/useApi.ts | 21 +----- frontend/shared/utils/api.ts | 59 +++++++++++++++ 4 files changed, 98 insertions(+), 69 deletions(-) diff --git a/frontend/modules/catalog/composables/useCategoriesAdmin.ts b/frontend/modules/catalog/composables/useCategoriesAdmin.ts index 2e9b430..181326c 100644 --- a/frontend/modules/catalog/composables/useCategoriesAdmin.ts +++ b/frontend/modules/catalog/composables/useCategoriesAdmin.ts @@ -17,6 +17,14 @@ import type { Category, CategoryType } from '~/modules/catalog/types/category' import type { HydraCollection } from '~/shared/utils/api' import { onAuthSessionCleared } from '~/shared/stores/auth' +/** + * Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie + * cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer + * toute la liste en un coup. A basculer en pagination serveur quand la + * volumetrie reelle depassera ce plafond — meme pattern que sites.vue. + */ +const HYDRA_NO_PAGINATION = 999 + // State singleton — partage entre tous les composants qui appellent le // composable dans la meme session. Les refs sont declarees au niveau module // (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle @@ -61,7 +69,7 @@ export function useCategoriesAdmin() { loading.value = true error.value = null try { - const query: Record = { itemsPerPage: 999 } + const query: Record = { itemsPerPage: HYDRA_NO_PAGINATION } if (includeDeleted) { query.includeDeleted = 'true' } @@ -92,7 +100,7 @@ export function useCategoriesAdmin() { try { const data = await api.get>( '/category_types', - { itemsPerPage: 999 }, + { itemsPerPage: HYDRA_NO_PAGINATION }, { toast: false }, ) types.value = data.member ?? [] diff --git a/frontend/modules/catalog/composables/useCategoryForm.ts b/frontend/modules/catalog/composables/useCategoryForm.ts index 06f94eb..36e928e 100644 --- a/frontend/modules/catalog/composables/useCategoryForm.ts +++ b/frontend/modules/catalog/composables/useCategoryForm.ts @@ -19,16 +19,7 @@ */ import { computed, ref } from 'vue' import type { Category } from '~/modules/catalog/types/category' - -/** - * Forme des violations renvoyees par API Platform 4 en 422. La cle peut etre - * `violations` ou `hydra:violations` selon la negociation de format — on - * tente les deux. - */ -interface ApiViolation { - propertyPath?: string - message?: string -} +import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api' /** * Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici @@ -136,62 +127,50 @@ export function useCategoryForm() { } /** - * Mappe une reponse 422 API Platform sur le state `errors`. API Platform 4 - * retourne `violations: [{ propertyPath, message }]` (ou - * `hydra:violations` selon negociation). On ne mappe que les chemins - * connus (`name`, `categoryType`) ; le reste fallback en erreur globale. + * Mappe les violations 422 d'API Platform sur les champs du formulaire. + * Renvoie true des qu'au moins une violation a ete posee — false sinon + * (payload sans violations exploitables, ou tous les `propertyPath` hors + * du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`) + * est centralisee dans `shared/utils/api.ts` pour rester reutilisable + * sur les futurs drawers de formulaire. */ function mapServerViolations(data: unknown): boolean { - if (!data || typeof data !== 'object') return false - const record = data as Record - const rawViolations = record.violations ?? record['hydra:violations'] - if (!Array.isArray(rawViolations)) return false - + const violations = extractApiViolations(data) + if (violations.length === 0) return false let mapped = false - for (const v of rawViolations as ApiViolation[]) { - if (!v || typeof v !== 'object') continue - const path = String(v.propertyPath ?? '') - const message = String(v.message ?? '') - if (path === 'name') { - errors.value.name = message + for (const v of violations) { + if (v.propertyPath === 'name') { + errors.value.name = v.message mapped = true - } else if (path === 'categoryType') { - errors.value.categoryType = message + } else if (v.propertyPath === 'categoryType') { + errors.value.categoryType = v.message mapped = true } } return mapped } - /** - * Extrait un message d'erreur lisible depuis un payload Hydra (champs - * `hydra:description`, `detail`, `description`). - */ - function extractErrorMessage(data: unknown): string { - if (!data || typeof data !== 'object') return '' - const record = data as Record - return ( - (record['hydra:description'] as string) - ?? (record.detail as string) - ?? (record.description as string) - ?? '' - ) - } - /** * Traite une erreur API : mappe selon le status, declenche les toasts * appropries. Centralise la logique entre create/update. * - * Retourne true si l'erreur a ete reconnue et traitee, false sinon - * (utile pour les tests). + * - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut + * le nom soumis. + * - 422 : tentative de mapping fin via les violations API Platform — si au + * moins une violation est mappee, pas de toast (erreur affichee inline + * sous le champ concerne). + * - autre : message global + toast generique. Le toast natif d'useApi + * est desactive (`toast: false`) pour permettre ce mapping fin ; il faut + * donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse. + * + * Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes), + * false sinon (fallback generique). */ function handleApiError(e: unknown, attemptedName: string): boolean { const status = (e as ApiFetchError)?.response?.status const data = (e as ApiFetchError)?.response?._data if (status === 409) { - // RG-1.07 — doublon (name, categoryType). Toast custom + - // erreur mappee sur le champ name (origine du conflit). const duplicateMessage = t('admin.categories.toast.duplicate', { name: attemptedName, }) @@ -204,12 +183,10 @@ export function useCategoryForm() { } if (status === 422 && mapServerViolations(data)) { - // Violations mappees sur les champs — pas de toast, l'utilisateur - // voit l'erreur directement sous le champ concerne. return true } - const extracted = extractErrorMessage(data) + const extracted = extractApiErrorMessage(data) errors.value._global = extracted || 'Une erreur est survenue.' toast.error({ title: 'Erreur', diff --git a/frontend/shared/composables/useApi.ts b/frontend/shared/composables/useApi.ts index 344a6f5..b51aaa0 100644 --- a/frontend/shared/composables/useApi.ts +++ b/frontend/shared/composables/useApi.ts @@ -1,5 +1,6 @@ import type { FetchOptions , FetchError } from 'ofetch' import { $fetch } from 'ofetch' +import { extractApiErrorMessage } from '~/shared/utils/api' export type AnyObject = Record @@ -41,24 +42,8 @@ export function useApi(): ApiClient { function extractErrorMessage(error: unknown, responseData?: unknown): string { const data = responseData ?? (error as FetchError)?.data - - if (typeof data === 'string') { - return data - } - - if (data && typeof data === 'object') { - const record = data as Record - return ( - (record['hydra:description'] as string) || - (record.detail as string) || - (record.message as string) || - (record.error as string) || - (record.title as string) || - (record['hydra:title'] as string) || - '' - ) - } - + const msg = extractApiErrorMessage(data) + if (msg) return msg return (error as FetchError)?.message ?? 'Erreur inconnue.' } diff --git a/frontend/shared/utils/api.ts b/frontend/shared/utils/api.ts index 8e57001..b8f24f6 100644 --- a/frontend/shared/utils/api.ts +++ b/frontend/shared/utils/api.ts @@ -31,3 +31,62 @@ export interface HydraCollection { export function extractHydraMembers(collection: HydraCollection): T[] { return collection.member ?? [] } + +/** + * Une violation de contrainte API Platform (reponse 422). Le `propertyPath` + * pointe le champ concerne, `message` est le libelle a afficher. + */ +export interface ApiViolation { + propertyPath: string + message: string +} + +/** + * Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte + * les deux formats de negociation (`violations` ou `hydra:violations`) et + * renvoie un tableau vide si le payload n'en contient pas d'exploitables. + * + * Utilise par useCategoryForm et tout futur composable de formulaire qui + * doit mapper les violations serveur sur ses champs. + */ +export function extractApiViolations(data: unknown): ApiViolation[] { + if (!data || typeof data !== 'object') return [] + const record = data as Record + const raw = record.violations ?? record['hydra:violations'] + if (!Array.isArray(raw)) return [] + const out: ApiViolation[] = [] + for (const v of raw) { + if (!v || typeof v !== 'object') continue + const obj = v as Record + out.push({ + propertyPath: String(obj.propertyPath ?? ''), + message: String(obj.message ?? ''), + }) + } + return out +} + +/** + * Extrait un message d'erreur lisible depuis un payload Hydra / JSON + * d'erreur API Platform. Essaie les champs courants dans l'ordre : + * `hydra:description` → `detail` → `description` → `message` → `error` → + * `title` → `hydra:title`. Renvoie '' si rien d'exploitable. + * + * Si `data` est une string, la renvoie telle quelle (cas des erreurs + * Symfony en text/plain ou des messages bruts). + */ +export function extractApiErrorMessage(data: unknown): string { + if (typeof data === 'string') return data + if (!data || typeof data !== 'object') return '' + const record = data as Record + return ( + (record['hydra:description'] as string) + ?? (record.detail as string) + ?? (record.description as string) + ?? (record.message as string) + ?? (record.error as string) + ?? (record.title as string) + ?? (record['hydra:title'] as string) + ?? '' + ) +}