upload (900->427 l.) et api-detail (700->330 l.) : le template inline géant passe en templateUrl. Aucun changement de comportement (build + eslint a11y + runtime OK). Composants bien plus navigables ; les .html sont désormais lintés par angular-eslint (a11y). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
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 { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
|
import { ApiEntry, ApiEntryListItem, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
|
|
|
|
@Component({
|
|
selector: 'app-upload',
|
|
standalone: true,
|
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, BreadcrumbComponent],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
templateUrl: "./upload.component.html",
|
|
})
|
|
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);
|
|
readonly breadcrumbItems: BreadcrumbItem[] = [
|
|
{ label: 'Parcourir', link: '/browse' },
|
|
{ label: 'Catalogue', link: '/catalog' },
|
|
{ label: 'Importer une API' },
|
|
];
|
|
|
|
private readonly maxFileSize = 5 * 1024 * 1024; // 5 Mo
|
|
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[]>([]);
|
|
/* API d'origine lors d'une création de nouvelle version (from=<id>) */
|
|
fromApi = signal<ApiEntry | null>(null);
|
|
/* Versions existantes de l'API d'origine (pour détecter les doublons) */
|
|
existingVersions = signal<ApiEntryListItem[]>([]);
|
|
|
|
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<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);
|
|
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<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);
|
|
});
|
|
}
|
|
}
|