refactor(catalog) : address Matthieu review on composables (constant + shared helpers)
- 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.
This commit is contained in:
@@ -17,6 +17,14 @@ import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
|||||||
import type { HydraCollection } from '~/shared/utils/api'
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
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
|
// State singleton — partage entre tous les composants qui appellent le
|
||||||
// composable dans la meme session. Les refs sont declarees au niveau module
|
// composable dans la meme session. Les refs sont declarees au niveau module
|
||||||
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
|
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
|
||||||
@@ -61,7 +69,7 @@ export function useCategoriesAdmin() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const query: Record<string, unknown> = { itemsPerPage: 999 }
|
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
|
||||||
if (includeDeleted) {
|
if (includeDeleted) {
|
||||||
query.includeDeleted = 'true'
|
query.includeDeleted = 'true'
|
||||||
}
|
}
|
||||||
@@ -92,7 +100,7 @@ export function useCategoriesAdmin() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.get<HydraCollection<CategoryType>>(
|
const data = await api.get<HydraCollection<CategoryType>>(
|
||||||
'/category_types',
|
'/category_types',
|
||||||
{ itemsPerPage: 999 },
|
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
types.value = data.member ?? []
|
types.value = data.member ?? []
|
||||||
|
|||||||
@@ -19,16 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { Category } from '~/modules/catalog/types/category'
|
import type { Category } from '~/modules/catalog/types/category'
|
||||||
|
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
|
* 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
|
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
|
||||||
* retourne `violations: [{ propertyPath, message }]` (ou
|
* Renvoie true des qu'au moins une violation a ete posee — false sinon
|
||||||
* `hydra:violations` selon negociation). On ne mappe que les chemins
|
* (payload sans violations exploitables, ou tous les `propertyPath` hors
|
||||||
* connus (`name`, `categoryType`) ; le reste fallback en erreur globale.
|
* 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 {
|
function mapServerViolations(data: unknown): boolean {
|
||||||
if (!data || typeof data !== 'object') return false
|
const violations = extractApiViolations(data)
|
||||||
const record = data as Record<string, unknown>
|
if (violations.length === 0) return false
|
||||||
const rawViolations = record.violations ?? record['hydra:violations']
|
|
||||||
if (!Array.isArray(rawViolations)) return false
|
|
||||||
|
|
||||||
let mapped = false
|
let mapped = false
|
||||||
for (const v of rawViolations as ApiViolation[]) {
|
for (const v of violations) {
|
||||||
if (!v || typeof v !== 'object') continue
|
if (v.propertyPath === 'name') {
|
||||||
const path = String(v.propertyPath ?? '')
|
errors.value.name = v.message
|
||||||
const message = String(v.message ?? '')
|
|
||||||
if (path === 'name') {
|
|
||||||
errors.value.name = message
|
|
||||||
mapped = true
|
mapped = true
|
||||||
} else if (path === 'categoryType') {
|
} else if (v.propertyPath === 'categoryType') {
|
||||||
errors.value.categoryType = message
|
errors.value.categoryType = v.message
|
||||||
mapped = true
|
mapped = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return mapped
|
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<string, unknown>
|
|
||||||
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
|
* Traite une erreur API : mappe selon le status, declenche les toasts
|
||||||
* appropries. Centralise la logique entre create/update.
|
* appropries. Centralise la logique entre create/update.
|
||||||
*
|
*
|
||||||
* Retourne true si l'erreur a ete reconnue et traitee, false sinon
|
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
|
||||||
* (utile pour les tests).
|
* 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 {
|
function handleApiError(e: unknown, attemptedName: string): boolean {
|
||||||
const status = (e as ApiFetchError)?.response?.status
|
const status = (e as ApiFetchError)?.response?.status
|
||||||
const data = (e as ApiFetchError)?.response?._data
|
const data = (e as ApiFetchError)?.response?._data
|
||||||
|
|
||||||
if (status === 409) {
|
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', {
|
const duplicateMessage = t('admin.categories.toast.duplicate', {
|
||||||
name: attemptedName,
|
name: attemptedName,
|
||||||
})
|
})
|
||||||
@@ -204,12 +183,10 @@ export function useCategoryForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 422 && mapServerViolations(data)) {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const extracted = extractErrorMessage(data)
|
const extracted = extractApiErrorMessage(data)
|
||||||
errors.value._global = extracted || 'Une erreur est survenue.'
|
errors.value._global = extracted || 'Une erreur est survenue.'
|
||||||
toast.error({
|
toast.error({
|
||||||
title: 'Erreur',
|
title: 'Erreur',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FetchOptions , FetchError } from 'ofetch'
|
import type { FetchOptions , FetchError } from 'ofetch'
|
||||||
import { $fetch } from 'ofetch'
|
import { $fetch } from 'ofetch'
|
||||||
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
export type AnyObject = Record<string, unknown>
|
export type AnyObject = Record<string, unknown>
|
||||||
|
|
||||||
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
|
|||||||
|
|
||||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||||
const data = responseData ?? (error as FetchError)?.data
|
const data = responseData ?? (error as FetchError)?.data
|
||||||
|
const msg = extractApiErrorMessage(data)
|
||||||
if (typeof data === 'string') {
|
if (msg) return msg
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data && typeof data === 'object') {
|
|
||||||
const record = data as Record<string, unknown>
|
|
||||||
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) ||
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
|
|||||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||||
return collection.member ?? []
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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)
|
||||||
|
?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user