feat(browse): gestion CRUD des dossiers depuis la page Browse

- 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 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
z3n
2026-05-22 13:59:16 +00:00
parent 921a6e652b
commit 93fadb43f9
4 changed files with 376 additions and 31 deletions

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm'; import { Repository, IsNull } from 'typeorm';
import { CategoryEntity } from './category.entity'; import { CategoryEntity } from './category.entity';
@@ -61,20 +61,23 @@ export class CategoriesService {
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
await this.findOneOrThrow(id); await this.findOneOrThrow(id);
/* Désassocier les APIs enfants */
await this.apiRepo /* Vérifier qu'aucune API n'est rattachée */
.createQueryBuilder() const apiCount = await this.apiRepo.count({ where: { categoryId: id } });
.update(ApiEntryEntity) if (apiCount > 0) {
.set({ categoryId: null }) throw new BadRequestException(
.where('category_id = :id', { id }) `Ce dossier contient ${apiCount} API(s). Déplacez-les avant de le supprimer.`,
.execute(); );
/* Détacher les sous-catégories enfants */ }
await this.categoryRepo
.createQueryBuilder() /* Vérifier qu'aucun sous-dossier n'existe */
.update(CategoryEntity) const subCount = await this.categoryRepo.count({ where: { parentId: id } });
.set({ parentId: null }) if (subCount > 0) {
.where('parent_id = :id', { id }) throw new BadRequestException(
.execute(); `Ce dossier contient ${subCount} sous-dossier(s). Supprimez-les d'abord.`,
);
}
await this.categoryRepo.delete(id); await this.categoryRepo.delete(id);
} }

View File

@@ -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

View File

@@ -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

View File

@@ -12,7 +12,7 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { CategoryService } from '../../core/category.service'; import { CategoryService } from '../../core/category.service';
import { CategoryBrowseResponse, Category } from '@datacat/shared'; import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/shared';
@Component({ @Component({
selector: 'app-browse', selector: 'app-browse',
@@ -32,15 +32,25 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
<a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a> <a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a>
</li> </li>
} }
@if (data()?.category) { @if (browseData()?.category) {
<li> <li>
<a class="fr-breadcrumb__link" aria-current="page">{{ data()!.category!.name }}</a> <a class="fr-breadcrumb__link" aria-current="page">{{ browseData()!.category!.name }}</a>
</li> </li>
} }
</ol> </ol>
</nav> </nav>
<h1 class="fr-h1 fr-mb-4w">{{ data()?.category?.name ?? 'Parcourir le catalogue' }}</h1> <!-- En-tête -->
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h1 class="fr-h2 fr-mb-0">{{ browseData()?.category?.name ?? 'Catalogue' }}</h1>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--icon-left fr-icon-folder-2-fill" (click)="openCreate()">
Nouveau dossier
</button>
</div>
</div>
@if (loading()) { @if (loading()) {
<div class="fr-callout fr-mb-4w"> <div class="fr-callout fr-mb-4w">
@@ -54,12 +64,12 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
</div> </div>
} }
@if (!loading() && data()) { @if (!loading() && browseData()) {
<!-- Sous-catégories --> <!-- Sous-catégories -->
@if (data()!.subcategories.length > 0) { @if (browseData()!.subcategories.length > 0) {
<h2 class="fr-h4 fr-mb-3w">Catégories</h2> <h2 class="fr-h4 fr-mb-3w">Catégories</h2>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w"> <div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of data()!.subcategories; track cat.id) { @for (cat of browseData()!.subcategories; track cat.id) {
<div class="fr-col-12 fr-col-md-4"> <div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm"> <div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body"> <div class="fr-tile__body">
@@ -73,6 +83,18 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
<p class="fr-tile__detail"> <p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s) {{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</p> </p>
<div style="position:relative;z-index:1;margin-top:0.5rem;display:flex;gap:0.25rem;order:5">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-edit-line"
(click)="openEdit(cat); $event.stopPropagation()">
Modifier
</button>
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-bin-line"
(click)="deleteCategory(cat); $event.stopPropagation()">
Supprimer
</button>
}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -82,10 +104,10 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
} }
<!-- APIs --> <!-- APIs -->
@if (data()!.apis.length > 0) { @if (browseData()!.apis.length > 0) {
<h2 class="fr-h4 fr-mb-3w">APIs</h2> <h2 class="fr-h4 fr-mb-3w">APIs</h2>
<div class="fr-grid-row fr-grid-row--gutters"> <div class="fr-grid-row fr-grid-row--gutters">
@for (api of data()!.apis; track api.id) { @for (api of browseData()!.apis; track api.id) {
<div class="fr-col-12 fr-col-md-4"> <div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm"> <div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body"> <div class="fr-tile__body">
@@ -109,12 +131,61 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
</div> </div>
} }
@if (data()!.subcategories.length === 0 && data()!.apis.length === 0) { @if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w"> <div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p> <p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
</div> </div>
} }
} }
<!-- Modale création / édition (overlay Angular, sans fr-modal pour éviter l'interférence du JS DSFR) -->
@if (showFormModal()) {
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:540px;margin:0 1rem 2rem">
<div class="fr-p-4w">
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h2 id="modal-title" class="fr-h3 fr-mb-0">
{{ formMode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="closeModal()">✕</button>
</div>
</div>
@if (formError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ formError() }}</p>
</div>
}
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="form-name">
Nom <span class="fr-hint-text">Obligatoire</span>
</label>
<input id="form-name" class="fr-input" type="text"
[value]="formName()"
(input)="formName.set($any($event.target).value)" />
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="form-desc">
Description <span class="fr-hint-text">Optionnel</span>
</label>
<textarea id="form-desc" class="fr-input" rows="3"
[value]="formDescription()"
(input)="formDescription.set($any($event.target).value)"></textarea>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="closeModal()">Annuler</button>
<button class="fr-btn" (click)="submitForm()" [disabled]="formLoading()">
{{ formMode() === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</div>
}
</div> </div>
`, `,
}) })
@@ -123,13 +194,23 @@ export class BrowseComponent implements OnInit {
private categoryService = inject(CategoryService); private categoryService = inject(CategoryService);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
data = signal<CategoryBrowseResponse | null>(null); browseData = signal<CategoryBrowseResponse | null>(null);
loading = signal(false); loading = signal(false);
error = signal<string | null>(null); error = signal<string | null>(null);
categoryId = signal<string | null>(null);
/* Signaux pour la modale */
showFormModal = signal(false);
formMode = signal<'create' | 'edit'>('create');
editingCategoryId = signal<string | null>(null);
formName = signal('');
formDescription = signal('');
formError = signal<string | null>(null);
formLoading = signal(false);
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */ /* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
ancestors = computed<Category[]>(() => { ancestors = computed<Category[]>(() => {
const d = this.data(); const d = this.browseData();
if (!d || !d.category) return []; if (!d || !d.category) return [];
return d.breadcrumb.slice(0, -1); return d.breadcrumb.slice(0, -1);
}); });
@@ -138,16 +219,17 @@ export class BrowseComponent implements OnInit {
this.route.paramMap this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .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.loading.set(true);
this.error.set(null); this.error.set(null);
this.categoryService.browse(id).subscribe({ this.categoryService.browse(this.categoryId()).subscribe({
next: (data) => { next: (data) => {
this.data.set(data); this.browseData.set(data);
this.loading.set(false); this.loading.set(false);
}, },
error: () => { 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 { typeBadgeClass(type: string): string {
return type === 'ASYNCAPI' return type === 'ASYNCAPI'
? 'fr-badge fr-badge--sm fr-badge--purple-glycine' ? 'fr-badge fr-badge--sm fr-badge--purple-glycine'