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:
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
101
documentation/files-samples-asyncapi/user-events.yaml
Normal file
101
documentation/files-samples-asyncapi/user-events.yaml
Normal 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
|
||||
96
documentation/files-samples-oas/petstore.yaml
Normal file
96
documentation/files-samples-oas/petstore.yaml
Normal 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
|
||||
@@ -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