Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions ece8146c03 chore: bump version to v0.1.51
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 32s
2026-05-29 09:18:36 +00:00
tristan 58589e93d0 [ERP-50] Implémenter les composables useCategoriesAdmin et useCategoryForm (#25)
Auto Tag Develop / tag (push) Successful in 6s
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<string, unknown>` (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: #25
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 09:18:29 +00:00
gitea-actions e0d59962d6 chore: bump version to v0.1.50
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-29 08:59:54 +00:00
tristan 3ce40a707f [ERP-49] Créer la page Gestion des catégories (datatable + drawer) (#22)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Ticket Lesstime : [#49](https://lesstime.malio.fr/tasks/460) — premier ticket front du M0 (Gestion des catégories).
Suit la chaîne back ERP-43..48 mergée sur develop.

## Contenu first draft (Claude Code)
- Page Nuxt `/admin/categories` (`MalioDataTable` + bouton `+ Ajouter`)
- Composant `<CategoryDrawer>` : modes création / consultation / édition, transition auto view → edit à la première modification, validation client miroir RG-1.02 (name requis) / RG-1.04 (longueur 2-120) / RG-1.05 (type requis), mapping erreurs 409 (doublon) et 422 (violations)
- Composant `<CategoryDeleteModal>` : confirmation suppression (soft delete RG-1.12)
- Types TS `Category`, `CategoryType`, `User`
- i18n `admin.categories.*` ajouté dans `fr.json`
- Fix latent en passant : ajout de `'categories'` à `AdminLinkSlug` du Page Object e2e (oublié lors d'ERP-47 quand l'item sidebar a été ajouté)

## Décisions marquantes
- Logique `fetch` inline dans `categories.vue` (sera extraite en composables `useCategoriesAdmin` + `useCategoryForm` au ticket ERP-50 / 0.8)
- Drawer dans composant séparé pour réutilisabilité
- Aucun état de tableau persisté dans l'URL (règle ABSOLUE n°6)
- Tous les composants formulaires sont `Malio*` (`MalioDataTable`, `MalioInputText`, `MalioSelect`, `MalioButton`, `MalioDrawer`)

## Polish à venir (Tristan)
Tristan testera en navigateur et peaufinera : UX, classes Tailwind, animations, icônes, wording de toasts.
Les commits de polish suivront sur la même branche.

## Tests
- `npx nuxi typecheck` : net 0 nouvelle erreur (mêmes erreurs pré-existantes que sur `develop`, infrastructure auto-import) + 1 latente corrigée (AdminLinkSlug)
- `make nuxt-test` : 43/43 passent (0 régression)
- Tests manuels navigateur : voir cahier de test du ticket Lesstime #49

## Note pre-commit hook
Le hook a remonté un échec PHPUnit pré-existant sur `develop` (`CategoryDeleteTest::testPatchOnSoftDeletedReturns404` → 401 au lieu de 404, JWT non initialisé en test runner). Aucun PHP touché dans cette MR. Commit avec `--no-verify` autorisé par Tristan.

## Reviewer suggéré
Matthieu (back ↔ front + permissions).

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #22
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 08:59:47 +00:00
14 changed files with 997 additions and 20 deletions
+8
View File
@@ -73,12 +73,20 @@ jobs:
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
- name: Bootstrap test database
# Aligne sur la cible `test-db-setup` du makefile : apres
# `schema:update --force`, on RECREE manuellement l'index unique
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
# deleted_at IS NULL) et `schema:update` les considere comme
# orphelins et les DROP — collisions non detectees, tests d'unicite
# qui attendent 409 recoivent 201.
run: |
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
php bin/console doctrine:migrations:migrate --env=test --no-interaction
php bin/console doctrine:schema:update --env=test --force --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
- name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.49'
app.version: '0.1.51'
+33
View File
@@ -230,6 +230,39 @@
"updated": "Site mis à jour avec succès",
"deleted": "Site supprimé avec succès"
}
},
"categories": {
"title": "Gestion des catégories",
"newCategory": "Ajouter",
"editCategory": "Modifier la catégorie",
"createCategory": "Créer une catégorie",
"viewCategory": "Détail de la catégorie",
"noCategories": "Aucune catégorie pour l'instant.",
"table": {
"name": "Nom",
"type": "Type"
},
"form": {
"name": "Nom",
"type": "Type de catégorie",
"typePlaceholder": "Sélectionner un type"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
"typeRequired": "Le type de catégorie est obligatoire."
},
"delete": {
"title": "Supprimer la catégorie",
"message": "Êtes-vous sûr de vouloir supprimer la catégorie \"{name}\" ? Cette action est irréversible."
},
"toast": {
"created": "Catégorie créée avec succès",
"updated": "Catégorie mise à jour avec succès",
"deleted": "Catégorie supprimée avec succès",
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
}
}
}
}
@@ -0,0 +1,48 @@
<template>
<MalioModal
:model-value="modelValue"
modal-class="max-w-md"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h3 class="text-lg font-semibold text-neutral-900">
{{ t('admin.categories.delete.title') }}
</h3>
</template>
<p class="text-sm text-neutral-600">
{{ t('admin.categories.delete.message', { name: categoryName }) }}
</p>
<template #footer>
<MalioButton
:label="t('common.cancel')"
variant="secondary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="loading"
@click="emit('confirm')"
/>
</template>
</MalioModal>
</template>
<script setup lang="ts">
const { t } = useI18n()
defineProps<{
modelValue: boolean
categoryName: string
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
}>()
</script>
@@ -0,0 +1,178 @@
<template>
<MalioDrawer
:model-value="modelValue"
drawer-class="w-full max-w-lg"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-2xl font-bold">
{{ headerLabel }}
</h2>
</template>
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<!-- 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.value"
:label="t('admin.categories.form.name')"
input-class="w-full"
:max-length="120"
: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
par le composable useCategoryForm. -->
<MalioSelect
v-model="form.categoryTypeId.value"
:options="typeOptions"
:label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.value.categoryType"
:disabled="loadingTypes"
/>
<!-- Erreur transverse (typiquement reseau / 5xx) separe des
erreurs de validation par champ. -->
<p v-if="form.errors.value._global" class="text-sm text-red-600">
{{ form.errors.value._global }}
</p>
</form>
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
v-if="canShowDelete"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-[150px]"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
v-if="canShowSave"
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="form.submitting.value || loadingTypes"
@click="handleSave"
/>
</template>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
// 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
category: Category | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
/**
* 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.
*/
type DrawerMode = 'create' | 'view' | 'edit'
const isCreateMode = computed(() => props.category === null)
const mode = computed<DrawerMode>(() => {
if (isCreateMode.value) return 'create'
return form.isDirty.value ? 'edit' : 'view'
})
const headerLabel = computed(() => {
if (mode.value === 'create') return t('admin.categories.createCategory')
if (mode.value === 'edit') return t('admin.categories.editCategory')
return t('admin.categories.viewCategory')
})
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
// existante et seulement pour les users ayant la permission manage. En mode
// creation on affiche un bouton Annuler a la place.
const canShowDelete = computed(
() => !isCreateMode.value && can('catalog.categories.manage'),
)
// Save : visible en creation, ou en edition (apres modification d'un champ).
// Masque en view tant que rien n'a change.
const canShowSave = computed(
() => mode.value === 'create' || mode.value === 'edit',
)
const typeOptions = computed(() =>
types.value.map(ct => ({
label: ct.label,
value: ct.id,
})),
)
// 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 })
// 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) {
form.loadFrom(props.category)
fetchTypes()
}
},
)
/**
* 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> {
let result: Category | null = null
if (mode.value === 'create') {
result = await form.submitCreate()
} else if (mode.value === 'edit' && props.category) {
result = await form.submitUpdate(props.category.id)
}
if (result) {
emit('saved')
emit('update:modelValue', 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
// 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
}
}
/**
* 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
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<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,
}
}
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,139 @@
<template>
<div>
<PageHeader>
{{ t('admin.categories.title') }}
<template #actions>
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</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. -->
<MalioDataTable
:columns="columns"
:items="categoryItems"
:total-items="categories.length"
:row-clickable="true"
:empty-message="t('admin.categories.noCategories')"
@row-click="onRowClick"
/>
<!-- Drawer creation / consultation / edition. -->
<CategoryDrawer
v-model="drawerOpen"
:category="selectedCategory"
@saved="onCategorySaved"
@delete="onDeleteRequest"
/>
<!-- Modale de confirmation suppression (soft delete cote serveur). -->
<CategoryDeleteModal
v-model="deleteModalOpen"
:category-name="categoryToDelete?.name ?? ''"
:loading="deleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
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 drawerOpen = ref(false)
const selectedCategory = ref<Category | null>(null)
const deleteModalOpen = ref(false)
const categoryToDelete = ref<Category | null>(null)
const deleting = ref(false)
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
// on aplatit en label lisible pour l'affichage.
const columns = [
{ key: 'name', label: t('admin.categories.table.name') },
{ key: 'typeLabel', label: t('admin.categories.table.type') },
]
const categoryItems = computed(() =>
categories.value.map(cat => ({
id: cat.id,
name: cat.name,
typeLabel: cat.categoryType?.label ?? '',
})),
)
function getCategoryById(id: number): Category | undefined {
return categories.value.find(c => c.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const category = getCategoryById(item.id as number)
if (category) openEditDrawer(category)
}
function openCreateDrawer() {
selectedCategory.value = null
drawerOpen.value = true
}
function openEditDrawer(category: Category) {
selectedCategory.value = category
drawerOpen.value = true
}
function onDeleteRequest() {
if (!selectedCategory.value) return
categoryToDelete.value = selectedCategory.value
deleteModalOpen.value = true
}
/**
* 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 {
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() {
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(() => {
fetchAll()
fetchTypes()
})
</script>
@@ -0,0 +1,71 @@
/**
* Types front du module Catalog (M0 — Gestion des categories).
*
* Contrats API consommes :
* - GET /api/categories → HydraCollection<Category>
* - GET /api/categories/{id} → Category
* - POST /api/categories → body { name, categoryType: IRI }
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
* - GET /api/category_types → HydraCollection<CategoryType>
*
* Notes :
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType, cf. spec-back § 3.4).
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
*/
/**
* Reference legere d'un user, telle qu'embarquee dans Category.createdBy /
* updatedBy. Volontairement minimaliste : on n'a besoin que de l'identifiant
* et de l'username pour l'affichage courant.
*/
export interface User {
id: number
username: string
}
/**
* Reference du referentiel CategoryType (lecture seule au M0).
*/
export interface CategoryType {
id: number
code: string
label: string
}
/**
* Categorie metier — telle qu'elle est lue depuis l'API. L'entite porte le
* pattern Timestampable+Blamable (cf. spec-back § 2.8).
*/
export interface Category {
id: number
name: string
categoryType: CategoryType
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
deletedAt: string | null
createdAt: string
updatedAt: string
createdBy: User | null
updatedBy: User | null
}
/**
* Payload accepte en POST /api/categories. `categoryType` est envoye en
* IRI Hydra (ex. `/api/category_types/3`).
*/
export interface CategoryCreateInput {
name: string
categoryType: string
}
/**
* Payload accepte en PATCH /api/categories/{id}. Tous les champs sont
* optionnels (modification partielle).
*/
export interface CategoryUpdateInput {
name?: string
categoryType?: string
}
+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)
?? ''
)
}
@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test'
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log'
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'categories' | 'audit-log'
/**
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin".