feat(upload): validation YAML côté serveur dès la sélection du fichier

- Nouvel endpoint POST /api/uploads/validate : validation en 3 niveaux
  (syntaxe js-yaml → détection type → asyncapi validate / redocly lint)
- Extraction automatique de info.title/version/description pour pré-remplir le formulaire
- Validation déclenchée immédiatement à la sélection, résultat affiché inline
- Bouton "Suivant" désactivé pendant la validation et si le fichier est invalide
- Sans fichier sélectionné, on peut toujours passer à l'étape 2

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-10 08:48:40 +00:00
parent abc20bc9e8
commit ef3884c68d
3 changed files with 224 additions and 12 deletions

View File

@@ -85,6 +85,26 @@ import { Category } from '@datacat/shared';
</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">
@@ -136,14 +156,19 @@ import { Category } from '@datacat/shared';
}
</div>
<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>
<div class="fr-mb-3w">
<p class="fr-label fr-mb-1w">Type <span style="color:var(--text-default-error)">*</span></p>
@if (detectedType()) {
<span class="fr-tag">{{ detectedType() }}</span>
<span class="fr-hint-text fr-mt-1w">Détecté automatiquement depuis le fichier YAML</span>
} @else {
<div class="fr-select-group">
<select id="type-input" class="fr-select" formControlName="type">
<option value="ASYNCAPI">AsyncAPI</option>
<option value="OPENAPI">OpenAPI</option>
</select>
</div>
}
</div>
<div class="fr-select-group fr-mb-3w">
@@ -339,6 +364,9 @@ export class UploadComponent implements OnInit {
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 = [
@@ -373,10 +401,11 @@ export class UploadComponent implements OnInit {
return this.categories().find((c) => c.id === id)?.name ?? '';
});
canProceedFromStep1 = computed(() => {
if (this.selectedFiles().length === 0) return false;
if (this.selectedFiles().length === 1) return true;
/* Plusieurs fichiers : un fichier principal doit être détecté */
return this.detectedMainFilename() !== null;
/* 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;
});
ngOnInit() {
@@ -395,6 +424,8 @@ export class UploadComponent implements OnInit {
this.fileError.set(null);
this.detectedType.set(null);
this.detectedMainFilename.set(null);
this.validationErrors.set([]);
this.validationDone.set(false);
const input = event.target as HTMLInputElement;
const fileList = input.files;
@@ -415,6 +446,9 @@ export class UploadComponent implements OnInit {
/* Détection côté client du fichier principal (pré-remplissage du type) */
void this.detectMainFileClient(files);
/* Validation serveur immédiate */
this.runValidation(files);
}
goToStep2() {
@@ -422,6 +456,44 @@ export class UploadComponent implements OnInit {
this.step.set(2);
}
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);
} else {
this.validationErrors.set([]);
if (result.type) {
this.detectedType.set(result.type);
this.metaForm.patchValue({ type: result.type });
}
if (result.mainFile) {
this.detectedMainFilename.set(result.mainFile);
}
/* Pré-remplissage du formulaire depuis les métadonnées du fichier */
if (result.title) this.metaForm.patchValue({ title: result.title });
if (result.version) this.metaForm.patchValue({ version: String(result.version) });
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.' }]);
},
});
}
goToStep3() {
if (this.metaForm.invalid) {
this.metaForm.markAllAsTouched();