Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 091ccb6f28 | |||
| b74d11fa6e | |||
| 9a21384fb6 | |||
| 934a12b28e | |||
| 216f38847b | |||
| 4046910a9d |
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.51'
|
||||
app.version: '0.1.49'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-2xl font-bold">
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ headerLabel }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
@@ -17,14 +17,6 @@ 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
|
||||
@@ -69,7 +61,7 @@ export function useCategoriesAdmin() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
|
||||
const query: Record<string, unknown> = { itemsPerPage: 999 }
|
||||
if (includeDeleted) {
|
||||
query.includeDeleted = 'true'
|
||||
}
|
||||
@@ -100,7 +92,7 @@ export function useCategoriesAdmin() {
|
||||
try {
|
||||
const data = await api.get<HydraCollection<CategoryType>>(
|
||||
'/category_types',
|
||||
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
types.value = data.member ?? []
|
||||
|
||||
@@ -19,7 +19,16 @@
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
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
|
||||
@@ -127,50 +136,62 @@ export function useCategoryForm() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
function mapServerViolations(data: unknown): boolean {
|
||||
const violations = extractApiViolations(data)
|
||||
if (violations.length === 0) return false
|
||||
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
|
||||
|
||||
let mapped = false
|
||||
for (const v of violations) {
|
||||
if (v.propertyPath === 'name') {
|
||||
errors.value.name = v.message
|
||||
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
|
||||
mapped = true
|
||||
} else if (v.propertyPath === 'categoryType') {
|
||||
errors.value.categoryType = v.message
|
||||
} else if (path === 'categoryType') {
|
||||
errors.value.categoryType = 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.
|
||||
*
|
||||
* - 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).
|
||||
* Retourne true si l'erreur a ete reconnue et traitee, false sinon
|
||||
* (utile pour les tests).
|
||||
*/
|
||||
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,
|
||||
})
|
||||
@@ -183,10 +204,12 @@ 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 = extractApiErrorMessage(data)
|
||||
const extracted = extractErrorMessage(data)
|
||||
errors.value._global = extracted || 'Une erreur est survenue.'
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
|
||||
@@ -13,11 +13,9 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des categories. Affichage exhaustif (volumetrie cible
|
||||
<= 300, cf. spec § 4.1) — tri 100% serveur via CategoryProvider
|
||||
(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. -->
|
||||
<!-- Table des categories : tri par defaut sur Nom ASC (RG-1.10).
|
||||
Tri serveur applique a la requete + pagination front via
|
||||
MalioDataTable (volumetrie cible <= 300, cf. spec § 4.1). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="categoryItems"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FetchOptions , FetchError } from 'ofetch'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
@@ -42,8 +41,24 @@ export function useApi(): ApiClient {
|
||||
|
||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||
const data = responseData ?? (error as FetchError)?.data
|
||||
const msg = extractApiErrorMessage(data)
|
||||
if (msg) return msg
|
||||
|
||||
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) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||
}
|
||||
|
||||
|
||||
@@ -31,62 +31,3 @@ 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)
|
||||
?? ''
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user