Compare commits

...

2 Commits

Author SHA1 Message Date
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
8 changed files with 722 additions and 1 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
+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,76 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="cancel"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-neutral-900">
{{ t('admin.categories.delete.title') }}
</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ t('admin.categories.delete.message', { name: categoryName }) }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
:label="t('common.cancel')"
variant="secondary"
@click="cancel"
/>
<MalioButton
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="loading"
@click="confirm"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const { t } = useI18n()
defineProps<{
modelValue: boolean
categoryName: string
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
}>()
function cancel() {
emit('update:modelValue', false)
}
function confirm() {
emit('confirm')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancel()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,370 @@
<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-[24px] 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"
:label="t('admin.categories.form.name')"
input-class="w-full"
:max-length="120"
:error="errors.name"
required
/>
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
number (categoryType id) ; conversion en IRI au moment du save. -->
<MalioSelect
v-model="form.categoryTypeId"
:options="categoryTypeOptions"
:label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="errors.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>
</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="saving || loadingTypes"
@click="handleSave"
/>
</template>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
const props = defineProps<{
modelValue: boolean
category: Category | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
/**
* Mode du drawer :
* - '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'
})
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 categoryTypeOptions = computed(() =>
categoryTypes.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 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.
watch(
() => props.modelValue,
(open) => {
if (open) {
resetForm()
loadCategoryTypes()
}
},
)
/**
* 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.
*/
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 {
if (mode.value === 'create') {
await api.post('/categories', payload, {
toastSuccessMessage: t('admin.categories.toast.created'),
toast: false, // gestion fine des erreurs ci-dessous
})
} else if (mode.value === 'edit' && props.category) {
await api.patch(`/categories/${props.category.id}`, payload, {
toastSuccessMessage: t('admin.categories.toast.updated'),
toast: false,
})
}
// 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'),
})
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>
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,162 @@
<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 : tri par defaut sur Nom ASC (RG-1.10).
Tri serveur applique a la requete + tri client en miroir pour
la pagination front (volumetrie cible <= 300, cf. spec § 4.1). -->
<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'
import type { HydraCollection } from '~/shared/utils/api'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
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)
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)
}
/**
* 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 (RG : volumetrie ≤ 300, pagination front via MalioDataTable).
*
* 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
}
function openEditDrawer(category: Category) {
selectedCategory.value = category
drawerOpen.value = true
}
function onDeleteRequest() {
if (!selectedCategory.value) return
categoryToDelete.value = selectedCategory.value
deleteModalOpen.value = true
}
/**
* 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).
*/
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'),
})
deleteModalOpen.value = false
categoryToDelete.value = null
drawerOpen.value = false
await loadCategories()
} finally {
deleting.value = false
}
}
function onCategorySaved() {
loadCategories()
}
onMounted(() => {
loadCategories()
})
</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
}
@@ -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".