feat: refonte métadonnées API + UX dossiers et import

- Nouveau schéma api_entries : convention+name, version X.X.X, provider,
  contacts splitées (name/entity/email), suppression des anciens champs
- Formulaire upload étape 2 : convention+nom, version 3 champs, fournisseur,
  contacts métier et technique obligatoires ; type détecté auto → select désactivé
- Validation YAML : redocly.yaml (extends: spec), support noms avec espaces,
  extraction métadonnées (name, version, description) depuis info YAML
- Dossiers : modale création avec champ Emplacement pré-initialisé depuis
  le contexte de navigation, options └ avec indentation par profondeur
- Import depuis un dossier : pré-sélection de la catégorie via queryParam
- Recherche étendue : name, provider, description, contacts
- Résolution @datacat/shared via workspace:* + compilation CJS avant NestJS

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-11 13:38:14 +00:00
parent ef3884c68d
commit e85696079c
20 changed files with 911 additions and 519 deletions

View File

@@ -23,16 +23,20 @@ export class ApiService {
}
update(id: string, data: {
title?: string;
version?: string;
description?: string;
convention?: string;
name?: string;
provider?: string;
versionMajor?: number;
versionMinor?: number;
versionPatch?: number;
description?: string | null;
categoryId?: string | null;
functionalDoc?: string | null;
technicalDoc?: string | null;
externalDocUrl?: string | null;
contactFunctional?: string | null;
contactTechnical?: string | null;
accessRights?: string | null;
contactFunctionalName?: string;
contactFunctionalEntity?: string;
contactFunctionalEmail?: string;
contactTechnicalName?: string;
contactTechnicalEntity?: string;
contactTechnicalEmail?: string;
}): Observable<ApiEntry> {
return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
}
@@ -53,8 +57,10 @@ export class ApiService {
valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: string | null;
title: string | null;
version: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}> {
@@ -62,8 +68,10 @@ export class ApiService {
valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: string | null;
title: string | null;
version: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}>('/api/uploads/validate', formData);

View File

@@ -10,7 +10,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { ApiEntry, Category } from '@datacat/shared';
import { ApiEntry, Category, ApiConvention } from '@datacat/shared';
@Component({
selector: 'app-api-detail',
@@ -81,10 +81,6 @@ import { ApiEntry, Category } from '@datacat/shared';
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Informations</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ entry.title }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Version</dt>
<dd>{{ entry.version }}</dd>
@@ -93,6 +89,10 @@ import { ApiEntry, Category } from '@datacat/shared';
<dt class="fr-text--bold">Type</dt>
<dd>{{ entry.type }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Fournisseur</dt>
<dd>{{ entry.provider }}</dd>
</div>
@if (entry.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
@@ -112,80 +112,28 @@ import { ApiEntry, Category } from '@datacat/shared';
</div>
</div>
<!-- Documentation fonctionnelle -->
@if (entry.functionalDoc) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Documentation fonctionnelle</h2>
<p style="white-space: pre-wrap">{{ entry.functionalDoc }}</p>
</div>
</div>
</div>
}
<!-- Documentation technique -->
@if (entry.technicalDoc) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Documentation technique</h2>
<p style="white-space: pre-wrap">{{ entry.technicalDoc }}</p>
</div>
</div>
</div>
}
<!-- Contacts -->
@if (entry.contactFunctional || entry.contactTechnical) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Contacts</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
@if (entry.contactFunctional) {
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact fonctionnel</dt>
<dd>{{ entry.contactFunctional }}</dd>
</div>
}
@if (entry.contactTechnical) {
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact technique</dt>
<dd>{{ entry.contactTechnical }}</dd>
</div>
}
</dl>
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Contacts</h2>
<div class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-6">
<p class="fr-text--bold fr-mb-1w">Contact métier</p>
<p class="fr-mb-0">{{ entry.contactFunctionalName }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEntity }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEmail }}</p>
</div>
<div class="fr-col-12 fr-col-md-6">
<p class="fr-text--bold fr-mb-1w">Contact technique</p>
<p class="fr-mb-0">{{ entry.contactTechnicalName }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEntity }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEmail }}</p>
</div>
</div>
</div>
</div>
}
<!-- Droits d'accès -->
@if (entry.accessRights) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Droits d'accès</h2>
<p style="white-space: pre-wrap">{{ entry.accessRights }}</p>
</div>
</div>
</div>
}
<!-- Documentation externe -->
@if (entry.externalDocUrl) {
<div class="fr-mb-4w">
<a
[href]="entry.externalDocUrl"
target="_blank"
rel="noopener noreferrer"
class="fr-btn fr-btn--secondary fr-icon-external-link-line fr-btn--icon-left"
>
Documentation externe
</a>
</div>
}
</div>
<!-- Actions -->
<div class="fr-btns-group fr-btns-group--inline-sm">
@@ -229,7 +177,7 @@ import { ApiEntry, Category } from '@datacat/shared';
@if (showEditModal()) {
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:600px;margin:0 1rem 2rem">
<div style="background:white;width:calc(100% - 2rem);max-width:640px;margin:0 1rem 2rem">
<div class="fr-p-4w">
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
@@ -242,57 +190,144 @@ import { ApiEntry, Category } from '@datacat/shared';
@if (editError()) {
<div class="fr-alert fr-alert--error fr-mb-2w"><p>{{ editError() }}</p></div>
}
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editTitleError()">
<label class="fr-label" for="edit-title">Titre <span style="color:var(--text-default-error)">*</span></label>
<input id="edit-title" class="fr-input" [class.fr-input--error]="editTitleError()" type="text" [value]="editTitle()"
(input)="editTitle.set($any($event.target).value); editTitleError.set(false)"
(blur)="editTitleError.set(!editTitle().trim())" />
@if (editTitleError()) { <p class="fr-error-text">Le titre est obligatoire.</p> }
</div>
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editVersionError()">
<label class="fr-label" for="edit-version">Version <span style="color:var(--text-default-error)">*</span></label>
<input id="edit-version" class="fr-input" [class.fr-input--error]="editVersionError()" type="text" [value]="editVersion()"
(input)="editVersion.set($any($event.target).value); editVersionError.set(false)"
(blur)="editVersionError.set(!editVersion().trim())" />
@if (editVersionError()) { <p class="fr-error-text">La version est obligatoire.</p> }
<!-- 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">
<label class="fr-label" for="edit-convention">Convention</label>
<select id="edit-convention" class="fr-select" [value]="editConvention()"
(change)="editConvention.set($any($event.target).value)">
<option value="CONSULTER">Consulter</option>
<option value="ENREGISTRER">Enregistrer</option>
<option value="ETRE_NOTIFIE">Être notifié</option>
</select>
</div>
</div>
<div class="fr-col-12 fr-col-md-8">
<div class="fr-input-group" [class.fr-input-group--error]="editNameError()">
<label class="fr-label" for="edit-name">Nom de l'API</label>
<input id="edit-name" class="fr-input" [class.fr-input--error]="editNameError()" type="text"
[value]="editName()"
(input)="editName.set($any($event.target).value); editNameError.set(false)"
(blur)="editNameError.set(!editName().trim())" />
@if (editNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
</div>
</div>
</div>
<p class="fr-hint-text fr-mb-2w">Titre : <strong>{{ editPreviewTitle() }}</strong></p>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-2w">
<label class="fr-label" for="edit-category">Catégorie</label>
<select id="edit-category" class="fr-select" [value]="editCategoryId()" (change)="editCategoryId.set($any($event.target).value || null)">
<select id="edit-category" class="fr-select" [value]="editCategoryId()"
(change)="editCategoryId.set($any($event.target).value || null)">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.name }}</option>
}
</select>
</div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editProviderError()">
<label class="fr-label" for="edit-provider">Fournisseur <span style="color:var(--text-default-error)">*</span></label>
<input id="edit-provider" class="fr-input" [class.fr-input--error]="editProviderError()" type="text"
[value]="editProvider()"
(input)="editProvider.set($any($event.target).value); editProviderError.set(false)"
(blur)="editProviderError.set(!editProvider().trim())" />
@if (editProviderError()) { <p class="fr-error-text">Le fournisseur est obligatoire.</p> }
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-description">Description</label>
<textarea id="edit-description" class="fr-input" rows="3" [value]="editDescription()" (input)="editDescription.set($any($event.target).value)"></textarea>
<textarea id="edit-description" class="fr-input" rows="3" [value]="editDescription()"
(input)="editDescription.set($any($event.target).value)"></textarea>
</div>
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-func-doc">Documentation fonctionnelle</label>
<textarea id="edit-func-doc" class="fr-input" rows="3" [value]="editFunctionalDoc()" (input)="editFunctionalDoc.set($any($event.target).value)"></textarea>
<!-- Version -->
<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-2w">
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-v-major">Majeure (X)</label>
<input id="edit-v-major" class="fr-input" type="number" min="0"
[value]="editVersionMajor()"
(input)="editVersionMajor.set(+$any($event.target).value)" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-v-minor">Mineure (Y)</label>
<input id="edit-v-minor" class="fr-input" type="number" min="0"
[value]="editVersionMinor()"
(input)="editVersionMinor.set(+$any($event.target).value)" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-v-patch">Correctif (Z)</label>
<input id="edit-v-patch" class="fr-input" type="number" min="0"
[value]="editVersionPatch()"
(input)="editVersionPatch.set(+$any($event.target).value)" />
</div>
</div>
</div>
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-tech-doc">Documentation technique</label>
<textarea id="edit-tech-doc" class="fr-input" rows="3" [value]="editTechnicalDoc()" (input)="editTechnicalDoc.set($any($event.target).value)"></textarea>
<!-- 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-2w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-cf-name">Nom et prénom</label>
<input id="edit-cf-name" class="fr-input" type="text" [value]="editContactFunctionalName()"
(input)="editContactFunctionalName.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-cf-entity">Entité</label>
<input id="edit-cf-entity" class="fr-input" type="text" [value]="editContactFunctionalEntity()"
(input)="editContactFunctionalEntity.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-cf-email">Email</label>
<input id="edit-cf-email" class="fr-input" type="email" [value]="editContactFunctionalEmail()"
(input)="editContactFunctionalEmail.set($any($event.target).value)" />
</div>
</div>
</div>
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-ext-url">URL documentation externe</label>
<input id="edit-ext-url" class="fr-input" type="text" [value]="editExternalDocUrl()" (input)="editExternalDocUrl.set($any($event.target).value)" />
</div>
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-contact-func">Contact fonctionnel</label>
<input id="edit-contact-func" class="fr-input" type="text" [value]="editContactFunctional()" (input)="editContactFunctional.set($any($event.target).value)" />
</div>
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-contact-tech">Contact technique</label>
<input id="edit-contact-tech" class="fr-input" type="text" [value]="editContactTechnical()" (input)="editContactTechnical.set($any($event.target).value)" />
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="edit-access">Droits d'accès</label>
<textarea id="edit-access" class="fr-input" rows="3" [value]="editAccessRights()" (input)="editAccessRights.set($any($event.target).value)"></textarea>
<!-- 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-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-ct-name">Nom et prénom</label>
<input id="edit-ct-name" class="fr-input" type="text" [value]="editContactTechnicalName()"
(input)="editContactTechnicalName.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-ct-entity">Entité</label>
<input id="edit-ct-entity" class="fr-input" type="text" [value]="editContactTechnicalEntity()"
(input)="editContactTechnicalEntity.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-ct-email">Email</label>
<input id="edit-ct-email" class="fr-input" type="email" [value]="editContactTechnicalEmail()"
(input)="editContactTechnicalEmail.set($any($event.target).value)" />
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="closeEdit()">Annuler</button>
<button class="fr-btn" (click)="submitEdit()" [disabled]="editLoading()">
@@ -321,19 +356,28 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
showEditModal = signal(false);
editLoading = signal(false);
editError = signal<string | null>(null);
editTitleError = signal(false);
editVersionError = signal(false);
editNameError = signal(false);
editProviderError = signal(false);
categories = signal<Category[]>([]);
editTitle = signal('');
editVersion = signal('');
editConvention = signal<ApiConvention>('CONSULTER');
editName = signal('');
editProvider = signal('');
editVersionMajor = signal(0);
editVersionMinor = signal(0);
editVersionPatch = signal(0);
editCategoryId = signal<string | null>(null);
editDescription = signal('');
editFunctionalDoc = signal('');
editTechnicalDoc = signal('');
editExternalDocUrl = signal('');
editContactFunctional = signal('');
editContactTechnical = signal('');
editAccessRights = signal('');
editContactFunctionalName = signal('');
editContactFunctionalEntity = signal('');
editContactFunctionalEmail = signal('');
editContactTechnicalName = signal('');
editContactTechnicalEntity = signal('');
editContactTechnicalEmail = signal('');
editPreviewTitle() {
const labels: Record<ApiConvention, string> = { CONSULTER: 'Consulter', ENREGISTRER: 'Enregistrer', ETRE_NOTIFIE: 'Être notifié' };
return `${labels[this.editConvention()]} ${this.editName()}`.trim();
}
private pollInterval: ReturnType<typeof setInterval> | null = null;
private id = '';
@@ -362,19 +406,23 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
openEdit() {
const entry = this.api();
if (!entry) return;
this.editTitle.set(entry.title);
this.editVersion.set(entry.version);
this.editConvention.set(entry.convention);
this.editName.set(entry.name);
this.editProvider.set(entry.provider);
this.editVersionMajor.set(entry.versionMajor);
this.editVersionMinor.set(entry.versionMinor);
this.editVersionPatch.set(entry.versionPatch);
this.editCategoryId.set(entry.categoryId ?? null);
this.editDescription.set(entry.description ?? '');
this.editFunctionalDoc.set(entry.functionalDoc ?? '');
this.editTechnicalDoc.set(entry.technicalDoc ?? '');
this.editExternalDocUrl.set(entry.externalDocUrl ?? '');
this.editContactFunctional.set(entry.contactFunctional ?? '');
this.editContactTechnical.set(entry.contactTechnical ?? '');
this.editAccessRights.set(entry.accessRights ?? '');
this.editContactFunctionalName.set(entry.contactFunctionalName);
this.editContactFunctionalEntity.set(entry.contactFunctionalEntity);
this.editContactFunctionalEmail.set(entry.contactFunctionalEmail);
this.editContactTechnicalName.set(entry.contactTechnicalName);
this.editContactTechnicalEntity.set(entry.contactTechnicalEntity);
this.editContactTechnicalEmail.set(entry.contactTechnicalEmail);
this.editError.set(null);
this.editTitleError.set(false);
this.editVersionError.set(false);
this.editNameError.set(false);
this.editProviderError.set(false);
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
this.showEditModal.set(true);
}
@@ -384,21 +432,25 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
}
submitEdit() {
this.editTitleError.set(!this.editTitle().trim());
this.editVersionError.set(!this.editVersion().trim());
if (!this.editTitle().trim() || !this.editVersion().trim()) return;
this.editNameError.set(!this.editName().trim());
this.editProviderError.set(!this.editProvider().trim());
if (!this.editName().trim() || !this.editProvider().trim()) return;
this.editLoading.set(true);
this.apiService.update(this.id, {
title: this.editTitle().trim(),
version: this.editVersion().trim(),
convention: this.editConvention(),
name: this.editName().trim(),
provider: this.editProvider().trim(),
versionMajor: this.editVersionMajor(),
versionMinor: this.editVersionMinor(),
versionPatch: this.editVersionPatch(),
categoryId: this.editCategoryId() || null,
description: this.editDescription().trim() || undefined,
functionalDoc: this.editFunctionalDoc().trim() || null,
technicalDoc: this.editTechnicalDoc().trim() || null,
externalDocUrl: this.editExternalDocUrl().trim() || null,
contactFunctional: this.editContactFunctional().trim() || null,
contactTechnical: this.editContactTechnical().trim() || null,
accessRights: this.editAccessRights().trim() || null,
description: this.editDescription().trim() || null,
contactFunctionalName: this.editContactFunctionalName().trim(),
contactFunctionalEntity: this.editContactFunctionalEntity().trim(),
contactFunctionalEmail: this.editContactFunctionalEmail().trim(),
contactTechnicalName: this.editContactTechnicalName().trim(),
contactTechnicalEntity: this.editContactTechnicalEntity().trim(),
contactTechnicalEmail: this.editContactTechnicalEmail().trim(),
}).subscribe({
next: (entry) => {
this.api.set(entry);

View File

@@ -47,7 +47,12 @@ import { forkJoin } from 'rxjs';
<div class="fr-col">
<h1 class="fr-h2 fr-mb-0">{{ browseData()?.category?.name ?? 'Catalogue' }}</h1>
</div>
<div class="fr-col-auto">
<div class="fr-col-auto" style="display:flex;gap:0.5rem">
<a class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-upload-2-line"
[routerLink]="['/upload']"
[queryParams]="categoryId() ? { categoryId: categoryId() } : {}">
Importer une API
</a>
<button class="fr-btn fr-btn--icon-left fr-icon-folder-2-fill" (click)="openCreate()">
Nouveau dossier
</button>
@@ -246,6 +251,18 @@ import { forkJoin } from 'rxjs';
<p>{{ formError() }}</p>
</div>
}
@if (formMode() === 'create') {
<div class="fr-select-group fr-mb-2w">
<label class="fr-label" for="form-parent">Emplacement</label>
<select id="form-parent" class="fr-select"
(change)="formParentId.set($any($event.target).value || null)">
<option value="" [selected]="!formParentId()"></option>
@for (opt of categoriesForSelect(); track opt.id) {
<option [value]="opt.id" [selected]="opt.id === formParentId()">{{ opt.label }}</option>
}
</select>
</div>
}
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="formNameError()">
<label class="fr-label" for="form-name">
Nom <span style="color:var(--text-default-error)">*</span>
@@ -308,9 +325,11 @@ export class BrowseComponent implements OnInit {
editingCategoryId = signal<string | null>(null);
formName = signal('');
formDescription = signal('');
formParentId = signal<string | null>(null);
formError = signal<string | null>(null);
formNameError = signal(false);
formLoading = signal(false);
allCategories = signal<Category[]>([]);
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
ancestors = computed<Category[]>(() => {
@@ -319,6 +338,39 @@ export class BrowseComponent implements OnInit {
return d.breadcrumb.slice(0, -1);
});
/* Options du select "Emplacement" avec indentation type └ similaire au formulaire d'import */
categoriesForSelect = computed<{ id: string; label: string }[]>(() => {
const cats = this.allCategories();
const map = new Map(cats.map((c) => [c.id, c]));
/* Chemin complet pour le tri */
const getSortPath = (id: string, depth = 0): string => {
if (depth > 10) return '';
const cat = map.get(id);
if (!cat) return '';
if (!cat.parentId) return cat.name;
return `${getSortPath(cat.parentId, depth + 1)} / ${cat.name}`;
};
/* Profondeur pour l'indentation */
const getDepth = (id: string, depth = 0): number => {
if (depth > 10) return depth;
const cat = map.get(id);
if (!cat?.parentId) return depth;
return getDepth(cat.parentId, depth + 1);
};
return cats
.map((c) => {
const depth = getDepth(c.id);
const indent = '\u00a0\u00a0'.repeat(depth);
const prefix = depth > 0 ? `${indent}\u00a0` : '';
return { id: c.id, label: `${prefix}${c.name}`, _sort: getSortPath(c.id) };
})
.sort((a, b) => a._sort.localeCompare(b._sort, 'fr'))
.map(({ id, label }) => ({ id, label }));
});
ngOnInit() {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -348,9 +400,20 @@ export class BrowseComponent implements OnInit {
this.editingCategoryId.set(null);
this.formName.set('');
this.formDescription.set('');
this.formParentId.set(this.categoryId());
this.formError.set(null);
this.formNameError.set(false);
this.showFormModal.set(true);
/* Charger les catégories d'abord, puis afficher la modale pour que la pré-sélection fonctionne */
this.categoryService.list().subscribe({
next: (res) => {
this.allCategories.set(res.items);
this.showFormModal.set(true);
},
error: () => {
this.allCategories.set([]);
this.showFormModal.set(true);
},
});
}
openEdit(cat: CategoryWithCounts) {
@@ -382,7 +445,7 @@ export class BrowseComponent implements OnInit {
? this.categoryService.create({
name,
description,
parentId: this.categoryId() ?? undefined,
parentId: this.formParentId() ?? undefined,
})
: this.categoryService.update(this.editingCategoryId()!, { name, description });

View File

@@ -6,12 +6,13 @@ import {
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 } from '@angular/router';
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared';
import { Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
@Component({
selector: 'app-upload',
@@ -122,58 +123,52 @@ import { Category } from '@datacat/shared';
<!-- Étape 2 : Métadonnées -->
@if (step() === 2) {
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="titleInvalid()">
<label class="fr-label" for="title-input">
Titre <span style="color:var(--text-default-error)">*</span>
</label>
<input
id="title-input"
class="fr-input"
[class.fr-input--error]="titleInvalid()"
formControlName="title"
type="text"
placeholder="Nom de l'API"
/>
@if (titleInvalid()) {
<p class="fr-error-text">Le titre est obligatoire.</p>
}
</div>
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="versionInvalid()">
<label class="fr-label" for="version-input">
Version <span style="color:var(--text-default-error)">*</span>
</label>
<input
id="version-input"
class="fr-input"
[class.fr-input--error]="versionInvalid()"
formControlName="version"
type="text"
placeholder="1.0.0"
/>
@if (versionInvalid()) {
<p class="fr-error-text">La version est obligatoire.</p>
}
</div>
<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>
<!-- 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>
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) {
@@ -182,9 +177,43 @@ import { Category } from '@datacat/shared';
</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>
Description
</label>
<textarea
id="description-input"
class="fr-input"
@@ -194,76 +223,127 @@ import { Category } from '@datacat/shared';
></textarea>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="functional-doc-input">
Documentation fonctionnelle </label>
<textarea
id="functional-doc-input"
class="fr-input"
formControlName="functionalDoc"
rows="4"
placeholder="Cas d'usage, contexte métier..."
></textarea>
<!-- 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-input-group fr-mb-3w">
<label class="fr-label" for="technical-doc-input">
Documentation technique </label>
<textarea
id="technical-doc-input"
class="fr-input"
formControlName="technicalDoc"
rows="4"
placeholder="Guide d'intégration technique..."
></textarea>
<!-- 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>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="external-doc-url-input">
URL documentation externe </label>
<input
id="external-doc-url-input"
class="fr-input"
formControlName="externalDocUrl"
type="text"
placeholder="https://..."
/>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="contact-functional-input">
Contact fonctionnel </label>
<input
id="contact-functional-input"
class="fr-input"
formControlName="contactFunctional"
type="text"
placeholder="Nom, email, téléphone..."
/>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="contact-technical-input">
Contact technique </label>
<input
id="contact-technical-input"
class="fr-input"
formControlName="contactTechnical"
type="text"
placeholder="Nom, email, téléphone..."
/>
</div>
<div class="fr-input-group fr-mb-4w">
<label class="fr-label" for="access-rights-input">
Droits d'accès </label>
<textarea
id="access-rights-input"
class="fr-input"
formControlName="accessRights"
rows="3"
placeholder="Processus d'habilitation, conditions d'accès..."
></textarea>
<!-- 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">
@@ -303,13 +383,17 @@ import { Category } from '@datacat/shared';
<dt class="fr-text--bold">Type</dt>
<dd>{{ metaForm.value.type }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<div class="fr-col-12">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ metaForm.value.title }}</dd>
<dd>{{ previewTitle() }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Version</dt>
<dd>{{ metaForm.value.version }}</dd>
<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">
@@ -356,6 +440,7 @@ export class UploadComponent implements OnInit {
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private router = inject(Router);
private route = inject(ActivatedRoute);
step = signal(1);
selectedFiles = signal<File[]>([]);
@@ -376,30 +461,57 @@ export class UploadComponent implements OnInit {
];
metaForm = this.fb.group({
title: ['', Validators.required],
version: ['', Validators.required],
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: [''],
functionalDoc: [''],
technicalDoc: [''],
externalDocUrl: [''],
contactFunctional: [''],
contactTechnical: [''],
accessRights: [''],
contactFunctionalName: ['', Validators.required],
contactFunctionalEntity: ['', Validators.required],
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
contactTechnicalName: ['', Validators.required],
contactTechnicalEntity: ['', Validators.required],
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
});
titleInvalid = computed(
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched),
);
versionInvalid = computed(
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched),
);
/* 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.metaForm.value.categoryId;
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;
@@ -408,9 +520,20 @@ export class UploadComponent implements OnInit {
return this.validationDone() && this.validationErrors().length === 0;
});
fieldInvalid(name: string): boolean {
const ctrl = this.metaForm.get(name);
return !!(ctrl?.invalid && ctrl?.touched);
}
ngOnInit() {
const preselectedCategoryId = this.route.snapshot.queryParamMap.get('categoryId');
this.categoryService.list().subscribe({
next: (res) => this.categories.set(res.items),
next: (res) => {
this.categories.set(res.items);
if (preselectedCategoryId) {
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
}
},
});
}
@@ -426,6 +549,7 @@ export class UploadComponent implements OnInit {
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;
@@ -471,18 +595,22 @@ export class UploadComponent implements OnInit {
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.title) this.metaForm.patchValue({ title: result.title });
if (result.version) this.metaForm.patchValue({ version: String(result.version) });
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 });
}
},
@@ -509,37 +637,30 @@ export class UploadComponent implements OnInit {
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('title', this.metaForm.value.title ?? '');
formData.append('version', this.metaForm.value.version ?? '');
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI');
if (this.metaForm.value.categoryId) {
formData.append('categoryId', this.metaForm.value.categoryId);
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 (this.metaForm.value.description) {
formData.append('description', this.metaForm.value.description);
}
if (this.metaForm.value.functionalDoc) {
formData.append('functionalDoc', this.metaForm.value.functionalDoc);
}
if (this.metaForm.value.technicalDoc) {
formData.append('technicalDoc', this.metaForm.value.technicalDoc);
}
if (this.metaForm.value.externalDocUrl) {
formData.append('externalDocUrl', this.metaForm.value.externalDocUrl);
}
if (this.metaForm.value.contactFunctional) {
formData.append('contactFunctional', this.metaForm.value.contactFunctional);
}
if (this.metaForm.value.contactTechnical) {
formData.append('contactTechnical', this.metaForm.value.contactTechnical);
}
if (this.metaForm.value.accessRights) {
formData.append('accessRights', this.metaForm.value.accessRights);
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) => {