Files
datacat/front-public/src/app/pages/upload/upload.component.ts
z3n 0976c33059 feat(api-detail): isCurrent + setCurrent + màj doc et seeder
- 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>
2026-06-19 14:23:31 +00:00

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);
});
}
}