From 93fadb43f9095d0c96a528ee108b32a9ecf309e4 Mon Sep 17 00:00:00 2001 From: z3n Date: Fri, 22 May 2026 13:59:16 +0000 Subject: [PATCH] feat(browse): gestion CRUD des dossiers depuis la page Browse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout boutons Modifier/Supprimer sur chaque tuile de dossier - Supprimer visible uniquement si dossier vide (0 APIs, 0 sous-dossiers) - Modale Angular (sans fr-modal DSFR) pour créer/éditer un dossier - Backend: remove() lève BadRequestException si dossier non vide - Ajout fichiers YAML de démo (OpenAPI Petstore, AsyncAPI User Events) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../modules/categories/categories.service.ts | 33 ++-- .../files-samples-asyncapi/user-events.yaml | 101 ++++++++++ documentation/files-samples-oas/petstore.yaml | 96 ++++++++++ .../src/app/pages/browse/browse.component.ts | 177 ++++++++++++++++-- 4 files changed, 376 insertions(+), 31 deletions(-) create mode 100644 documentation/files-samples-asyncapi/user-events.yaml create mode 100644 documentation/files-samples-oas/petstore.yaml diff --git a/back/src/modules/categories/categories.service.ts b/back/src/modules/categories/categories.service.ts index d2a9668..25996a2 100644 --- a/back/src/modules/categories/categories.service.ts +++ b/back/src/modules/categories/categories.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, IsNull } from 'typeorm'; import { CategoryEntity } from './category.entity'; @@ -61,20 +61,23 @@ export class CategoriesService { async remove(id: string): Promise { await this.findOneOrThrow(id); - /* Désassocier les APIs enfants */ - await this.apiRepo - .createQueryBuilder() - .update(ApiEntryEntity) - .set({ categoryId: null }) - .where('category_id = :id', { id }) - .execute(); - /* Détacher les sous-catégories enfants */ - await this.categoryRepo - .createQueryBuilder() - .update(CategoryEntity) - .set({ parentId: null }) - .where('parent_id = :id', { id }) - .execute(); + + /* Vérifier qu'aucune API n'est rattachée */ + const apiCount = await this.apiRepo.count({ where: { categoryId: id } }); + if (apiCount > 0) { + throw new BadRequestException( + `Ce dossier contient ${apiCount} API(s). Déplacez-les avant de le supprimer.`, + ); + } + + /* Vérifier qu'aucun sous-dossier n'existe */ + const subCount = await this.categoryRepo.count({ where: { parentId: id } }); + if (subCount > 0) { + throw new BadRequestException( + `Ce dossier contient ${subCount} sous-dossier(s). Supprimez-les d'abord.`, + ); + } + await this.categoryRepo.delete(id); } diff --git a/documentation/files-samples-asyncapi/user-events.yaml b/documentation/files-samples-asyncapi/user-events.yaml new file mode 100644 index 0000000..be6c720 --- /dev/null +++ b/documentation/files-samples-asyncapi/user-events.yaml @@ -0,0 +1,101 @@ +asyncapi: "3.0.0" +info: + title: User Events API + version: "1.0.0" + description: API événementielle pour les actions utilisateurs +channels: + userSignedUp: + address: user.signed.up + messages: + userSignedUp: + $ref: "#/components/messages/UserSignedUp" + userLoggedIn: + address: user.logged.in + messages: + userLoggedIn: + $ref: "#/components/messages/UserLoggedIn" + orderPlaced: + address: order.placed + messages: + orderPlaced: + $ref: "#/components/messages/OrderPlaced" +operations: + onUserSignedUp: + action: receive + channel: + $ref: "#/channels/userSignedUp" + onUserLoggedIn: + action: receive + channel: + $ref: "#/channels/userLoggedIn" + onOrderPlaced: + action: receive + channel: + $ref: "#/channels/orderPlaced" +components: + messages: + UserSignedUp: + name: UserSignedUp + title: Utilisateur inscrit + summary: Émis lorsqu'un utilisateur crée un compte + payload: + type: object + required: + - userId + - email + - createdAt + properties: + userId: + type: string + format: uuid + email: + type: string + format: email + createdAt: + type: string + format: date-time + UserLoggedIn: + name: UserLoggedIn + title: Utilisateur connecté + summary: Émis lorsqu'un utilisateur se connecte + payload: + type: object + required: + - userId + - loginAt + properties: + userId: + type: string + format: uuid + loginAt: + type: string + format: date-time + OrderPlaced: + name: OrderPlaced + title: Commande passée + summary: Émis lorsqu'une commande est créée + payload: + type: object + required: + - orderId + - userId + - total + properties: + orderId: + type: string + format: uuid + userId: + type: string + format: uuid + total: + type: number + format: float + items: + type: array + items: + type: object + properties: + productId: + type: string + quantity: + type: integer diff --git a/documentation/files-samples-oas/petstore.yaml b/documentation/files-samples-oas/petstore.yaml new file mode 100644 index 0000000..4e27976 --- /dev/null +++ b/documentation/files-samples-oas/petstore.yaml @@ -0,0 +1,96 @@ +openapi: "3.0.0" +info: + title: Petstore API + version: "1.0.0" + description: API de gestion d'une animalerie +paths: + /pets: + get: + summary: Liste des animaux + operationId: listPets + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 100 + responses: + "200": + description: Liste des animaux + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + post: + summary: Créer un animal + operationId: createPet + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewPet" + responses: + "201": + description: Animal créé + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + /pets/{id}: + get: + summary: Récupérer un animal par ID + operationId: getPet + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Animal trouvé + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "404": + description: Animal non trouvé + delete: + summary: Supprimer un animal + operationId: deletePet + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "204": + description: Animal supprimé +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + tag: + type: string + NewPet: + type: object + required: + - name + properties: + name: + type: string + tag: + type: string diff --git a/front-public/src/app/pages/browse/browse.component.ts b/front-public/src/app/pages/browse/browse.component.ts index 4405f3f..4094a97 100644 --- a/front-public/src/app/pages/browse/browse.component.ts +++ b/front-public/src/app/pages/browse/browse.component.ts @@ -12,7 +12,7 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { ActivatedRoute } from '@angular/router'; import { CategoryService } from '../../core/category.service'; -import { CategoryBrowseResponse, Category } from '@datacat/shared'; +import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/shared'; @Component({ selector: 'app-browse', @@ -32,15 +32,25 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared'; {{ crumb.name }} } - @if (data()?.category) { + @if (browseData()?.category) {
  • - {{ data()!.category!.name }} + {{ browseData()!.category!.name }}
  • } -

    {{ data()?.category?.name ?? 'Parcourir le catalogue' }}

    + +
    +
    +

    {{ browseData()?.category?.name ?? 'Catalogue' }}

    +
    +
    + +
    +
    @if (loading()) {
    @@ -54,12 +64,12 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
    } - @if (!loading() && data()) { + @if (!loading() && browseData()) { - @if (data()!.subcategories.length > 0) { + @if (browseData()!.subcategories.length > 0) {

    Catégories

    - @for (cat of data()!.subcategories; track cat.id) { + @for (cat of browseData()!.subcategories; track cat.id) {
    @@ -82,10 +104,10 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared'; } - @if (data()!.apis.length > 0) { + @if (browseData()!.apis.length > 0) {

    APIs

    - @for (api of data()!.apis; track api.id) { + @for (api of browseData()!.apis; track api.id) {
    `, }) @@ -123,13 +194,23 @@ export class BrowseComponent implements OnInit { private categoryService = inject(CategoryService); private destroyRef = inject(DestroyRef); - data = signal(null); + browseData = signal(null); loading = signal(false); error = signal(null); + categoryId = signal(null); + + /* Signaux pour la modale */ + showFormModal = signal(false); + formMode = signal<'create' | 'edit'>('create'); + editingCategoryId = signal(null); + formName = signal(''); + formDescription = signal(''); + formError = signal(null); + formLoading = signal(false); /* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */ ancestors = computed(() => { - const d = this.data(); + const d = this.browseData(); if (!d || !d.category) return []; return d.breadcrumb.slice(0, -1); }); @@ -138,16 +219,17 @@ export class BrowseComponent implements OnInit { this.route.paramMap .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { - this.loadBrowse(params.get('id')); + this.categoryId.set(params.get('id')); + this.loadBrowse(); }); } - private loadBrowse(id: string | null) { + loadBrowse() { this.loading.set(true); this.error.set(null); - this.categoryService.browse(id).subscribe({ + this.categoryService.browse(this.categoryId()).subscribe({ next: (data) => { - this.data.set(data); + this.browseData.set(data); this.loading.set(false); }, error: () => { @@ -157,6 +239,69 @@ export class BrowseComponent implements OnInit { }); } + openCreate() { + this.formMode.set('create'); + this.editingCategoryId.set(null); + this.formName.set(''); + this.formDescription.set(''); + this.formError.set(null); + this.showFormModal.set(true); + } + + openEdit(cat: CategoryWithCounts) { + this.formMode.set('edit'); + this.editingCategoryId.set(cat.id); + this.formName.set(cat.name); + this.formDescription.set(cat.description ?? ''); + this.formError.set(null); + this.showFormModal.set(true); + } + + closeModal() { + this.showFormModal.set(false); + } + + submitForm() { + if (!this.formName().trim()) { + this.formError.set('Le nom est obligatoire.'); + return; + } + this.formLoading.set(true); + const name = this.formName().trim(); + const description = this.formDescription().trim() || undefined; + + const obs = + this.formMode() === 'create' + ? this.categoryService.create({ + name, + description, + parentId: this.categoryId() ?? undefined, + }) + : this.categoryService.update(this.editingCategoryId()!, { name, description }); + + obs.subscribe({ + next: () => { + this.formLoading.set(false); + this.showFormModal.set(false); + this.loadBrowse(); + }, + error: (err: { error?: { message?: string } }) => { + this.formLoading.set(false); + this.formError.set(err?.error?.message ?? 'Une erreur est survenue.'); + }, + }); + } + + deleteCategory(cat: CategoryWithCounts) { + if (cat.apiCount > 0 || cat.subcategoryCount > 0) return; + if (!confirm(`Supprimer le dossier "${cat.name}" ?`)) return; + this.categoryService.delete(cat.id).subscribe({ + next: () => this.loadBrowse(), + error: (err: { error?: { message?: string } }) => + alert(err?.error?.message ?? 'Erreur lors de la suppression.'), + }); + } + typeBadgeClass(type: string): string { return type === 'ASYNCAPI' ? 'fr-badge fr-badge--sm fr-badge--purple-glycine'