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:
@@ -64,6 +64,11 @@ export class ApisController {
|
|||||||
res.send(entry.yamlContent);
|
res.send(entry.yamlContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/versions')
|
||||||
|
getVersions(@Param('id') id: string) {
|
||||||
|
return this.apisService.findVersionsOf(id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Sert les assets statiques (CSS, JS) générés par AsyncAPI CLI */
|
/** Sert les assets statiques (CSS, JS) générés par AsyncAPI CLI */
|
||||||
@Get(':id/*path')
|
@Get(':id/*path')
|
||||||
async getDocAsset(@Param('id') id: string, @Req() req: Request, @Res() res: Response) {
|
async getDocAsset(@Param('id') id: string, @Req() req: Request, @Res() res: Response) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Repository } from 'typeorm';
|
|||||||
import { ApiEntryEntity } from './api-entry.entity';
|
import { ApiEntryEntity } from './api-entry.entity';
|
||||||
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
||||||
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
||||||
import { ApiListResponse, ApiListQuery, ApiEntry, ApiType, ApiConvention, API_CONVENTION_LABELS } from '@datacat/shared';
|
import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus, ApiConvention, API_CONVENTION_LABELS } from '@datacat/shared';
|
||||||
import { toApiEntryListItem } from './api-entry.mapper';
|
import { toApiEntryListItem } from './api-entry.mapper';
|
||||||
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||||
|
|
||||||
@@ -107,6 +107,19 @@ export class ApisService {
|
|||||||
await this.repo.delete(id);
|
await this.repo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findVersionsOf(id: string): Promise<ApiEntryListItem[]> {
|
||||||
|
const entry = await this.findOneOrThrow(id);
|
||||||
|
const entities = await this.repo.find({
|
||||||
|
where: { name: entry.name, convention: entry.convention },
|
||||||
|
order: {
|
||||||
|
versionMajor: 'DESC',
|
||||||
|
versionMinor: 'DESC',
|
||||||
|
versionPatch: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return entities.map(toApiEntryListItem);
|
||||||
|
}
|
||||||
|
|
||||||
async regenerate(id: string): Promise<ApiEntry> {
|
async regenerate(id: string): Promise<ApiEntry> {
|
||||||
await this.findOneOrThrow(id);
|
await this.findOneOrThrow(id);
|
||||||
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
||||||
|
|||||||
@@ -765,6 +765,46 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
contactTechnicalEntity: 'API Commerce',
|
contactTechnicalEntity: 'API Commerce',
|
||||||
contactTechnicalEmail: 'api-orders@example.com',
|
contactTechnicalEmail: 'api-orders@example.com',
|
||||||
}),
|
}),
|
||||||
|
this.apiRepo.create({
|
||||||
|
convention: 'CONSULTER',
|
||||||
|
name: 'Référentiel Produits',
|
||||||
|
versionMajor: 3,
|
||||||
|
versionMinor: 0,
|
||||||
|
versionPatch: 3,
|
||||||
|
description:
|
||||||
|
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||||
|
type: 'OPENAPI',
|
||||||
|
yamlContent: PETSTORE_YAML,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: produits.id,
|
||||||
|
provider: 'DINUM',
|
||||||
|
contactFunctionalName: 'Alice Martin',
|
||||||
|
contactFunctionalEntity: 'Équipe Produit',
|
||||||
|
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
|
||||||
|
contactTechnicalName: 'Bob Dupont',
|
||||||
|
contactTechnicalEntity: 'Équipe API',
|
||||||
|
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
|
||||||
|
}),
|
||||||
|
this.apiRepo.create({
|
||||||
|
convention: 'CONSULTER',
|
||||||
|
name: 'Référentiel Produits',
|
||||||
|
versionMajor: 3,
|
||||||
|
versionMinor: 0,
|
||||||
|
versionPatch: 2,
|
||||||
|
description:
|
||||||
|
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||||
|
type: 'OPENAPI',
|
||||||
|
yamlContent: PETSTORE_YAML,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: produits.id,
|
||||||
|
provider: 'DINUM',
|
||||||
|
contactFunctionalName: 'Alice Martin',
|
||||||
|
contactFunctionalEntity: 'Équipe Produit',
|
||||||
|
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
|
||||||
|
contactTechnicalName: 'Bob Dupont',
|
||||||
|
contactTechnicalEntity: 'Équipe API',
|
||||||
|
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
|
||||||
|
}),
|
||||||
this.apiRepo.create({
|
this.apiRepo.create({
|
||||||
convention: 'CONSULTER',
|
convention: 'CONSULTER',
|
||||||
name: 'Authentification JWT',
|
name: 'Authentification JWT',
|
||||||
@@ -786,7 +826,7 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...');
|
console.log('[Seeder] 8 catégories et 7 APIs de démo créées — génération docs en cours...');
|
||||||
|
|
||||||
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
|
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { ApiEntry, ApiListResponse, ApiListQuery } from '@datacat/shared';
|
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery } from '@datacat/shared';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApiService {
|
export class ApiService {
|
||||||
@@ -45,6 +45,10 @@ export class ApiService {
|
|||||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getVersions(id: string): Observable<ApiEntryListItem[]> {
|
||||||
|
return this.http.get<ApiEntryListItem[]>(`${this.baseUrl}/${id}/versions`);
|
||||||
|
}
|
||||||
|
|
||||||
regenerate(id: string): Observable<ApiEntry> {
|
regenerate(id: string): Observable<ApiEntry> {
|
||||||
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
|
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
import { CategoryService } from '../../core/category.service';
|
import { CategoryService } from '../../core/category.service';
|
||||||
import { ApiEntry, Category, ApiConvention } from '@datacat/shared';
|
import { ApiEntry, ApiEntryListItem, Category, ApiConvention } from '@datacat/shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-api-detail',
|
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="fr-tag">v{{ entry.version }}</span>
|
||||||
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
|
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,6 +166,9 @@ import { ApiEntry, Category, ApiConvention } from '@datacat/shared';
|
|||||||
<button class="fr-btn fr-btn--secondary" (click)="openEdit()">
|
<button class="fr-btn fr-btn--secondary" (click)="openEdit()">
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
|
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line" (click)="onNewVersion()">
|
||||||
|
Nouvelle version
|
||||||
|
</button>
|
||||||
<a
|
<a
|
||||||
[href]="'/api/apis/' + entry.id + '/yaml'"
|
[href]="'/api/apis/' + entry.id + '/yaml'"
|
||||||
class="fr-btn fr-btn--secondary"
|
class="fr-btn fr-btn--secondary"
|
||||||
@@ -354,6 +368,7 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
private categoryService = inject(CategoryService);
|
private categoryService = inject(CategoryService);
|
||||||
|
|
||||||
api = signal<ApiEntry | null>(null);
|
api = signal<ApiEntry | null>(null);
|
||||||
|
versions = signal<ApiEntryListItem[]>([]);
|
||||||
categoryBreadcrumb = signal<Category[]>([]);
|
categoryBreadcrumb = signal<Category[]>([]);
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
@@ -388,17 +403,37 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private routeSub: Subscription | null = null;
|
||||||
private id = '';
|
private id = '';
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.id = this.route.snapshot.paramMap.get('id') ?? '';
|
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();
|
this.loadApi();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
this.routeSub?.unsubscribe();
|
||||||
this.clearPoll();
|
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() {
|
onRegenerate() {
|
||||||
this.regenerating.set(true);
|
this.regenerating.set(true);
|
||||||
this.apiService.regenerate(this.id).subscribe({
|
this.apiService.regenerate(this.id).subscribe({
|
||||||
@@ -494,6 +529,10 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.api.set(entry);
|
this.api.set(entry);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.startPollIfPending();
|
this.startPollIfPending();
|
||||||
|
this.apiService.getVersions(this.id).subscribe({
|
||||||
|
next: (v) => this.versions.set(v),
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
if (entry.categoryId) {
|
if (entry.categoryId) {
|
||||||
this.categoryService.browse(entry.categoryId).subscribe({
|
this.categoryService.browse(entry.categoryId).subscribe({
|
||||||
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
|
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
|||||||
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
import { CategoryService } from '../../core/category.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({
|
@Component({
|
||||||
selector: 'app-upload',
|
selector: 'app-upload',
|
||||||
@@ -526,7 +526,10 @@ export class UploadComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
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({
|
this.categoryService.list().subscribe({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
this.categories.set(res.items);
|
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 {
|
categoryLabel(cat: Category): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user