From 3ce40a707f0cad7f8682f956321989a14e82cad4 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 29 May 2026 08:59:47 +0000 Subject: [PATCH 1/6] =?UTF-8?q?[ERP-49]=20Cr=C3=A9er=20la=20page=20Gestion?= =?UTF-8?q?=20des=20cat=C3=A9gories=20(datatable=20+=20drawer)=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `` : 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 `` : 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 Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/22 Co-authored-by: tristan Co-committed-by: tristan --- .gitea/workflows/pull-request.yml | 8 + frontend/i18n/locales/fr.json | 33 ++ .../components/CategoryDeleteModal.vue | 48 +++ .../catalog/components/CategoryDrawer.vue | 370 ++++++++++++++++++ frontend/modules/catalog/nuxt.config.ts | 1 + .../catalog/pages/admin/categories.vue | 167 ++++++++ frontend/modules/catalog/types/category.ts | 71 ++++ .../e2e/helpers/pages/SidebarComponent.ts | 2 +- 8 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 frontend/modules/catalog/components/CategoryDeleteModal.vue create mode 100644 frontend/modules/catalog/components/CategoryDrawer.vue create mode 100644 frontend/modules/catalog/nuxt.config.ts create mode 100644 frontend/modules/catalog/pages/admin/categories.vue create mode 100644 frontend/modules/catalog/types/category.ts diff --git a/.gitea/workflows/pull-request.yml b/.gitea/workflows/pull-request.yml index c161627..c0f9425 100644 --- a/.gitea/workflows/pull-request.yml +++ b/.gitea/workflows/pull-request.yml @@ -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 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 @@ + + + diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue new file mode 100644 index 0000000..73991f0 --- /dev/null +++ b/frontend/modules/catalog/components/CategoryDrawer.vue @@ -0,0 +1,370 @@ + + + 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..482bce8 --- /dev/null +++ b/frontend/modules/catalog/pages/admin/categories.vue @@ -0,0 +1,167 @@ + + + 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/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". From e0d59962d63b57f278db943ae1eac6c744ce431c Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Fri, 29 May 2026 08:59:54 +0000 Subject: [PATCH 2/6] chore: bump version to v0.1.50 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index 3fde966..79cca0d 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.49' + app.version: '0.1.50' From 58589e93d02248de531c6fbc6eb0a0b6fea371f0 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 29 May 2026 09:18:29 +0000 Subject: [PATCH 3/6] =?UTF-8?q?[ERP-50]=20Impl=C3=A9menter=20les=20composa?= =?UTF-8?q?bles=20useCategoriesAdmin=20et=20useCategoryForm=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` (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: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/25 Co-authored-by: tristan Co-committed-by: tristan --- .../catalog/components/CategoryDrawer.vue | 270 +++------------ .../catalog/composables/useCategoriesAdmin.ts | 134 ++++++++ .../catalog/composables/useCategoryForm.ts | 319 ++++++++++++++++++ .../catalog/pages/admin/categories.vue | 66 ++-- frontend/modules/core/pages/logout.vue | 2 + frontend/shared/composables/useApi.ts | 21 +- frontend/shared/utils/api.ts | 59 ++++ 7 files changed, 575 insertions(+), 296 deletions(-) create mode 100644 frontend/modules/catalog/composables/useCategoriesAdmin.ts create mode 100644 frontend/modules/catalog/composables/useCategoryForm.ts diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue index 73991f0..d780808 100644 --- a/frontend/modules/catalog/components/CategoryDrawer.vue +++ b/frontend/modules/catalog/components/CategoryDrawer.vue @@ -16,29 +16,30 @@ + number (categoryType id) ; conversion en IRI au moment du save + par le composable useCategoryForm. --> -

- {{ errors._global }} +

+ {{ form.errors.value._global }}

@@ -66,7 +67,7 @@ :label="t('common.save')" variant="primary" button-class="w-[150px]" - :disabled="saving || loadingTypes" + :disabled="form.submitting.value || loadingTypes" @click="handleSave" /> @@ -74,12 +75,14 @@ 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/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue index 482bce8..2acd257 100644 --- a/frontend/modules/catalog/pages/admin/categories.vue +++ b/frontend/modules/catalog/pages/admin/categories.vue @@ -18,7 +18,6 @@ (name ASC, RG-1.10). La barre de pagination du MalioDataTable reste cosmetique tant qu'aucun slice client n'est cable : a traiter cote @malio/layer-ui le jour ou la volumetrie monte. --> - import type { Category } from '~/modules/catalog/types/category' -import type { HydraCollection } from '~/shared/utils/api' const { t } = useI18n() -const api = useApi() 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 categories = ref([]) -const loading = ref(false) const drawerOpen = ref(false) const selectedCategory = ref(null) const deleteModalOpen = ref(false) @@ -90,35 +87,6 @@ function onRowClick(item: Record) { if (category) openEditDrawer(category) } -/** - * Charge la liste des categories. Le serveur exclut les soft-deleted par - * defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination - * serveur (volumetrie cible <= 300) ni de slice client — toute la liste - * est rendue d'un coup ; la barre du MalioDataTable est donc cosmetique - * jusqu'a la mise a jour layer-ui (ticket ERP-70). - * - * Logique inline volontaire au M0 (decision prompt ERP-49) : extraction - * en composable `useCategoriesAdmin` au ticket 0.8 (ERP-50). - */ -async function loadCategories(): Promise { - loading.value = true - try { - const data = await api.get>( - '/categories', - { itemsPerPage: 999 }, - { toast: false }, - ) - categories.value = data.member ?? [] - } catch { - // Reset sur echec pour ne pas afficher de donnees stale. Pas de - // toast : un user sans permission view recoit 403 et voit une - // liste vide propre — le mecanisme de gating se fait cote sidebar. - categories.value = [] - } finally { - loading.value = false - } -} - function openCreateDrawer() { selectedCategory.value = null drawerOpen.value = true @@ -136,32 +104,36 @@ function onDeleteRequest() { } /** - * DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose - * `deleted_at = now()` et retourne 204. Refresh de la liste a la fin - * pour retirer la ligne (l'index unique partiel autorise une recreation - * ulterieure avec le meme couple (name, type) — RG-1.07). + * Soft delete via le composable de form (qui gere toast + erreur). Refresh + * de la liste a la fin pour retirer la ligne. L'index unique partiel + * autorise une recreation ulterieure avec le meme couple (name, type) — + * RG-1.07. */ async function handleDelete(): Promise { 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() + 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() { - loadCategories() + fetchAll() } +// Chargement initial des deux ressources (liste + referentiel des types). +// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le +// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ». onMounted(() => { - loadCategories() + fetchAll() + fetchTypes() }) 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) + ?? '' + ) +} From ece8146c0380c8c33e0885454869c47b2e653b0d Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Fri, 29 May 2026 09:18:36 +0000 Subject: [PATCH 4/6] chore: bump version to v0.1.51 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index 79cca0d..5151383 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.50' + app.version: '0.1.51' From 53e19d61acb2dfdc20141e3e20a33558837aac87 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 29 May 2026 09:23:41 +0000 Subject: [PATCH 5/6] =?UTF-8?q?[ERP-51]=20=C3=89crire=20les=20tests=20Vite?= =?UTF-8?q?st=20des=20composables=20Catalog=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Résumé Couvre les deux composables Catalog extraits du refactor ERP-50 avec **42 tests Vitest unitaires** (happy-dom, sans dépendance backend). - 14 tests sur \`useCategoriesAdmin\` (fetchAll/fetchTypes, includeDeleted, loading, error, reset, singleton) - 28 tests sur \`useCategoryForm\` (validation RG-1.02/1.04/1.05 + trim, POST/PATCH/DELETE, mapping 409 RG-1.07 + 422 violations, isDirty, loadFrom, reset, isolation) Mocks via \`vi.stubGlobal\` (useApi / useI18n / useToast) et \`vi.mock\` (\`~/shared/stores/auth\` pour neutraliser l'auto-enregistrement \`onAuthSessionCleared\`). La suite tourne en **~1.2s**. Ticket Lesstime : #51 ## Tests automatisés - \`make nuxt-test\` ✓ 85 tests (dont 42 nouveaux), 0 échec, 1.2s ## Reviewer @matthieu ## À tester en local - [ ] \`make nuxt-test\` passe - [ ] Mock \`useApi\` reste stable si le pattern d'auto-import Nuxt évolue - [ ] Couverture jugée suffisante des cas back miroir Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/26 Co-authored-by: tristan Co-committed-by: tristan --- .../__tests__/useCategoriesAdmin.spec.ts | 250 ++++++++++ .../__tests__/useCategoryForm.spec.ts | 454 ++++++++++++++++++ 2 files changed, 704 insertions(+) create mode 100644 frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts create mode 100644 frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts 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) + }) + }) +}) From fc78f434d18a2976a07ab1ff3af6c9a84e08ee2f Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Fri, 29 May 2026 09:23:47 +0000 Subject: [PATCH 6/6] chore: bump version to v0.1.52 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index 5151383..85c0748 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.51' + app.version: '0.1.52'