Compare commits

..

6 Commits

Author SHA1 Message Date
tristan 091ccb6f28 test(catalog) : cover useCategoriesAdmin and useCategoryForm composables
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m50s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 59s
Adds 42 Vitest unit tests for the two composables extracted from the
admin categories page in ERP-50.

- useCategoriesAdmin (14 tests): fetchAll/fetchTypes, includeDeleted toggle,
  loading flags, error handling, reset, singleton sharing.
- useCategoryForm (28 tests): validation rules RG-1.02/1.04/1.05, trim,
  POST/PATCH/DELETE wiring, 409 (RG-1.07) and 422 violation mapping,
  isDirty, loadFrom, reset, instance isolation.

Mocks useApi/useI18n/useToast via vi.stubGlobal and ~/shared/stores/auth
to keep the suite hermetic (no backend required).
2026-05-29 09:53:14 +02:00
tristan b74d11fa6e fix(chore) : package-lock.json
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m32s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m18s
2026-05-29 09:11:07 +02:00
tristan 9a21384fb6 refactor(catalog) : extract page logic into useCategoriesAdmin and useCategoryForm composables
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m16s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 10s
Extrait la logique fetch/CRUD inline de la page categories (ERP-49) vers
deux composables dedies, conformement au pattern Starseed :

- useCategoriesAdmin : singleton state (categories + types + loading +
  error). Pre-chargement des types au mount de la page (au lieu du
  fetch par ouverture du drawer). Reset au logout via
  onAuthSessionCleared + appel explicite dans logout.vue.

- useCategoryForm : state local par form (pas singleton). Valide
  cote client en miroir des RG back (RG-1.02 / RG-1.04 / RG-1.05),
  mappe les erreurs 409 (doublon RG-1.07) et 422 (violations API
  Platform) sur les bons champs. submitCreate / submitUpdate /
  submitDelete renvoient la ressource ou null pour decoupler la
  decision de fermeture du drawer.

La page et le drawer deviennent purement presentationnels. Aucune
regression UX : meme validations, memes toasts, meme pattern
view -> edit du drawer (via isDirty expose par useCategoryForm).
2026-05-29 08:29:12 +02:00
tristan 934a12b28e fix(category): update category modal to MalioModal component
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m27s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 11s
2026-05-29 08:12:03 +02:00
Matthieu 216f38847b fix(ci) : recreer l'index partiel uq_category_name_type_active apres schema:update
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m11s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m28s
doctrine:schema:update --force drop l'index unique partiel cree par la
migration M0 Catalog (LOWER(name), category_type_id) WHERE deleted_at IS NULL :
Doctrine ORM ne sait pas exprimer les index fonctionnels partiels via les
mappings, donc le voit comme orphelin.

Resultat : en CI les tests CategoryUniqueTest::testDuplicateName* attendent
un 409 (collision) et recoivent 201 — l'index unique n'existant plus, le
doublon passe.

Aligne le step CI sur la cible makefile test-db-setup qui recreait deja
l'index manuellement apres schema:update.
2026-05-28 15:40:31 +02:00
tristan 4046910a9d feat(catalog) : add admin categories page with MalioDataTable and drawer (first draft)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 1m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m5s
- Page Nuxt /admin/categories : MalioDataTable + bouton Ajouter
- CategoryDrawer : modes creation / consultation / edition (transition
  auto view -> edit a la 1re modif), validation client RG-1.02/04/05,
  mapping erreurs server 409 (doublon) et 422 (violations)
- CategoryDeleteModal : confirmation suppression (soft delete cote API)
- Types Category, CategoryType, User
- i18n admin.categories.* (titre, table, form, validation, toasts)
- Fix latent : ajout 'categories' a AdminLinkSlug e2e (oubli ERP-47)

Logique fetch inline volontaire au M0 — extraction en composables a
ERP-50 (ticket 0.8). Aucune persistance d'etat de tableau dans l'URL.
2026-05-28 15:11:45 +02:00
7 changed files with 74 additions and 105 deletions
+1 -1
View File
@@ -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"
+18 -3
View File
@@ -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.'
}
-59
View File
@@ -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)
?? ''
)
}