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

@@ -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';
<a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a>
</li>
}
@if (data()?.category) {
@if (browseData()?.category) {
<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>
}
</ol>
</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()) {
<div class="fr-callout fr-mb-4w">
@@ -54,12 +64,12 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
</div>
}
@if (!loading() && data()) {
@if (!loading() && browseData()) {
<!-- Sous-catégories -->
@if (data()!.subcategories.length > 0) {
@if (browseData()!.subcategories.length > 0) {
<h2 class="fr-h4 fr-mb-3w">Catégories</h2>
<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-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
@@ -73,6 +83,18 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
<p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</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>
@@ -82,10 +104,10 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
}
<!-- APIs -->
@if (data()!.apis.length > 0) {
@if (browseData()!.apis.length > 0) {
<h2 class="fr-h4 fr-mb-3w">APIs</h2>
<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-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
@@ -109,12 +131,61 @@ import { CategoryBrowseResponse, Category } from '@datacat/shared';
</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">
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
</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>
`,
})
@@ -123,13 +194,23 @@ export class BrowseComponent implements OnInit {
private categoryService = inject(CategoryService);
private destroyRef = inject(DestroyRef);
data = signal<CategoryBrowseResponse | null>(null);
browseData = signal<CategoryBrowseResponse | null>(null);
loading = signal(false);
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) */
ancestors = computed<Category[]>(() => {
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'