[ERP-50] Implémenter les composables useCategoriesAdmin et useCategoryForm #25

Merged
tristan merged 2 commits from feature/ERP-50-0-8-frontend-m-implementer-les-composables-usecate into develop 2026-05-29 09:18:30 +00:00
4 changed files with 98 additions and 69 deletions
Showing only changes of commit 62e1b019a1 - Show all commits
@@ -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
Outdated
Review

error est peuple par fetchAll/fetchTypes et exporte, mais consomme nulle part (ni la page ni le drawer ne le destructurent). State mort — et c'est precisement le canal qui aurait du porter l'echec de chargement des types (cf. mon commentaire sur le drawer). Soit on le branche cote UI, soit on le retire.

`error` est peuple par fetchAll/fetchTypes et exporte, mais consomme nulle part (ni la page ni le drawer ne le destructurent). State mort — et c'est precisement le canal qui aurait du porter l'echec de chargement des types (cf. mon commentaire sur le drawer). Soit on le branche cote UI, soit on le retire.
// 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<string, unknown> = { itemsPerPage: 999 }
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
if (includeDeleted) {
query.includeDeleted = 'true'
}
1
@@ -92,7 +100,7 @@ export function useCategoriesAdmin() {
try {
const data = await api.get<HydraCollection<CategoryType>>(
'/category_types',
{ itemsPerPage: 999 },
{ itemsPerPage: HYDRA_NO_PAGINATION },
{ toast: false },
)
types.value = data.member ?? []
@@ -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<string, unknown>
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<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
* 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
Outdated
Review

extractErrorMessage est reimplemente alors que useApi en a deja une version plus complete (hydra:description, detail, message, error, title, hydra:title + fallback FetchError.message). Ici on ne couvre que hydra:description / detail / description : on rate notamment message et title, donc certaines 500 afficheront le fallback generique 'Une erreur est survenue.' la ou useApi aurait sorti un vrai message. Le helper de useApi n'est pas exporte aujourd'hui : autant l'exposer et le partager plutot que de maintenir une 2e version divergente.

extractErrorMessage est reimplemente alors que useApi en a deja une version plus complete (hydra:description, detail, message, error, title, hydra:title + fallback FetchError.message). Ici on ne couvre que hydra:description / detail / description : on rate notamment `message` et `title`, donc certaines 500 afficheront le fallback generique 'Une erreur est survenue.' la ou useApi aurait sorti un vrai message. Le helper de useApi n'est pas exporte aujourd'hui : autant l'exposer et le partager plutot que de maintenir une 2e version divergente.
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
}
Outdated
Review

Altitude : handleApiError + mapServerViolations (violations vs hydra:violations, strategie 409/422/global) sont transverses et vont se repeter dans chaque composable de form (clients, produits...). Les dupliquer par module garantit le drift — c'est exactement ce qui arrive deja avec extractErrorMessage juste au-dessus. Leur place est dans frontend/shared (un useFormErrors() ou un helper dans utils/api.ts), parametre par la liste des champs connus. OK pour ce M0 mono-form, mais a faire avant que le pattern se duplique.

Altitude : handleApiError + mapServerViolations (violations vs hydra:violations, strategie 409/422/global) sont transverses et vont se repeter dans chaque composable de form (clients, produits...). Les dupliquer par module garantit le drift — c'est exactement ce qui arrive deja avec extractErrorMessage juste au-dessus. Leur place est dans frontend/shared (un useFormErrors() ou un helper dans utils/api.ts), parametre par la liste des champs connus. OK pour ce M0 mono-form, mais a faire avant que le pattern se duplique.
const extracted = extractErrorMessage(data)
const extracted = extractApiErrorMessage(data)
errors.value._global = extracted || 'Une erreur est survenue.'
toast.error({
title: 'Erreur',
1
+3 -18
View File
@@ -1,5 +1,6 @@
import type { FetchOptions , FetchError } from 'ofetch'
import { $fetch } from 'ofetch'
import { extractApiErrorMessage } from '~/shared/utils/api'
export type AnyObject = Record<string, unknown>
@@ -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<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) ||
''
)
}
const msg = extractApiErrorMessage(data)
if (msg) return msg
return (error as FetchError)?.message ?? 'Erreur inconnue.'
}
+59
View File
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
export function extractHydraMembers<T>(collection: HydraCollection<T>): 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<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)
?? ''
)
}