feat(browse): tag version sur les tuiles + statut francisé sur la fiche détail

- Supprime le badge statut (GENERATED/PENDING/ERROR) des tuiles browse et cartes search
- Ajoute un badge version (ex: v1.2.0) sur les tuiles et cartes
- Traduit le statut en français sur la fiche détail (Générée / En attente / Erreur)

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-06-17 14:50:51 +00:00
parent dbf5af81ca
commit eb07ca085f
2 changed files with 474 additions and 373 deletions

View File

@@ -8,16 +8,17 @@ import {
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { ApiEntry, ApiEntryListItem, Category, ApiConvention } from '@datacat/shared';
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
@Component({
selector: 'app-api-detail',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
@@ -55,310 +56,357 @@ import { ApiEntry, ApiEntryListItem, Category, ApiConvention } from '@datacat/sh
}
@if (api(); as entry) {
<!-- Retour -->
<a class="fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w" style="display:inline-flex;"
[routerLink]="backUrl()">Retour</a>
<!-- 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>
<div class="fr-mb-3w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<!-- Gauche : titre + version -->
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;min-width:0;">
<h1 class="fr-h2 fr-mb-0">{{ entry.title }}</h1>
@if (versions().length <= 1) {
<span class="fr-tag">v{{ entry.version }}</span>
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
} @else {
<select class="fr-select" id="version-select" style="width:auto;" (change)="onVersionChange($event)">
@for (v of versions(); track v.id) {
<option [value]="v.id" [selected]="v.id === entry.id">
{{ v.version }}{{ v.isCurrent ? ' (courante)' : '' }}
</option>
}
</select>
}
</div>
<!-- Droite : bouton Actions + dropdown -->
<div style="position:relative;flex-shrink:0;">
<button class="fr-btn fr-btn--secondary fr-btn--icon-right fr-icon-arrow-down-s-line"
(click)="actionsOpen.set(!actionsOpen())">
Actions
</button>
@if (actionsOpen()) {
<div style="position:absolute;right:0;top:calc(100% + 4px);z-index:1000;background:#fff;border:1px solid #ddd;box-shadow:0 4px 12px rgba(0,0,0,.15);min-width:220px;border-radius:4px;overflow:hidden;">
<a [routerLink]="['/catalog', entry.id, 'docs']"
(click)="actionsOpen.set(false)"
style="display:block;padding:0.75rem 1rem;text-decoration:none;color:#161616;border-bottom:1px solid #eee;"
[style.opacity]="entry.status !== 'GENERATED' ? '0.5' : '1'"
[style.pointer-events]="entry.status !== 'GENERATED' ? 'none' : 'auto'">
Voir la documentation
</a>
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;border-bottom:1px solid #eee;cursor:pointer;color:#161616;"
(click)="onNewVersion(); actionsOpen.set(false)">
Nouvelle version
</button>
<a [href]="'/api/apis/' + entry.id + '/yaml'"
download
(click)="actionsOpen.set(false)"
style="display:block;padding:0.75rem 1rem;text-decoration:none;color:#161616;border-bottom:1px solid #eee;">
Télécharger YAML
</a>
@if (entry.status !== 'GENERATED') {
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;border-bottom:1px solid #eee;cursor:pointer;color:#161616;"
[disabled]="regenerating()"
(click)="onRegenerate(); actionsOpen.set(false)">
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
</button>
}
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;cursor:pointer;color:#CE0500;"
[disabled]="deleting()"
(click)="onDelete(); actionsOpen.set(false)">
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
</button>
</div>
}
</div>
</div>
<!-- Navigation onglets -->
<div style="display:flex;border-bottom:2px solid #e5e5e5;margin-bottom:1.5rem;">
<button (click)="activeTab.set('info')"
[style.border-bottom]="activeTab()==='info' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='info' ? '#000091' : '#3a3a3a'"
[style.font-weight]="activeTab()==='info' ? '700' : '400'"
style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
Informations
</button>
<button (click)="activeTab.set('contacts')"
[style.border-bottom]="activeTab()==='contacts' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='contacts' ? '#000091' : '#3a3a3a'"
[style.font-weight]="activeTab()==='contacts' ? '700' : '400'"
style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
Contacts
</button>
</div>
<!-- Onglet Informations -->
@if (activeTab() === 'info') {
@if (!editingInfo()) {
<div class="fr-card fr-mb-3w">
<div class="fr-card__body">
<div class="fr-card__content">
<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 style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
{{ entry.version }}
@if (!entry.isCurrent) {
<button class="fr-btn fr-btn--sm fr-btn--secondary" [disabled]="settingCurrent()"
(click)="onSetCurrent()">
{{ settingCurrent() ? '...' : 'Passer en courante' }}
</button>
}
</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">Statut</dt>
<dd>
<span [class]="badgeClass(entry.status)">{{ statusLabel(entry.status) }}</span>
@if (entry.status === 'ERROR' && entry.errorMessage) {
<p class="fr-text--sm fr-mt-1w">{{ entry.errorMessage }}</p>
}
</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 class="fr-mt-2w">
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-edit-line" (click)="startEditInfo()">
Modifier les informations
</button>
</div>
</div>
</div>
</div>
@if (versions().length > 1) {
<div class="fr-select-group fr-mt-2w" style="max-width:200px">
<label class="fr-label" for="version-select">Version</label>
<select class="fr-select" id="version-select" (change)="onVersionChange($event)">
@for (v of versions(); track v.id) {
<option [value]="v.id" [selected]="v.id === entry.id">{{ v.version }}</option>
}
@if (editingInfo()) {
<form [formGroup]="infoForm" (ngSubmit)="submitInfo()" novalidate class="fr-mb-3w">
<!-- 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="info-convention">Convention</label>
<select id="info-convention" class="fr-select" formControlName="convention">
<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]="infoForm.get('name')?.invalid && infoForm.get('name')?.touched">
<label class="fr-label" for="info-name">Nom de l'API <span style="color:var(--text-default-error)">*</span></label>
<input id="info-name" class="fr-input" type="text" formControlName="name"
[class.fr-input--error]="infoForm.get('name')?.invalid && infoForm.get('name')?.touched" />
@if (infoForm.get('name')?.invalid && infoForm.get('name')?.touched) {
<p class="fr-error-text">Le nom est obligatoire.</p>
}
</div>
</div>
</div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-2w"
[class.fr-input-group--error]="infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched">
<label class="fr-label" for="info-provider">Fournisseur <span style="color:var(--text-default-error)">*</span></label>
<input id="info-provider" class="fr-input" type="text" formControlName="provider"
[class.fr-input--error]="infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched" />
@if (infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched) {
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
}
</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="info-v-major">Majeure (X)</label>
<input id="info-v-major" class="fr-input" type="number" min="0" formControlName="versionMajor" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="info-v-minor">Mineure (Y)</label>
<input id="info-v-minor" class="fr-input" type="number" min="0" formControlName="versionMinor" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="info-v-patch">Correctif (Z)</label>
<input id="info-v-patch" class="fr-input" type="number" min="0" formControlName="versionPatch" />
</div>
</div>
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="info-description">Description</label>
<textarea id="info-description" class="fr-input" rows="3" formControlName="description"></textarea>
</div>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="info-category">Catégorie</label>
<select id="info-category" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.name }}</option>
}
</select>
</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>
<div class="fr-btns-group fr-btns-group--inline fr-btns-group--right">
<button type="button" class="fr-btn fr-btn--secondary" (click)="editingInfo.set(false)">Annuler</button>
<button type="submit" class="fr-btn" [disabled]="savingInfo()">
{{ savingInfo() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</form>
}
}
<!-- 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>
<!-- Onglet Contacts -->
@if (activeTab() === 'contacts') {
@if (!editingContacts()) {
<div class="fr-card fr-mb-3w">
<div class="fr-card__body">
<div class="fr-card__content">
<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 class="fr-mt-2w">
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-edit-line" (click)="startEditContacts()">
Modifier les contacts
</button>
</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>
}
@if (editingContacts()) {
<form [formGroup]="contactForm" (ngSubmit)="submitContacts()" novalidate class="fr-mb-3w">
<!-- 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-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched">
<label class="fr-label" for="cf-name">Nom et prénom</label>
<input id="cf-name" class="fr-input" type="text" formControlName="contactFunctionalName" placeholder="Prénom Nom"
[class.fr-input--error]="contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched" />
@if (contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched">
<label class="fr-label" for="cf-entity">Entité</label>
<input id="cf-entity" class="fr-input" type="text" formControlName="contactFunctionalEntity" placeholder="Direction XYZ"
[class.fr-input--error]="contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched" />
@if (contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched">
<label class="fr-label" for="cf-email">Email</label>
<input id="cf-email" class="fr-input" type="email" formControlName="contactFunctionalEmail" placeholder="prenom.nom@example.fr"
[class.fr-input--error]="contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched" />
@if (contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched) {
<p class="fr-error-text">Email valide requis.</p>
}
</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"
[class.fr-input-group--error]="contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched">
<label class="fr-label" for="ct-name">Nom et prénom</label>
<input id="ct-name" class="fr-input" type="text" formControlName="contactTechnicalName" placeholder="Prénom Nom"
[class.fr-input--error]="contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched" />
@if (contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched">
<label class="fr-label" for="ct-entity">Entité</label>
<input id="ct-entity" class="fr-input" type="text" formControlName="contactTechnicalEntity" placeholder="Équipe Technique"
[class.fr-input--error]="contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched" />
@if (contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched">
<label class="fr-label" for="ct-email">Email</label>
<input id="ct-email" class="fr-input" type="email" formControlName="contactTechnicalEmail" placeholder="tech@example.fr"
[class.fr-input--error]="contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched" />
@if (contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--inline fr-btns-group--right">
<button type="button" class="fr-btn fr-btn--secondary" (click)="editingContacts.set(false)">Annuler</button>
<button type="submit" class="fr-btn" [disabled]="savingContacts()">
{{ savingContacts() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</form>
}
}
<!-- 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>
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line" (click)="onNewVersion()">
Nouvelle version
</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 {
@@ -366,6 +414,7 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private router = inject(Router);
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private fb = inject(FormBuilder);
api = signal<ApiEntry | null>(null);
versions = signal<ApiEntryListItem[]>([]);
@@ -374,32 +423,41 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
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);
settingCurrent = 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('');
actionsOpen = signal(false);
editPreviewTitle = computed(() => {
const labels: Record<ApiConvention, string> = { CONSULTER: 'Consulter', ENREGISTRER: 'Enregistrer', ETRE_NOTIFIE: 'Être notifié' };
return `${labels[this.editConvention()]} ${this.editName()}`.trim();
backUrl = computed<string[]>(() => {
const crumbs = this.categoryBreadcrumb();
if (crumbs.length > 0) return ['/browse', crumbs[crumbs.length - 1].id];
return ['/browse'];
});
/* Onglets */
activeTab = signal<'info' | 'contacts'>('info');
editingInfo = signal(false);
editingContacts = signal(false);
savingInfo = signal(false);
savingContacts = signal(false);
infoForm = this.fb.group({
convention: ['CONSULTER', Validators.required],
name: ['', Validators.required],
provider: ['', Validators.required],
versionMajor: [0, [Validators.required, Validators.min(0)]],
versionMinor: [0, [Validators.required, Validators.min(0)]],
versionPatch: [0, [Validators.required, Validators.min(0)]],
description: [''],
categoryId: [''],
});
contactForm = this.fb.group({
contactFunctionalName: ['', Validators.required],
contactFunctionalEntity: ['', Validators.required],
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
contactTechnicalName: ['', Validators.required],
contactTechnicalEntity: ['', Validators.required],
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
});
private pollInterval: ReturnType<typeof setInterval> | null = null;
@@ -407,12 +465,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private id = '';
ngOnInit() {
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
this.routeSub = this.route.paramMap.subscribe((params) => {
this.id = params.get('id') ?? '';
this.api.set(null);
this.versions.set([]);
this.error.set(null);
this.categoryBreadcrumb.set([]);
this.editingInfo.set(false);
this.editingContacts.set(false);
this.clearPoll();
this.loadApi();
});
@@ -434,6 +495,21 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/upload'], { queryParams: { from: this.api()?.id } });
}
onSetCurrent() {
this.settingCurrent.set(true);
this.apiService.setCurrent(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.settingCurrent.set(false);
this.apiService.getVersions(this.id).subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
},
error: () => this.settingCurrent.set(false),
});
}
onRegenerate() {
this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({
@@ -446,64 +522,73 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
});
}
openEdit() {
startEditInfo() {
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);
this.infoForm.patchValue({
convention: entry.convention,
name: entry.name,
provider: entry.provider,
versionMajor: entry.versionMajor,
versionMinor: entry.versionMinor,
versionPatch: entry.versionPatch,
description: entry.description ?? '',
categoryId: entry.categoryId ?? '',
});
this.editingInfo.set(true);
}
closeEdit() {
this.showEditModal.set(false);
startEditContacts() {
const entry = this.api();
if (!entry) return;
this.contactForm.patchValue({
contactFunctionalName: entry.contactFunctionalName,
contactFunctionalEntity: entry.contactFunctionalEntity,
contactFunctionalEmail: entry.contactFunctionalEmail,
contactTechnicalName: entry.contactTechnicalName,
contactTechnicalEntity: entry.contactTechnicalEntity,
contactTechnicalEmail: entry.contactTechnicalEmail,
});
this.editingContacts.set(true);
}
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(),
submitInfo() {
if (this.infoForm.invalid) { this.infoForm.markAllAsTouched(); return; }
const entry = this.api();
if (!entry) return;
this.savingInfo.set(true);
const raw = this.infoForm.getRawValue();
this.apiService.update(entry.id, {
convention: raw.convention ?? 'CONSULTER',
name: raw.name ?? '',
provider: raw.provider ?? '',
versionMajor: raw.versionMajor ?? 0,
versionMinor: raw.versionMinor ?? 0,
versionPatch: raw.versionPatch ?? 0,
categoryId: raw.categoryId || null,
description: raw.description || null,
}).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.');
},
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
error: () => this.savingInfo.set(false),
});
}
submitContacts() {
if (this.contactForm.invalid) { this.contactForm.markAllAsTouched(); return; }
const entry = this.api();
if (!entry) return;
this.savingContacts.set(true);
const raw = this.contactForm.getRawValue();
this.apiService.update(entry.id, {
contactFunctionalName: raw.contactFunctionalName ?? '',
contactFunctionalEntity: raw.contactFunctionalEntity ?? '',
contactFunctionalEmail: raw.contactFunctionalEmail ?? '',
contactTechnicalName: raw.contactTechnicalName ?? '',
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
}).subscribe({
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
error: () => this.savingContacts.set(false),
});
}
@@ -522,6 +607,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
return 'fr-badge fr-badge--info';
}
statusLabel(status: string): string {
if (status === 'GENERATED') return 'Générée';
if (status === 'ERROR') return 'Erreur';
return 'En attente';
}
private loadApi() {
this.loading.set(true);
this.apiService.get(this.id).subscribe({

View File

@@ -45,6 +45,10 @@ import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal
</nav>
<!-- En-tête -->
@if (categoryId()) {
<a class="fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w" style="display:inline-flex;"
[routerLink]="backUrl()">Retour</a>
}
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h1 class="fr-h2 fr-mb-0">
@@ -146,8 +150,10 @@ import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal
}
<p class="fr-tile__detail">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
&nbsp;
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
&nbsp;<span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
@if (api.isCurrent) {
&nbsp;<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
}
</p>
</div>
</div>
@@ -215,7 +221,10 @@ import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal
}
<div class="fr-badges-group fr-mt-1w">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
<span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
@if (api.isCurrent) {
<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
}
</div>
</div>
</div>
@@ -300,6 +309,12 @@ export class BrowseComponent implements OnInit {
return d.breadcrumb.slice(0, -1);
});
backUrl = computed<string[]>(() => {
const anc = this.ancestors();
if (anc.length > 0) return ['/browse', anc[anc.length - 1].id];
return ['/browse'];
});
ngOnInit() {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -398,9 +413,4 @@ export class BrowseComponent implements OnInit {
: 'fr-badge fr-badge--sm fr-badge--blue-ecume';
}
statusBadgeClass(status: string): string {
if (status === 'GENERATED') return 'fr-badge fr-badge--sm fr-badge--success';
if (status === 'ERROR') return 'fr-badge fr-badge--sm fr-badge--error';
return 'fr-badge fr-badge--sm fr-badge--info';
}
}