From 58589e93d02248de531c6fbc6eb0a0b6fea371f0 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 29 May 2026 09:18:29 +0000 Subject: [PATCH] =?UTF-8?q?[ERP-50]=20Impl=C3=A9menter=20les=20composables?= =?UTF-8?q?=20useCategoriesAdmin=20et=20useCategoryForm=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lien Lesstime : #50 ## Résumé Refacto : extraction de la logique fetch/CRUD inline de la page categories (ERP-49) vers deux composables dédiés, conformément au pattern Starseed (useSidebar / useModules). - **useCategoriesAdmin** : singleton state (`categories` + `types` + `loading` + `error`). Pré-chargement des types au mount de la page (au lieu d'un fetch par ouverture du drawer). Reset au logout via `onAuthSessionCleared` + appel explicite dans `logout.vue`. - **useCategoryForm** : state local par form (pas singleton, contrairement à `useCategoriesAdmin`). Valide côté client en miroir des RG back (RG-1.02 / RG-1.04 / RG-1.05), mappe les erreurs 409 (RG-1.07 doublon) et 422 (violations API Platform) sur les bons champs. `submitCreate` / `submitUpdate` / `submitDelete` renvoient la ressource ou `null` pour découpler la décision de fermeture du drawer. La page et le drawer deviennent purement présentationnels — aucune régression UX attendue (mêmes validations, mêmes toasts, même bascule view → edit via `isDirty` exposé par le composable). ## Décisions - `useCategoriesAdmin` porte aussi les types (`fetchTypes`), pas seulement `categories` — sinon le drawer continuerait à fetcher tout seul et la refacto n'aurait rien centralisé. - `buildCreatePayload` retourne `Record` (pas `CategoryCreateInput`) car la signature `useApi.post(body: AnyObject)` n'accepte pas les types stricts (variance TS). - Reset au logout : double mécanisme conservé (auto via `onAuthSessionCleared` pour 401, explicite dans `logout.vue` pour logout volontaire — pattern existant Starseed). ## Tests - `npx nuxi typecheck` ✓ 0 erreur nouvelle (1 erreur pré-existante sur `modules/catalog/nuxt.config.ts` héritée d'ERP-49) - `make nuxt-test` ✓ 43/43, 0 régression - PHPUnit ✓ 311/311 (pre-commit) - Manuel navigateur : à valider (cahier de test consigné dans Lesstime #50) ## ⚠ Note d'intégration La branche contient encore les 3 commits ERP-49 (`4046910`, `216f388`, `934a12b`) car elle a été créée depuis la branche ERP-49 avant son merge sur develop. Selon l'ordre de merge : soit ERP-49 est mergée d'abord (cette MR ne contiendra plus que le commit ERP-50 après rebase auto), soit cette MR embarque tout l'historique catalog. Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/25 Co-authored-by: tristan Co-committed-by: tristan --- .../catalog/components/CategoryDrawer.vue | 270 +++------------ .../catalog/composables/useCategoriesAdmin.ts | 134 ++++++++ .../catalog/composables/useCategoryForm.ts | 319 ++++++++++++++++++ .../catalog/pages/admin/categories.vue | 66 ++-- frontend/modules/core/pages/logout.vue | 2 + frontend/shared/composables/useApi.ts | 21 +- frontend/shared/utils/api.ts | 59 ++++ 7 files changed, 575 insertions(+), 296 deletions(-) create mode 100644 frontend/modules/catalog/composables/useCategoriesAdmin.ts create mode 100644 frontend/modules/catalog/composables/useCategoryForm.ts diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue index 73991f0..d780808 100644 --- a/frontend/modules/catalog/components/CategoryDrawer.vue +++ b/frontend/modules/catalog/components/CategoryDrawer.vue @@ -16,29 +16,30 @@ + number (categoryType id) ; conversion en IRI au moment du save + par le composable useCategoryForm. --> -

- {{ errors._global }} +

+ {{ form.errors.value._global }}

@@ -66,7 +67,7 @@ :label="t('common.save')" variant="primary" button-class="w-[150px]" - :disabled="saving || loadingTypes" + :disabled="form.submitting.value || loadingTypes" @click="handleSave" /> @@ -74,12 +75,14 @@ diff --git a/frontend/modules/catalog/composables/useCategoriesAdmin.ts b/frontend/modules/catalog/composables/useCategoriesAdmin.ts new file mode 100644 index 0000000..181326c --- /dev/null +++ b/frontend/modules/catalog/composables/useCategoriesAdmin.ts @@ -0,0 +1,134 @@ +/** + * Composable d'administration des categories (M0 — Gestion des categories). + * + * Centralise le chargement et le state des deux ressources lues par la page + * `/admin/categories` : la liste des categories et le referentiel + * CategoryType (utilise dans le select du drawer). + * + * State singleton au niveau module (meme convention que `useSidebar` / + * `useModules` / `useAuditLog`) : reset automatique au logout via + * `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables + * avec state singleton doivent etre reinitialises au logout »), et reset + * explicite expose via `resetCategoriesAdmin()` appele depuis + * `modules/core/pages/logout.vue`. + */ +import { ref } from 'vue' +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 +// instance soit creee a chaque appel. +const categories = ref([]) +const types = ref([]) +const loading = ref(false) +const loadingTypes = ref(false) +const error = ref(null) + +function resetCategoriesAdminState(): void { + categories.value = [] + types.value = [] + loading.value = false + loadingTypes.value = false + error.value = null +} + +// Auto-enregistrement singleton : purge le state sur 401/clearSession pour +// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de +// l'ancien. Le logout volontaire (page logout.vue) appelle directement +// `resetCategoriesAdmin()` ci-dessous. +onAuthSessionCleared(resetCategoriesAdminState) + +export function useCategoriesAdmin() { + const api = useApi() + + /** + * Charge la liste des categories. Le serveur exclut les soft-deleted par + * defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination + * serveur (volumetrie ≤ 300, pagination front via MalioDataTable). + * + * `includeDeleted=true` permet a un user avec `catalog.categories.manage` + * de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette + * option mais on l'expose pour la suite (corbeille future). + * + * Swallow volontaire : un 403 (user sans permission view) ne doit pas + * toaster — la sidebar masque deja l'entree pour ces users, on tombe sur + * la page seulement par URL directe et on affiche un tableau vide propre. + */ + async function fetchAll(includeDeleted = false): Promise { + loading.value = true + error.value = null + try { + const query: Record = { itemsPerPage: HYDRA_NO_PAGINATION } + if (includeDeleted) { + query.includeDeleted = 'true' + } + const data = await api.get>( + '/categories', + query, + { toast: false }, + ) + categories.value = data.member ?? [] + } catch (e) { + categories.value = [] + error.value = (e as Error)?.message ?? 'Erreur de chargement' + } finally { + loading.value = false + } + } + + /** + * Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a + * l'ouverture de la page admin pour que le select du drawer ait deja les + * options pretes au moment de la creation/edition. + * + * Toast desactive : on stocke l'erreur dans `error` plutot que de + * spammer un toast — le drawer affichera l'erreur inline s'il y a lieu. + */ + async function fetchTypes(): Promise { + loadingTypes.value = true + try { + const data = await api.get>( + '/category_types', + { itemsPerPage: HYDRA_NO_PAGINATION }, + { toast: false }, + ) + types.value = data.member ?? [] + } catch (e) { + types.value = [] + error.value = (e as Error)?.message ?? 'Erreur de chargement des types' + } finally { + loadingTypes.value = false + } + } + + /** + * Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour + * garantir que la prochaine session reparte sur un state propre meme si + * `clearSession()` n'a pas ete declenche (cas logout volontaire). + */ + function resetCategoriesAdmin(): void { + resetCategoriesAdminState() + } + + return { + categories, + types, + loading, + loadingTypes, + error, + fetchAll, + fetchTypes, + resetCategoriesAdmin, + } +} diff --git a/frontend/modules/catalog/composables/useCategoryForm.ts b/frontend/modules/catalog/composables/useCategoryForm.ts new file mode 100644 index 0000000..36e928e --- /dev/null +++ b/frontend/modules/catalog/composables/useCategoryForm.ts @@ -0,0 +1,319 @@ +/** + * Composable de formulaire categorie (M0 — Gestion des categories). + * + * Centralise la logique de validation client + appels API (POST / PATCH / + * DELETE) du drawer de creation/edition. Contrairement a + * `useCategoriesAdmin` qui porte un state singleton partage entre composants, + * ce composable est instancie par formulaire (les refs vivent dans la + * fonction `useCategoryForm()`) — chaque drawer ouvert a son propre state + * isole. + * + * Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) : + * elles servent juste a eviter l'aller-retour reseau evitable. Le serveur + * revalide toujours (defense en profondeur). + * + * Mapping erreurs API : + * - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name` + * - 422 (violations API Platform) → mapping sur les champs concernes + * - autre → erreur globale `_global` + toast generique + */ +import { computed, ref } from 'vue' +import type { Category } from '~/modules/catalog/types/category' +import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api' + +/** + * Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici + * (status et payload data) pour eviter de typer toute la lib. + */ +interface ApiFetchError { + response?: { + status?: number + _data?: unknown + } +} + +export function useCategoryForm() { + const api = useApi() + const { t } = useI18n() + const toast = useToast() + + // State local du formulaire — pas singleton, chaque appel a useCategoryForm + // cree son propre state (cohérent avec le pattern « un drawer = un form »). + const name = ref('') + const categoryTypeId = ref(null) + + // Snapshot des valeurs initiales : sert a calculer `isDirty` pour le + // pattern view → edit du drawer (le bouton Enregistrer reste masque tant + // que rien n'a change en mode consultation). + const initialName = ref('') + const initialCategoryTypeId = ref(null) + + const errors = ref<{ + name: string + categoryType: string + _global: string + }>({ + name: '', + categoryType: '', + _global: '', + }) + + const submitting = ref(false) + + const isDirty = computed( + () => + name.value !== initialName.value + || categoryTypeId.value !== initialCategoryTypeId.value, + ) + + /** + * Pre-remplit le formulaire a partir d'une categorie existante (mode + * consultation/edition) ou vide (mode creation). Reinitialise les + * erreurs et le snapshot initial pour repartir d'un etat propre. + */ + function loadFrom(category: Category | null): void { + errors.value = { name: '', categoryType: '', _global: '' } + if (category) { + name.value = category.name + categoryTypeId.value = category.categoryType.id + initialName.value = category.name + initialCategoryTypeId.value = category.categoryType.id + } else { + name.value = '' + categoryTypeId.value = null + initialName.value = '' + initialCategoryTypeId.value = null + } + } + + /** + * Validation client miroir des RG back. Renvoie true si tout passe et + * peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03) + * mais le serveur retrim de toute facon — pas de risque de divergence. + */ + function validate(): boolean { + errors.value = { name: '', categoryType: '', _global: '' } + const trimmedName = name.value.trim() + + // RG-1.02 — name obligatoire (vide / whitespace-only). + if (trimmedName === '') { + errors.value.name = t('admin.categories.validation.nameRequired') + } else if (trimmedName.length < 2 || trimmedName.length > 120) { + // RG-1.04 — longueur 2-120 apres trim. + errors.value.name = t('admin.categories.validation.nameLength') + } + + // RG-1.05 — categoryType obligatoire. + if (categoryTypeId.value === null) { + errors.value.categoryType = t('admin.categories.validation.typeRequired') + } + + return errors.value.name === '' && errors.value.categoryType === '' + } + + /** + * Construit le payload POST a partir du state. Le `categoryType` est + * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API + * Platform pour referencer une ressource liee. Retourne un object literal + * compatible avec `AnyObject` de `useApi()` (un type nomme strict comme + * `CategoryCreateInput` ne serait pas assignable a `Record` + * en TS strict). + */ + function buildCreatePayload(): Record { + return { + name: name.value.trim(), + categoryType: `/api/category_types/${categoryTypeId.value}`, + } + } + + /** + * 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 { + const violations = extractApiViolations(data) + if (violations.length === 0) return false + let mapped = false + for (const v of violations) { + if (v.propertyPath === 'name') { + errors.value.name = v.message + mapped = true + } else if (v.propertyPath === 'categoryType') { + errors.value.categoryType = v.message + mapped = true + } + } + return mapped + } + + /** + * Traite une erreur API : mappe selon le status, declenche les toasts + * appropries. Centralise la logique entre create/update. + * + * - 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) { + const duplicateMessage = t('admin.categories.toast.duplicate', { + name: attemptedName, + }) + errors.value.name = duplicateMessage + toast.error({ + title: 'Erreur', + message: duplicateMessage, + }) + return true + } + + if (status === 422 && mapServerViolations(data)) { + return true + } + + const extracted = extractApiErrorMessage(data) + errors.value._global = extracted || 'Une erreur est survenue.' + toast.error({ + title: 'Erreur', + message: errors.value._global, + }) + return false + } + + /** + * POST /api/categories. Renvoie la categorie creee, ou `null` si la + * validation client a echoue ou si le serveur a renvoye une erreur. Le + * caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert). + */ + async function submitCreate(): Promise { + if (!validate()) return null + submitting.value = true + errors.value._global = '' + const payload = buildCreatePayload() + try { + const created = await api.post('/categories', payload, { + toast: false, + }) + toast.success({ + title: 'Succès', + message: t('admin.categories.toast.created'), + }) + return created + } catch (e) { + handleApiError(e, String(payload.name)) + return null + } finally { + submitting.value = false + } + } + + /** + * PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour + * coller a la semantique merge-patch (Content-Type pose par useApi). + * Renvoie la categorie mise a jour, ou `null` en cas d'echec. + */ + async function submitUpdate(id: number): Promise { + if (!validate()) return null + submitting.value = true + errors.value._global = '' + const payload: Record = {} + if (name.value !== initialName.value) { + payload.name = name.value.trim() + } + if (categoryTypeId.value !== initialCategoryTypeId.value) { + payload.categoryType = `/api/category_types/${categoryTypeId.value}` + } + // Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement + // empeche par le drawer (bouton Enregistrer masque si !isDirty) mais + // on protege le composable contre un appel direct mal utilise. + if (Object.keys(payload).length === 0) { + submitting.value = false + return null + } + try { + const updated = await api.patch(`/categories/${id}`, payload, { + toast: false, + }) + toast.success({ + title: 'Succès', + message: t('admin.categories.toast.updated'), + }) + return updated + } catch (e) { + const attemptedName = typeof payload.name === 'string' + ? payload.name + : name.value.trim() + handleApiError(e, attemptedName) + return null + } finally { + submitting.value = false + } + } + + /** + * DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose + * `deleted_at = now()` et retourne 204. Renvoie true en cas de succes, + * false sinon (avec toast erreur deja affiche). + */ + async function submitDelete(id: number): Promise { + submitting.value = true + errors.value._global = '' + try { + await api.delete(`/categories/${id}`, {}, { toast: false }) + toast.success({ + title: 'Succès', + message: t('admin.categories.toast.deleted'), + }) + return true + } catch (e) { + handleApiError(e, name.value) + return false + } finally { + submitting.value = false + } + } + + /** + * Reset complet du formulaire — utilise par le drawer apres save ou + * fermeture pour ne pas garder de donnees stale entre deux ouvertures. + */ + function reset(): void { + name.value = '' + categoryTypeId.value = null + initialName.value = '' + initialCategoryTypeId.value = null + errors.value = { name: '', categoryType: '', _global: '' } + submitting.value = false + } + + return { + // State + name, + categoryTypeId, + errors, + submitting, + isDirty, + // Methods + loadFrom, + validate, + submitCreate, + submitUpdate, + submitDelete, + reset, + } +} diff --git a/frontend/modules/catalog/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue index 482bce8..2acd257 100644 --- a/frontend/modules/catalog/pages/admin/categories.vue +++ b/frontend/modules/catalog/pages/admin/categories.vue @@ -18,7 +18,6 @@ (name ASC, RG-1.10). La barre de pagination du MalioDataTable reste cosmetique tant qu'aucun slice client n'est cable : a traiter cote @malio/layer-ui le jour ou la volumetrie monte. --> - import type { Category } from '~/modules/catalog/types/category' -import type { HydraCollection } from '~/shared/utils/api' const { t } = useI18n() -const api = useApi() const { can } = usePermissions() +const { categories, fetchAll, fetchTypes } = useCategoriesAdmin() +const { submitDelete } = useCategoryForm() useHead({ title: t('admin.categories.title') }) const canManage = computed(() => can('catalog.categories.manage')) -const categories = ref([]) -const loading = ref(false) const drawerOpen = ref(false) const selectedCategory = ref(null) const deleteModalOpen = ref(false) @@ -90,35 +87,6 @@ function onRowClick(item: Record) { if (category) openEditDrawer(category) } -/** - * Charge la liste des categories. Le serveur exclut les soft-deleted par - * defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination - * serveur (volumetrie cible <= 300) ni de slice client — toute la liste - * est rendue d'un coup ; la barre du MalioDataTable est donc cosmetique - * jusqu'a la mise a jour layer-ui (ticket ERP-70). - * - * Logique inline volontaire au M0 (decision prompt ERP-49) : extraction - * en composable `useCategoriesAdmin` au ticket 0.8 (ERP-50). - */ -async function loadCategories(): Promise { - loading.value = true - try { - const data = await api.get>( - '/categories', - { itemsPerPage: 999 }, - { toast: false }, - ) - categories.value = data.member ?? [] - } catch { - // Reset sur echec pour ne pas afficher de donnees stale. Pas de - // toast : un user sans permission view recoit 403 et voit une - // liste vide propre — le mecanisme de gating se fait cote sidebar. - categories.value = [] - } finally { - loading.value = false - } -} - function openCreateDrawer() { selectedCategory.value = null drawerOpen.value = true @@ -136,32 +104,36 @@ function onDeleteRequest() { } /** - * DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose - * `deleted_at = now()` et retourne 204. Refresh de la liste a la fin - * pour retirer la ligne (l'index unique partiel autorise une recreation - * ulterieure avec le meme couple (name, type) — RG-1.07). + * Soft delete via le composable de form (qui gere toast + erreur). Refresh + * de la liste a la fin pour retirer la ligne. L'index unique partiel + * autorise une recreation ulterieure avec le meme couple (name, type) — + * RG-1.07. */ async function handleDelete(): Promise { if (!categoryToDelete.value) return deleting.value = true try { - await api.delete(`/categories/${categoryToDelete.value.id}`, {}, { - toastSuccessMessage: t('admin.categories.toast.deleted'), - }) - deleteModalOpen.value = false - categoryToDelete.value = null - drawerOpen.value = false - await loadCategories() + const ok = await submitDelete(categoryToDelete.value.id) + if (ok) { + deleteModalOpen.value = false + categoryToDelete.value = null + drawerOpen.value = false + await fetchAll() + } } finally { deleting.value = false } } function onCategorySaved() { - loadCategories() + fetchAll() } +// Chargement initial des deux ressources (liste + referentiel des types). +// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le +// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ». onMounted(() => { - loadCategories() + fetchAll() + fetchTypes() }) diff --git a/frontend/modules/core/pages/logout.vue b/frontend/modules/core/pages/logout.vue index c25c9ad..21ff0a0 100644 --- a/frontend/modules/core/pages/logout.vue +++ b/frontend/modules/core/pages/logout.vue @@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar() const { resetModules } = useModules() const { resetCurrentSite } = useCurrentSite() const { resetAuditLog } = useAuditLog() +const { resetCategoriesAdmin } = useCategoriesAdmin() onMounted(async () => { try { @@ -27,6 +28,7 @@ onMounted(async () => { resetModules() resetCurrentSite() resetAuditLog() + resetCategoriesAdmin() await navigateTo('/login') } }) 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) + ?? '' + ) +}