import { Component, signal, computed, inject, DestroyRef, OnInit, ChangeDetectionStrategy, } from '@angular/core'; import { takeUntilDestroyed, 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, ApiEntryListItem, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared'; @Component({ selector: 'app-upload', standalone: true, imports: [CommonModule, ReactiveFormsModule, RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: `

Importer une API

@for (title of stepTitles; track $index) {
@if (step() > $index + 1) { } @else { {{ $index + 1 }} }
{{ title }}
@if ($index < stepTitles.length - 1) {
} }
@if (step() === 1) {
@if (fileError()) {

{{ fileError() }}

} @if (selectedFiles().length > 0) {
@for (file of selectedFiles(); track file.name) {
{{ file.name }} ({{ (file.size / 1024).toFixed(1) }} Ko) @if (file.name === detectedMainFilename()) { Principal } @else if (selectedFiles().length > 1) { Secondaire }
}
@if (validating()) {

Validation en cours...

} @else if (validationDone()) { @if (validationErrors().length > 0) {

Fichier(s) invalide(s)

@for (err of validationErrors(); track err.file) {

{{ err.file ? err.file + ' : ' : '' }}{{ err.message }}

}
} @else {

Fichier(s) valide(s)

Le fichier a été validé avec succès.

} } }
Annuler
} @if (step() === 2) { @if (fromApi(); as origin) {

Nouvelle version de : {{ origin.title }} (v{{ origin.version }}) — le nom et la convention sont verrouillés.

}

Titre *

@if (conventionInvalid()) {

La convention est obligatoire.

}
@if (nameInvalid()) {

Le nom est obligatoire.

}

Titre complet : {{ previewTitle() }}

@if (providerInvalid()) {

Le fournisseur est obligatoire.

}
@if (detectedType()) {

Détecté automatiquement depuis le fichier YAML

}

Version *

@if (versionAlreadyExists()) {

Version déjà existante

La version {{ formValues().versionMajor }}.{{ formValues().versionMinor }}.{{ formValues().versionPatch }} existe déjà pour cette API. Choisissez un numéro de version différent.

}
} @if (step() === 3) {

Contact métier *

@if (fieldInvalid('contactFunctionalName')) {

Obligatoire.

}
@if (fieldInvalid('contactFunctionalEntity')) {

Obligatoire.

}
@if (fieldInvalid('contactFunctionalEmail')) {

Email valide requis.

}

Contact technique *

@if (fieldInvalid('contactTechnicalName')) {

Obligatoire.

}
@if (fieldInvalid('contactTechnicalEntity')) {

Obligatoire.

}
@if (fieldInvalid('contactTechnicalEmail')) {

Email valide requis.

}
} @if (step() === 4) {

Récapitulatif

Fichier{{ selectedFiles().length > 1 ? 's' : '' }}
@if (selectedFiles().length === 0) { Aucun fichier } @else if (selectedFiles().length === 1) { {{ selectedFiles()[0].name }} } @else { @for (f of selectedFiles(); track f.name) {
{{ f.name }} @if (f.name === detectedMainFilename()) { Principal }
} }
Type
{{ displayType() }}
Titre
{{ previewTitle() }}
Version
{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}
Fournisseur
{{ metaForm.value.provider }}
@if (metaForm.value.categoryId) {
Catégorie
{{ selectedCategoryName() }}
} @if (metaForm.value.description) {
Description
{{ metaForm.value.description }}
}
Contact métier
{{ metaForm.value.contactFunctionalName }}
{{ metaForm.value.contactFunctionalEntity }}
{{ metaForm.value.contactFunctionalEmail }}
Contact technique
{{ metaForm.value.contactTechnicalName }}
{{ metaForm.value.contactTechnicalEntity }}
{{ metaForm.value.contactTechnicalEmail }}
@if (uploadError()) {

Erreur lors de l'import

{{ uploadError() }}

}
}
`, }) 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); private destroyRef = inject(DestroyRef); step = signal(1); private readonly maxFileSize = 5 * 1024 * 1024; // 5 Mo selectedFiles = signal([]); detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null); detectedMainFilename = signal(null); fileError = signal(null); uploading = signal(false); uploadError = signal(null); validating = signal(false); validationDone = signal(false); validationErrors = signal>([]); categories = signal([]); /* API d'origine lors d'une création de nouvelle version (from=) */ fromApi = signal(null); /* Versions existantes de l'API d'origine (pour détecter les doublons) */ existingVersions = signal([]); 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 */ protected readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value }); previewTitle = computed(() => { const values = this.formValues(); const origin = this.fromApi(); /* Quand convention/name sont désactivés, ils sont absents de valueChanges → fallback sur fromApi */ const convention = (values.convention ?? origin?.convention ?? 'CONSULTER') as ApiConvention; const name = values.name ?? origin?.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 ?? ''; }); /* Vérifie si la version saisie existe déjà parmi les versions de l'API d'origine */ versionAlreadyExists = computed(() => { const versions = this.existingVersions(); if (versions.length === 0) return false; const values = this.formValues(); const candidate = `${values.versionMajor ?? 0}.${values.versionMinor ?? 0}.${values.versionPatch ?? 0}`; return versions.some((v) => v.version === candidate); }); /* Type effectif : getRawValue() inclut les champs désactivés */ displayType = computed(() => { const origin = this.fromApi(); if (origin) return origin.type; return (this.formValues().type ?? null) as 'ASYNCAPI' | 'OPENAPI' | null; }); /* 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(() => { if (this.selectedFiles().length === 0) return true; // fichier optionnel 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() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: (res) => { this.categories.set(res.items); if (preselectedCategoryId) { this.metaForm.patchValue({ categoryId: preselectedCategoryId }); } }, }); if (fromId) { this.apiService .get(fromId) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: (api) => this.prefillFromApi(api), error: () => {}, }); this.apiService .getVersions(fromId) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: (versions) => this.existingVersions.set(versions), error: () => {}, }); } } private prefillFromApi(api: ApiEntry): void { this.fromApi.set(api); 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, }); /* Verrouiller les champs qui définissent l'identité de l'API */ this.metaForm.get('convention')?.disable(); this.metaForm.get('name')?.disable(); this.metaForm.get('type')?.disable(); } 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.uploadError.set(null); this.detectedType.set(null); this.detectedMainFilename.set(null); this.validationErrors.set([]); this.validationDone.set(false); /* Ne pas réactiver le type s'il est verrouillé par fromApi */ if (!this.fromApi()) { 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; } const tooLarge = files.find((f) => f.size > this.maxFileSize); if (tooLarge) { this.fileError.set( `Fichier trop volumineux (${tooLarge.name}). Taille max : ${this.maxFileSize / (1024 * 1024)} Mo.`, ); 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; } if (this.versionAlreadyExists()) 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) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: (result) => { this.validating.set(false); this.validationDone.set(true); if (!result.valid) { this.validationErrors.set(result.errors); /* Ne pas réactiver le type s'il est verrouillé par fromApi */ if (!this.fromApi()) { this.metaForm.get('type')?.enable(); } } else { this.validationErrors.set([]); if (result.type) { this.detectedType.set(result.type); /* Ne pas modifier le type s'il est verrouillé par fromApi */ if (!this.fromApi()) { 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 */ /* Ne pas écraser le nom s'il est verrouillé par fromApi */ if (result.name && !this.fromApi()) 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(); this.metaForm.markAllAsTouched(); if (this.metaForm.invalid) { /* Naviguer vers l'étape avec les erreurs pour les rendre visibles */ const infoInvalid = this.INFO_FIELDS.some((f) => this.metaForm.get(f)?.invalid); this.step.set(infoInvalid ? 2 : 3); 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) .pipe(takeUntilDestroyed(this.destroyRef)) .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 { 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); if (!this.fromApi()) this.metaForm.patchValue({ type: 'OPENAPI' }); return; } if (/^asyncapi\s*:/m.test(content)) { this.detectedType.set('ASYNCAPI'); this.detectedMainFilename.set(file.name); if (!this.fromApi()) 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 { 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); }); } }