[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
7 changed files with 575 additions and 296 deletions
@@ -16,29 +16,30 @@
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
<MalioInputText
v-model="form.name"
v-model="form.name.value"
:label="t('admin.categories.form.name')"
input-class="w-full"
:max-length="120"
:error="errors.name"
:error="form.errors.value.name"
required
/>
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
number (categoryType id) ; conversion en IRI au moment du save. -->
number (categoryType id) ; conversion en IRI au moment du save
par le composable useCategoryForm. -->
<MalioSelect
v-model="form.categoryTypeId"
:options="categoryTypeOptions"
v-model="form.categoryTypeId.value"
:options="typeOptions"
:label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="errors.categoryType"
:error="form.errors.value.categoryType"
:disabled="loadingTypes"
/>
<!-- Erreur transverse (typiquement reseau / 5xx) separe des
erreurs de validation par champ. -->
<p v-if="errors._global" class="text-sm text-red-600">
{{ errors._global }}
<p v-if="form.errors.value._global" class="text-sm text-red-600">
{{ form.errors.value._global }}
</p>
</form>
@@ -66,7 +67,7 @@
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="saving || loadingTypes"
:disabled="form.submitting.value || loadingTypes"
@click="handleSave"
/>
</template>
@@ -74,12 +75,14 @@
</template>
<script setup lang="ts">
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
Outdated
Review

Regression vs ERP-49 : le drawer ne destructure que { types, loadingTypes, fetchTypes }, pas error. Avant, l'echec de chargement des types posait errors._global = typesLoadFailed et affichait un message inline. Desormais fetchTypes() range l'echec dans useCategoriesAdmin.error, qui n'est lu ni ici ni dans le template (seul form.errors.value._global est rendu). Resultat : si /category_types tombe, le select reste vide et desactive sans aucun message, et la cle i18n admin.categories.toast.typesLoadFailed devient orpheline. A brancher (afficher useCategoriesAdmin.error dans le drawer) ou a repositionner sur form._global.

Regression vs ERP-49 : le drawer ne destructure que { types, loadingTypes, fetchTypes }, pas `error`. Avant, l'echec de chargement des types posait `errors._global = typesLoadFailed` et affichait un message inline. Desormais fetchTypes() range l'echec dans useCategoriesAdmin.error, qui n'est lu ni ici ni dans le template (seul form.errors.value._global est rendu). Resultat : si /category_types tombe, le select reste vide et desactive sans aucun message, et la cle i18n admin.categories.toast.typesLoadFailed devient orpheline. A brancher (afficher useCategoriesAdmin.error dans le drawer) ou a repositionner sur form._global.
// Instance dediee de form pour ce drawer — state isole (cf. useCategoryForm
// n'est pas singleton, contrairement a useCategoriesAdmin).
const form = useCategoryForm()
const props = defineProps<{
modelValue: boolean
@@ -93,56 +96,20 @@ const emit = defineEmits<{
}>()
/**
* Mode du drawer :
* Mode du drawer (dérivé du composable `useCategoryForm`) :
* - 'create' : pas de category prop, formulaire vide, POST au save.
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
* jusqu'a ce que l'utilisateur modifie un champ.
* - 'edit' : category prop set et formulaire « dirty » (au moins un
* champ different de l'original), PATCH au save.
*
* La bascule view → edit est automatique des qu'un champ change (cf. watch
* sur form). Le label du header suit le mode courant.
*/
type DrawerMode = 'create' | 'view' | 'edit'
const saving = ref(false)
const loadingTypes = ref(false)
const categoryTypes = ref<CategoryType[]>([])
const form = ref({
name: '',
categoryTypeId: null as number | null,
})
// Snapshot des valeurs initiales pour detecter le « dirty » (view → edit).
const initial = ref({
name: '',
categoryTypeId: null as number | null,
})
// Erreurs par champ + erreur transverse globale. Pattern propre pour mapper
// les violations 422 sur les MalioInputText / MalioSelect.
const errors = ref<{
name: string
categoryType: string
_global: string
}>({
name: '',
categoryType: '',
_global: '',
})
const isCreateMode = computed(() => props.category === null)
const isDirty = computed(
() =>
form.value.name !== initial.value.name
|| form.value.categoryTypeId !== initial.value.categoryTypeId,
)
const mode = computed<DrawerMode>(() => {
if (isCreateMode.value) return 'create'
return isDirty.value ? 'edit' : 'view'
return form.isDirty.value ? 'edit' : 'view'
})
const headerLabel = computed(() => {
@@ -164,207 +131,48 @@ const canShowSave = computed(
() => mode.value === 'create' || mode.value === 'edit',
)
const categoryTypeOptions = computed(() =>
categoryTypes.value.map(ct => ({
const typeOptions = computed(() =>
types.value.map(ct => ({
label: ct.label,
value: ct.id,
})),
)
/**
* Charge le referentiel CategoryType. Appele a chaque ouverture du drawer
* (pas seulement au mount) pour rester a jour si un type est ajoute en
* arriere-plan. Volontairement sans toast en cas d'echec : on affiche un
* message inline via `errors._global` pour ne pas spammer.
*/
async function loadCategoryTypes(): Promise<void> {
loadingTypes.value = true
try {
const data = await api.get<HydraCollection<CategoryType>>(
'/category_types',
{ itemsPerPage: 999 },
{ toast: false },
)
categoryTypes.value = data.member ?? []
} catch {
categoryTypes.value = []
errors.value._global = t('admin.categories.toast.typesLoadFailed')
} finally {
loadingTypes.value = false
}
}
// Re-initialise le form quand la categorie selectionnee change (clic sur une
// autre ligne sans fermer le drawer entre-temps).
watch(() => props.category, (cat) => {
form.loadFrom(cat)
}, { immediate: true })
/**
* Re-initialise le formulaire a partir de la prop `category`. Aussi appele
* a l'ouverture du drawer pour repartir d'un etat propre.
*/
function resetForm(): void {
errors.value = { name: '', categoryType: '', _global: '' }
if (props.category) {
form.value.name = props.category.name
form.value.categoryTypeId = props.category.categoryType.id
initial.value.name = props.category.name
initial.value.categoryTypeId = props.category.categoryType.id
} else {
form.value.name = ''
form.value.categoryTypeId = null
initial.value.name = ''
initial.value.categoryTypeId = null
}
}
// Re-initialiser quand la categorie selectionnee change (clic sur une autre
// ligne sans fermer le drawer entre-temps).
watch(() => props.category, resetForm, { immediate: true })
// A chaque ouverture du drawer : reset + chargement frais des types. Pas
// d'optimisation cache au M0 — le referentiel est petit et statique.
// A chaque ouverture du drawer : reload du form + refresh des types (au cas
// ou un type aurait ete ajoute en arriere-plan depuis le dernier fetch — pas
// d'optimisation cache au M0, le referentiel est petit).
watch(
() => props.modelValue,
(open) => {
if (open) {
resetForm()
loadCategoryTypes()
form.loadFrom(props.category)
fetchTypes()
}
},
)
/**
* Validation client-side miroir des RG back. Renvoie true si tout passe et
* peuple `errors` sinon. Le serveur valide aussi (defense en profondeur) ;
* la validation client sert juste a eviter l'aller-retour evitable.
*/
function validate(): boolean {
errors.value = { name: '', categoryType: '', _global: '' }
const trimmedName = form.value.name.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 (form.value.categoryTypeId === null) {
errors.value.categoryType = t('admin.categories.validation.typeRequired')
}
return errors.value.name === '' && errors.value.categoryType === ''
}
/**
* Mappe une reponse 422 d'API Platform sur le state `errors`. API Platform 4
* retourne soit `violations: [{ propertyPath, message }]` soit
* `hydra:violations` selon la negociation de format.
*/
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
let mapped = false
for (const v of rawViolations) {
if (!v || typeof v !== 'object') continue
const violation = v as Record<string, unknown>
const path = String(violation.propertyPath ?? '')
const message = String(violation.message ?? '')
if (path === 'name') {
errors.value.name = message
mapped = true
} else if (path === 'categoryType') {
errors.value.categoryType = message
mapped = true
}
}
return mapped
}
/**
* Extrait un message d'erreur HTTP au format API Platform / Hydra.
*/
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)
?? ''
)
}
/**
* Sauvegarde la categorie (POST en mode create, PATCH en mode edit).
* Trim cote client (miroir RG-1.03), conversion ID → IRI pour categoryType,
* mapping des erreurs server.
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
* refresh la liste.
*/
async function handleSave(): Promise<void> {
if (!validate()) return
saving.value = true
errors.value._global = ''
// Trim cote client (miroir RG-1.03). Le serveur retrim de toute facon.
const payload = {
name: form.value.name.trim(),
categoryType: `/api/category_types/${form.value.categoryTypeId}`,
}
try {
let result: Category | null = null
if (mode.value === 'create') {
await api.post('/categories', payload, {
toastSuccessMessage: t('admin.categories.toast.created'),
toast: false, // gestion fine des erreurs ci-dessous
})
result = await form.submitCreate()
} else if (mode.value === 'edit' && props.category) {
await api.patch(`/categories/${props.category.id}`, payload, {
toastSuccessMessage: t('admin.categories.toast.updated'),
toast: false,
})
result = await form.submitUpdate(props.category.id)
}
// Succes : toast manuel (car on a desactive le toast du composable
// pour gerer finement les erreurs) + propagation au parent.
useToast().success({
title: 'Succès',
message:
mode.value === 'create'
? t('admin.categories.toast.created')
: t('admin.categories.toast.updated'),
})
if (result) {
emit('saved')
emit('update:modelValue', false)
} catch (err: unknown) {
const error = err as { response?: { status?: number, _data?: unknown } }
const status = error?.response?.status
const data = error?.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: payload.name,
})
errors.value.name = duplicateMessage
useToast().error({
title: 'Erreur',
message: duplicateMessage,
})
} else if (status === 422 && mapServerViolations(data)) {
// Violations mappees sur les champs concernes — pas de toast,
// l'utilisateur voit l'erreur directement sous le champ.
} else {
const extracted = extractErrorMessage(data)
errors.value._global = extracted || 'Une erreur est survenue.'
useToast().error({
title: 'Erreur',
message: errors.value._global,
})
}
} finally {
saving.value = false
}
}
</script>
@@ -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
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
// instance soit creee a chaque appel.
const categories = ref<Category[]>([])
const types = ref<CategoryType[]>([])
const loading = ref(false)
const loadingTypes = ref(false)
const error = ref<string | null>(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<void> {
loading.value = true
error.value = null
try {
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
if (includeDeleted) {
query.includeDeleted = 'true'
}
const data = await api.get<HydraCollection<Category>>(
'/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
}
}
/**
Outdated
Review

Race possible : fetchTypes() est appele par categories.vue (onMounted) et par le drawer (watch modelValue), sur le meme state singleton, sans garde in-flight. Deux appels concurrents partent en double sur /category_types, et le premier qui repond remet loadingTypes=false alors que le second tourne encore (select reactive trop tot). Pas de corruption (meme payload), impact UX mineur. Une garde if (loadingTypes.value) return ou un petit cache suffit.

Race possible : fetchTypes() est appele par categories.vue (onMounted) et par le drawer (watch modelValue), sur le meme state singleton, sans garde in-flight. Deux appels concurrents partent en double sur /category_types, et le premier qui repond remet loadingTypes=false alors que le second tourne encore (select reactive trop tot). Pas de corruption (meme payload), impact UX mineur. Une garde `if (loadingTypes.value) return` ou un petit cache suffit.
* 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<void> {
loadingTypes.value = true
try {
const data = await api.get<HydraCollection<CategoryType>>(
'/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,
}
}
@@ -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<number | null>(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<number | null>(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<string, unknown>`
* en TS strict).
*/
function buildCreatePayload(): Record<string, unknown> {
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
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) {
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
}
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 = 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<Category | null> {
if (!validate()) return null
submitting.value = true
errors.value._global = ''
const payload = buildCreatePayload()
try {
const created = await api.post<Category>('/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<Category | null> {
if (!validate()) return null
submitting.value = true
errors.value._global = ''
const payload: Record<string, unknown> = {}
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<Category>(`/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<boolean> {
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,
}
Outdated
Review

reset() est exporte mais jamais appele (aucun .reset() dans modules/catalog). loadFrom(null) couvre deja le reset complet. A supprimer, ou justifier s'il est prevu pour les tests.

reset() est exporte mais jamais appele (aucun .reset() dans modules/catalog). loadFrom(null) couvre deja le reset complet. A supprimer, ou justifier s'il est prevu pour les tests.
}
@@ -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. -->
<MalioDataTable
:columns="columns"
:items="categoryItems"
@@ -48,18 +47,16 @@
<script setup lang="ts">
import type { Category } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
Outdated
Review

categories.vue cree une instance complete de useCategoryForm uniquement pour submitDelete. Cette instance n'appelle jamais loadFrom, donc name.value reste vide et est passe a handleApiError — inoffensif aujourd'hui (un DELETE ne renvoie pas 409, branche generique n'utilise pas le nom), mais couplage fragile : toute la machinerie de form pour une methode quasi stateless. submitDelete gagnerait a etre une fonction a part (ou vivre dans useCategoriesAdmin).

categories.vue cree une instance complete de useCategoryForm uniquement pour submitDelete. Cette instance n'appelle jamais loadFrom, donc name.value reste vide et est passe a handleApiError — inoffensif aujourd'hui (un DELETE ne renvoie pas 409, branche generique n'utilise pas le nom), mais couplage fragile : toute la machinerie de form pour une methode quasi stateless. submitDelete gagnerait a etre une fonction a part (ou vivre dans useCategoriesAdmin).
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
const canManage = computed(() => can('catalog.categories.manage'))
const categories = ref<Category[]>([])
const loading = ref(false)
const drawerOpen = ref(false)
const selectedCategory = ref<Category | null>(null)
const deleteModalOpen = ref(false)
@@ -90,35 +87,6 @@ function onRowClick(item: Record<string, unknown>) {
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<void> {
loading.value = true
try {
const data = await api.get<HydraCollection<Category>>(
'/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<void> {
if (!categoryToDelete.value) return
deleting.value = true
try {
await api.delete(`/categories/${categoryToDelete.value.id}`, {}, {
toastSuccessMessage: t('admin.categories.toast.deleted'),
})
const ok = await submitDelete(categoryToDelete.value.id)
if (ok) {
deleteModalOpen.value = false
categoryToDelete.value = null
drawerOpen.value = false
await loadCategories()
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()
})
</script>
+2
View File
@@ -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')
}
})
+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)
?? ''
)
}