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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user