diff --git a/config/version.yaml b/config/version.yaml index 3fde966..85c0748 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.49' + app.version: '0.1.52' diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index a71d4db..77849a7 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -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." + } } } } diff --git a/frontend/modules/catalog/components/CategoryDeleteModal.vue b/frontend/modules/catalog/components/CategoryDeleteModal.vue new file mode 100644 index 0000000..5842904 --- /dev/null +++ b/frontend/modules/catalog/components/CategoryDeleteModal.vue @@ -0,0 +1,48 @@ + + + + + {{ t('admin.categories.delete.title') }} + + + + + {{ t('admin.categories.delete.message', { name: categoryName }) }} + + + + + + + + + + diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue new file mode 100644 index 0000000..d780808 --- /dev/null +++ b/frontend/modules/catalog/components/CategoryDrawer.vue @@ -0,0 +1,178 @@ + + + + + {{ headerLabel }} + + + + + + + + + + + + + {{ form.errors.value._global }} + + + + + + + + + + + + + diff --git a/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts new file mode 100644 index 0000000..a018863 --- /dev/null +++ b/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Category, CategoryType } from '~/modules/catalog/types/category' +import type { HydraCollection } from '~/shared/utils/api' + +// Mock du store auth : useCategoriesAdmin s'auto-enregistre via +// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour +// eviter de charger Pinia et la vraie store (pas necessaire ici). +vi.mock('~/shared/stores/auth', () => ({ + onAuthSessionCleared: vi.fn(), +})) + +// Le client API est un auto-import Nuxt. On le remplace par un stub +// global pour intercepter les appels et controler les reponses dans +// chaque test (cf. pattern utilise dans useCurrentSite.spec.ts). +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +// Import APRES vi.mock / vi.stubGlobal : le module n'est evalue qu'a +// ce moment-la, donc le mock auth est bien actif au top-level. +const { useCategoriesAdmin } = await import('../useCategoriesAdmin') + +const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' } +const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' } + +const CAT_A: Category = { + id: 10, + name: 'Vis', + categoryType: TYPE_VENTE, + deletedAt: null, + createdAt: '2026-01-01T10:00:00+00:00', + updatedAt: '2026-01-01T10:00:00+00:00', + createdBy: null, + updatedBy: null, +} +const CAT_B: Category = { + id: 11, + name: 'Boulons', + categoryType: TYPE_VENTE, + deletedAt: null, + createdAt: '2026-01-02T10:00:00+00:00', + updatedAt: '2026-01-02T10:00:00+00:00', + createdBy: null, + updatedBy: null, +} + +function makeHydra(items: T[]): HydraCollection { + return { + totalItems: items.length, + member: items, + } +} + +describe('useCategoriesAdmin', () => { + beforeEach(() => { + mockGet.mockReset() + // Reset systematique du state singleton entre tests : sans ca, + // les categories chargees dans un test fuiteraient dans le suivant. + const { resetCategoriesAdmin } = useCategoriesAdmin() + resetCategoriesAdmin() + }) + + describe('fetchAll', () => { + it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + const { fetchAll } = useCategoriesAdmin() + + await fetchAll() + + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockGet).toHaveBeenCalledWith( + '/categories', + { itemsPerPage: 999 }, + { toast: false }, + ) + }) + + it('peuple categories.value depuis le champ Hydra member', async () => { + mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B])) + const { fetchAll, categories } = useCategoriesAdmin() + + await fetchAll() + + expect(categories.value).toEqual([CAT_A, CAT_B]) + }) + + it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + const { fetchAll } = useCategoriesAdmin() + + await fetchAll() + + const queryArg = mockGet.mock.calls[0]?.[1] as Record + expect(queryArg).not.toHaveProperty('includeDeleted') + }) + + it('ajoute includeDeleted=true quand demande explicitement', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + const { fetchAll } = useCategoriesAdmin() + + await fetchAll(true) + + expect(mockGet).toHaveBeenCalledWith( + '/categories', + { itemsPerPage: 999, includeDeleted: 'true' }, + { toast: false }, + ) + }) + + it('passe loading a true pendant la requete et false apres', async () => { + let resolveRequest: (v: HydraCollection) => void = () => {} + mockGet.mockImplementationOnce( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + const { fetchAll, loading } = useCategoriesAdmin() + + const pending = fetchAll() + expect(loading.value).toBe(true) + + resolveRequest(makeHydra([])) + await pending + + expect(loading.value).toBe(false) + }) + + it('peuple error.value et vide categories en cas d echec', async () => { + mockGet.mockRejectedValueOnce(new Error('Network down')) + const { fetchAll, categories, error, loading } = useCategoriesAdmin() + // Pre-charge volontairement quelque chose pour verifier la purge. + categories.value = [CAT_A] + + await fetchAll() + + expect(categories.value).toEqual([]) + expect(error.value).toBe('Network down') + expect(loading.value).toBe(false) + }) + + it('gere une reponse sans champ member (fallback tableau vide)', async () => { + mockGet.mockResolvedValueOnce({ + totalItems: 0, + } as unknown as HydraCollection) + const { fetchAll, categories } = useCategoriesAdmin() + + await fetchAll() + + expect(categories.value).toEqual([]) + }) + }) + + describe('fetchTypes', () => { + it('appelle GET /category_types avec itemsPerPage=999', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + const { fetchTypes } = useCategoriesAdmin() + + await fetchTypes() + + expect(mockGet).toHaveBeenCalledWith( + '/category_types', + { itemsPerPage: 999 }, + { toast: false }, + ) + }) + + it('peuple types.value depuis le champ Hydra member', async () => { + mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT])) + const { fetchTypes, types } = useCategoriesAdmin() + + await fetchTypes() + + expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT]) + }) + + it('peuple error.value et vide types en cas d echec', async () => { + mockGet.mockRejectedValueOnce(new Error('500')) + const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin() + types.value = [TYPE_VENTE] + + await fetchTypes() + + expect(types.value).toEqual([]) + expect(error.value).toContain('500') + expect(loadingTypes.value).toBe(false) + }) + + it('passe loadingTypes a true pendant la requete et false apres', async () => { + let resolveRequest: (v: HydraCollection) => void = () => {} + mockGet.mockImplementationOnce( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + const { fetchTypes, loadingTypes } = useCategoriesAdmin() + + const pending = fetchTypes() + expect(loadingTypes.value).toBe(true) + + resolveRequest(makeHydra([])) + await pending + + expect(loadingTypes.value).toBe(false) + }) + }) + + describe('resetCategoriesAdmin', () => { + it('vide categories, types, loading, loadingTypes et error', () => { + const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error } + = useCategoriesAdmin() + // Pre-peuple le state pour verifier la purge effective. + categories.value = [CAT_A] + types.value = [TYPE_VENTE] + loading.value = true + loadingTypes.value = true + error.value = 'oops' + + resetCategoriesAdmin() + + expect(categories.value).toEqual([]) + expect(types.value).toEqual([]) + expect(loading.value).toBe(false) + expect(loadingTypes.value).toBe(false) + expect(error.value).toBeNull() + }) + }) + + describe('singleton', () => { + it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => { + const a = useCategoriesAdmin() + const b = useCategoriesAdmin() + + // Les fonctions sont reinstanciees a chaque appel mais les refs + // doivent etre rigoureusement les memes (state au niveau module). + expect(a.categories).toBe(b.categories) + expect(a.types).toBe(b.types) + expect(a.loading).toBe(b.loading) + }) + + it('une mutation via une instance est visible depuis une autre instance', () => { + const a = useCategoriesAdmin() + const b = useCategoriesAdmin() + + a.categories.value = [CAT_A] + + expect(b.categories.value).toEqual([CAT_A]) + }) + }) +}) diff --git a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts new file mode 100644 index 0000000..c3500cd --- /dev/null +++ b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts @@ -0,0 +1,454 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Category, CategoryType } from '~/modules/catalog/types/category' +import { useCategoryForm } from '../useCategoryForm' + +// Stubs des auto-imports Nuxt consommes par le composable. +const mockGet = vi.hoisted(() => vi.fn()) +const mockPost = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) +const mockDelete = vi.hoisted(() => vi.fn()) +const mockToastSuccess = vi.hoisted(() => vi.fn()) +const mockToastError = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: mockPost, + put: vi.fn(), + patch: mockPatch, + delete: mockDelete, +})) +vi.stubGlobal('useToast', () => ({ + success: mockToastSuccess, + error: mockToastError, +})) +// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus). +// Quand le composable passe des params (ex: doublon), on les serialise pour +// pouvoir verifier que l'interpolation a bien recu le bon nom. +vi.stubGlobal('useI18n', () => ({ + t: (key: string, params?: Record) => + params ? `${key}::${JSON.stringify(params)}` : key, +})) + +const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' } +const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' } + +const CAT: Category = { + id: 42, + name: 'Vis', + categoryType: TYPE_VENTE, + deletedAt: null, + createdAt: '2026-01-01T10:00:00+00:00', + updatedAt: '2026-01-01T10:00:00+00:00', + createdBy: null, + updatedBy: null, +} + +describe('useCategoryForm', () => { + beforeEach(() => { + mockGet.mockReset() + mockPost.mockReset() + mockPatch.mockReset() + mockDelete.mockReset() + mockToastSuccess.mockReset() + mockToastError.mockReset() + }) + + describe('loadFrom', () => { + it('pre-remplit le formulaire depuis une categorie existante', () => { + const form = useCategoryForm() + + form.loadFrom(CAT) + + expect(form.name.value).toBe('Vis') + expect(form.categoryTypeId.value).toBe(1) + expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) + }) + + it('vide le formulaire en mode creation (null)', () => { + const form = useCategoryForm() + form.name.value = 'old' + form.categoryTypeId.value = 99 + + form.loadFrom(null) + + expect(form.name.value).toBe('') + expect(form.categoryTypeId.value).toBeNull() + }) + + it('reinitialise le snapshot initial → isDirty=false juste apres', () => { + const form = useCategoryForm() + + form.loadFrom(CAT) + + expect(form.isDirty.value).toBe(false) + }) + }) + + describe('isDirty', () => { + it('passe a true des qu une valeur diverge du snapshot initial', () => { + const form = useCategoryForm() + form.loadFrom(CAT) + expect(form.isDirty.value).toBe(false) + + form.name.value = 'Vis modifie' + + expect(form.isDirty.value).toBe(true) + }) + }) + + describe('validate', () => { + it('signale une erreur si name est vide (RG-1.02)', () => { + const form = useCategoryForm() + form.name.value = '' + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired') + }) + + it('signale erreur si name est whitespace-only (trim → vide)', () => { + const form = useCategoryForm() + form.name.value = ' ' + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired') + }) + + it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => { + const form = useCategoryForm() + form.name.value = 'A' + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.name).toBe('admin.categories.validation.nameLength') + }) + + it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => { + const form = useCategoryForm() + form.name.value = 'A'.repeat(121) + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.name).toBe('admin.categories.validation.nameLength') + }) + + it('signale erreur si categoryTypeId est null (RG-1.05)', () => { + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = null + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired') + }) + + it('passe quand name et categoryType sont valides', () => { + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(true) + expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) + }) + + it('reinitialise les erreurs avant chaque validation', () => { + const form = useCategoryForm() + // Erreur prealable. + form.errors.value._global = 'erreur ancienne' + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + form.validate() + + expect(form.errors.value._global).toBe('') + }) + }) + + describe('submitCreate', () => { + it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => { + mockPost.mockResolvedValueOnce(CAT) + const form = useCategoryForm() + form.name.value = ' Vis ' + form.categoryTypeId.value = 1 + + const result = await form.submitCreate() + + expect(mockPost).toHaveBeenCalledWith( + '/categories', + { name: 'Vis', categoryType: '/api/category_types/1' }, + { toast: false }, + ) + expect(result).toEqual(CAT) + }) + + it('ne declenche aucun appel API si la validation client echoue', async () => { + const form = useCategoryForm() + form.name.value = '' + form.categoryTypeId.value = 1 + + const result = await form.submitCreate() + + expect(mockPost).not.toHaveBeenCalled() + expect(result).toBeNull() + }) + + it('declenche un toast de succes en cas de creation reussie', async () => { + mockPost.mockResolvedValueOnce(CAT) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + await form.submitCreate() + + expect(mockToastSuccess).toHaveBeenCalledWith({ + title: 'Succès', + message: 'admin.categories.toast.created', + }) + }) + + it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => { + mockPost.mockRejectedValueOnce({ + response: { status: 409, _data: {} }, + }) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + const result = await form.submitCreate() + + expect(result).toBeNull() + // La cle est interpolee avec le nom soumis : on retrouve "Vis" dans + // les params i18n (stub serialise les params). + expect(form.errors.value.name).toContain('admin.categories.toast.duplicate') + expect(form.errors.value.name).toContain('"name":"Vis"') + expect(mockToastError).toHaveBeenCalledTimes(1) + const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string } + expect(toastArg.message).toContain('Vis') + }) + + it('mappe un 422 violations sur les champs concernes (errors.name)', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { + violations: [ + { propertyPath: 'name', message: 'name should not be blank.' }, + ], + }, + }, + }) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + const result = await form.submitCreate() + + expect(result).toBeNull() + expect(form.errors.value.name).toBe('name should not be blank.') + // Pas de toast quand on a mappe les violations : l erreur est + // affichee inline sous le champ concerne. + expect(mockToastError).not.toHaveBeenCalled() + }) + + it('mappe aussi hydra:violations (negociation de format alternative)', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { + 'hydra:violations': [ + { propertyPath: 'categoryType', message: 'Type invalide.' }, + ], + }, + }, + }) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + await form.submitCreate() + + expect(form.errors.value.categoryType).toBe('Type invalide.') + }) + + it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => { + mockPost.mockRejectedValueOnce({ + response: { status: 500, _data: { 'hydra:description': 'Boom server' } }, + }) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + await form.submitCreate() + + expect(form.errors.value._global).toBe('Boom server') + expect(mockToastError).toHaveBeenCalledWith({ + title: 'Erreur', + message: 'Boom server', + }) + }) + + it('passe submitting a true pendant la requete et a false apres', async () => { + let resolveRequest: (v: Category) => void = () => {} + mockPost.mockImplementationOnce( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + const pending = form.submitCreate() + expect(form.submitting.value).toBe(true) + + resolveRequest(CAT) + await pending + + expect(form.submitting.value).toBe(false) + }) + }) + + describe('submitUpdate', () => { + it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => { + mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' }) + const form = useCategoryForm() + form.loadFrom(CAT) + form.name.value = 'Vis V2' // categoryTypeId inchange + + await form.submitUpdate(42) + + expect(mockPatch).toHaveBeenCalledWith( + '/categories/42', + { name: 'Vis V2' }, // pas de categoryType car non modifie + { toast: false }, + ) + }) + + it('envoie categoryType en IRI quand seul le type a change', async () => { + mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT }) + const form = useCategoryForm() + form.loadFrom(CAT) + form.categoryTypeId.value = 2 + + await form.submitUpdate(42) + + expect(mockPatch).toHaveBeenCalledWith( + '/categories/42', + { categoryType: '/api/category_types/2' }, + { toast: false }, + ) + }) + + it('court-circuite l appel API si aucun champ n a change', async () => { + const form = useCategoryForm() + form.loadFrom(CAT) + // Aucune modification — isDirty=false, patch payload vide. + + const result = await form.submitUpdate(42) + + expect(mockPatch).not.toHaveBeenCalled() + expect(result).toBeNull() + expect(form.submitting.value).toBe(false) + }) + + it('declenche un toast de succes au PATCH reussi', async () => { + mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' }) + const form = useCategoryForm() + form.loadFrom(CAT) + form.name.value = 'Vis V2' + + await form.submitUpdate(42) + + expect(mockToastSuccess).toHaveBeenCalledWith({ + title: 'Succès', + message: 'admin.categories.toast.updated', + }) + }) + + it('mappe le 409 sur errors.name en mode update aussi', async () => { + mockPatch.mockRejectedValueOnce({ + response: { status: 409, _data: {} }, + }) + const form = useCategoryForm() + form.loadFrom(CAT) + form.name.value = 'Doublon' + + const result = await form.submitUpdate(42) + + expect(result).toBeNull() + expect(form.errors.value.name).toContain('admin.categories.toast.duplicate') + expect(form.errors.value.name).toContain('"name":"Doublon"') + }) + }) + + describe('submitDelete', () => { + it('appelle DELETE /categories/{id} et declenche un toast succes', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const form = useCategoryForm() + + const ok = await form.submitDelete(42) + + expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false }) + expect(ok).toBe(true) + expect(mockToastSuccess).toHaveBeenCalledWith({ + title: 'Succès', + message: 'admin.categories.toast.deleted', + }) + }) + + it('retourne false et toast erreur en cas d echec', async () => { + mockDelete.mockRejectedValueOnce({ + response: { status: 500, _data: { detail: 'down' } }, + }) + const form = useCategoryForm() + + const ok = await form.submitDelete(42) + + expect(ok).toBe(false) + expect(form.errors.value._global).toBe('down') + expect(mockToastError).toHaveBeenCalled() + }) + }) + + describe('reset', () => { + it('vide le formulaire et les erreurs', () => { + const form = useCategoryForm() + form.loadFrom(CAT) + form.name.value = 'edit' + form.errors.value._global = 'erreur' + form.submitting.value = true + + form.reset() + + expect(form.name.value).toBe('') + expect(form.categoryTypeId.value).toBeNull() + expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) + expect(form.submitting.value).toBe(false) + }) + }) + + describe('isolation', () => { + it('deux instances useCategoryForm() ont des states independants', () => { + const a = useCategoryForm() + const b = useCategoryForm() + + a.name.value = 'A' + b.name.value = 'B' + + expect(a.name.value).toBe('A') + expect(b.name.value).toBe('B') + // Les refs sont distinctes (pas singleton — chaque drawer son state). + expect(a.name).not.toBe(b.name) + }) + }) +}) diff --git a/frontend/modules/catalog/composables/useCategoriesAdmin.ts b/frontend/modules/catalog/composables/useCategoriesAdmin.ts new file mode 100644 index 0000000..181326c --- /dev/null +++ b/frontend/modules/catalog/composables/useCategoriesAdmin.ts @@ -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([]) +const types = ref([]) +const loading = ref(false) +const loadingTypes = ref(false) +const error = ref(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 { + loading.value = true + error.value = null + try { + const query: Record = { itemsPerPage: HYDRA_NO_PAGINATION } + if (includeDeleted) { + query.includeDeleted = 'true' + } + const data = await api.get>( + '/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 { + loadingTypes.value = true + try { + const data = await api.get>( + '/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, + } +} diff --git a/frontend/modules/catalog/composables/useCategoryForm.ts b/frontend/modules/catalog/composables/useCategoryForm.ts new file mode 100644 index 0000000..36e928e --- /dev/null +++ b/frontend/modules/catalog/composables/useCategoryForm.ts @@ -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(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(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` + * en TS strict). + */ + function buildCreatePayload(): Record { + 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 { + if (!validate()) return null + submitting.value = true + errors.value._global = '' + const payload = buildCreatePayload() + try { + const created = await api.post('/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 { + if (!validate()) return null + submitting.value = true + errors.value._global = '' + const payload: Record = {} + 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(`/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 { + 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, + } +} diff --git a/frontend/modules/catalog/nuxt.config.ts b/frontend/modules/catalog/nuxt.config.ts new file mode 100644 index 0000000..268da7f --- /dev/null +++ b/frontend/modules/catalog/nuxt.config.ts @@ -0,0 +1 @@ +export default defineNuxtConfig({}) diff --git a/frontend/modules/catalog/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue new file mode 100644 index 0000000..2acd257 --- /dev/null +++ b/frontend/modules/catalog/pages/admin/categories.vue @@ -0,0 +1,139 @@ + + + + {{ t('admin.categories.title') }} + + + + + + + + + + + + + + + + + diff --git a/frontend/modules/catalog/types/category.ts b/frontend/modules/catalog/types/category.ts new file mode 100644 index 0000000..acb154d --- /dev/null +++ b/frontend/modules/catalog/types/category.ts @@ -0,0 +1,71 @@ +/** + * Types front du module Catalog (M0 — Gestion des categories). + * + * Contrats API consommes : + * - GET /api/categories → HydraCollection + * - 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 + * + * 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 +} diff --git a/frontend/modules/core/pages/logout.vue b/frontend/modules/core/pages/logout.vue index c25c9ad..21ff0a0 100644 --- a/frontend/modules/core/pages/logout.vue +++ b/frontend/modules/core/pages/logout.vue @@ -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') } }) diff --git a/frontend/shared/composables/useApi.ts b/frontend/shared/composables/useApi.ts index 344a6f5..b51aaa0 100644 --- a/frontend/shared/composables/useApi.ts +++ b/frontend/shared/composables/useApi.ts @@ -1,5 +1,6 @@ import type { FetchOptions , FetchError } from 'ofetch' import { $fetch } from 'ofetch' +import { extractApiErrorMessage } from '~/shared/utils/api' export type AnyObject = Record @@ -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 - 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.' } diff --git a/frontend/shared/utils/api.ts b/frontend/shared/utils/api.ts index 8e57001..b8f24f6 100644 --- a/frontend/shared/utils/api.ts +++ b/frontend/shared/utils/api.ts @@ -31,3 +31,62 @@ export interface HydraCollection { export function extractHydraMembers(collection: HydraCollection): 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 + 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 + 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 + 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) + ?? '' + ) +} diff --git a/frontend/tests/e2e/helpers/pages/SidebarComponent.ts b/frontend/tests/e2e/helpers/pages/SidebarComponent.ts index 9bdae9e..9e7a130 100644 --- a/frontend/tests/e2e/helpers/pages/SidebarComponent.ts +++ b/frontend/tests/e2e/helpers/pages/SidebarComponent.ts @@ -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".
+ {{ t('admin.categories.delete.message', { name: categoryName }) }} +
+ {{ form.errors.value._global }} +