Files
datacat/front-public/src/app/pages/api-detail/api-detail.component.ts
z3n b9d45c0c3c feat(api-detail): fil d'Ariane aligné sur la hiérarchie des catégories
Accueil > [ancêtres de catégorie] > [titre API], ou
Accueil > Parcourir > [titre API] si l'API n'a pas de catégorie.

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>
2026-06-11 13:54:24 +00:00

533 lines
22 KiB
TypeScript

import {
Component,
signal,
inject,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { ApiEntry, Category, ApiConvention } from '@datacat/shared';
@Component({
selector: 'app-api-detail',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
<!-- Fil d'Ariane -->
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li>
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</li>
@if (categoryBreadcrumb().length === 0) {
<li><a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a></li>
}
@for (crumb of categoryBreadcrumb(); track crumb.id) {
<li><a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a></li>
}
<li>
<a class="fr-breadcrumb__link" aria-current="page">
{{ api()?.title ?? 'Chargement...' }}
</a>
</li>
</ol>
</nav>
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (error()) {
<div class="fr-alert fr-alert--error fr-mb-4w">
<p class="fr-alert__title">Erreur</p>
<p>{{ error() }}</p>
</div>
}
@if (api(); as entry) {
<!-- 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-1w">{{ entry.title }}</h1>
<div class="fr-tags-group">
<span class="fr-tag">{{ entry.type }}</span>
<span class="fr-tag">v{{ entry.version }}</span>
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
</div>
</div>
</div>
<!-- Alerte statut PENDING -->
@if (entry.status === 'PENDING') {
<div class="fr-alert fr-alert--info fr-mb-3w">
<p class="fr-alert__title">Génération en cours</p>
<p>La documentation est en cours de génération. Cette page se rafraîchit automatiquement.</p>
</div>
}
<!-- Alerte statut ERROR -->
@if (entry.status === 'ERROR') {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Erreur de génération</p>
<p>{{ entry.errorMessage }}</p>
</div>
}
<!-- Métadonnées -->
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Informations</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Version</dt>
<dd>{{ entry.version }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Type</dt>
<dd>{{ entry.type }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Fournisseur</dt>
<dd>{{ entry.provider }}</dd>
</div>
@if (entry.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ entry.description }}</dd>
</div>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Créé le</dt>
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Mis à jour le</dt>
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- Contacts -->
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Contacts</h2>
<div class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-6">
<p class="fr-text--bold fr-mb-1w">Contact métier</p>
<p class="fr-mb-0">{{ entry.contactFunctionalName }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEntity }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEmail }}</p>
</div>
<div class="fr-col-12 fr-col-md-6">
<p class="fr-text--bold fr-mb-1w">Contact technique</p>
<p class="fr-mb-0">{{ entry.contactTechnicalName }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEntity }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEmail }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="fr-btns-group fr-btns-group--inline-sm">
<a
[routerLink]="['/catalog', entry.id, 'docs']"
class="fr-btn"
[class.fr-btn--disabled]="entry.status !== 'GENERATED'"
[attr.aria-disabled]="entry.status !== 'GENERATED' ? 'true' : null"
>
Voir la documentation
</a>
<button class="fr-btn fr-btn--secondary" (click)="openEdit()">
Modifier
</button>
<a
[href]="'/api/apis/' + entry.id + '/yaml'"
class="fr-btn fr-btn--secondary"
download
>
Télécharger YAML
</a>
<button
class="fr-btn fr-btn--secondary"
(click)="onRegenerate()"
[disabled]="regenerating()"
>
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
</button>
<button
class="fr-btn fr-btn--secondary"
(click)="onDelete()"
[disabled]="deleting()"
>
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
</button>
</div>
}
</div>
<!-- Modale édition -->
@if (showEditModal()) {
<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="edit-modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:640px;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="edit-modal-title" class="fr-h3 fr-mb-0">Modifier l'API</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm" (click)="closeEdit()">✕</button>
</div>
</div>
@if (editError()) {
<div class="fr-alert fr-alert--error fr-mb-2w"><p>{{ editError() }}</p></div>
}
<!-- Convention + Nom -->
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-select-group">
<label class="fr-label" for="edit-convention">Convention</label>
<select id="edit-convention" class="fr-select" [value]="editConvention()"
(change)="editConvention.set($any($event.target).value)">
<option value="CONSULTER">Consulter</option>
<option value="ENREGISTRER">Enregistrer</option>
<option value="ETRE_NOTIFIE">Être notifié</option>
</select>
</div>
</div>
<div class="fr-col-12 fr-col-md-8">
<div class="fr-input-group" [class.fr-input-group--error]="editNameError()">
<label class="fr-label" for="edit-name">Nom de l'API</label>
<input id="edit-name" class="fr-input" [class.fr-input--error]="editNameError()" type="text"
[value]="editName()"
(input)="editName.set($any($event.target).value); editNameError.set(false)"
(blur)="editNameError.set(!editName().trim())" />
@if (editNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
</div>
</div>
</div>
<p class="fr-hint-text fr-mb-2w">Titre : <strong>{{ editPreviewTitle() }}</strong></p>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-2w">
<label class="fr-label" for="edit-category">Catégorie</label>
<select id="edit-category" class="fr-select" [value]="editCategoryId()"
(change)="editCategoryId.set($any($event.target).value || null)">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.name }}</option>
}
</select>
</div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editProviderError()">
<label class="fr-label" for="edit-provider">Fournisseur <span style="color:var(--text-default-error)">*</span></label>
<input id="edit-provider" class="fr-input" [class.fr-input--error]="editProviderError()" type="text"
[value]="editProvider()"
(input)="editProvider.set($any($event.target).value); editProviderError.set(false)"
(blur)="editProviderError.set(!editProvider().trim())" />
@if (editProviderError()) { <p class="fr-error-text">Le fournisseur est obligatoire.</p> }
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-description">Description</label>
<textarea id="edit-description" class="fr-input" rows="3" [value]="editDescription()"
(input)="editDescription.set($any($event.target).value)"></textarea>
</div>
<!-- Version -->
<p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-2w">
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-v-major">Majeure (X)</label>
<input id="edit-v-major" class="fr-input" type="number" min="0"
[value]="editVersionMajor()"
(input)="editVersionMajor.set(+$any($event.target).value)" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-v-minor">Mineure (Y)</label>
<input id="edit-v-minor" class="fr-input" type="number" min="0"
[value]="editVersionMinor()"
(input)="editVersionMinor.set(+$any($event.target).value)" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-v-patch">Correctif (Z)</label>
<input id="edit-v-patch" class="fr-input" type="number" min="0"
[value]="editVersionPatch()"
(input)="editVersionPatch.set(+$any($event.target).value)" />
</div>
</div>
</div>
<!-- Contact métier -->
<p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-2w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-cf-name">Nom et prénom</label>
<input id="edit-cf-name" class="fr-input" type="text" [value]="editContactFunctionalName()"
(input)="editContactFunctionalName.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-cf-entity">Entité</label>
<input id="edit-cf-entity" class="fr-input" type="text" [value]="editContactFunctionalEntity()"
(input)="editContactFunctionalEntity.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-cf-email">Email</label>
<input id="edit-cf-email" class="fr-input" type="email" [value]="editContactFunctionalEmail()"
(input)="editContactFunctionalEmail.set($any($event.target).value)" />
</div>
</div>
</div>
<!-- Contact technique -->
<p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-ct-name">Nom et prénom</label>
<input id="edit-ct-name" class="fr-input" type="text" [value]="editContactTechnicalName()"
(input)="editContactTechnicalName.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-ct-entity">Entité</label>
<input id="edit-ct-entity" class="fr-input" type="text" [value]="editContactTechnicalEntity()"
(input)="editContactTechnicalEntity.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-ct-email">Email</label>
<input id="edit-ct-email" class="fr-input" type="email" [value]="editContactTechnicalEmail()"
(input)="editContactTechnicalEmail.set($any($event.target).value)" />
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="closeEdit()">Annuler</button>
<button class="fr-btn" (click)="submitEdit()" [disabled]="editLoading()">
{{ editLoading() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</div>
}
`,
})
export class ApiDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
api = signal<ApiEntry | null>(null);
categoryBreadcrumb = signal<Category[]>([]);
loading = signal(false);
error = signal<string | null>(null);
regenerating = signal(false);
deleting = signal(false);
/* Modale édition */
showEditModal = signal(false);
editLoading = signal(false);
editError = signal<string | null>(null);
editNameError = signal(false);
editProviderError = signal(false);
categories = signal<Category[]>([]);
editConvention = signal<ApiConvention>('CONSULTER');
editName = signal('');
editProvider = signal('');
editVersionMajor = signal(0);
editVersionMinor = signal(0);
editVersionPatch = signal(0);
editCategoryId = signal<string | null>(null);
editDescription = signal('');
editContactFunctionalName = signal('');
editContactFunctionalEntity = signal('');
editContactFunctionalEmail = signal('');
editContactTechnicalName = signal('');
editContactTechnicalEntity = signal('');
editContactTechnicalEmail = signal('');
editPreviewTitle() {
const labels: Record<ApiConvention, string> = { CONSULTER: 'Consulter', ENREGISTRER: 'Enregistrer', ETRE_NOTIFIE: 'Être notifié' };
return `${labels[this.editConvention()]} ${this.editName()}`.trim();
}
private pollInterval: ReturnType<typeof setInterval> | null = null;
private id = '';
ngOnInit() {
this.id = this.route.snapshot.paramMap.get('id') ?? '';
this.loadApi();
}
ngOnDestroy() {
this.clearPoll();
}
onRegenerate() {
this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.regenerating.set(false);
this.startPollIfPending();
},
error: () => this.regenerating.set(false),
});
}
openEdit() {
const entry = this.api();
if (!entry) return;
this.editConvention.set(entry.convention);
this.editName.set(entry.name);
this.editProvider.set(entry.provider);
this.editVersionMajor.set(entry.versionMajor);
this.editVersionMinor.set(entry.versionMinor);
this.editVersionPatch.set(entry.versionPatch);
this.editCategoryId.set(entry.categoryId ?? null);
this.editDescription.set(entry.description ?? '');
this.editContactFunctionalName.set(entry.contactFunctionalName);
this.editContactFunctionalEntity.set(entry.contactFunctionalEntity);
this.editContactFunctionalEmail.set(entry.contactFunctionalEmail);
this.editContactTechnicalName.set(entry.contactTechnicalName);
this.editContactTechnicalEntity.set(entry.contactTechnicalEntity);
this.editContactTechnicalEmail.set(entry.contactTechnicalEmail);
this.editError.set(null);
this.editNameError.set(false);
this.editProviderError.set(false);
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
this.showEditModal.set(true);
}
closeEdit() {
this.showEditModal.set(false);
}
submitEdit() {
this.editNameError.set(!this.editName().trim());
this.editProviderError.set(!this.editProvider().trim());
if (!this.editName().trim() || !this.editProvider().trim()) return;
this.editLoading.set(true);
this.apiService.update(this.id, {
convention: this.editConvention(),
name: this.editName().trim(),
provider: this.editProvider().trim(),
versionMajor: this.editVersionMajor(),
versionMinor: this.editVersionMinor(),
versionPatch: this.editVersionPatch(),
categoryId: this.editCategoryId() || null,
description: this.editDescription().trim() || null,
contactFunctionalName: this.editContactFunctionalName().trim(),
contactFunctionalEntity: this.editContactFunctionalEntity().trim(),
contactFunctionalEmail: this.editContactFunctionalEmail().trim(),
contactTechnicalName: this.editContactTechnicalName().trim(),
contactTechnicalEntity: this.editContactTechnicalEntity().trim(),
contactTechnicalEmail: this.editContactTechnicalEmail().trim(),
}).subscribe({
next: (entry) => {
this.api.set(entry);
this.editLoading.set(false);
this.showEditModal.set(false);
},
error: (err: { error?: { message?: string } }) => {
this.editLoading.set(false);
this.editError.set(err?.error?.message ?? 'Une erreur est survenue.');
},
});
}
onDelete() {
if (!confirm('Supprimer cette API ?')) return;
this.deleting.set(true);
this.apiService.delete(this.id).subscribe({
next: () => this.router.navigate(['/catalog']),
error: () => this.deleting.set(false),
});
}
badgeClass(status: string): string {
if (status === 'GENERATED') return 'fr-badge fr-badge--success';
if (status === 'ERROR') return 'fr-badge fr-badge--error';
return 'fr-badge fr-badge--info';
}
private loadApi() {
this.loading.set(true);
this.apiService.get(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.loading.set(false);
this.startPollIfPending();
if (entry.categoryId) {
this.categoryService.browse(entry.categoryId).subscribe({
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
error: () => {},
});
}
},
error: (err) => {
this.error.set(err.message ?? 'Erreur lors du chargement');
this.loading.set(false);
},
});
}
private startPollIfPending() {
this.clearPoll();
if (this.api()?.status === 'PENDING') {
this.pollInterval = setInterval(() => this.pollStatus(), 3000);
}
}
private pollStatus() {
this.apiService.get(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
if (entry.status !== 'PENDING') this.clearPoll();
},
});
}
private clearPoll() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
}