- Shared types : ajout isCurrent sur ApiEntry et ApiEntryListItem - Backend entity : colonne is_current (boolean, default true) - Backend service : isCurrent calculé à la création, setCurrent() marque une version comme courante (siblings → false) - Backend controller : POST /api/apis/:id/set-current - Frontend ApiService : méthode setCurrent() - Browse/upload : affichage badge "Courante" via isCurrent - Seeder : données de démo mises à jour - Documentation : api-reference, architecture, local-setup, CLAUDE.md 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>
804 lines
32 KiB
TypeScript
804 lines
32 KiB
TypeScript
import {
|
|
Component,
|
|
signal,
|
|
computed,
|
|
inject,
|
|
OnInit,
|
|
ChangeDetectionStrategy,
|
|
} from '@angular/core';
|
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
import { CommonModule } from '@angular/common';
|
|
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
|
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
|
import { ApiService } from '../../core/api.service';
|
|
import { CategoryService } from '../../core/category.service';
|
|
import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
|
|
|
|
@Component({
|
|
selector: 'app-upload',
|
|
standalone: true,
|
|
imports: [CommonModule, ReactiveFormsModule, 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="/browse">Parcourir</a></li>
|
|
<li><a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a></li>
|
|
<li><a class="fr-breadcrumb__link" aria-current="page">Importer une API</a></li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
|
|
|
|
<!-- Stepper numéroté -->
|
|
<div class="fr-mb-4w" style="display:flex;align-items:flex-start;">
|
|
@for (title of stepTitles; track $index) {
|
|
<div style="display:flex;flex-direction:column;align-items:center;flex:none;min-width:6rem;">
|
|
<div
|
|
[style.background]="step() > $index + 1 ? '#1f8d49' : step() === $index + 1 ? '#000091' : '#e5e5e5'"
|
|
[style.color]="step() >= $index + 1 ? 'white' : '#666'"
|
|
style="width:2rem;height:2rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:0.875rem;transition:background 0.2s;">
|
|
@if (step() > $index + 1) {
|
|
<span class="fr-icon-check-line" aria-hidden="true" style="font-size:0.875rem"></span>
|
|
} @else {
|
|
{{ $index + 1 }}
|
|
}
|
|
</div>
|
|
<span style="font-size:0.75rem;margin-top:0.4rem;text-align:center;line-height:1.2;"
|
|
[style.font-weight]="step() === $index + 1 ? '700' : '400'"
|
|
[style.color]="step() === $index + 1 ? '#000091' : step() > $index + 1 ? '#1f8d49' : '#666'">
|
|
{{ title }}
|
|
</span>
|
|
</div>
|
|
@if ($index < stepTitles.length - 1) {
|
|
<div style="flex:1;height:2px;margin-top:1rem;transition:background 0.2s;"
|
|
[style.background]="step() > $index + 1 ? '#1f8d49' : '#e5e5e5'">
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
<!-- Étape 1 : Fichier(s) YAML -->
|
|
@if (step() === 1) {
|
|
<div class="fr-upload-group fr-mb-4w">
|
|
<label class="fr-label" for="file-input">
|
|
Fichier(s) YAML
|
|
<span class="fr-hint-text">
|
|
Formats acceptés : .yaml, .yml — Sélectionnez plusieurs fichiers si votre spec utilise des $ref
|
|
</span>
|
|
</label>
|
|
<input
|
|
id="file-input"
|
|
class="fr-upload"
|
|
type="file"
|
|
accept=".yaml,.yml"
|
|
multiple
|
|
(change)="onFileChange($event)"
|
|
/>
|
|
</div>
|
|
|
|
@if (fileError()) {
|
|
<div class="fr-alert fr-alert--error fr-mb-3w">
|
|
<p>{{ fileError() }}</p>
|
|
</div>
|
|
}
|
|
|
|
@if (selectedFiles().length > 0) {
|
|
<div class="fr-mb-3w">
|
|
@for (file of selectedFiles(); track file.name) {
|
|
<div class="fr-callout fr-callout--blue-cumulus fr-mb-1w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
|
|
<span>
|
|
<strong>{{ file.name }}</strong>
|
|
({{ (file.size / 1024).toFixed(1) }} Ko)
|
|
</span>
|
|
@if (file.name === detectedMainFilename()) {
|
|
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
|
|
} @else if (selectedFiles().length > 1) {
|
|
<span class="fr-badge fr-badge--blue-cumulus fr-badge--sm">Secondaire</span>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (validating()) {
|
|
<div class="fr-callout fr-mb-3w">
|
|
<p>Validation en cours...</p>
|
|
</div>
|
|
} @else if (validationDone()) {
|
|
@if (validationErrors().length > 0) {
|
|
<div class="fr-alert fr-alert--error fr-mb-3w">
|
|
<p class="fr-alert__title">Fichier(s) invalide(s)</p>
|
|
@for (err of validationErrors(); track err.file) {
|
|
<p>{{ err.file ? err.file + ' : ' : '' }}{{ err.message }}</p>
|
|
}
|
|
</div>
|
|
} @else {
|
|
<div class="fr-alert fr-alert--success fr-mb-3w">
|
|
<p class="fr-alert__title">Fichier(s) valide(s)</p>
|
|
<p>Le fichier a été validé avec succès.</p>
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
|
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
|
<button
|
|
class="fr-btn"
|
|
(click)="goToStep2()"
|
|
[disabled]="!canProceedFromStep1()"
|
|
>
|
|
Suivant
|
|
</button>
|
|
<a routerLink="/catalog" class="fr-btn fr-btn--secondary">Annuler</a>
|
|
</div>
|
|
}
|
|
|
|
<!-- Étape 2 : Informations -->
|
|
@if (step() === 2) {
|
|
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
|
|
|
|
<!-- Titre : 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" [class.fr-select-group--error]="conventionInvalid()">
|
|
<label class="fr-label" for="convention-input">Convention</label>
|
|
<select
|
|
id="convention-input"
|
|
class="fr-select"
|
|
[class.fr-select--error]="conventionInvalid()"
|
|
formControlName="convention"
|
|
>
|
|
<option value="CONSULTER">Consulter</option>
|
|
<option value="ENREGISTRER">Enregistrer</option>
|
|
<option value="ETRE_NOTIFIE">Être notifié</option>
|
|
</select>
|
|
@if (conventionInvalid()) {
|
|
<p class="fr-error-text">La convention est obligatoire.</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="fr-col-12 fr-col-md-8">
|
|
<div class="fr-input-group" [class.fr-input-group--error]="nameInvalid()">
|
|
<label class="fr-label" for="name-input">Nom de l'API</label>
|
|
<input
|
|
id="name-input"
|
|
class="fr-input"
|
|
[class.fr-input--error]="nameInvalid()"
|
|
formControlName="name"
|
|
type="text"
|
|
placeholder="Référentiel Adresses"
|
|
/>
|
|
@if (nameInvalid()) {
|
|
<p class="fr-error-text">Le nom est obligatoire.</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="fr-hint-text fr-mb-3w">Titre complet : <strong>{{ previewTitle() }}</strong></p>
|
|
|
|
<!-- Catégorie -->
|
|
<div class="fr-select-group fr-mb-3w">
|
|
<label class="fr-label" for="category-input">
|
|
Catégorie
|
|
</label>
|
|
<select id="category-input" class="fr-select" formControlName="categoryId">
|
|
<option value="">— Sans catégorie —</option>
|
|
@for (cat of categories(); track cat.id) {
|
|
<option [value]="cat.id">{{ categoryLabel(cat) }}</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Fournisseur -->
|
|
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="providerInvalid()">
|
|
<label class="fr-label" for="provider-input">
|
|
Fournisseur <span style="color:var(--text-default-error)">*</span>
|
|
</label>
|
|
<input
|
|
id="provider-input"
|
|
class="fr-input"
|
|
[class.fr-input--error]="providerInvalid()"
|
|
formControlName="provider"
|
|
type="text"
|
|
placeholder="DINUM, Direction XYZ..."
|
|
/>
|
|
@if (providerInvalid()) {
|
|
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
|
|
}
|
|
</div>
|
|
|
|
<!-- Type -->
|
|
<div class="fr-select-group fr-mb-3w">
|
|
<label class="fr-label" for="type-input">
|
|
Type <span style="color:var(--text-default-error)">*</span>
|
|
</label>
|
|
<select id="type-input" class="fr-select" formControlName="type">
|
|
<option value="ASYNCAPI">AsyncAPI</option>
|
|
<option value="OPENAPI">OpenAPI</option>
|
|
</select>
|
|
@if (detectedType()) {
|
|
<p class="fr-hint-text">Détecté automatiquement depuis le fichier YAML</p>
|
|
}
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="fr-input-group fr-mb-3w">
|
|
<label class="fr-label" for="description-input">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
id="description-input"
|
|
class="fr-input"
|
|
formControlName="description"
|
|
rows="3"
|
|
placeholder="Description de l'API..."
|
|
></textarea>
|
|
</div>
|
|
|
|
<!-- Version X.Y.Z -->
|
|
<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-3w">
|
|
<div class="fr-col-4">
|
|
<div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
|
|
<label class="fr-label" for="version-major-input">Majeure (X)</label>
|
|
<input
|
|
id="version-major-input"
|
|
class="fr-input"
|
|
[class.fr-input--error]="versionMajorInvalid()"
|
|
formControlName="versionMajor"
|
|
type="number"
|
|
min="0"
|
|
placeholder="1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="fr-col-4">
|
|
<div class="fr-input-group" [class.fr-input-group--error]="versionMinorInvalid()">
|
|
<label class="fr-label" for="version-minor-input">Mineure (Y)</label>
|
|
<input
|
|
id="version-minor-input"
|
|
class="fr-input"
|
|
[class.fr-input--error]="versionMinorInvalid()"
|
|
formControlName="versionMinor"
|
|
type="number"
|
|
min="0"
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="fr-col-4">
|
|
<div class="fr-input-group" [class.fr-input-group--error]="versionPatchInvalid()">
|
|
<label class="fr-label" for="version-patch-input">Correctif (Z)</label>
|
|
<input
|
|
id="version-patch-input"
|
|
class="fr-input"
|
|
[class.fr-input--error]="versionPatchInvalid()"
|
|
formControlName="versionPatch"
|
|
type="number"
|
|
min="0"
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
|
<button type="submit" class="fr-btn">Suivant</button>
|
|
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
|
|
Retour
|
|
</button>
|
|
</div>
|
|
</form>
|
|
}
|
|
|
|
<!-- Étape 3 : Contacts -->
|
|
@if (step() === 3) {
|
|
<form [formGroup]="metaForm" (ngSubmit)="goToStep4()" novalidate>
|
|
|
|
<!-- 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]="fieldInvalid('contactFunctionalName')">
|
|
<label class="fr-label" for="cf-name-input">Nom et prénom</label>
|
|
<input id="cf-name-input" class="fr-input"
|
|
[class.fr-input--error]="fieldInvalid('contactFunctionalName')"
|
|
formControlName="contactFunctionalName" type="text" placeholder="Prénom Nom" />
|
|
@if (fieldInvalid('contactFunctionalName')) {
|
|
<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]="fieldInvalid('contactFunctionalEntity')">
|
|
<label class="fr-label" for="cf-entity-input">Entité</label>
|
|
<input id="cf-entity-input" class="fr-input"
|
|
[class.fr-input--error]="fieldInvalid('contactFunctionalEntity')"
|
|
formControlName="contactFunctionalEntity" type="text" placeholder="Direction XYZ" />
|
|
@if (fieldInvalid('contactFunctionalEntity')) {
|
|
<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]="fieldInvalid('contactFunctionalEmail')">
|
|
<label class="fr-label" for="cf-email-input">Email</label>
|
|
<input id="cf-email-input" class="fr-input"
|
|
[class.fr-input--error]="fieldInvalid('contactFunctionalEmail')"
|
|
formControlName="contactFunctionalEmail" type="email" placeholder="prenom.nom@example.fr" />
|
|
@if (fieldInvalid('contactFunctionalEmail')) {
|
|
<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-4w">
|
|
<div class="fr-col-12 fr-col-md-4">
|
|
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalName')">
|
|
<label class="fr-label" for="ct-name-input">Nom et prénom</label>
|
|
<input id="ct-name-input" class="fr-input"
|
|
[class.fr-input--error]="fieldInvalid('contactTechnicalName')"
|
|
formControlName="contactTechnicalName" type="text" placeholder="Prénom Nom" />
|
|
@if (fieldInvalid('contactTechnicalName')) {
|
|
<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]="fieldInvalid('contactTechnicalEntity')">
|
|
<label class="fr-label" for="ct-entity-input">Entité</label>
|
|
<input id="ct-entity-input" class="fr-input"
|
|
[class.fr-input--error]="fieldInvalid('contactTechnicalEntity')"
|
|
formControlName="contactTechnicalEntity" type="text" placeholder="Équipe Technique" />
|
|
@if (fieldInvalid('contactTechnicalEntity')) {
|
|
<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]="fieldInvalid('contactTechnicalEmail')">
|
|
<label class="fr-label" for="ct-email-input">Email</label>
|
|
<input id="ct-email-input" class="fr-input"
|
|
[class.fr-input--error]="fieldInvalid('contactTechnicalEmail')"
|
|
formControlName="contactTechnicalEmail" type="email" placeholder="tech@example.fr" />
|
|
@if (fieldInvalid('contactTechnicalEmail')) {
|
|
<p class="fr-error-text">Email valide requis.</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
|
<button type="submit" class="fr-btn">Suivant</button>
|
|
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(2)">
|
|
Retour
|
|
</button>
|
|
</div>
|
|
</form>
|
|
}
|
|
|
|
<!-- Étape 4 : Confirmation -->
|
|
@if (step() === 4) {
|
|
<div class="fr-card fr-mb-4w">
|
|
<div class="fr-card__body">
|
|
<div class="fr-card__content">
|
|
<h2 class="fr-h5 fr-mb-3w">Récapitulatif</h2>
|
|
<dl class="fr-grid-row fr-grid-row--gutters">
|
|
<div class="fr-col-12 fr-col-md-6">
|
|
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
|
|
<dd>
|
|
@if (selectedFiles().length === 1) {
|
|
{{ selectedFiles()[0].name }}
|
|
} @else {
|
|
@for (f of selectedFiles(); track f.name) {
|
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
|
{{ f.name }}
|
|
@if (f.name === detectedMainFilename()) {
|
|
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
</dd>
|
|
</div>
|
|
<div class="fr-col-12 fr-col-md-6">
|
|
<dt class="fr-text--bold">Type</dt>
|
|
<dd>{{ metaForm.value.type }}</dd>
|
|
</div>
|
|
<div class="fr-col-12">
|
|
<dt class="fr-text--bold">Titre</dt>
|
|
<dd>{{ previewTitle() }}</dd>
|
|
</div>
|
|
<div class="fr-col-12 fr-col-md-6">
|
|
<dt class="fr-text--bold">Version</dt>
|
|
<dd>{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}</dd>
|
|
</div>
|
|
<div class="fr-col-12 fr-col-md-6">
|
|
<dt class="fr-text--bold">Fournisseur</dt>
|
|
<dd>{{ metaForm.value.provider }}</dd>
|
|
</div>
|
|
@if (metaForm.value.categoryId) {
|
|
<div class="fr-col-12 fr-col-md-6">
|
|
<dt class="fr-text--bold">Catégorie</dt>
|
|
<dd>{{ selectedCategoryName() }}</dd>
|
|
</div>
|
|
}
|
|
@if (metaForm.value.description) {
|
|
<div class="fr-col-12">
|
|
<dt class="fr-text--bold">Description</dt>
|
|
<dd>{{ metaForm.value.description }}</dd>
|
|
</div>
|
|
}
|
|
<div class="fr-col-12 fr-col-md-6">
|
|
<dt class="fr-text--bold">Contact métier</dt>
|
|
<dd>
|
|
{{ metaForm.value.contactFunctionalName }}<br>
|
|
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEntity }}</span><br>
|
|
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEmail }}</span>
|
|
</dd>
|
|
</div>
|
|
<div class="fr-col-12 fr-col-md-6">
|
|
<dt class="fr-text--bold">Contact technique</dt>
|
|
<dd>
|
|
{{ metaForm.value.contactTechnicalName }}<br>
|
|
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEntity }}</span><br>
|
|
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEmail }}</span>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (uploadError()) {
|
|
<div class="fr-alert fr-alert--error fr-mb-3w">
|
|
<p class="fr-alert__title">Erreur lors de l'import</p>
|
|
<p>{{ uploadError() }}</p>
|
|
</div>
|
|
}
|
|
|
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
|
<button
|
|
class="fr-btn"
|
|
(click)="onSubmit()"
|
|
[disabled]="uploading()"
|
|
>
|
|
{{ uploading() ? 'Import en cours...' : 'Importer' }}
|
|
</button>
|
|
<button class="fr-btn fr-btn--secondary" (click)="step.set(3)" [disabled]="uploading()">
|
|
Retour
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
`,
|
|
})
|
|
export class UploadComponent implements OnInit {
|
|
private fb = inject(FormBuilder);
|
|
private apiService = inject(ApiService);
|
|
private categoryService = inject(CategoryService);
|
|
private router = inject(Router);
|
|
private route = inject(ActivatedRoute);
|
|
|
|
step = signal(1);
|
|
selectedFiles = signal<File[]>([]);
|
|
detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null);
|
|
detectedMainFilename = signal<string | null>(null);
|
|
fileError = signal<string | null>(null);
|
|
uploading = signal(false);
|
|
uploadError = signal<string | null>(null);
|
|
validating = signal(false);
|
|
validationDone = signal(false);
|
|
validationErrors = signal<Array<{ file: string; message: string }>>([]);
|
|
categories = signal<Category[]>([]);
|
|
|
|
readonly stepTitles = [
|
|
'Choisir le fichier',
|
|
'Informations',
|
|
'Contacts',
|
|
"Confirmer l'import",
|
|
];
|
|
|
|
private readonly INFO_FIELDS = [
|
|
'convention', 'name', 'provider', 'type',
|
|
'versionMajor', 'versionMinor', 'versionPatch',
|
|
];
|
|
|
|
private readonly CONTACT_FIELDS = [
|
|
'contactFunctionalName', 'contactFunctionalEntity', 'contactFunctionalEmail',
|
|
'contactTechnicalName', 'contactTechnicalEntity', 'contactTechnicalEmail',
|
|
];
|
|
|
|
metaForm = 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)]],
|
|
type: ['ASYNCAPI', Validators.required],
|
|
categoryId: [''],
|
|
description: [''],
|
|
contactFunctionalName: ['', Validators.required],
|
|
contactFunctionalEntity: ['', Validators.required],
|
|
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
|
|
contactTechnicalName: ['', Validators.required],
|
|
contactTechnicalEntity: ['', Validators.required],
|
|
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
|
|
});
|
|
|
|
/* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
|
|
private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
|
|
|
|
previewTitle = computed(() => {
|
|
const values = this.formValues();
|
|
const convention = (values.convention ?? 'CONSULTER') as ApiConvention;
|
|
const name = values.name ?? '';
|
|
return `${API_CONVENTION_LABELS[convention]} ${name}`.trim();
|
|
});
|
|
selectedCategoryName = computed(() => {
|
|
const id = this.formValues().categoryId;
|
|
if (!id) return '';
|
|
return this.categories().find((c) => c.id === id)?.name ?? '';
|
|
});
|
|
|
|
/* Méthodes de validation (pas de computed — la re-exécution est déclenchée par les événements DOM blur/input) */
|
|
conventionInvalid(): boolean {
|
|
return !!(this.metaForm.get('convention')?.invalid && this.metaForm.get('convention')?.touched);
|
|
}
|
|
nameInvalid(): boolean {
|
|
return !!(this.metaForm.get('name')?.invalid && this.metaForm.get('name')?.touched);
|
|
}
|
|
providerInvalid(): boolean {
|
|
return !!(this.metaForm.get('provider')?.invalid && this.metaForm.get('provider')?.touched);
|
|
}
|
|
versionMajorInvalid(): boolean {
|
|
return !!(this.metaForm.get('versionMajor')?.invalid && this.metaForm.get('versionMajor')?.touched);
|
|
}
|
|
versionMinorInvalid(): boolean {
|
|
return !!(this.metaForm.get('versionMinor')?.invalid && this.metaForm.get('versionMinor')?.touched);
|
|
}
|
|
versionPatchInvalid(): boolean {
|
|
return !!(this.metaForm.get('versionPatch')?.invalid && this.metaForm.get('versionPatch')?.touched);
|
|
}
|
|
canProceedFromStep1 = computed(() => {
|
|
/* Sans fichier, on peut toujours passer à l'étape 2 */
|
|
if (this.selectedFiles().length === 0) return true;
|
|
/* Avec fichier : validation serveur doit être terminée et sans erreur */
|
|
if (this.validating()) return false;
|
|
return this.validationDone() && this.validationErrors().length === 0;
|
|
});
|
|
|
|
fieldInvalid(name: string): boolean {
|
|
const ctrl = this.metaForm.get(name);
|
|
return !!(ctrl?.invalid && ctrl?.touched);
|
|
}
|
|
|
|
ngOnInit() {
|
|
const params = this.route.snapshot.queryParamMap;
|
|
const preselectedCategoryId = params.get('categoryId');
|
|
const fromId = params.get('from');
|
|
|
|
this.categoryService.list().subscribe({
|
|
next: (res) => {
|
|
this.categories.set(res.items);
|
|
if (preselectedCategoryId) {
|
|
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
|
|
}
|
|
},
|
|
});
|
|
|
|
if (fromId) {
|
|
this.apiService.get(fromId).subscribe({
|
|
next: (api) => this.prefillFromApi(api),
|
|
error: () => {},
|
|
});
|
|
}
|
|
}
|
|
|
|
private prefillFromApi(api: ApiEntry): void {
|
|
this.metaForm.patchValue({
|
|
convention: api.convention,
|
|
name: api.name,
|
|
provider: api.provider,
|
|
versionMajor: api.versionMajor,
|
|
versionMinor: api.versionMinor,
|
|
versionPatch: api.versionPatch + 1,
|
|
description: api.description ?? '',
|
|
categoryId: api.categoryId ?? '',
|
|
contactFunctionalName: api.contactFunctionalName,
|
|
contactFunctionalEntity: api.contactFunctionalEntity,
|
|
contactFunctionalEmail: api.contactFunctionalEmail,
|
|
contactTechnicalName: api.contactTechnicalName,
|
|
contactTechnicalEntity: api.contactTechnicalEntity,
|
|
contactTechnicalEmail: api.contactTechnicalEmail,
|
|
});
|
|
}
|
|
|
|
categoryLabel(cat: Category): string {
|
|
/* Indente visuellement les sous-catégories via le parentId */
|
|
const hasParent = !!cat.parentId;
|
|
return hasParent ? ` └ ${cat.name}` : cat.name;
|
|
}
|
|
|
|
onFileChange(event: Event) {
|
|
this.fileError.set(null);
|
|
this.detectedType.set(null);
|
|
this.detectedMainFilename.set(null);
|
|
this.validationErrors.set([]);
|
|
this.validationDone.set(false);
|
|
this.metaForm.get('type')?.enable();
|
|
|
|
const input = event.target as HTMLInputElement;
|
|
const fileList = input.files;
|
|
if (!fileList || fileList.length === 0) {
|
|
this.selectedFiles.set([]);
|
|
return;
|
|
}
|
|
|
|
const files = Array.from(fileList);
|
|
const invalid = files.find((f) => !f.name.match(/\.(yaml|yml)$/i));
|
|
if (invalid) {
|
|
this.fileError.set('Seuls les fichiers .yaml et .yml sont acceptés.');
|
|
this.selectedFiles.set([]);
|
|
return;
|
|
}
|
|
|
|
this.selectedFiles.set(files);
|
|
|
|
/* Détection côté client du fichier principal (pré-remplissage du type) */
|
|
void this.detectMainFileClient(files);
|
|
|
|
/* Validation serveur immédiate */
|
|
this.runValidation(files);
|
|
}
|
|
|
|
goToStep2() {
|
|
if (!this.canProceedFromStep1()) return;
|
|
this.step.set(2);
|
|
}
|
|
|
|
goToStep3() {
|
|
const invalid = this.INFO_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
|
|
if (invalid) {
|
|
this.INFO_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
|
|
return;
|
|
}
|
|
this.step.set(3);
|
|
}
|
|
|
|
goToStep4() {
|
|
const invalid = this.CONTACT_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
|
|
if (invalid) {
|
|
this.CONTACT_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
|
|
return;
|
|
}
|
|
this.step.set(4);
|
|
}
|
|
|
|
private runValidation(files: File[]): void {
|
|
this.validating.set(true);
|
|
this.validationDone.set(false);
|
|
|
|
const formData = new FormData();
|
|
for (const f of files) {
|
|
formData.append('files', f);
|
|
}
|
|
|
|
this.apiService.validate(formData).subscribe({
|
|
next: (result) => {
|
|
this.validating.set(false);
|
|
this.validationDone.set(true);
|
|
if (!result.valid) {
|
|
this.validationErrors.set(result.errors);
|
|
this.metaForm.get('type')?.enable();
|
|
} else {
|
|
this.validationErrors.set([]);
|
|
if (result.type) {
|
|
this.detectedType.set(result.type);
|
|
this.metaForm.patchValue({ type: result.type });
|
|
this.metaForm.get('type')?.disable();
|
|
}
|
|
if (result.mainFile) {
|
|
this.detectedMainFilename.set(result.mainFile);
|
|
}
|
|
/* Pré-remplissage du formulaire depuis les métadonnées du fichier */
|
|
if (result.name) this.metaForm.patchValue({ name: result.name });
|
|
if (result.versionMajor !== null) this.metaForm.patchValue({ versionMajor: result.versionMajor });
|
|
if (result.versionMinor !== null) this.metaForm.patchValue({ versionMinor: result.versionMinor });
|
|
if (result.versionPatch !== null) this.metaForm.patchValue({ versionPatch: result.versionPatch });
|
|
if (result.description) this.metaForm.patchValue({ description: result.description });
|
|
}
|
|
},
|
|
error: (err) => {
|
|
this.validating.set(false);
|
|
this.validationDone.set(true);
|
|
this.validationErrors.set([{ file: '', message: err?.error?.message ?? 'Erreur de validation.' }]);
|
|
},
|
|
});
|
|
}
|
|
|
|
onSubmit() {
|
|
const files = this.selectedFiles();
|
|
if (files.length === 0 || this.metaForm.invalid) return;
|
|
|
|
this.uploading.set(true);
|
|
this.uploadError.set(null);
|
|
|
|
const raw = this.metaForm.getRawValue();
|
|
const formData = new FormData();
|
|
for (const f of files) {
|
|
formData.append('files', f);
|
|
}
|
|
formData.append('convention', raw.convention ?? 'CONSULTER');
|
|
formData.append('name', raw.name ?? '');
|
|
formData.append('provider', raw.provider ?? '');
|
|
formData.append('versionMajor', String(raw.versionMajor ?? 0));
|
|
formData.append('versionMinor', String(raw.versionMinor ?? 0));
|
|
formData.append('versionPatch', String(raw.versionPatch ?? 0));
|
|
formData.append('type', raw.type ?? 'ASYNCAPI');
|
|
if (raw.categoryId) {
|
|
formData.append('categoryId', raw.categoryId);
|
|
}
|
|
if (raw.description) {
|
|
formData.append('description', raw.description);
|
|
}
|
|
formData.append('contactFunctionalName', raw.contactFunctionalName ?? '');
|
|
formData.append('contactFunctionalEntity', raw.contactFunctionalEntity ?? '');
|
|
formData.append('contactFunctionalEmail', raw.contactFunctionalEmail ?? '');
|
|
formData.append('contactTechnicalName', raw.contactTechnicalName ?? '');
|
|
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
|
|
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
|
|
|
|
this.apiService.upload(formData).subscribe({
|
|
next: (entry) => {
|
|
this.uploading.set(false);
|
|
this.router.navigate(['/catalog', entry.id]);
|
|
},
|
|
error: (err) => {
|
|
this.uploading.set(false);
|
|
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
|
|
},
|
|
});
|
|
}
|
|
|
|
private async detectMainFileClient(files: File[]): Promise<void> {
|
|
for (const file of files) {
|
|
const content = await this.readFileAsText(file);
|
|
if (/^openapi\s*:/m.test(content)) {
|
|
this.detectedType.set('OPENAPI');
|
|
this.detectedMainFilename.set(file.name);
|
|
this.metaForm.patchValue({ type: 'OPENAPI' });
|
|
return;
|
|
}
|
|
if (/^asyncapi\s*:/m.test(content)) {
|
|
this.detectedType.set('ASYNCAPI');
|
|
this.detectedMainFilename.set(file.name);
|
|
this.metaForm.patchValue({ type: 'ASYNCAPI' });
|
|
return;
|
|
}
|
|
}
|
|
/* Aucun fichier principal détecté parmi plusieurs fichiers */
|
|
if (files.length > 1) {
|
|
this.fileError.set(
|
|
'Aucun fichier principal détecté (clé openapi: ou asyncapi: requise dans au moins un fichier).',
|
|
);
|
|
}
|
|
}
|
|
|
|
private readFileAsText(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => resolve((e.target?.result as string) ?? '');
|
|
reader.onerror = () => reject(new Error('Lecture fichier échouée'));
|
|
reader.readAsText(file);
|
|
});
|
|
}
|
|
}
|