feat(api-detail): navigation entre versions + bouton nouvelle version

- GET /api/apis/:id/versions — retourne toutes les versions du même API
  (même name + convention), triées par version DESC
- Sélecteur DSFR sur la page détail quand plusieurs versions existent ;
  changement de version navigue vers /catalog/:newId en rechargeant
  le composant via abonnement à paramMap (fix réutilisation instance Angular)
- Bouton "Nouvelle version" → /upload?from=:id avec pré-remplissage du
  formulaire (convention, name, provider, contacts, version patch+1)
- Seeder : 2 versions démo supplémentaires de "Référentiel Produits"
  (v3.0.3, v3.0.2) pour tester le sélecteur
- Fix import ApiStatus manquant dans apis.service.ts

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-16 15:07:31 +00:00
parent 1adb6820dd
commit e1a3dbc85b
6 changed files with 138 additions and 8 deletions

View File

@@ -9,9 +9,10 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { ApiEntry, Category, ApiConvention } from '@datacat/shared';
import { ApiEntry, ApiEntryListItem, Category, ApiConvention } from '@datacat/shared';
@Component({
selector: 'app-api-detail',
@@ -63,6 +64,16 @@ import { ApiEntry, Category, ApiConvention } from '@datacat/shared';
<span class="fr-tag">v{{ entry.version }}</span>
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
</div>
@if (versions().length > 1) {
<div class="fr-select-group fr-mt-2w" style="max-width:200px">
<label class="fr-label" for="version-select">Version</label>
<select class="fr-select" id="version-select" (change)="onVersionChange($event)">
@for (v of versions(); track v.id) {
<option [value]="v.id" [selected]="v.id === entry.id">{{ v.version }}</option>
}
</select>
</div>
}
</div>
</div>
@@ -155,6 +166,9 @@ import { ApiEntry, Category, ApiConvention } from '@datacat/shared';
<button class="fr-btn fr-btn--secondary" (click)="openEdit()">
Modifier
</button>
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line" (click)="onNewVersion()">
Nouvelle version
</button>
<a
[href]="'/api/apis/' + entry.id + '/yaml'"
class="fr-btn fr-btn--secondary"
@@ -354,6 +368,7 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private categoryService = inject(CategoryService);
api = signal<ApiEntry | null>(null);
versions = signal<ApiEntryListItem[]>([]);
categoryBreadcrumb = signal<Category[]>([]);
loading = signal(false);
error = signal<string | null>(null);
@@ -388,17 +403,37 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
});
private pollInterval: ReturnType<typeof setInterval> | null = null;
private routeSub: Subscription | null = null;
private id = '';
ngOnInit() {
this.id = this.route.snapshot.paramMap.get('id') ?? '';
this.loadApi();
this.routeSub = this.route.paramMap.subscribe((params) => {
this.id = params.get('id') ?? '';
this.api.set(null);
this.versions.set([]);
this.error.set(null);
this.categoryBreadcrumb.set([]);
this.clearPoll();
this.loadApi();
});
}
ngOnDestroy() {
this.routeSub?.unsubscribe();
this.clearPoll();
}
onVersionChange(event: Event): void {
const id = (event.target as HTMLSelectElement).value;
if (id && id !== this.api()?.id) {
this.router.navigate(['/catalog', id]);
}
}
onNewVersion(): void {
this.router.navigate(['/upload'], { queryParams: { from: this.api()?.id } });
}
onRegenerate() {
this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({
@@ -494,6 +529,10 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
this.api.set(entry);
this.loading.set(false);
this.startPollIfPending();
this.apiService.getVersions(this.id).subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
if (entry.categoryId) {
this.categoryService.browse(entry.categoryId).subscribe({
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),

View File

@@ -12,7 +12,7 @@ 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 { Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
@Component({
selector: 'app-upload',
@@ -526,7 +526,10 @@ export class UploadComponent implements OnInit {
}
ngOnInit() {
const preselectedCategoryId = this.route.snapshot.queryParamMap.get('categoryId');
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);
@@ -535,6 +538,32 @@ export class UploadComponent implements OnInit {
}
},
});
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 {