Compare commits

..

16 Commits

Author SHA1 Message Date
z3n
c8b3e393ea fix(search): correction recherche fuzzy catégories
- Backend : seuil word_similarity >= 0.4 (au lieu de > 0.4) sur cat.name
- Frontend : correction erreur TypeScript dans CategoryService.list()
  (params typé Record<string,string> pour satisfaire Angular HttpClient)
  — le code précédent ne compilait pas, la recherche était ignorée

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>
2026-06-19 13:31:49 +00:00
z3n
eb07ca085f feat(browse): tag version sur les tuiles + statut francisé sur la fiche détail
- Supprime le badge statut (GENERATED/PENDING/ERROR) des tuiles browse et cartes search
- Ajoute un badge version (ex: v1.2.0) sur les tuiles et cartes
- Traduit le statut en français sur la fiche détail (Générée / En attente / Erreur)

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>
2026-06-17 14:50:51 +00:00
z3n
dbf5af81ca fix(search): seuils word_similarity différenciés par champ
- name/provider : seuil 0.4 (textes courts, scores plus précis)
- description   : seuil 0.5 (textes longs, évite les faux positifs)
- "auten" trouve désormais "Authentification JWT" via fuzzy sur le nom

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>
2026-06-17 09:04:47 +00:00
z3n
498c6577e4 feat(search): recherche intelligente — parsing backend + pg_trgm fuzzy
- parseSearchTokens() côté backend : extrait type (async/openapi),
  version (v1, 1.2.3) et latestOnly depuis la chaîne brute
- Recherche fuzzy via word_similarity (pg_trgm, seuil 0.5) + ILIKE
- latestOnly automatique quand champ non vide sans version explicite
- Suppression de parseSearch() dans BrowseComponent et CatalogComponent
- Suppression de version/latestOnly de ApiListQuery (géré par le backend)
- Activation de pg_trgm via CREATE EXTENSION dans SeederService
- Tests unitaires Vitest pour parseSearchTokens (24 cas)

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>
2026-06-17 08:57:45 +00:00
z3n
e1a3dbc85b 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>
2026-06-16 15:07:31 +00:00
z3n
1adb6820dd refactor: fix bugs et réduire duplication + extraire modales BrowseComponent
- fix(dto): ajouter @IsEmail() sur contactFunctionalEmail/TechnicalEmail dans UpdateApiEntryDto
- fix(docs): corriger stringify des erreurs (String(error) → error.message)
- refactor(mapper): extraire toApiEntryListItem() dans api-entry.mapper.ts partagé
- refactor(categories): supprimer CONVENTION_LABELS et toApiEntryListItem locaux dupliqués
- refactor(browse): extraire CategoryFormModalComponent et CategoryDeleteModalComponent
- refactor(api-detail): convertir editPreviewTitle() en computed()

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>
2026-06-11 15:10:38 +00:00
z3n
e939d592f8 feat(browse): modale de suppression + crayon d'édition sur le titre
- Remplace le confirm() natif par une vraie modale de confirmation avec gestion d'erreur inline
- Ajoute un bouton crayon dans le <h1> pour éditer la catégorie courante (visible uniquement dans /browse/:id)
- Signature openEdit() assouplie pour accepter Category (au lieu de CategoryWithCounts)

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>
2026-06-11 14:50:16 +00:00
z3n
b9d45c0c3c feat(api-detail): fil d'Ariane aligné sur la hiérarchie des catégories
Accueil > [ancêtres de catégorie] > [titre API], ou
Accueil > Parcourir > [titre API] si l'API n'a pas de catégorie.

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>
2026-06-11 13:54:24 +00:00
z3n
e85696079c 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>
2026-06-11 13:38:14 +00:00
z3n
ef3884c68d 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>
2026-06-10 08:48:40 +00:00
z3n
abc20bc9e8 fix(forms): erreurs de validation par champ au blur
- Erreurs affichées sous chaque champ obligatoire (fr-error-text DSFR)
  au lieu d'un message global en haut du formulaire
- Déclenchement sur blur (sortie du champ) et effacement sur input
- Concerne la modale édition API et la modale catégorie

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>
2026-06-09 13:17:18 +00:00
z3n
af02cf6808 feat(api-detail): modale d'édition des métadonnées d'une API
- Bouton "Modifier" sur la page détail ouvre une modale avec tous les
  champs éditables (titre, version, catégorie, description, docs, contacts,
  droits d'accès)
- ApiService.update() étendu à tous les champs du UpdateApiEntryDto
- Remplacement des labels "Obligatoire"/"Optionnel" par un astérisque
  rouge sur les champs requis dans tous les formulaires

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>
2026-06-09 13:06:53 +00:00
z3n
ecb45e8248 feat(browse): icône crayon inline sur les tuiles + apiCount récursif
- Bouton modifier réduit à l'icône crayon, placé à côté du titre de la tuile
- apiCount dans CategoryWithCounts inclut désormais les APIs de tous les
  sous-dossiers récursivement (countApisRecursively)

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>
2026-06-09 13:01:46 +00:00
z3n
e54c21042f fix(assets): servir dist/icons/ au bon chemin pour dsfr.min.css
dsfr.min.css référence les SVG via ../icons/ (résout vers assets/icons/)
alors que icons.min.css les référence via ../../icons/ (assets/dsfr/icons/).
Les deux chemins sont maintenant mappés dans angular.json.

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>
2026-06-09 12:53:12 +00:00
z3n
9a8f28bbce feat(browse): recherche unifiée dossiers + APIs + fix icônes DSFR
- Recherche simultanée dans les dossiers (filtrage client-side) et les
  APIs (backend) via forkJoin — affiche dossiers en tuiles puis APIs en
  cartes, avec labels "Dossiers"/"APIs" si les deux ont des résultats
- Loader instantané dès la frappe (avant le debounce 400ms)
- Fix angular.json : ajout du mapping dist/icons/ → assets/dsfr/icons/
  pour servir les SVG référencés par le CSS DSFR (tous les icônes étaient
  cassés car le chemin relatif ../../icons/ donnait 404)

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>
2026-06-09 12:51:28 +00:00
z3n
a82c52c491 feat(browse): fusion Parcourir + Catalogue en une seule page
- Barre de recherche intégrée dans BrowseComponent
- Mode navigation (tuiles) si recherche vide, mode liste (cartes) si texte
- Debounce 400ms + pagination pour les résultats de recherche
- Suppression de l'entrée "Catalogue" du menu de navigation
- Redirection /catalog → /browse (vieux liens toujours fonctionnels)

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>
2026-06-09 09:41:10 +00:00
32 changed files with 2287 additions and 775 deletions

View File

@@ -12,6 +12,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@datacat/shared": "workspace:*",
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",

3
back/redocly.yaml Normal file
View File

@@ -0,0 +1,3 @@
# Validation structurelle uniquement — pas de règles de style
extends:
- spec

View File

@@ -1,16 +1,16 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ApiType, ApiStatus } from '@datacat/shared'; import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
@Entity('api_entries') @Entity('api_entries')
export class ApiEntryEntity { export class ApiEntryEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@Column() @Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
title!: string; convention!: ApiConvention;
@Column() @Column({ type: 'varchar', length: 255, default: '' })
version!: string; name!: string;
@Column({ nullable: true, type: 'text' }) @Column({ nullable: true, type: 'text' })
description!: string | null; description!: string | null;
@@ -18,6 +18,18 @@ export class ApiEntryEntity {
@Column({ type: 'varchar', length: 10 }) @Column({ type: 'varchar', length: 10 })
type!: ApiType; type!: ApiType;
@Column({ type: 'varchar', length: 255, default: '', name: 'provider' })
provider!: string;
@Column({ type: 'int', default: 0, name: 'version_major' })
versionMajor!: number;
@Column({ type: 'int', default: 0, name: 'version_minor' })
versionMinor!: number;
@Column({ type: 'int', default: 0, name: 'version_patch' })
versionPatch!: number;
@Column({ type: 'text', name: 'yaml_content' }) @Column({ type: 'text', name: 'yaml_content' })
yamlContent!: string; yamlContent!: string;
@@ -33,23 +45,23 @@ export class ApiEntryEntity {
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' }) @Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
categoryId!: string | null; categoryId!: string | null;
@Column({ nullable: true, type: 'text', name: 'functional_doc' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
functionalDoc!: string | null; contactFunctionalName!: string;
@Column({ nullable: true, type: 'text', name: 'technical_doc' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_entity' })
technicalDoc!: string | null; contactFunctionalEntity!: string;
@Column({ nullable: true, type: 'varchar', length: 500, name: 'external_doc_url' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_email' })
externalDocUrl!: string | null; contactFunctionalEmail!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_functional' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_name' })
contactFunctional!: string | null; contactTechnicalName!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_technical' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_entity' })
contactTechnical!: string | null; contactTechnicalEntity!: string;
@Column({ nullable: true, type: 'text', name: 'access_rights' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_email' })
accessRights!: string | null; contactTechnicalEmail!: string;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt!: Date; createdAt!: Date;

View File

@@ -0,0 +1,19 @@
import { ApiEntryEntity } from './api-entry.entity';
import { ApiEntryListItem, API_CONVENTION_LABELS } from '@datacat/shared';
export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: `${API_CONVENTION_LABELS[entity.convention]} ${entity.name}`,
name: entity.name,
convention: entity.convention,
version: `${entity.versionMajor}.${entity.versionMinor}.${entity.versionPatch}`,
description: entity.description,
type: entity.type,
provider: entity.provider,
status: entity.status,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}

View File

@@ -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) {

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ApisService } from './apis.service';
import { ApiType } from '@datacat/shared';
/** Accès à la méthode privée pour les tests unitaires */
function parse(raw: string) {
return (ApisService.prototype as any).parseSearchTokens.call({}, raw) as {
text: string;
type?: ApiType;
version?: string;
latestOnly: boolean;
};
}
describe('ApisService.parseSearchTokens()', () => {
describe('chaîne vide', () => {
it('retourne text vide, pas de type, pas de version, latestOnly=false', () => {
expect(parse('')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
});
it('gère les espaces seuls', () => {
expect(parse(' ')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
});
});
describe('texte libre seul (sans version ni type)', () => {
it('"auth" → latestOnly=true automatique', () => {
expect(parse('auth')).toEqual({ text: 'auth', type: undefined, version: undefined, latestOnly: true });
});
it('"paiement commande" → texte libre multi-mots, latestOnly=true', () => {
expect(parse('paiement commande')).toEqual({
text: 'paiement commande',
type: undefined,
version: undefined,
latestOnly: true,
});
});
});
describe('détection du type', () => {
it('"async" → ASYNCAPI', () => {
const r = parse('async');
expect(r.type).toBe('ASYNCAPI');
});
it('"asyncapi" → ASYNCAPI', () => {
expect(parse('asyncapi').type).toBe('ASYNCAPI');
});
it('"openapi" → OPENAPI', () => {
expect(parse('openapi').type).toBe('OPENAPI');
});
it('"open" → OPENAPI', () => {
expect(parse('open').type).toBe('OPENAPI');
});
it('type seul → latestOnly=true (champ non vide, pas de version)', () => {
expect(parse('async').latestOnly).toBe(true);
});
it('mots de type ne font pas partie du texte libre', () => {
expect(parse('auth async').text).toBe('auth');
});
});
describe('détection de version', () => {
it('"v1" → version="1", latestOnly=false', () => {
expect(parse('v1')).toEqual({ text: '', type: undefined, version: '1', latestOnly: false });
});
it('"1.2" → version="1.2"', () => {
expect(parse('1.2').version).toBe('1.2');
});
it('"v1.2.3" → version="1.2.3" (préfixe v supprimé)', () => {
expect(parse('v1.2.3').version).toBe('1.2.3');
});
it('"1.0.0" → version exacte', () => {
expect(parse('1.0.0').version).toBe('1.0.0');
});
it('version présente → latestOnly=false', () => {
expect(parse('auth v1').latestOnly).toBe(false);
});
it('numéro de version ne fait pas partie du texte libre', () => {
expect(parse('auth v1').text).toBe('auth');
});
});
describe('mot-clé "latest"', () => {
it('"latest" seul → latestOnly=true, pas de texte', () => {
expect(parse('latest')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: true });
});
it('"latest" + type → latestOnly=true + type détecté', () => {
const r = parse('latest async');
expect(r.latestOnly).toBe(true);
expect(r.type).toBe('ASYNCAPI');
});
it('"latest" + version → les deux flags actifs', () => {
const r = parse('latest v1');
expect(r.latestOnly).toBe(true);
expect(r.version).toBe('1');
});
});
describe('combinaisons', () => {
it('"auth async" → texte=auth, type=ASYNCAPI, latestOnly=true', () => {
expect(parse('auth async')).toEqual({
text: 'auth',
type: 'ASYNCAPI',
version: undefined,
latestOnly: true,
});
});
it('"auth async v1" → texte=auth, type=ASYNCAPI, version=1, latestOnly=false', () => {
expect(parse('auth async v1')).toEqual({
text: 'auth',
type: 'ASYNCAPI',
version: '1',
latestOnly: false,
});
});
it('"auth 1.0.0" → texte=auth, version=1.0.0, latestOnly=false', () => {
expect(parse('auth 1.0.0')).toEqual({
text: 'auth',
type: undefined,
version: '1.0.0',
latestOnly: false,
});
});
it('"openapi latest" → type=OPENAPI, latestOnly=true', () => {
expect(parse('openapi latest')).toEqual({
text: '',
type: 'OPENAPI',
version: undefined,
latestOnly: true,
});
});
it('insensible à la casse pour les mots-clés', () => {
const r = parse('ASYNC Auth LATEST');
expect(r.type).toBe('ASYNCAPI');
expect(r.latestOnly).toBe(true);
expect(r.text).toBe('Auth');
});
});
});

View File

@@ -4,14 +4,12 @@ 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, ApiEntryListItem, ApiType, ApiStatus } from '@datacat/shared'; import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus, ApiConvention, API_CONVENTION_LABELS } from '@datacat/shared';
import { toApiEntryListItem } from './api-entry.mapper';
import { DocsGenerationService } from '../docs-generation/docs-generation.service'; import { DocsGenerationService } from '../docs-generation/docs-generation.service';
/** Champs internes mis à jour par DocsGenerationService */ /** Champs internes mis à jour par DocsGenerationService */
interface InternalUpdateFields { interface InternalUpdateFields {
title?: string;
version?: string;
description?: string;
htmlPath?: string | null; htmlPath?: string | null;
status?: ApiStatus; status?: ApiStatus;
errorMessage?: string | null; errorMessage?: string | null;
@@ -28,54 +26,130 @@ export class ApisService {
async create(dto: CreateApiEntryDto): Promise<ApiEntry> { async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
const entity = this.repo.create({ const entity = this.repo.create({
title: dto.title, convention: dto.convention,
version: dto.version, name: dto.name,
provider: dto.provider,
versionMajor: dto.versionMajor,
versionMinor: dto.versionMinor,
versionPatch: dto.versionPatch,
description: dto.description ?? null, description: dto.description ?? null,
type: dto.type, type: dto.type,
yamlContent: dto.yamlContent, yamlContent: dto.yamlContent,
status: 'PENDING', status: 'PENDING',
categoryId: dto.categoryId ?? null, categoryId: dto.categoryId ?? null,
functionalDoc: dto.functionalDoc ?? null, contactFunctionalName: dto.contactFunctionalName,
technicalDoc: dto.technicalDoc ?? null, contactFunctionalEntity: dto.contactFunctionalEntity,
externalDocUrl: dto.externalDocUrl ?? null, contactFunctionalEmail: dto.contactFunctionalEmail,
contactFunctional: dto.contactFunctional ?? null, contactTechnicalName: dto.contactTechnicalName,
contactTechnical: dto.contactTechnical ?? null, contactTechnicalEntity: dto.contactTechnicalEntity,
accessRights: dto.accessRights ?? null, contactTechnicalEmail: dto.contactTechnicalEmail,
}); });
const saved = await this.repo.save(entity); const saved = await this.repo.save(entity);
return toApiEntry(saved); return toApiEntry(saved);
} }
async findAll(query: ApiListQuery): Promise<ApiListResponse> { async findAll(query: ApiListQuery): Promise<ApiListResponse> {
const page = Math.max(1, Number(query.page ?? 1)); const page = Math.max(1, Number(query.page ?? 1));
const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20))); const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20)));
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const qb = this.repo.createQueryBuilder('api'); const qb = this.repo.createQueryBuilder('api');
if (query.type) { let effectiveType = query.type as ApiType | undefined;
qb.andWhere('api.type = :type', { type: query.type as ApiType }); let version: string | undefined;
let latestOnly = false;
if (query.search?.trim()) {
const parsed = this.parseSearchTokens(query.search);
// Le type détecté dans la saisie override le select UI
if (parsed.type) effectiveType = parsed.type;
version = parsed.version;
latestOnly = parsed.latestOnly;
if (parsed.text) {
// Recherche fuzzy via pg_trgm + ILIKE pour les matches partiels courts
qb.andWhere(
`(
api.name ILIKE :likeText
OR api.provider ILIKE :likeText
OR COALESCE(api.description, '') ILIKE :likeText
OR api.contact_functional_name ILIKE :likeText
OR api.contact_technical_name ILIKE :likeText
OR word_similarity(:text, api.name) > 0.4
OR word_similarity(:text, api.provider) > 0.4
OR word_similarity(:text, COALESCE(api.description, '')) > 0.5
)`,
{ likeText: `%${parsed.text}%`, text: parsed.text },
);
}
} }
if (query.search) {
qb.andWhere( if (effectiveType) {
'(LOWER(api.title) LIKE :search OR LOWER(api.description) LIKE :search)', qb.andWhere('api.type = :type', { type: effectiveType });
{ search: `%${query.search.toLowerCase()}%` },
);
} }
if (query.categoryId) { if (query.categoryId) {
qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId }); qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId });
} }
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit); // Filtre par version (partielle ou complète)
if (version) {
const parts = version.split('.').map(Number);
if (parts.length >= 1 && !isNaN(parts[0]))
qb.andWhere('api.version_major = :major', { major: parts[0] });
if (parts.length >= 2 && !isNaN(parts[1]))
qb.andWhere('api.version_minor = :minor', { minor: parts[1] });
if (parts.length >= 3 && !isNaN(parts[2]))
qb.andWhere('api.version_patch = :patch', { patch: parts[2] });
}
// Latest-only : exclure les entrées dont il existe une version plus récente du même API
if (latestOnly) {
qb.andWhere(`NOT EXISTS (
SELECT 1 FROM api_entries e2
WHERE e2.name = api.name AND e2.convention = api.convention
AND (
e2.version_major > api.version_major
OR (e2.version_major = api.version_major AND e2.version_minor > api.version_minor)
OR (e2.version_major = api.version_major AND e2.version_minor = api.version_minor
AND e2.version_patch > api.version_patch)
)
)`);
}
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
const [entities, total] = await qb.getManyAndCount(); const [entities, total] = await qb.getManyAndCount();
return { return { items: entities.map(toApiEntryListItem), total, page, limit };
items: entities.map(toApiEntryListItem), }
total,
page, private parseSearchTokens(raw: string): {
limit, text: string;
type?: ApiType;
version?: string;
latestOnly: boolean;
} {
const TYPE_MAP: Record<string, ApiType> = {
async: 'ASYNCAPI', asyncapi: 'ASYNCAPI',
openapi: 'OPENAPI', open: 'OPENAPI',
}; };
const VERSION_RE = /^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/i;
let type: ApiType | undefined;
let version: string | undefined;
let latestOnly = false;
const remaining: string[] = [];
for (const word of raw.trim().split(/\s+/).filter(Boolean)) {
const lower = word.toLowerCase();
if (lower === 'latest') { latestOnly = true; }
else if (TYPE_MAP[lower]) { type = TYPE_MAP[lower]; }
else if (VERSION_RE.test(word)) { version = word.replace(/^v/i, ''); }
else { remaining.push(word); }
}
const text = remaining.join(' ');
// Pas de version explicite ET champ non vide → latest par défaut
if (!latestOnly && raw.trim().length > 0 && !version) latestOnly = true;
return { text, type, version, latestOnly };
} }
async findOneOrThrow(id: string): Promise<ApiEntry> { async findOneOrThrow(id: string): Promise<ApiEntry> {
@@ -101,6 +175,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 });
@@ -109,39 +196,40 @@ export class ApisService {
} }
} }
function buildTitle(convention: ApiConvention, name: string): string {
return `${API_CONVENTION_LABELS[convention]} ${name}`;
}
function buildVersion(major: number, minor: number, patch: number): string {
return `${major}.${minor}.${patch}`;
}
function toApiEntry(entity: ApiEntryEntity): ApiEntry { function toApiEntry(entity: ApiEntryEntity): ApiEntry {
return { return {
id: entity.id, id: entity.id,
title: entity.title, title: buildTitle(entity.convention, entity.name),
version: entity.version, name: entity.name,
convention: entity.convention,
version: buildVersion(entity.versionMajor, entity.versionMinor, entity.versionPatch),
versionMajor: entity.versionMajor,
versionMinor: entity.versionMinor,
versionPatch: entity.versionPatch,
description: entity.description, description: entity.description,
type: entity.type, type: entity.type,
provider: entity.provider,
yamlContent: entity.yamlContent, yamlContent: entity.yamlContent,
htmlPath: entity.htmlPath, htmlPath: entity.htmlPath,
status: entity.status, status: entity.status,
errorMessage: entity.errorMessage, errorMessage: entity.errorMessage,
categoryId: entity.categoryId, categoryId: entity.categoryId,
functionalDoc: entity.functionalDoc, contactFunctionalName: entity.contactFunctionalName,
technicalDoc: entity.technicalDoc, contactFunctionalEntity: entity.contactFunctionalEntity,
externalDocUrl: entity.externalDocUrl, contactFunctionalEmail: entity.contactFunctionalEmail,
contactFunctional: entity.contactFunctional, contactTechnicalName: entity.contactTechnicalName,
contactTechnical: entity.contactTechnical, contactTechnicalEntity: entity.contactTechnicalEntity,
accessRights: entity.accessRights, contactTechnicalEmail: entity.contactTechnicalEmail,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(), updatedAt: entity.updatedAt.toISOString(),
}; };
} }
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: entity.title,
version: entity.version,
description: entity.description,
type: entity.type,
status: entity.status,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}

View File

@@ -1,14 +1,33 @@
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID } from 'class-validator'; import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
import { ApiType } from '@datacat/shared'; import { Type } from 'class-transformer';
import { ApiType, ApiConvention } from '@datacat/shared';
export class CreateApiEntryDto { export class CreateApiEntryDto {
@IsString() @IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
@IsNotEmpty() convention!: ApiConvention;
title!: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
version!: string; name!: string;
@IsString()
@IsNotEmpty()
provider!: string;
@IsInt()
@Min(0)
@Type(() => Number)
versionMajor!: number;
@IsInt()
@Min(0)
@Type(() => Number)
versionMinor!: number;
@IsInt()
@Min(0)
@Type(() => Number)
versionPatch!: number;
@IsString() @IsString()
@IsOptional() @IsOptional()
@@ -26,26 +45,24 @@ export class CreateApiEntryDto {
categoryId?: string; categoryId?: string;
@IsString() @IsString()
@IsOptional() @IsNotEmpty()
functionalDoc?: string; contactFunctionalName!: string;
@IsString() @IsString()
@IsOptional() @IsNotEmpty()
technicalDoc?: string; contactFunctionalEntity!: string;
@IsEmail()
contactFunctionalEmail!: string;
@IsString() @IsString()
@IsOptional() @IsNotEmpty()
externalDocUrl?: string; contactTechnicalName!: string;
@IsString() @IsString()
@IsOptional() @IsNotEmpty()
contactFunctional?: string; contactTechnicalEntity!: string;
@IsString() @IsEmail()
@IsOptional() contactTechnicalEmail!: string;
contactTechnical?: string;
@IsString()
@IsOptional()
accessRights?: string;
} }

View File

@@ -1,17 +1,41 @@
import { IsString, IsOptional, IsUUID } from 'class-validator'; import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiConvention } from '@datacat/shared';
export class UpdateApiEntryDto { export class UpdateApiEntryDto {
@IsString() @IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
@IsOptional() @IsOptional()
title?: string; convention?: ApiConvention;
@IsString() @IsString()
@IsOptional() @IsOptional()
version?: string; name?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; provider?: string;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionMajor?: number;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionMinor?: number;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionPatch?: number;
@IsString()
@IsOptional()
description?: string | null;
@IsUUID() @IsUUID()
@IsOptional() @IsOptional()
@@ -19,25 +43,25 @@ export class UpdateApiEntryDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
functionalDoc?: string | null; contactFunctionalName?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
technicalDoc?: string | null; contactFunctionalEntity?: string;
@IsEmail()
@IsOptional()
contactFunctionalEmail?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
externalDocUrl?: string | null; contactTechnicalName?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
contactFunctional?: string | null; contactTechnicalEntity?: string;
@IsString() @IsEmail()
@IsOptional() @IsOptional()
contactTechnical?: string | null; contactTechnicalEmail?: string;
@IsString()
@IsOptional()
accessRights?: string | null;
} }

View File

@@ -10,10 +10,8 @@ import {
CategoryWithCounts, CategoryWithCounts,
CategoryBrowseResponse, CategoryBrowseResponse,
CategoryListResponse, CategoryListResponse,
ApiEntryListItem,
ApiType,
ApiStatus,
} from '@datacat/shared'; } from '@datacat/shared';
import { toApiEntryListItem } from '../apis/api-entry.mapper';
@Injectable() @Injectable()
export class CategoriesService { export class CategoriesService {
@@ -24,7 +22,26 @@ export class CategoriesService {
private readonly apiRepo: Repository<ApiEntryEntity>, private readonly apiRepo: Repository<ApiEntryEntity>,
) {} ) {}
async findAll(): Promise<CategoryListResponse> { async findAll(search?: string): Promise<CategoryListResponse> {
if (search?.trim()) {
const text = search.trim();
const entities = await this.categoryRepo
.createQueryBuilder('cat')
.where(
`(
cat.name ILIKE :likeText
OR COALESCE(cat.description, '') ILIKE :likeText
OR word_similarity(:text, cat.name) >= 0.4
OR word_similarity(:text, COALESCE(cat.description, '')) > 0.5
)`,
{ likeText: `%${text}%`, text },
)
.orderBy('cat.order_index', 'ASC')
.addOrderBy('cat.name', 'ASC')
.getMany();
return { items: entities.map(toCategory) };
}
const entities = await this.categoryRepo.find({ const entities = await this.categoryRepo.find({
order: { orderIndex: 'ASC', name: 'ASC' }, order: { orderIndex: 'ASC', name: 'ASC' },
}); });
@@ -81,6 +98,13 @@ export class CategoriesService {
await this.categoryRepo.delete(id); await this.categoryRepo.delete(id);
} }
private async countApisRecursively(categoryId: string): Promise<number> {
const direct = await this.apiRepo.count({ where: { categoryId } });
const children = await this.categoryRepo.find({ where: { parentId: categoryId }, select: ['id'] });
const childCounts = await Promise.all(children.map((c) => this.countApisRecursively(c.id)));
return direct + childCounts.reduce((sum, n) => sum + n, 0);
}
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> { async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
/* 1. Charge la catégorie courante */ /* 1. Charge la catégorie courante */
let currentCategory: CategoryEntity | null = null; let currentCategory: CategoryEntity | null = null;
@@ -111,7 +135,7 @@ export class CategoriesService {
const subcategories: CategoryWithCounts[] = await Promise.all( const subcategories: CategoryWithCounts[] = await Promise.all(
directChildren.map(async (child) => { directChildren.map(async (child) => {
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } }); const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
const apiCount = await this.apiRepo.count({ where: { categoryId: child.id } }); const apiCount = await this.countApisRecursively(child.id);
return { ...toCategory(child), subcategoryCount, apiCount }; return { ...toCategory(child), subcategoryCount, apiCount };
}), }),
); );
@@ -143,16 +167,3 @@ function toCategory(entity: CategoryEntity): Category {
}; };
} }
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: entity.title,
version: entity.version,
description: entity.description,
type: entity.type as ApiType,
status: entity.status as ApiStatus,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}

View File

@@ -46,7 +46,7 @@ export class DocsGenerationService {
} catch (error) { } catch (error) {
await this.apisService.updateInternal(entryId, { await this.apisService.updateInternal(entryId, {
status: 'ERROR', status: 'ERROR',
errorMessage: String(error), errorMessage: error instanceof Error ? error.message : String(error),
}); });
} }
} }

View File

@@ -1,6 +1,6 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository, DataSource } from 'typeorm';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import { ApiEntryEntity } from '../apis/api-entry.entity'; import { ApiEntryEntity } from '../apis/api-entry.entity';
import { CategoryEntity } from '../categories/category.entity'; import { CategoryEntity } from '../categories/category.entity';
@@ -593,9 +593,13 @@ export class SeederService implements OnApplicationBootstrap {
@InjectRepository(CategoryEntity) @InjectRepository(CategoryEntity)
private readonly categoryRepo: Repository<CategoryEntity>, private readonly categoryRepo: Repository<CategoryEntity>,
private readonly docsService: DocsGenerationService, private readonly docsService: DocsGenerationService,
private readonly dataSource: DataSource,
) {} ) {}
async onApplicationBootstrap(): Promise<void> { async onApplicationBootstrap(): Promise<void> {
// Activer l'extension pg_trgm pour la recherche fuzzy
await this.dataSource.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
const forceReseed = process.env.FORCE_RESEED === 'true'; const forceReseed = process.env.FORCE_RESEED === 'true';
if (forceReseed) { if (forceReseed) {
await this.reset(); await this.reset();
@@ -688,85 +692,145 @@ export class SeederService implements OnApplicationBootstrap {
/* APIs de démo */ /* APIs de démo */
const entries = await this.apiRepo.save([ const entries = await this.apiRepo.save([
this.apiRepo.create({ this.apiRepo.create({
title: 'Swagger Petstore', convention: 'CONSULTER',
version: '3.0.4', name: 'Référentiel Produits',
versionMajor: 3,
versionMinor: 0,
versionPatch: 4,
description: description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.', 'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI', type: 'OPENAPI',
yamlContent: PETSTORE_YAML, yamlContent: PETSTORE_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: produits.id, categoryId: produits.id,
functionalDoc: 'Cas d\'usage : gestion du catalogue produits. Permet de lister, créer et consulter des animaux de compagnie. Usage typique : intégration e-commerce, démo API REST.', provider: 'DINUM',
technicalDoc: 'Authentification Bearer token requise. Pagination via paramètre `limit` (max 100). Réponses JSON selon schéma OpenAPI 3.0. Endpoint base : https://petstore.swagger.io/v3.', contactFunctionalName: 'Alice Martin',
externalDocUrl: null, contactFunctionalEntity: 'Équipe Produit',
contactFunctional: 'Équipe Produit', contactFunctionalEmail: 'alice.martin@example.gouv.fr',
contactTechnical: 'Équipe API', contactTechnicalName: 'Bob Dupont',
accessRights: 'API publique — aucune habilitation requise.', contactTechnicalEntity: 'Équipe API',
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
}), }),
this.apiRepo.create({ this.apiRepo.create({
title: 'Streetlights Kafka API', convention: 'ETRE_NOTIFIE',
version: '1.0.0', name: 'Éclairage Urbain Kafka',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: description:
'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.', 'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.',
type: 'ASYNCAPI', type: 'ASYNCAPI',
yamlContent: STREETLIGHTS_YAML, yamlContent: STREETLIGHTS_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: evenements.id, categoryId: evenements.id,
functionalDoc: 'Gestion de l\'éclairage urbain IoT. Permet de recevoir les mesures de luminosité des lampadaires et d\'envoyer des commandes d\'allumage/extinction à distance.', provider: 'Bureau Infrastructure',
technicalDoc: 'Broker Kafka sécurisé avec SCRAM-SHA-512. Topics : smartylighting.streetlights.1.0.*. Schémas de messages définis en AsyncAPI 3.0. Adresse broker : test.mykafkacluster.org:18092.', contactFunctionalName: 'Claire Leroy',
externalDocUrl: null, contactFunctionalEntity: 'Bureau Infrastructure',
contactFunctional: 'Bureau Infrastructure', contactFunctionalEmail: 'claire.leroy@example.gouv.fr',
contactTechnical: 'ops@example.com', contactTechnicalName: 'David Chen',
accessRights: 'Accès sur demande — habilitation DSI requise.', contactTechnicalEntity: 'Ops',
contactTechnicalEmail: 'ops@example.com',
}), }),
this.apiRepo.create({ this.apiRepo.create({
title: 'Account Service', convention: 'ETRE_NOTIFIE',
version: '1.0.0', name: 'Comptes Utilisateurs',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service de gestion des comptes utilisateurs avec publication d'événements.", description: "Service de gestion des comptes utilisateurs avec publication d'événements.",
type: 'ASYNCAPI', type: 'ASYNCAPI',
yamlContent: ACCOUNT_SERVICE_YAML, yamlContent: ACCOUNT_SERVICE_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: notifications.id, categoryId: notifications.id,
functionalDoc: 'Cycle de vie du compte utilisateur. Publie des événements lors de l\'inscription et du changement de mot de passe. Utilisé par les services de notification et d\'audit.', provider: 'Équipe Comptes',
technicalDoc: 'Consommer via broker AMQP ou Kafka. Canaux : user/signedup, user/passwordchanged. Payloads JSON avec horodatage ISO 8601. Spec AsyncAPI 3.0.', contactFunctionalName: 'Emma Roux',
externalDocUrl: null, contactFunctionalEntity: 'Équipe Comptes',
contactFunctional: 'Équipe Comptes', contactFunctionalEmail: 'emma.roux@example.gouv.fr',
contactTechnical: 'dev-core@example.com', contactTechnicalName: 'François Blanc',
accessRights: 'Interne uniquement — accès réservé aux services du SI.', contactTechnicalEntity: 'Dev Core',
contactTechnicalEmail: 'dev-core@example.com',
}), }),
this.apiRepo.create({ this.apiRepo.create({
title: 'Orders API', convention: 'ENREGISTRER',
version: '1.0.0', name: 'Commandes Clients',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: 'API de gestion des commandes clients.', description: 'API de gestion des commandes clients.',
type: 'OPENAPI', type: 'OPENAPI',
yamlContent: ORDERS_YAML, yamlContent: ORDERS_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: commandes.id, categoryId: commandes.id,
functionalDoc: 'Gestion des commandes clients : création, consultation, mise à jour du statut. Supporte les webhooks pour les transitions de statut (confirmed, shipped, delivered, cancelled).', provider: 'Équipe Commerce',
technicalDoc: 'REST + JWT Bearer. Pagination via paramètres `page` et `limit`. Webhook de statut : POST vers URL configurée lors de la transition. Base URL : https://api.example.com/v1.', contactFunctionalName: 'Gaëlle Moreau',
externalDocUrl: null, contactFunctionalEntity: 'Équipe Commerce',
contactFunctional: 'Équipe Commerce', contactFunctionalEmail: 'gaelle.moreau@example.gouv.fr',
contactTechnical: 'api-orders@example.com', contactTechnicalName: 'Hugo Simon',
accessRights: 'Partenaires agréés uniquement — convention de partenariat requise.', contactTechnicalEntity: 'API Commerce',
contactTechnicalEmail: 'api-orders@example.com',
}), }),
this.apiRepo.create({ this.apiRepo.create({
title: 'Auth API', convention: 'CONSULTER',
version: '1.0.0', 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({
convention: 'CONSULTER',
name: 'Authentification JWT',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service d'authentification et d'autorisation.", description: "Service d'authentification et d'autorisation.",
type: 'OPENAPI', type: 'OPENAPI',
yamlContent: AUTH_YAML, yamlContent: AUTH_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: auth.id, categoryId: auth.id,
functionalDoc: 'SSO centralisé avec tokens JWT. Gère la connexion, le rafraîchissement de token et la déconnexion. Exposé à tous les services du SI comme fournisseur d\'identité.', provider: 'Équipe Sécurité',
technicalDoc: 'Bearer JWT (RS256). Endpoint principal : POST /auth/login. Refresh via POST /auth/refresh. Durée access token : 15 min. Durée refresh token : 7 jours. Base URL : https://auth.example.com/v1.', contactFunctionalName: 'Isabelle Petit',
externalDocUrl: null, contactFunctionalEntity: 'Équipe Sécurité',
contactFunctional: 'Équipe Sécurité', contactFunctionalEmail: 'isabelle.petit@example.gouv.fr',
contactTechnical: 'securite@example.com', contactTechnicalName: 'Julien Bernard',
accessRights: 'Restreint — habilitation RSSI requise avant intégration.', contactTechnicalEntity: 'Sécurité SI',
contactTechnicalEmail: 'securite@example.com',
}), }),
]); ]);
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) {

View File

@@ -39,6 +39,139 @@ export class UploadsController {
private readonly docsService: DocsGenerationService, private readonly docsService: DocsGenerationService,
) {} ) {}
@Post('validate')
@UseInterceptors(
FilesInterceptor('files', 20, {
storage: diskStorage({ destination: path.join(os.tmpdir(), 'datacat-uploads') }),
fileFilter: (_req, file, cb) => {
if (!file.originalname.match(/\.(yaml|yml)$/i)) {
return cb(new BadRequestException('Only YAML files are allowed'), false);
}
cb(null, true);
},
}),
)
async validate(
@UploadedFiles() files: Express.Multer.File[],
): Promise<{
valid: boolean;
type: ApiType | null;
mainFile: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}> {
if (!files || files.length === 0) {
return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors: [{ file: '', message: 'Aucun fichier fourni.' }] };
}
/* Lire tous les contenus en mémoire avant toute suppression */
const fileContents = files.map((file) => ({
name: file.originalname,
path: file.path,
content: fs.readFileSync(file.path, 'utf-8'),
}));
/* Nettoyage immédiat des fichiers temporaires */
for (const file of files) {
fsPromises.unlink(file.path).catch(() => undefined);
}
const errors: Array<{ file: string; message: string }> = [];
/* Validation syntaxique de chaque fichier */
for (const { name, content } of fileContents) {
try {
const parsed = yaml.load(content);
if (parsed === null || typeof parsed !== 'object') {
errors.push({ file: name, message: 'le fichier YAML est vide ou invalide' });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push({ file: name, message: `erreur de syntaxe YAML — ${msg}` });
}
}
if (errors.length > 0) {
return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors };
}
/* Détection du fichier principal */
let mainFileName: string | null = null;
let detectedType: ApiType | null = null;
for (const { name, content } of fileContents) {
const type = this.detectTypeFromContent(content);
if (type) {
mainFileName = name;
detectedType = type;
break;
}
}
if (!mainFileName) {
return {
valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
errors: [{ file: '', message: 'Aucun fichier principal trouvé (clé openapi: ou asyncapi: manquante).' }],
};
}
/* Validation complète via CLI (asyncapi validate / redocly lint) */
const tempDir = path.join(os.tmpdir(), 'datacat-validate', randomUUID());
try {
await fsPromises.mkdir(tempDir, { recursive: true });
/* Écrire tous les fichiers dans le répertoire temp avec leurs noms d'origine (pour les $ref) */
for (const { name, content } of fileContents) {
await fsPromises.writeFile(path.join(tempDir, name), content, 'utf-8');
}
const mainPath = path.join(tempDir, mainFileName);
try {
if (detectedType === 'ASYNCAPI') {
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, timeout: 30000 });
} else {
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, timeout: 30000 });
}
} catch (cliError) {
const err = cliError as { stdout?: string; stderr?: string };
const output = (err.stdout || err.stderr || String(cliError)).trim();
return {
valid: false, type: detectedType, mainFile: mainFileName,
name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
errors: [{ file: mainFileName, message: output }],
};
}
} finally {
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
/* Extraction des métadonnées depuis le fichier principal */
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
const parsed = yaml.load(mainContent) as Record<string, unknown>;
const info = parsed['info'] as Record<string, unknown> | undefined;
const name = typeof info?.['title'] === 'string' ? info['title'] : null;
const description = typeof info?.['description'] === 'string' ? info['description'] : null;
/* Décomposer la version en major.minor.patch */
let versionMajor: number | null = null;
let versionMinor: number | null = null;
let versionPatch: number | null = null;
const versionStr = typeof info?.['version'] === 'string' ? info['version'] : null;
if (versionStr) {
const parts = versionStr.split('.').map((p) => parseInt(p, 10));
if (parts.length >= 1 && !isNaN(parts[0])) versionMajor = parts[0];
if (parts.length >= 2 && !isNaN(parts[1])) versionMinor = parts[1];
if (parts.length >= 3 && !isNaN(parts[2])) versionPatch = parts[2];
}
return { valid: true, type: detectedType, mainFile: mainFileName, name, versionMajor, versionMinor, versionPatch, description, errors: [] };
}
@Post() @Post()
@UseInterceptors( @UseInterceptors(
FilesInterceptor('files', 20, { FilesInterceptor('files', 20, {
@@ -55,22 +188,26 @@ export class UploadsController {
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
@Body() @Body()
body: { body: {
title: string; convention: string;
version: string; name: string;
provider: string;
versionMajor: string;
versionMinor: string;
versionPatch: string;
description?: string; description?: string;
type?: ApiType; type?: ApiType;
categoryId?: string; categoryId?: string;
functionalDoc?: string; contactFunctionalName: string;
technicalDoc?: string; contactFunctionalEntity: string;
externalDocUrl?: string; contactFunctionalEmail: string;
contactFunctional?: string; contactTechnicalName: string;
contactTechnical?: string; contactTechnicalEntity: string;
accessRights?: string; contactTechnicalEmail: string;
}, },
) { ) {
if (!files || files.length === 0) throw new BadRequestException('YAML file is required'); if (!files || files.length === 0) throw new BadRequestException('YAML file is required');
if (!body.title) throw new BadRequestException('title is required'); if (!body.name) throw new BadRequestException('name is required');
if (!body.version) throw new BadRequestException('version is required'); if (!body.convention) throw new BadRequestException('convention is required');
/* Crée le répertoire si absent */ /* Crée le répertoire si absent */
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads'); const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
@@ -105,18 +242,22 @@ export class UploadsController {
} }
const entry = await this.apisService.create({ const entry = await this.apisService.create({
title: body.title, convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER',
version: body.version, name: body.name,
provider: body.provider,
versionMajor: parseInt(body.versionMajor, 10) || 0,
versionMinor: parseInt(body.versionMinor, 10) || 0,
versionPatch: parseInt(body.versionPatch, 10) || 0,
description: body.description, description: body.description,
type: apiType, type: apiType,
yamlContent, yamlContent,
categoryId: body.categoryId || undefined, categoryId: body.categoryId || undefined,
functionalDoc: body.functionalDoc || undefined, contactFunctionalName: body.contactFunctionalName,
technicalDoc: body.technicalDoc || undefined, contactFunctionalEntity: body.contactFunctionalEntity,
externalDocUrl: body.externalDocUrl || undefined, contactFunctionalEmail: body.contactFunctionalEmail,
contactFunctional: body.contactFunctional || undefined, contactTechnicalName: body.contactTechnicalName,
contactTechnical: body.contactTechnical || undefined, contactTechnicalEntity: body.contactTechnicalEntity,
accessRights: body.accessRights || undefined, contactTechnicalEmail: body.contactTechnicalEmail,
}); });
/* Fire-and-forget : génération asynchrone */ /* Fire-and-forget : génération asynchrone */
@@ -180,7 +321,7 @@ export class UploadsController {
if (type === 'OPENAPI') { if (type === 'OPENAPI') {
/* redocly bundle : pas d'appel réseau, fonctionne en local */ /* redocly bundle : pas d'appel réseau, fonctionne en local */
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath], { shell: true, env: execEnv }); await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath], { env: execEnv });
return await fsPromises.readFile(bundledPath, 'utf-8'); return await fsPromises.readFile(bundledPath, 'utf-8');
} else { } else {
/* @asyncapi/bundler API programmatique : aucun appel réseau */ /* @asyncapi/bundler API programmatique : aucun appel réseau */

View File

@@ -10,10 +10,9 @@
"target": "ES2021", "target": "ES2021",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "../",
"baseUrl": "./", "baseUrl": "./",
"incremental": true, "incremental": true
"paths": { },
"@datacat/shared": ["../shared/src/index.ts"] "include": ["src/**/*.ts"]
}
}
} }

9
back/vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
},
});

View File

@@ -36,6 +36,16 @@
"glob": "**/*", "glob": "**/*",
"input": "node_modules/@gouvfr/dsfr/dist/utility", "input": "node_modules/@gouvfr/dsfr/dist/utility",
"output": "assets/dsfr/utility" "output": "assets/dsfr/utility"
},
{
"glob": "**/*",
"input": "node_modules/@gouvfr/dsfr/dist/icons",
"output": "assets/dsfr/icons"
},
{
"glob": "**/*",
"input": "node_modules/@gouvfr/dsfr/dist/icons",
"output": "assets/icons"
} }
], ],
"styles": ["src/styles.scss"], "styles": ["src/styles.scss"],

View File

@@ -1,10 +1,10 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; import { RouterOutlet, RouterLink } from '@angular/router';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive], imports: [RouterOutlet, RouterLink],
template: ` template: `
<header role="banner" class="fr-header"> <header role="banner" class="fr-header">
<div class="fr-header__body"> <div class="fr-header__body">
@@ -17,7 +17,7 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
</div> </div>
</div> </div>
<div class="fr-header__service"> <div class="fr-header__service">
<a href="/" routerLink="/browse" title="Accueil — Datacat"> <a href="/" routerLink="/" title="Accueil — Datacat">
<p class="fr-header__service-title">Datacat</p> <p class="fr-header__service-title">Datacat</p>
</a> </a>
<p class="fr-header__service-tagline">Catalogue de données d'API</p> <p class="fr-header__service-tagline">Catalogue de données d'API</p>
@@ -26,34 +26,6 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
</div> </div>
</div> </div>
</div> </div>
<div class="fr-header__menu">
<div class="fr-container">
<nav class="fr-nav" role="navigation" aria-label="Menu principal">
<ul class="fr-nav__list">
<li class="fr-nav__item">
<a
class="fr-nav__link"
routerLink="/browse"
routerLinkActive="fr-nav__link--active"
[routerLinkActiveOptions]="{ exact: false }"
>
Parcourir
</a>
</li>
<li class="fr-nav__item">
<a
class="fr-nav__link"
routerLink="/catalog"
routerLinkActive="fr-nav__link--active"
[routerLinkActiveOptions]="{ exact: false }"
>
Catalogue
</a>
</li>
</ul>
</nav>
</div>
</div>
</header> </header>
<main> <main>
<router-outlet /> <router-outlet />

View File

@@ -3,8 +3,8 @@ import { Routes } from '@angular/router';
export const routes: Routes = [ export const routes: Routes = [
{ {
path: '', path: '',
redirectTo: 'browse', loadComponent: () =>
pathMatch: 'full', import('./pages/browse/browse.component').then((m) => m.BrowseComponent),
}, },
{ {
path: 'browse', path: 'browse',
@@ -18,8 +18,8 @@ export const routes: Routes = [
}, },
{ {
path: 'catalog', path: 'catalog',
loadComponent: () => redirectTo: 'browse',
import('./pages/catalog/catalog.component').then((m) => m.CatalogComponent), pathMatch: 'full',
}, },
{ {
path: 'catalog/:id', path: 'catalog/:id',
@@ -38,6 +38,6 @@ export const routes: Routes = [
}, },
{ {
path: '**', path: '**',
redirectTo: 'browse', redirectTo: '',
}, },
]; ];

View File

@@ -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 {
@@ -22,7 +22,22 @@ export class ApiService {
return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`); return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`);
} }
update(id: string, data: { title?: string; version?: string; description?: string }): Observable<ApiEntry> { update(id: string, data: {
convention?: string;
name?: string;
provider?: string;
versionMajor?: number;
versionMinor?: number;
versionPatch?: number;
description?: string | null;
categoryId?: 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); return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
} }
@@ -30,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`, {});
} }
@@ -37,4 +56,28 @@ export class ApiService {
upload(formData: FormData): Observable<ApiEntry> { upload(formData: FormData): Observable<ApiEntry> {
return this.http.post<ApiEntry>('/api/uploads', formData); return this.http.post<ApiEntry>('/api/uploads', formData);
} }
validate(formData: FormData): Observable<{
valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}> {
return this.http.post<{
valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: 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

@@ -12,8 +12,10 @@ export class CategoryService {
private http = inject(HttpClient); private http = inject(HttpClient);
private baseUrl = '/api/categories'; private baseUrl = '/api/categories';
list(): Observable<CategoryListResponse> { list(search?: string): Observable<CategoryListResponse> {
return this.http.get<CategoryListResponse>(this.baseUrl); const params: Record<string, string> = {};
if (search?.trim()) params['search'] = search;
return this.http.get<CategoryListResponse>(this.baseUrl, { params });
} }
browse(id: string | null): Observable<CategoryBrowseResponse> { browse(id: string | null): Observable<CategoryBrowseResponse> {

View File

@@ -1,20 +1,24 @@
import { import {
Component, Component,
signal, signal,
computed,
inject, inject,
OnInit, OnInit,
OnDestroy, OnDestroy,
ChangeDetectionStrategy, ChangeDetectionStrategy,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
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 { ApiEntry } from '@datacat/shared'; import { CategoryService } from '../../core/category.service';
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
@Component({ @Component({
selector: 'app-api-detail', selector: 'app-api-detail',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink], imports: [CommonModule, RouterLink, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="fr-container fr-mt-4w fr-mb-4w"> <div class="fr-container fr-mt-4w fr-mb-4w">
@@ -22,8 +26,14 @@ import { ApiEntry } from '@datacat/shared';
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici"> <nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list"> <ol class="fr-breadcrumb__list">
<li> <li>
<a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a> <a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</li> </li>
@if (categoryBreadcrumb().length === 0) {
<li><a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a></li>
}
@for (crumb of categoryBreadcrumb(); track crumb.id) {
<li><a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a></li>
}
<li> <li>
<a class="fr-breadcrumb__link" aria-current="page"> <a class="fr-breadcrumb__link" aria-current="page">
{{ api()?.title ?? 'Chargement...' }} {{ api()?.title ?? 'Chargement...' }}
@@ -46,178 +56,355 @@ import { ApiEntry } from '@datacat/shared';
} }
@if (api(); as entry) { @if (api(); as entry) {
<!-- Retour -->
<a class="fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w" style="display:inline-flex;"
[routerLink]="backUrl()">Retour</a>
<!-- En-tête --> <!-- En-tête -->
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w"> <div class="fr-mb-3w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<div class="fr-col"> <!-- Gauche : titre + version -->
<h1 class="fr-h2 fr-mb-1w">{{ entry.title }}</h1> <div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;min-width:0;">
<div class="fr-tags-group"> <h1 class="fr-h2 fr-mb-0">{{ entry.title }}</h1>
<span class="fr-tag">{{ entry.type }}</span> @if (versions().length <= 1) {
<span class="fr-tag">v{{ entry.version }}</span> <span class="fr-tag">v{{ entry.version }}</span>
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span> } @else {
</div> <select class="fr-select" id="version-select" style="width:auto;" (change)="onVersionChange($event)">
</div> @for (v of versions(); track v.id) {
</div> <option [value]="v.id" [selected]="v.id === entry.id">
{{ v.version }}{{ v.isCurrent ? ' (courante)' : '' }}
<!-- Alerte statut PENDING --> </option>
@if (entry.status === 'PENDING') {
<div class="fr-alert fr-alert--info fr-mb-3w">
<p class="fr-alert__title">Génération en cours</p>
<p>La documentation est en cours de génération. Cette page se rafraîchit automatiquement.</p>
</div>
}
<!-- Alerte statut ERROR -->
@if (entry.status === 'ERROR') {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Erreur de génération</p>
<p>{{ entry.errorMessage }}</p>
</div>
}
<!-- Métadonnées -->
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<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>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Type</dt>
<dd>{{ entry.type }}</dd>
</div>
@if (entry.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ entry.description }}</dd>
</div>
} }
<div class="fr-col-12 fr-col-md-6"> </select>
<dt class="fr-text--bold">Créé le</dt> }
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd> </div>
</div> <!-- Droite : bouton Actions + dropdown -->
<div class="fr-col-12 fr-col-md-6"> <div style="position:relative;flex-shrink:0;">
<dt class="fr-text--bold">Mis à jour le</dt> <button class="fr-btn fr-btn--secondary fr-btn--icon-right fr-icon-arrow-down-s-line"
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd> (click)="actionsOpen.set(!actionsOpen())">
</div> Actions
</dl> </button>
</div> @if (actionsOpen()) {
<div style="position:absolute;right:0;top:calc(100% + 4px);z-index:1000;background:#fff;border:1px solid #ddd;box-shadow:0 4px 12px rgba(0,0,0,.15);min-width:220px;border-radius:4px;overflow:hidden;">
<a [routerLink]="['/catalog', entry.id, 'docs']"
(click)="actionsOpen.set(false)"
style="display:block;padding:0.75rem 1rem;text-decoration:none;color:#161616;border-bottom:1px solid #eee;"
[style.opacity]="entry.status !== 'GENERATED' ? '0.5' : '1'"
[style.pointer-events]="entry.status !== 'GENERATED' ? 'none' : 'auto'">
Voir la documentation
</a>
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;border-bottom:1px solid #eee;cursor:pointer;color:#161616;"
(click)="onNewVersion(); actionsOpen.set(false)">
Nouvelle version
</button>
<a [href]="'/api/apis/' + entry.id + '/yaml'"
download
(click)="actionsOpen.set(false)"
style="display:block;padding:0.75rem 1rem;text-decoration:none;color:#161616;border-bottom:1px solid #eee;">
Télécharger YAML
</a>
@if (entry.status !== 'GENERATED') {
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;border-bottom:1px solid #eee;cursor:pointer;color:#161616;"
[disabled]="regenerating()"
(click)="onRegenerate(); actionsOpen.set(false)">
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
</button>
}
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;cursor:pointer;color:#CE0500;"
[disabled]="deleting()"
(click)="onDelete(); actionsOpen.set(false)">
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
</button>
</div>
}
</div> </div>
</div> </div>
<!-- Documentation fonctionnelle --> <!-- Navigation onglets -->
@if (entry.functionalDoc) { <div style="display:flex;border-bottom:2px solid #e5e5e5;margin-bottom:1.5rem;">
<div class="fr-card fr-mb-4w"> <button (click)="activeTab.set('info')"
<div class="fr-card__body"> [style.border-bottom]="activeTab()==='info' ? '2px solid #000091' : '2px solid transparent'"
<div class="fr-card__content"> [style.color]="activeTab()==='info' ? '#000091' : '#3a3a3a'"
<h2 class="fr-h5 fr-mb-2w">Documentation fonctionnelle</h2> [style.font-weight]="activeTab()==='info' ? '700' : '400'"
<p style="white-space: pre-wrap">{{ entry.functionalDoc }}</p> style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
</div> Informations
</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>
</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>
}
<!-- Actions -->
<div class="fr-btns-group fr-btns-group--inline-sm">
<a
[routerLink]="['/catalog', entry.id, 'docs']"
class="fr-btn"
[class.fr-btn--disabled]="entry.status !== 'GENERATED'"
[attr.aria-disabled]="entry.status !== 'GENERATED' ? 'true' : null"
>
Voir la documentation
</a>
<a
[href]="'/api/apis/' + entry.id + '/yaml'"
class="fr-btn fr-btn--secondary"
download
>
Télécharger YAML
</a>
<button
class="fr-btn fr-btn--secondary"
(click)="onRegenerate()"
[disabled]="regenerating()"
>
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
</button> </button>
<button <button (click)="activeTab.set('contacts')"
class="fr-btn fr-btn--secondary" [style.border-bottom]="activeTab()==='contacts' ? '2px solid #000091' : '2px solid transparent'"
(click)="onDelete()" [style.color]="activeTab()==='contacts' ? '#000091' : '#3a3a3a'"
[disabled]="deleting()" [style.font-weight]="activeTab()==='contacts' ? '700' : '400'"
> style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
{{ deleting() ? 'Suppression...' : 'Supprimer' }} Contacts
</button> </button>
</div> </div>
<!-- Onglet Informations -->
@if (activeTab() === 'info') {
@if (!editingInfo()) {
<div class="fr-card fr-mb-3w">
<div class="fr-card__body">
<div class="fr-card__content">
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Version</dt>
<dd style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
{{ entry.version }}
@if (!entry.isCurrent) {
<button class="fr-btn fr-btn--sm fr-btn--secondary" [disabled]="settingCurrent()"
(click)="onSetCurrent()">
{{ settingCurrent() ? '...' : 'Passer en courante' }}
</button>
}
</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<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">Statut</dt>
<dd>
<span [class]="badgeClass(entry.status)">{{ statusLabel(entry.status) }}</span>
@if (entry.status === 'ERROR' && entry.errorMessage) {
<p class="fr-text--sm fr-mt-1w">{{ entry.errorMessage }}</p>
}
</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>
<dd>{{ entry.description }}</dd>
</div>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Créé le</dt>
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Mis à jour le</dt>
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
</dl>
<div class="fr-mt-2w">
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-edit-line" (click)="startEditInfo()">
Modifier les informations
</button>
</div>
</div>
</div>
</div>
}
@if (editingInfo()) {
<form [formGroup]="infoForm" (ngSubmit)="submitInfo()" novalidate class="fr-mb-3w">
<!-- 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="info-convention">Convention</label>
<select id="info-convention" class="fr-select" formControlName="convention">
<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]="infoForm.get('name')?.invalid && infoForm.get('name')?.touched">
<label class="fr-label" for="info-name">Nom de l'API <span style="color:var(--text-default-error)">*</span></label>
<input id="info-name" class="fr-input" type="text" formControlName="name"
[class.fr-input--error]="infoForm.get('name')?.invalid && infoForm.get('name')?.touched" />
@if (infoForm.get('name')?.invalid && infoForm.get('name')?.touched) {
<p class="fr-error-text">Le nom est obligatoire.</p>
}
</div>
</div>
</div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-2w"
[class.fr-input-group--error]="infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched">
<label class="fr-label" for="info-provider">Fournisseur <span style="color:var(--text-default-error)">*</span></label>
<input id="info-provider" class="fr-input" type="text" formControlName="provider"
[class.fr-input--error]="infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched" />
@if (infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched) {
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
}
</div>
<!-- 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="info-v-major">Majeure (X)</label>
<input id="info-v-major" class="fr-input" type="number" min="0" formControlName="versionMajor" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="info-v-minor">Mineure (Y)</label>
<input id="info-v-minor" class="fr-input" type="number" min="0" formControlName="versionMinor" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="info-v-patch">Correctif (Z)</label>
<input id="info-v-patch" class="fr-input" type="number" min="0" formControlName="versionPatch" />
</div>
</div>
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="info-description">Description</label>
<textarea id="info-description" class="fr-input" rows="3" formControlName="description"></textarea>
</div>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="info-category">Catégorie</label>
<select id="info-category" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.name }}</option>
}
</select>
</div>
<div class="fr-btns-group fr-btns-group--inline fr-btns-group--right">
<button type="button" class="fr-btn fr-btn--secondary" (click)="editingInfo.set(false)">Annuler</button>
<button type="submit" class="fr-btn" [disabled]="savingInfo()">
{{ savingInfo() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</form>
}
}
<!-- Onglet Contacts -->
@if (activeTab() === 'contacts') {
@if (!editingContacts()) {
<div class="fr-card fr-mb-3w">
<div class="fr-card__body">
<div class="fr-card__content">
<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 class="fr-mt-2w">
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-edit-line" (click)="startEditContacts()">
Modifier les contacts
</button>
</div>
</div>
</div>
</div>
}
@if (editingContacts()) {
<form [formGroup]="contactForm" (ngSubmit)="submitContacts()" novalidate class="fr-mb-3w">
<!-- 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]="contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched">
<label class="fr-label" for="cf-name">Nom et prénom</label>
<input id="cf-name" class="fr-input" type="text" formControlName="contactFunctionalName" placeholder="Prénom Nom"
[class.fr-input--error]="contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched" />
@if (contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched) {
<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]="contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched">
<label class="fr-label" for="cf-entity">Entité</label>
<input id="cf-entity" class="fr-input" type="text" formControlName="contactFunctionalEntity" placeholder="Direction XYZ"
[class.fr-input--error]="contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched" />
@if (contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched) {
<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]="contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched">
<label class="fr-label" for="cf-email">Email</label>
<input id="cf-email" class="fr-input" type="email" formControlName="contactFunctionalEmail" placeholder="prenom.nom@example.fr"
[class.fr-input--error]="contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched" />
@if (contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<!-- 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"
[class.fr-input-group--error]="contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched">
<label class="fr-label" for="ct-name">Nom et prénom</label>
<input id="ct-name" class="fr-input" type="text" formControlName="contactTechnicalName" placeholder="Prénom Nom"
[class.fr-input--error]="contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched" />
@if (contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched) {
<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]="contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched">
<label class="fr-label" for="ct-entity">Entité</label>
<input id="ct-entity" class="fr-input" type="text" formControlName="contactTechnicalEntity" placeholder="Équipe Technique"
[class.fr-input--error]="contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched" />
@if (contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched) {
<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]="contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched">
<label class="fr-label" for="ct-email">Email</label>
<input id="ct-email" class="fr-input" type="email" formControlName="contactTechnicalEmail" placeholder="tech@example.fr"
[class.fr-input--error]="contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched" />
@if (contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--inline fr-btns-group--right">
<button type="button" class="fr-btn fr-btn--secondary" (click)="editingContacts.set(false)">Annuler</button>
<button type="submit" class="fr-btn" [disabled]="savingContacts()">
{{ savingContacts() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</form>
}
}
} }
</div> </div>
`, `,
@@ -226,25 +413,103 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private apiService = inject(ApiService); private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private fb = inject(FormBuilder);
api = signal<ApiEntry | null>(null); api = signal<ApiEntry | null>(null);
versions = signal<ApiEntryListItem[]>([]);
categoryBreadcrumb = signal<Category[]>([]);
loading = signal(false); loading = signal(false);
error = signal<string | null>(null); error = signal<string | null>(null);
regenerating = signal(false); regenerating = signal(false);
deleting = signal(false); deleting = signal(false);
settingCurrent = signal(false);
categories = signal<Category[]>([]);
actionsOpen = signal(false);
backUrl = computed<string[]>(() => {
const crumbs = this.categoryBreadcrumb();
if (crumbs.length > 0) return ['/browse', crumbs[crumbs.length - 1].id];
return ['/browse'];
});
/* Onglets */
activeTab = signal<'info' | 'contacts'>('info');
editingInfo = signal(false);
editingContacts = signal(false);
savingInfo = signal(false);
savingContacts = signal(false);
infoForm = 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)]],
description: [''],
categoryId: [''],
});
contactForm = this.fb.group({
contactFunctionalName: ['', Validators.required],
contactFunctionalEntity: ['', Validators.required],
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
contactTechnicalName: ['', Validators.required],
contactTechnicalEntity: ['', Validators.required],
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
});
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.categoryService.list().subscribe((res) => this.categories.set(res.items));
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.editingInfo.set(false);
this.editingContacts.set(false);
this.clearPoll();
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 } });
}
onSetCurrent() {
this.settingCurrent.set(true);
this.apiService.setCurrent(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.settingCurrent.set(false);
this.apiService.getVersions(this.id).subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
},
error: () => this.settingCurrent.set(false),
});
}
onRegenerate() { onRegenerate() {
this.regenerating.set(true); this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({ this.apiService.regenerate(this.id).subscribe({
@@ -257,6 +522,76 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
}); });
} }
startEditInfo() {
const entry = this.api();
if (!entry) return;
this.infoForm.patchValue({
convention: entry.convention,
name: entry.name,
provider: entry.provider,
versionMajor: entry.versionMajor,
versionMinor: entry.versionMinor,
versionPatch: entry.versionPatch,
description: entry.description ?? '',
categoryId: entry.categoryId ?? '',
});
this.editingInfo.set(true);
}
startEditContacts() {
const entry = this.api();
if (!entry) return;
this.contactForm.patchValue({
contactFunctionalName: entry.contactFunctionalName,
contactFunctionalEntity: entry.contactFunctionalEntity,
contactFunctionalEmail: entry.contactFunctionalEmail,
contactTechnicalName: entry.contactTechnicalName,
contactTechnicalEntity: entry.contactTechnicalEntity,
contactTechnicalEmail: entry.contactTechnicalEmail,
});
this.editingContacts.set(true);
}
submitInfo() {
if (this.infoForm.invalid) { this.infoForm.markAllAsTouched(); return; }
const entry = this.api();
if (!entry) return;
this.savingInfo.set(true);
const raw = this.infoForm.getRawValue();
this.apiService.update(entry.id, {
convention: raw.convention ?? 'CONSULTER',
name: raw.name ?? '',
provider: raw.provider ?? '',
versionMajor: raw.versionMajor ?? 0,
versionMinor: raw.versionMinor ?? 0,
versionPatch: raw.versionPatch ?? 0,
categoryId: raw.categoryId || null,
description: raw.description || null,
}).subscribe({
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
error: () => this.savingInfo.set(false),
});
}
submitContacts() {
if (this.contactForm.invalid) { this.contactForm.markAllAsTouched(); return; }
const entry = this.api();
if (!entry) return;
this.savingContacts.set(true);
const raw = this.contactForm.getRawValue();
this.apiService.update(entry.id, {
contactFunctionalName: raw.contactFunctionalName ?? '',
contactFunctionalEntity: raw.contactFunctionalEntity ?? '',
contactFunctionalEmail: raw.contactFunctionalEmail ?? '',
contactTechnicalName: raw.contactTechnicalName ?? '',
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
}).subscribe({
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
error: () => this.savingContacts.set(false),
});
}
onDelete() { onDelete() {
if (!confirm('Supprimer cette API ?')) return; if (!confirm('Supprimer cette API ?')) return;
this.deleting.set(true); this.deleting.set(true);
@@ -272,6 +607,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
return 'fr-badge fr-badge--info'; return 'fr-badge fr-badge--info';
} }
statusLabel(status: string): string {
if (status === 'GENERATED') return 'Générée';
if (status === 'ERROR') return 'Erreur';
return 'En attente';
}
private loadApi() { private loadApi() {
this.loading.set(true); this.loading.set(true);
this.apiService.get(this.id).subscribe({ this.apiService.get(this.id).subscribe({
@@ -279,6 +620,16 @@ 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) {
this.categoryService.browse(entry.categoryId).subscribe({
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
error: () => {},
});
}
}, },
error: (err) => { error: (err) => {
this.error.set(err.message ?? 'Erreur lors du chargement'); this.error.set(err.message ?? 'Erreur lors du chargement');

View File

@@ -12,12 +12,16 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { CategoryService } from '../../core/category.service'; import { CategoryService } from '../../core/category.service';
import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/shared'; import { ApiService } from '../../core/api.service';
import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared';
import { forkJoin } from 'rxjs';
import { CategoryFormModalComponent } from '../../shared/category-form-modal/category-form-modal.component';
import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal/category-delete-modal.component';
@Component({ @Component({
selector: 'app-browse', selector: 'app-browse',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink], imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="fr-container fr-mt-4w fr-mb-4w"> <div class="fr-container fr-mt-4w fr-mb-4w">
@@ -25,7 +29,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici"> <nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list"> <ol class="fr-breadcrumb__list">
<li> <li>
<a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a> <a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</li> </li>
@for (crumb of ancestors(); track crumb.id) { @for (crumb of ancestors(); track crumb.id) {
<li> <li>
@@ -41,17 +45,44 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</nav> </nav>
<!-- En-tête --> <!-- En-tête -->
@if (categoryId()) {
<a class="fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w" style="display:inline-flex;"
[routerLink]="backUrl()">Retour</a>
}
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w"> <div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col"> <div class="fr-col">
<h1 class="fr-h2 fr-mb-0">{{ browseData()?.category?.name ?? 'Catalogue' }}</h1> <h1 class="fr-h2 fr-mb-0">
{{ browseData()?.category?.name ?? 'Catalogue' }}
@if (browseData()?.category) {
<button
title="Modifier ce dossier"
style="background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
(click)="openEdit(browseData()!.category!)">
<span class="fr-icon-edit-line" aria-hidden="true" style="font-size:0.875rem"></span>
</button>
}
</h1>
</div> </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()"> <button class="fr-btn fr-btn--icon-left fr-icon-folder-2-fill" (click)="openCreate()">
Nouveau dossier Nouveau dossier
</button> </button>
</div> </div>
</div> </div>
<!-- Barre de recherche -->
<div class="fr-search-bar fr-mb-3w">
<label class="fr-label" for="search-api">Rechercher</label>
<input id="search-api" class="fr-input" type="search" placeholder="Ex : auth async v1.0, openapi latest..."
[value]="searchQuery()"
(input)="onSearchChange($any($event.target).value)" />
</div>
@if (loading()) { @if (loading()) {
<div class="fr-callout fr-mb-4w"> <div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p> <p class="fr-callout__text">Chargement...</p>
@@ -64,134 +95,187 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</div> </div>
} }
@if (!loading() && browseData()) { @if (!isSearchMode()) {
<!-- Sous-catégories --> @if (!loading() && browseData()) {
@if (browseData()!.subcategories.length > 0) { <!-- Sous-catégories -->
<h2 class="fr-h4 fr-mb-3w">Catégories</h2> @if (browseData()!.subcategories.length > 0) {
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w"> <div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of browseData()!.subcategories; track cat.id) { @for (cat of browseData()!.subcategories; track cat.id) {
<div class="fr-col-12 fr-col-md-4"> <div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm"> <div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body"> <div class="fr-tile__body">
<div class="fr-tile__content"> <div class="fr-tile__content">
<h3 class="fr-tile__title"> <h3 class="fr-tile__title">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a> <a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
</h3> <button title="Modifier"
@if (cat.description) { style="position:relative;z-index:1;background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
<p class="fr-tile__desc">{{ cat.description }}</p> (click)="openEdit(cat); $event.stopPropagation()">
} <span class="fr-icon-edit-line" aria-hidden="true" style="font-size:0.75rem"></span>
<p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</p>
<div style="position:relative;z-index:1;margin-top:0.5rem;display:flex;gap:0.25rem;order:5">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-edit-line"
(click)="openEdit(cat); $event.stopPropagation()">
Modifier
</button>
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-bin-line"
(click)="deleteCategory(cat); $event.stopPropagation()">
Supprimer
</button> </button>
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
<button title="Supprimer"
style="position:relative;z-index:1;background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
(click)="openDelete(cat); $event.stopPropagation()">
<span class="fr-icon-delete-bin-line" aria-hidden="true" style="font-size:0.75rem"></span>
</button>
}
</h3>
@if (cat.description) {
<p class="fr-tile__desc">{{ cat.description }}</p>
}
<p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</p>
</div>
</div>
</div>
</div>
}
</div>
}
<!-- APIs -->
@if (browseData()!.apis.length > 0) {
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of browseData()!.apis; track api.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
</h3>
@if (api.description) {
<p class="fr-tile__desc">{{ api.description }}</p>
}
<p class="fr-tile__detail">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
&nbsp;<span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
@if (api.isCurrent) {
&nbsp;<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
}
</p>
</div>
</div>
</div>
</div>
}
</div>
}
@if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
</div>
}
}
} @else {
<!-- Mode recherche -->
@if (searchLoading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (!searchLoading()) {
<!-- Dossiers -->
@if (searchCategoryResults().length > 0) {
@if (searchResults().length > 0) {
<p class="fr-text--sm fr-text--bold fr-mb-2w">Dossiers</p>
}
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of searchCategoryResults(); track cat.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
</h3>
@if (cat.description) {
<p class="fr-tile__desc">{{ cat.description }}</p>
} }
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> }
} </div>
</div> }
}
<!-- APIs --> <!-- APIs -->
@if (browseData()!.apis.length > 0) { @if (searchResults().length > 0) {
<h2 class="fr-h4 fr-mb-3w">APIs</h2> @if (searchCategoryResults().length > 0) {
<div class="fr-grid-row fr-grid-row--gutters"> <p class="fr-text--sm fr-text--bold fr-mb-2w">APIs</p>
@for (api of browseData()!.apis; track api.id) { }
<div class="fr-col-12 fr-col-md-4"> <div class="fr-grid-row fr-grid-row--gutters">
<div class="fr-tile fr-enlarge-link fr-tile--sm"> @for (api of searchResults(); track api.id) {
<div class="fr-tile__body"> <div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
<div class="fr-tile__content"> <div class="fr-card fr-card--sm fr-enlarge-link">
<h3 class="fr-tile__title"> <div class="fr-card__body">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a> <div class="fr-card__content">
</h3> <h3 class="fr-card__title">
@if (api.description) { <a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
<p class="fr-tile__desc">{{ api.description }}</p> </h3>
} @if (api.description) {
<p class="fr-tile__detail"> <p class="fr-card__desc">{{ api.description }}</p>
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span> }
&nbsp; <div class="fr-badges-group fr-mt-1w">
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span> <span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
</p> <span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
@if (api.isCurrent) {
<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
}
</div>
@if (searchTotalPages() > 1) {
<div class="fr-grid-row fr-grid-row--center fr-mt-4w">
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === 1"
(click)="setSearchPage(searchPage() - 1)">Précédent</button>
<span class="fr-mx-2w">Page {{ searchPage() }} / {{ searchTotalPages() }}</span>
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === searchTotalPages()"
(click)="setSearchPage(searchPage() + 1)">Suivant</button>
</div> </div>
} }
</div> }
}
@if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) { <!-- Aucun résultat -->
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w"> @if (searchCategoryResults().length === 0 && searchResults().length === 0) {
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p> <p class="fr-text--lead">Aucun résultat pour « {{ searchQuery() }} »</p>
</div> }
} }
} }
<!-- Modale création / édition (overlay Angular, sans fr-modal pour éviter l'interférence du JS DSFR) -->
@if (showFormModal()) {
<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="modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:540px;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">
<h2 id="modal-title" class="fr-h3 fr-mb-0">
{{ formMode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="closeModal()">✕</button>
</div>
</div>
@if (formError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ formError() }}</p>
</div>
}
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="form-name">
Nom <span class="fr-hint-text">Obligatoire</span>
</label>
<input id="form-name" class="fr-input" type="text"
[value]="formName()"
(input)="formName.set($any($event.target).value)" />
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="form-desc">
Description <span class="fr-hint-text">Optionnel</span>
</label>
<textarea id="form-desc" class="fr-input" rows="3"
[value]="formDescription()"
(input)="formDescription.set($any($event.target).value)"></textarea>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="closeModal()">Annuler</button>
<button class="fr-btn" (click)="submitForm()" [disabled]="formLoading()">
{{ formMode() === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</div>
}
</div> </div>
<!-- Modale de suppression -->
@if (deletingCategory()) {
<app-category-delete-modal
[category]="deletingCategory()!"
(confirmed)="onDeleteConfirmed()"
(cancelled)="deletingCategory.set(null)"
/>
}
<!-- Modale création / édition -->
@if (showFormModal()) {
<app-category-form-modal
[mode]="formMode()"
[category]="editingCategory()"
[parentId]="categoryId()"
(saved)="onFormSaved()"
(closed)="showFormModal.set(false)"
/>
}
`, `,
}) })
export class BrowseComponent implements OnInit { export class BrowseComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private categoryService = inject(CategoryService); private categoryService = inject(CategoryService);
private apiService = inject(ApiService);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
browseData = signal<CategoryBrowseResponse | null>(null); browseData = signal<CategoryBrowseResponse | null>(null);
@@ -199,22 +283,38 @@ export class BrowseComponent implements OnInit {
error = signal<string | null>(null); error = signal<string | null>(null);
categoryId = signal<string | null>(null); categoryId = signal<string | null>(null);
/* Signaux pour la modale */ /* Signaux recherche */
searchQuery = signal('');
searchResults = signal<ApiEntryListItem[]>([]);
searchCategoryResults = signal<Category[]>([]);
searchTotal = signal(0);
searchPage = signal(1);
searchLoading = signal(false);
readonly searchLimit = 12;
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
searchTotalPages = computed(() => Math.ceil(this.searchTotal() / this.searchLimit));
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
/* Signaux pour les modales */
showFormModal = signal(false); showFormModal = signal(false);
formMode = signal<'create' | 'edit'>('create'); formMode = signal<'create' | 'edit'>('create');
editingCategoryId = signal<string | null>(null); editingCategory = signal<Category | null>(null);
formName = signal(''); deletingCategory = signal<CategoryWithCounts | null>(null);
formDescription = signal('');
formError = signal<string | null>(null);
formLoading = signal(false);
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
ancestors = computed<Category[]>(() => { ancestors = computed<Category[]>(() => {
const d = this.browseData(); const d = this.browseData();
if (!d || !d.category) return []; if (!d || !d.category) return [];
return d.breadcrumb.slice(0, -1); return d.breadcrumb.slice(0, -1);
}); });
backUrl = computed<string[]>(() => {
const anc = this.ancestors();
if (anc.length > 0) return ['/browse', anc[anc.length - 1].id];
return ['/browse'];
});
ngOnInit() { ngOnInit() {
this.route.paramMap this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -241,65 +341,70 @@ export class BrowseComponent implements OnInit {
openCreate() { openCreate() {
this.formMode.set('create'); this.formMode.set('create');
this.editingCategoryId.set(null); this.editingCategory.set(null);
this.formName.set('');
this.formDescription.set('');
this.formError.set(null);
this.showFormModal.set(true); this.showFormModal.set(true);
} }
openEdit(cat: CategoryWithCounts) { openEdit(cat: Category) {
this.formMode.set('edit'); this.formMode.set('edit');
this.editingCategoryId.set(cat.id); this.editingCategory.set(cat);
this.formName.set(cat.name);
this.formDescription.set(cat.description ?? '');
this.formError.set(null);
this.showFormModal.set(true); this.showFormModal.set(true);
} }
closeModal() { openDelete(cat: CategoryWithCounts) {
this.deletingCategory.set(cat);
}
onFormSaved() {
this.showFormModal.set(false); this.showFormModal.set(false);
this.loadBrowse();
} }
submitForm() { onDeleteConfirmed() {
if (!this.formName().trim()) { this.deletingCategory.set(null);
this.formError.set('Le nom est obligatoire.'); this.loadBrowse();
return; }
onSearchChange(value: string) {
this.searchQuery.set(value);
this.searchPage.set(1);
if (this.searchDebounce) clearTimeout(this.searchDebounce);
if (value.trim()) {
this.searchLoading.set(true);
this.searchDebounce = setTimeout(() => this.loadSearch(), 400);
} else {
this.searchLoading.set(false);
this.searchCategoryResults.set([]);
} }
this.formLoading.set(true); }
const name = this.formName().trim();
const description = this.formDescription().trim() || undefined;
const obs = loadSearch() {
this.formMode() === 'create' this.searchLoading.set(true);
? this.categoryService.create({ const raw = this.searchQuery();
name, const q = raw.trim().toLowerCase();
description,
parentId: this.categoryId() ?? undefined,
})
: this.categoryService.update(this.editingCategoryId()!, { name, description });
obs.subscribe({ forkJoin({
next: () => { apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
this.formLoading.set(false); categories: this.categoryService.list(),
this.showFormModal.set(false); }).subscribe({
this.loadBrowse(); next: ({ apis, categories }) => {
}, this.searchResults.set(apis.items);
error: (err: { error?: { message?: string } }) => { this.searchTotal.set(apis.total);
this.formLoading.set(false); this.searchCategoryResults.set(
this.formError.set(err?.error?.message ?? 'Une erreur est survenue.'); categories.items.filter(
(c) => c.name.toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q),
),
);
this.searchLoading.set(false);
}, },
error: () => this.searchLoading.set(false),
}); });
} }
deleteCategory(cat: CategoryWithCounts) { setSearchPage(p: number) {
if (cat.apiCount > 0 || cat.subcategoryCount > 0) return; if (p < 1 || p > this.searchTotalPages()) return;
if (!confirm(`Supprimer le dossier "${cat.name}" ?`)) return; this.searchPage.set(p);
this.categoryService.delete(cat.id).subscribe({ this.loadSearch();
next: () => this.loadBrowse(),
error: (err: { error?: { message?: string } }) =>
alert(err?.error?.message ?? 'Erreur lors de la suppression.'),
});
} }
typeBadgeClass(type: string): string { typeBadgeClass(type: string): string {
@@ -308,9 +413,4 @@ export class BrowseComponent implements OnInit {
: 'fr-badge fr-badge--sm fr-badge--blue-ecume'; : 'fr-badge fr-badge--sm fr-badge--blue-ecume';
} }
statusBadgeClass(status: string): string {
if (status === 'GENERATED') return 'fr-badge fr-badge--sm fr-badge--success';
if (status === 'ERROR') return 'fr-badge fr-badge--sm fr-badge--error';
return 'fr-badge fr-badge--sm fr-badge--info';
}
} }

View File

@@ -41,7 +41,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared';
id="search-input" id="search-input"
class="fr-input" class="fr-input"
type="search" type="search"
placeholder="Rechercher par titre ou description..." placeholder="Ex : auth async v1.0, openapi latest..."
[ngModel]="search()" [ngModel]="search()"
(ngModelChange)="onSearchChange($event)" (ngModelChange)="onSearchChange($event)"
/> />

View File

@@ -6,12 +6,13 @@ import {
OnInit, OnInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
} from '@angular/core'; } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; 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 { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service'; import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared'; import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
@Component({ @Component({
selector: 'app-upload', selector: 'app-upload',
@@ -85,6 +86,26 @@ import { Category } from '@datacat/shared';
</div> </div>
} }
</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"> <div class="fr-btns-group fr-btns-group--inline-sm">
@@ -102,53 +123,51 @@ import { Category } from '@datacat/shared';
<!-- Étape 2 : Métadonnées --> <!-- Étape 2 : Métadonnées -->
@if (step() === 2) { @if (step() === 2) {
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate> <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 class="fr-hint-text">Obligatoire</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()"> <!-- Titre : convention + nom -->
<label class="fr-label" for="version-input"> <p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
Version <span class="fr-hint-text">Obligatoire</span> <div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
</label> <div class="fr-col-12 fr-col-md-4">
<input <div class="fr-select-group" [class.fr-select-group--error]="conventionInvalid()">
id="version-input" <label class="fr-label" for="convention-input">Convention</label>
class="fr-input" <select
[class.fr-input--error]="versionInvalid()" id="convention-input"
formControlName="version" class="fr-select"
type="text" [class.fr-select--error]="conventionInvalid()"
placeholder="1.0.0" formControlName="convention"
/> >
@if (versionInvalid()) { <option value="CONSULTER">Consulter</option>
<p class="fr-error-text">La version est obligatoire.</p> <option value="ENREGISTRER">Enregistrer</option>
} <option value="ETRE_NOTIFIE">Être notifié</option>
</div> </select>
@if (conventionInvalid()) {
<div class="fr-select-group fr-mb-3w"> <p class="fr-error-text">La convention est obligatoire.</p>
<label class="fr-label" for="type-input"> }
Type <span class="fr-hint-text">Obligatoire</span> </div>
</label> </div>
<select id="type-input" class="fr-select" formControlName="type"> <div class="fr-col-12 fr-col-md-8">
<option value="ASYNCAPI">AsyncAPI</option> <div class="fr-input-group" [class.fr-input-group--error]="nameInvalid()">
<option value="OPENAPI">OpenAPI</option> <label class="fr-label" for="name-input">Nom de l'API</label>
</select> <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> </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"> <div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="category-input"> <label class="fr-label" for="category-input">
Catégorie <span class="fr-hint-text">Optionnel</span> Catégorie
</label> </label>
<select id="category-input" class="fr-select" formControlName="categoryId"> <select id="category-input" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option> <option value="">— Sans catégorie —</option>
@@ -158,9 +177,42 @@ import { Category } from '@datacat/shared';
</select> </select>
</div> </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"> <div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="description-input"> <label class="fr-label" for="description-input">
Description <span class="fr-hint-text">Optionnel</span> Description
</label> </label>
<textarea <textarea
id="description-input" id="description-input"
@@ -171,82 +223,127 @@ import { Category } from '@datacat/shared';
></textarea> ></textarea>
</div> </div>
<div class="fr-input-group fr-mb-3w"> <!-- Version X.Y.Z -->
<label class="fr-label" for="functional-doc-input"> <p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
Documentation fonctionnelle <span class="fr-hint-text">Optionnel</span> <div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
</label> <div class="fr-col-4">
<textarea <div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
id="functional-doc-input" <label class="fr-label" for="version-major-input">Majeure (X)</label>
class="fr-input" <input
formControlName="functionalDoc" id="version-major-input"
rows="4" class="fr-input"
placeholder="Cas d'usage, contexte métier..." [class.fr-input--error]="versionMajorInvalid()"
></textarea> 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>
<div class="fr-input-group fr-mb-3w"> <!-- Contact métier -->
<label class="fr-label" for="technical-doc-input"> <p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
Documentation technique <span class="fr-hint-text">Optionnel</span> <div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
</label> <div class="fr-col-12 fr-col-md-4">
<textarea <div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalName')">
id="technical-doc-input" <label class="fr-label" for="cf-name-input">Nom et prénom</label>
class="fr-input" <input id="cf-name-input" class="fr-input"
formControlName="technicalDoc" [class.fr-input--error]="fieldInvalid('contactFunctionalName')"
rows="4" formControlName="contactFunctionalName" type="text" placeholder="Prénom Nom" />
placeholder="Guide d'intégration technique..." @if (fieldInvalid('contactFunctionalName')) {
></textarea> <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>
<div class="fr-input-group fr-mb-3w"> <!-- Contact technique -->
<label class="fr-label" for="external-doc-url-input"> <p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
URL documentation externe <span class="fr-hint-text">Optionnel</span> <div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
</label> <div class="fr-col-12 fr-col-md-4">
<input <div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalName')">
id="external-doc-url-input" <label class="fr-label" for="ct-name-input">Nom et prénom</label>
class="fr-input" <input id="ct-name-input" class="fr-input"
formControlName="externalDocUrl" [class.fr-input--error]="fieldInvalid('contactTechnicalName')"
type="text" formControlName="contactTechnicalName" type="text" placeholder="Prénom Nom" />
placeholder="https://..." @if (fieldInvalid('contactTechnicalName')) {
/> <p class="fr-error-text">Obligatoire.</p>
</div> }
</div>
<div class="fr-input-group fr-mb-3w"> </div>
<label class="fr-label" for="contact-functional-input"> <div class="fr-col-12 fr-col-md-4">
Contact fonctionnel <span class="fr-hint-text">Optionnel</span> <div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEntity')">
</label> <label class="fr-label" for="ct-entity-input">Entité</label>
<input <input id="ct-entity-input" class="fr-input"
id="contact-functional-input" [class.fr-input--error]="fieldInvalid('contactTechnicalEntity')"
class="fr-input" formControlName="contactTechnicalEntity" type="text" placeholder="Équipe Technique" />
formControlName="contactFunctional" @if (fieldInvalid('contactTechnicalEntity')) {
type="text" <p class="fr-error-text">Obligatoire.</p>
placeholder="Nom, email, téléphone..." }
/> </div>
</div> </div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group fr-mb-3w"> <div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEmail')">
<label class="fr-label" for="contact-technical-input"> <label class="fr-label" for="ct-email-input">Email</label>
Contact technique <span class="fr-hint-text">Optionnel</span> <input id="ct-email-input" class="fr-input"
</label> [class.fr-input--error]="fieldInvalid('contactTechnicalEmail')"
<input formControlName="contactTechnicalEmail" type="email" placeholder="tech@example.fr" />
id="contact-technical-input" @if (fieldInvalid('contactTechnicalEmail')) {
class="fr-input" <p class="fr-error-text">Email valide requis.</p>
formControlName="contactTechnical" }
type="text" </div>
placeholder="Nom, email, téléphone..." </div>
/>
</div>
<div class="fr-input-group fr-mb-4w">
<label class="fr-label" for="access-rights-input">
Droits d'accès <span class="fr-hint-text">Optionnel</span>
</label>
<textarea
id="access-rights-input"
class="fr-input"
formControlName="accessRights"
rows="3"
placeholder="Processus d'habilitation, conditions d'accès..."
></textarea>
</div> </div>
<div class="fr-btns-group fr-btns-group--inline-sm"> <div class="fr-btns-group fr-btns-group--inline-sm">
@@ -286,13 +383,17 @@ import { Category } from '@datacat/shared';
<dt class="fr-text--bold">Type</dt> <dt class="fr-text--bold">Type</dt>
<dd>{{ metaForm.value.type }}</dd> <dd>{{ metaForm.value.type }}</dd>
</div> </div>
<div class="fr-col-12 fr-col-md-6"> <div class="fr-col-12">
<dt class="fr-text--bold">Titre</dt> <dt class="fr-text--bold">Titre</dt>
<dd>{{ metaForm.value.title }}</dd> <dd>{{ previewTitle() }}</dd>
</div> </div>
<div class="fr-col-12 fr-col-md-6"> <div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Version</dt> <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> </div>
@if (metaForm.value.categoryId) { @if (metaForm.value.categoryId) {
<div class="fr-col-12 fr-col-md-6"> <div class="fr-col-12 fr-col-md-6">
@@ -339,6 +440,7 @@ export class UploadComponent implements OnInit {
private apiService = inject(ApiService); private apiService = inject(ApiService);
private categoryService = inject(CategoryService); private categoryService = inject(CategoryService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute);
step = signal(1); step = signal(1);
selectedFiles = signal<File[]>([]); selectedFiles = signal<File[]>([]);
@@ -347,6 +449,9 @@ export class UploadComponent implements OnInit {
fileError = signal<string | null>(null); fileError = signal<string | null>(null);
uploading = signal(false); uploading = signal(false);
uploadError = signal<string | null>(null); uploadError = signal<string | null>(null);
validating = signal(false);
validationDone = signal(false);
validationErrors = signal<Array<{ file: string; message: string }>>([]);
categories = signal<Category[]>([]); categories = signal<Category[]>([]);
readonly stepTitles = [ readonly stepTitles = [
@@ -356,40 +461,108 @@ export class UploadComponent implements OnInit {
]; ];
metaForm = this.fb.group({ metaForm = this.fb.group({
title: ['', Validators.required], convention: ['CONSULTER', Validators.required],
version: ['', 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], type: ['ASYNCAPI', Validators.required],
categoryId: [''], categoryId: [''],
description: [''], description: [''],
functionalDoc: [''], contactFunctionalName: ['', Validators.required],
technicalDoc: [''], contactFunctionalEntity: ['', Validators.required],
externalDocUrl: [''], contactFunctionalEmail: ['', [Validators.required, Validators.email]],
contactFunctional: [''], contactTechnicalName: ['', Validators.required],
contactTechnical: [''], contactTechnicalEntity: ['', Validators.required],
accessRights: [''], contactTechnicalEmail: ['', [Validators.required, Validators.email]],
}); });
titleInvalid = computed( /* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched), private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
);
versionInvalid = computed( previewTitle = computed(() => {
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched), const values = this.formValues();
); const convention = (values.convention ?? 'CONSULTER') as ApiConvention;
const name = values.name ?? '';
return `${API_CONVENTION_LABELS[convention]} ${name}`.trim();
});
selectedCategoryName = computed(() => { selectedCategoryName = computed(() => {
const id = this.metaForm.value.categoryId; const id = this.formValues().categoryId;
if (!id) return ''; if (!id) return '';
return this.categories().find((c) => c.id === id)?.name ?? ''; 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(() => { canProceedFromStep1 = computed(() => {
if (this.selectedFiles().length === 0) return false; /* Sans fichier, on peut toujours passer à l'étape 2 */
if (this.selectedFiles().length === 1) return true; if (this.selectedFiles().length === 0) return true;
/* Plusieurs fichiers : un fichier principal doit être détecté */ /* Avec fichier : validation serveur doit être terminée et sans erreur */
return this.detectedMainFilename() !== null; 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() { ngOnInit() {
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) => this.categories.set(res.items), next: (res) => {
this.categories.set(res.items);
if (preselectedCategoryId) {
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
}
},
});
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,
}); });
} }
@@ -403,6 +576,9 @@ export class UploadComponent implements OnInit {
this.fileError.set(null); this.fileError.set(null);
this.detectedType.set(null); this.detectedType.set(null);
this.detectedMainFilename.set(null); this.detectedMainFilename.set(null);
this.validationErrors.set([]);
this.validationDone.set(false);
this.metaForm.get('type')?.enable();
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const fileList = input.files; const fileList = input.files;
@@ -423,6 +599,9 @@ export class UploadComponent implements OnInit {
/* Détection côté client du fichier principal (pré-remplissage du type) */ /* Détection côté client du fichier principal (pré-remplissage du type) */
void this.detectMainFileClient(files); void this.detectMainFileClient(files);
/* Validation serveur immédiate */
this.runValidation(files);
} }
goToStep2() { goToStep2() {
@@ -430,6 +609,48 @@ export class UploadComponent implements OnInit {
this.step.set(2); 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);
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.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 });
}
},
error: (err) => {
this.validating.set(false);
this.validationDone.set(true);
this.validationErrors.set([{ file: '', message: err?.error?.message ?? 'Erreur de validation.' }]);
},
});
}
goToStep3() { goToStep3() {
if (this.metaForm.invalid) { if (this.metaForm.invalid) {
this.metaForm.markAllAsTouched(); this.metaForm.markAllAsTouched();
@@ -445,37 +666,30 @@ export class UploadComponent implements OnInit {
this.uploading.set(true); this.uploading.set(true);
this.uploadError.set(null); this.uploadError.set(null);
const raw = this.metaForm.getRawValue();
const formData = new FormData(); const formData = new FormData();
for (const f of files) { for (const f of files) {
formData.append('files', f); formData.append('files', f);
} }
formData.append('title', this.metaForm.value.title ?? ''); formData.append('convention', raw.convention ?? 'CONSULTER');
formData.append('version', this.metaForm.value.version ?? ''); formData.append('name', raw.name ?? '');
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI'); formData.append('provider', raw.provider ?? '');
if (this.metaForm.value.categoryId) { formData.append('versionMajor', String(raw.versionMajor ?? 0));
formData.append('categoryId', this.metaForm.value.categoryId); 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) { if (raw.description) {
formData.append('description', this.metaForm.value.description); formData.append('description', raw.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);
} }
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({ this.apiService.upload(formData).subscribe({
next: (entry) => { next: (entry) => {

View File

@@ -0,0 +1,79 @@
import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { CategoryWithCounts } from '@datacat/shared';
@Component({
selector: 'app-category-delete-modal',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<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="delete-modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:480px;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">
<h2 id="delete-modal-title" class="fr-h3 fr-mb-0">Supprimer le dossier</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="cancel()">✕</button>
</div>
</div>
<p class="fr-text--lead fr-mb-1w">
Êtes-vous sûr de vouloir supprimer le dossier
<strong>« {{ category().name }} »</strong> ?
</p>
<p class="fr-text--sm fr-text--mention-grey fr-mb-3w">
Cette action est irréversible.
</p>
@if (deleteError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ deleteError() }}</p>
</div>
}
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="cancel()"
[disabled]="deleteLoading()">Annuler</button>
<button class="fr-btn fr-btn--error" (click)="confirmDelete()"
[disabled]="deleteLoading()">
{{ deleteLoading() ? 'Suppression...' : 'Supprimer' }}
</button>
</div>
</div>
</div>
</div>
`,
})
export class CategoryDeleteModalComponent {
private categoryService = inject(CategoryService);
category = input.required<CategoryWithCounts>();
confirmed = output<void>();
cancelled = output<void>();
deleteLoading = signal(false);
deleteError = signal<string | null>(null);
cancel() {
this.cancelled.emit();
}
confirmDelete() {
const cat = this.category();
this.deleteLoading.set(true);
this.deleteError.set(null);
this.categoryService.delete(cat.id).subscribe({
next: () => {
this.deleteLoading.set(false);
this.confirmed.emit();
},
error: (err: { error?: { message?: string } }) => {
this.deleteLoading.set(false);
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
},
});
}
}

View File

@@ -0,0 +1,173 @@
import {
Component,
signal,
computed,
inject,
OnInit,
ChangeDetectionStrategy,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared';
@Component({
selector: 'app-category-form-modal',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<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="modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:540px;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">
<h2 id="modal-title" class="fr-h3 fr-mb-0">
{{ mode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="close()">✕</button>
</div>
</div>
@if (formError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ formError() }}</p>
</div>
}
@if (mode() === '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>
</label>
<input id="form-name" class="fr-input" [class.fr-input--error]="formNameError()" type="text"
[value]="formName()"
(input)="formName.set($any($event.target).value); formNameError.set(false)"
(blur)="formNameError.set(!formName().trim())" />
@if (formNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="form-desc">Description</label>
<textarea id="form-desc" class="fr-input" rows="3"
[value]="formDescription()"
(input)="formDescription.set($any($event.target).value)"></textarea>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="close()">Annuler</button>
<button class="fr-btn" (click)="submit()" [disabled]="formLoading()">
{{ mode() === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</div>
`,
})
export class CategoryFormModalComponent implements OnInit {
private categoryService = inject(CategoryService);
mode = input<'create' | 'edit'>('create');
category = input<Category | null>(null);
parentId = input<string | null>(null);
saved = output<void>();
closed = output<void>();
formName = signal('');
formDescription = signal('');
formParentId = signal<string | null>(null);
formError = signal<string | null>(null);
formNameError = signal(false);
formLoading = signal(false);
allCategories = signal<Category[]>([]);
editingCategoryId = signal<string | null>(null);
categoriesForSelect = computed<{ id: string; label: string }[]>(() => {
const cats = this.allCategories();
const map = new Map(cats.map((c) => [c.id, c]));
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}`;
};
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() {
const cat = this.category();
if (this.mode() === 'edit' && cat) {
this.editingCategoryId.set(cat.id);
this.formName.set(cat.name);
this.formDescription.set(cat.description ?? '');
} else {
this.formParentId.set(this.parentId());
this.categoryService.list().subscribe({
next: (res) => this.allCategories.set(res.items),
error: () => this.allCategories.set([]),
});
}
}
close() {
this.closed.emit();
}
submit() {
if (!this.formName().trim()) {
this.formNameError.set(true);
return;
}
this.formNameError.set(false);
this.formLoading.set(true);
const name = this.formName().trim();
const description = this.formDescription().trim() || undefined;
const obs =
this.mode() === 'create'
? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined })
: this.categoryService.update(this.editingCategoryId()!, { name, description });
obs.subscribe({
next: () => {
this.formLoading.set(false);
this.saved.emit();
},
error: (err: { error?: { message?: string } }) => {
this.formLoading.set(false);
this.formError.set(err?.error?.message ?? 'Une erreur est survenue.');
},
});
}
}

View File

@@ -21,7 +21,7 @@ services:
NODE_ENV: development NODE_ENV: development
DOCS_OUTPUT_DIR: /app/docs-output DOCS_OUTPUT_DIR: /app/docs-output
working_dir: /workspace/back working_dir: /workspace/back
command: sh -c "cd /workspace && pnpm install && cd /workspace/back && pnpm run start:dev" command: sh -c "cd /workspace && pnpm install && /workspace/back/node_modules/.bin/tsc --project /workspace/shared/tsconfig.build.json && cd /workspace/back && pnpm run start:dev"
networks: networks:
- network - network
depends_on: depends_on:

51
pnpm-lock.yaml generated
View File

@@ -22,6 +22,9 @@ importers:
'@asyncapi/html-template': '@asyncapi/html-template':
specifier: 3.5.6 specifier: 3.5.6
version: 3.5.6(@noble/hashes@1.8.0)(encoding@0.1.13)(typescript@5.9.3) version: 3.5.6(@noble/hashes@1.8.0)(encoding@0.1.13)(typescript@5.9.3)
'@datacat/shared':
specifier: workspace:*
version: link:../shared
'@nestjs/common': '@nestjs/common':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.1.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.1.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -10189,7 +10192,7 @@ snapshots:
dependencies: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3) '@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
'@angular-devkit/build-webpack': 0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) '@angular-devkit/build-webpack': 0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(postcss@8.5.12))
'@angular-devkit/core': 19.2.26(chokidar@4.0.3) '@angular-devkit/core': 19.2.26(chokidar@4.0.3)
'@angular/build': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(@angular/compiler@19.2.22)(@types/node@22.19.19)(chokidar@4.0.3)(jiti@1.21.7)(less@4.2.2)(postcss@8.5.12)(tailwindcss@3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(terser@5.39.0)(typescript@5.8.3)(yaml@2.9.0) '@angular/build': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(@angular/compiler@19.2.22)(@types/node@22.19.19)(chokidar@4.0.3)(jiti@1.21.7)(less@4.2.2)(postcss@8.5.12)(tailwindcss@3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(terser@5.39.0)(typescript@5.8.3)(yaml@2.9.0)
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3) '@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
@@ -10203,14 +10206,14 @@ snapshots:
'@babel/preset-env': 7.26.9(@babel/core@7.26.10) '@babel/preset-env': 7.26.9(@babel/core@7.26.10)
'@babel/runtime': 7.26.10 '@babel/runtime': 7.26.10
'@discoveryjs/json-ext': 0.6.3 '@discoveryjs/json-ext': 0.6.3
'@ngtools/webpack': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) '@ngtools/webpack': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))
'@vitejs/plugin-basic-ssl': 1.2.0(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0)) '@vitejs/plugin-basic-ssl': 1.2.0(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0))
ansi-colors: 4.1.3 ansi-colors: 4.1.3
autoprefixer: 10.4.20(postcss@8.5.12) autoprefixer: 10.4.20(postcss@8.5.12)
babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.105.0(postcss@8.5.12))
browserslist: 4.28.2 browserslist: 4.28.2
copy-webpack-plugin: 12.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) copy-webpack-plugin: 12.0.2(webpack@5.105.0(postcss@8.5.12))
css-loader: 7.1.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) css-loader: 7.1.2(webpack@5.105.0(postcss@8.5.12))
esbuild-wasm: 0.28.0 esbuild-wasm: 0.28.0
fast-glob: 3.3.3 fast-glob: 3.3.3
http-proxy-middleware: 3.0.5 http-proxy-middleware: 3.0.5
@@ -10218,22 +10221,22 @@ snapshots:
jsonc-parser: 3.3.1 jsonc-parser: 3.3.1
karma-source-map-support: 1.4.0 karma-source-map-support: 1.4.0
less: 4.2.2 less: 4.2.2
less-loader: 12.2.0(less@4.2.2)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) less-loader: 12.2.0(less@4.2.2)(webpack@5.105.0(postcss@8.5.12))
license-webpack-plugin: 4.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) license-webpack-plugin: 4.0.2(webpack@5.105.0(postcss@8.5.12))
loader-utils: 3.3.1 loader-utils: 3.3.1
mini-css-extract-plugin: 2.9.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) mini-css-extract-plugin: 2.9.2(webpack@5.105.0(postcss@8.5.12))
open: 10.1.0 open: 10.1.0
ora: 5.4.1 ora: 5.4.1
picomatch: 4.0.4 picomatch: 4.0.4
piscina: 4.8.0 piscina: 4.8.0
postcss: 8.5.12 postcss: 8.5.12
postcss-loader: 8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) postcss-loader: 8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))
resolve-url-loader: 5.0.0 resolve-url-loader: 5.0.0
rxjs: 7.8.1 rxjs: 7.8.1
sass: 1.85.0 sass: 1.85.0
sass-loader: 16.0.5(sass@1.85.0)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) sass-loader: 16.0.5(sass@1.85.0)(webpack@5.105.0(postcss@8.5.12))
semver: 7.7.1 semver: 7.7.1
source-map-loader: 5.0.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) source-map-loader: 5.0.0(webpack@5.105.0(postcss@8.5.12))
source-map-support: 0.5.21 source-map-support: 0.5.21
terser: 5.39.0 terser: 5.39.0
tree-kill: 1.2.2 tree-kill: 1.2.2
@@ -10243,7 +10246,7 @@ snapshots:
webpack-dev-middleware: 7.4.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)) webpack-dev-middleware: 7.4.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
webpack-dev-server: 5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)) webpack-dev-server: 5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
webpack-merge: 6.0.1 webpack-merge: 6.0.1
webpack-subresource-integrity: 5.1.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) webpack-subresource-integrity: 5.1.0(webpack@5.105.0(postcss@8.5.12))
optionalDependencies: optionalDependencies:
esbuild: 0.28.0 esbuild: 0.28.0
tailwindcss: 3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)) tailwindcss: 3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3))
@@ -10277,7 +10280,7 @@ snapshots:
- webpack-cli - webpack-cli
- yaml - yaml
'@angular-devkit/build-webpack@0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))': '@angular-devkit/build-webpack@0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(postcss@8.5.12))':
dependencies: dependencies:
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3) '@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
rxjs: 7.8.1 rxjs: 7.8.1
@@ -13785,7 +13788,7 @@ snapshots:
'@next/swc-win32-x64-msvc@14.2.33': '@next/swc-win32-x64-msvc@14.2.33':
optional: true optional: true
'@ngtools/webpack@19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))': '@ngtools/webpack@19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))':
dependencies: dependencies:
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3) '@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
typescript: 5.8.3 typescript: 5.8.3
@@ -16051,7 +16054,7 @@ snapshots:
b4a@1.8.1: {} b4a@1.8.1: {}
babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
'@babel/core': 7.26.10 '@babel/core': 7.26.10
find-cache-dir: 4.0.0 find-cache-dir: 4.0.0
@@ -16729,7 +16732,7 @@ snapshots:
dependencies: dependencies:
is-what: 3.14.1 is-what: 3.14.1
copy-webpack-plugin@12.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): copy-webpack-plugin@12.0.2(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
fast-glob: 3.3.3 fast-glob: 3.3.3
glob-parent: 6.0.2 glob-parent: 6.0.2
@@ -16804,7 +16807,7 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
css-loader@7.1.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): css-loader@7.1.2(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
icss-utils: 5.1.0(postcss@8.5.12) icss-utils: 5.1.0(postcss@8.5.12)
postcss: 8.5.12 postcss: 8.5.12
@@ -18750,7 +18753,7 @@ snapshots:
dependencies: dependencies:
readable-stream: 2.3.8 readable-stream: 2.3.8
less-loader@12.2.0(less@4.2.2)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): less-loader@12.2.0(less@4.2.2)(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
less: 4.2.2 less: 4.2.2
optionalDependencies: optionalDependencies:
@@ -18776,7 +18779,7 @@ snapshots:
libphonenumber-js@1.13.2: {} libphonenumber-js@1.13.2: {}
license-webpack-plugin@4.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): license-webpack-plugin@4.0.2(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
webpack-sources: 3.4.1 webpack-sources: 3.4.1
optionalDependencies: optionalDependencies:
@@ -19068,7 +19071,7 @@ snapshots:
mimic-response@4.0.0: {} mimic-response@4.0.0: {}
mini-css-extract-plugin@2.9.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): mini-css-extract-plugin@2.9.2(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
schema-utils: 4.3.3 schema-utils: 4.3.3
tapable: 2.3.3 tapable: 2.3.3
@@ -20167,7 +20170,7 @@ snapshots:
postcss: 8.5.15 postcss: 8.5.15
ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3)
postcss-loader@8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): postcss-loader@8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
cosmiconfig: 9.0.1(typescript@5.8.3) cosmiconfig: 9.0.1(typescript@5.8.3)
jiti: 1.21.7 jiti: 1.21.7
@@ -20863,7 +20866,7 @@ snapshots:
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
sass-loader@16.0.5(sass@1.85.0)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): sass-loader@16.0.5(sass@1.85.0)(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
neo-async: 2.6.2 neo-async: 2.6.2
optionalDependencies: optionalDependencies:
@@ -21241,7 +21244,7 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map-loader@5.0.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): source-map-loader@5.0.0(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
iconv-lite: 0.6.3 iconv-lite: 0.6.3
source-map-js: 1.2.1 source-map-js: 1.2.1
@@ -22344,7 +22347,7 @@ snapshots:
webpack-sources@3.4.1: {} webpack-sources@3.4.1: {}
webpack-subresource-integrity@5.1.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): webpack-subresource-integrity@5.1.0(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
typed-assert: 1.0.9 typed-assert: 1.0.9
webpack: 5.105.0(esbuild@0.28.0)(postcss@8.5.12) webpack: 5.105.0(esbuild@0.28.0)(postcss@8.5.12)

View File

@@ -2,6 +2,9 @@
"name": "@datacat/shared", "name": "@datacat/shared",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "src/index.ts", "main": "dist/index.js",
"types": "src/index.ts" "types": "dist/index.d.ts",
"scripts": {
"build": "tsc --project tsconfig.build.json"
}
} }

View File

@@ -1,19 +1,3 @@
// Zod schemas for validation (used in backend)
// Install zod in back/ if needed: pnpm add zod
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const; export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const; export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;
export interface CreateApiEntryDto {
title: string;
version: string;
description?: string;
type: 'ASYNCAPI' | 'OPENAPI';
}
export interface UpdateApiEntryDto {
title?: string;
version?: string;
description?: string;
categoryId?: string | null;
}

View File

@@ -2,23 +2,37 @@ export type ApiType = 'ASYNCAPI' | 'OPENAPI';
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR'; export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR';
export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE';
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
CONSULTER: 'Consulter',
ENREGISTRER: 'Enregistrer',
ETRE_NOTIFIE: 'Être notifié',
};
export interface ApiEntry { export interface ApiEntry {
id: string; id: string;
title: string; title: string;
name: string;
convention: ApiConvention;
version: string; version: string;
versionMajor: number;
versionMinor: number;
versionPatch: number;
description: string | null; description: string | null;
type: ApiType; type: ApiType;
provider: string;
yamlContent: string; yamlContent: string;
htmlPath: string | null; htmlPath: string | null;
status: ApiStatus; status: ApiStatus;
errorMessage: string | null; errorMessage: string | null;
categoryId: string | null; categoryId: string | null;
functionalDoc: string | null; contactFunctionalName: string;
technicalDoc: string | null; contactFunctionalEntity: string;
externalDocUrl: string | null; contactFunctionalEmail: string;
contactFunctional: string | null; contactTechnicalName: string;
contactTechnical: string | null; contactTechnicalEntity: string;
accessRights: string | null; contactTechnicalEmail: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -26,9 +40,12 @@ export interface ApiEntry {
export interface ApiEntryListItem { export interface ApiEntryListItem {
id: string; id: string;
title: string; title: string;
name: string;
convention: ApiConvention;
version: string; version: string;
description: string | null; description: string | null;
type: ApiType; type: ApiType;
provider: string;
status: ApiStatus; status: ApiStatus;
categoryId: string | null; categoryId: string | null;
createdAt: string; createdAt: string;

View File

@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2021",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"]
}