Compare commits

...

10 Commits

Author SHA1 Message Date
z3n
787d8f32ee back(test): specs validation env + DTO upload (48 -> 59 tests)
- env.validation.spec : config valide/coercion/défauts + rejets (var manquante,
  port hors plage, NODE_ENV inconnu)
- create-api-upload.dto.spec : name requis, emails @ValidateIf (vides OK,
  invalides rejetés), énum convention, MaxLength
- vitest-setup charge reflect-metadata (décorateurs class-validator en test)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:21:36 +00:00
z3n
d2d140640e front: extrait les templates inline vers des fichiers .html
upload (900->427 l.) et api-detail (700->330 l.) : le template inline géant
passe en templateUrl. Aucun changement de comportement (build + eslint a11y
+ runtime OK). Composants bien plus navigables ; les .html sont désormais
lintés par angular-eslint (a11y).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:19:21 +00:00
z3n
f64efda8a3 front: composant <app-breadcrumb> mutualisé (dédup markup DSFR)
Extrait le fil d'Ariane DSFR dupliqué dans browse/api-detail/upload vers un
composant présentationnel piloté par une liste d'items. Chaque page construit
ses items (computed pour browse/api-detail, statique pour upload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:17:18 +00:00
z3n
15e05cc46c front(upload): garde-fou de taille de fichier (5 Mo)
Rejette les fichiers trop volumineux à la sélection avec un message clair,
avant lecture FileReader/envoi serveur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 07:06:45 +00:00
z3n
5cdaa7d4a1 back(db): infra migrations TypeORM + migration initiale
- data-source.ts (CLI) + scripts migration:generate/run/revert/show
- migration InitialSchema : schéma complet (categories, api_entries),
  vérifiée en l'appliquant sur une base vierge (crée aussi l'ext uuid-ossp)
- app.module: charge les migrations et migrationsRun en prod uniquement.
  Corrige un trou : la prod avait synchronize:false SANS migrations
  (schéma vide au déploiement). Dev inchangé (synchronize:true).

Bascule de dev vers migrations-only (synchronize:false + baseline de la base
dev existante) = étape ultérieure à faire en supervisé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 07:05:30 +00:00
z3n
f9df2dd1bb front(test): Vitest + premier spec (ErrorService)
- Vitest (cohérent avec le back) pour les tests de logique/services purs ;
  vitest-setup charge @angular/compiler (fallback JIT requis pour importer
  les symboles @angular/* partiellement compilés)
- error.service.spec.ts : 6 cas couvrant toMessage()
- script test -> vitest run (+ test:watch). Tests de composants TestBed =
  plugin Angular dédié, hors périmètre pour l'instant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:17:00 +00:00
z3n
c9fa0cd97b front(lint): ESLint 9 flat config + angular-eslint 19
- eslint.config.js (flat) : recommended TS + angular-eslint (ts + templates
  a11y), règles selectors app + no-unused-vars (^_ ignoré)
- script lint -> 'eslint .' (+ lint:fix), évite la résolution du builder
  Angular sous pnpm
- fix de la seule violation (variable _ inutilisée dans l'effect catalog)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:13:54 +00:00
z3n
cd43c88294 back(test): coverage vitest (@vitest/coverage-v8)
Ajoute le provider de coverage + section coverage dans vitest.config (reporter
text/html/lcov, exclusions modules/entities/dto/main) et un script test:cov.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:11:44 +00:00
z3n
9151932eb6 back(uploads): DTO validé sur l'upload + contraintes de longueur
- CreateApiUploadDto : valide le multipart POST /uploads via ValidationPipe
  (types, @IsIn énums partagés, @IsUUID, @MaxLength(255)). Emails validés
  seulement si renseignés (@ValidateIf) -> n'casse pas les contacts vides.
  Remplace le body inline + les checks manuels du contrôleur.
- create-api-entry.dto: @MaxLength(255) sur name/provider/contacts (matche les
  colonnes entité) + @MaxLength sur emails.

Vérifié: email invalide -> 400 message clair, contacts vides -> 201 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:08:02 +00:00
z3n
c38adf3a7c front: stoppe les fuites de souscriptions RxJS (takeUntilDestroyed)
Toutes les souscriptions des composants passent par
takeUntilDestroyed(this.destroyRef) : browse, catalog, api-detail, upload,
category-form-modal, category-delete-modal. api-detail abandonne le pattern
manuel Subscription/ngOnDestroy au profit de DestroyRef (ngOnDestroy ne garde
que le clearPoll du setInterval).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:49:11 +00:00
26 changed files with 2493 additions and 1119 deletions

View File

@@ -9,41 +9,47 @@
"start:debug": "nest start --debug --watch",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/data-source.ts",
"migration:run": "typeorm-ts-node-commonjs migration:run -d src/data-source.ts",
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/data-source.ts",
"migration:show": "typeorm-ts-node-commonjs migration:show -d src/data-source.ts"
},
"dependencies": {
"@datacat/shared": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/typeorm": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/serve-static": "^5.0.0",
"typeorm": "^0.3.20",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"multer": "^1.4.5-lts.1",
"uuid": "^11.0.0",
"pino": "^9.0.0",
"pino-http": "^10.0.0",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.0",
"@asyncapi/cli": "6.0.0",
"@asyncapi/bundler": "^1.0.1",
"js-yaml": "^4.1.0",
"@asyncapi/cli": "6.0.0",
"@asyncapi/generator": "3.0.1",
"@asyncapi/html-template": "3.5.6",
"@redocly/cli": "latest"
"@datacat/shared": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/serve-static": "^5.0.0",
"@nestjs/typeorm": "^11.0.0",
"@redocly/cli": "latest",
"class-transformer": "^0.5.0",
"class-validator": "^0.14.0",
"js-yaml": "^4.1.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"pino": "^9.0.0",
"pino-http": "^10.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"typeorm": "^0.3.20",
"uuid": "^11.0.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/express": "^5.0.0",
"@types/js-yaml": "^4.0.9",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",
"@types/js-yaml": "^4.0.9",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.6",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}

View File

@@ -22,7 +22,11 @@ import { validateEnv } from './config/env.validation';
password: config.getOrThrow<string>('DATABASE_PASSWORD'),
database: config.getOrThrow<string>('DATABASE_NAME'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
// Dev : synchronize auto (itération rapide). Prod : pas de synchronize,
// le schéma est appliqué par les migrations exécutées au démarrage.
synchronize: config.get('NODE_ENV') !== 'production',
migrationsRun: config.get('NODE_ENV') === 'production',
logging: config.get('NODE_ENV') === 'development',
}),
}),

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { validateEnv } from './env.validation';
const base = {
DATABASE_HOST: 'localhost',
DATABASE_PORT: '5432',
DATABASE_USER: 'datacat',
DATABASE_PASSWORD: 'datacat',
DATABASE_NAME: 'datacat',
};
describe('validateEnv', () => {
it('accepte une config valide, coerce le port et applique les défauts', () => {
const cfg = validateEnv({ ...base });
expect(cfg.DATABASE_PORT).toBe(5432);
expect(typeof cfg.DATABASE_PORT).toBe('number');
expect(cfg.NODE_ENV).toBe('development');
expect(cfg.PORT).toBe(3000);
});
it('rejette une variable requise manquante (message explicite)', () => {
const { DATABASE_HOST: _omit, ...rest } = base;
expect(() => validateEnv(rest)).toThrow(/DATABASE_HOST/);
});
it('rejette un port hors plage', () => {
expect(() => validateEnv({ ...base, DATABASE_PORT: '70000' })).toThrow();
});
it('rejette un NODE_ENV inconnu', () => {
expect(() => validateEnv({ ...base, NODE_ENV: 'staging' })).toThrow();
});
});

20
back/src/data-source.ts Normal file
View File

@@ -0,0 +1,20 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
/**
* DataSource dédié à la CLI TypeORM (migrations). Lit l'environnement
* directement (les mêmes DATABASE_* que l'app). Utilisé par les scripts
* migration:generate / migration:run / migration:revert.
*
* NB : la config runtime de l'app vit dans app.module.ts (TypeOrmModule).
*/
export default new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST ?? 'localhost',
port: Number(process.env.DATABASE_PORT ?? 5432),
username: process.env.DATABASE_USER ?? 'datacat',
password: process.env.DATABASE_PASSWORD ?? 'datacat',
database: process.env.DATABASE_NAME ?? 'datacat',
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
});

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialSchema1782543826810 implements MigrationInterface {
name = 'InitialSchema1782543826810'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "description" text, "parent_id" character varying(36), "order_index" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_24dbc6126a28ff948da33e97d3b" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "api_entries" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "convention" character varying(30) NOT NULL DEFAULT 'CONSULTER', "name" character varying(255) NOT NULL DEFAULT '', "description" text, "type" character varying(10) NOT NULL, "provider" character varying(255) NOT NULL DEFAULT '', "version_major" integer NOT NULL DEFAULT '0', "version_minor" integer NOT NULL DEFAULT '0', "version_patch" integer NOT NULL DEFAULT '0', "yaml_content" text NOT NULL, "html_path" character varying(500), "status" character varying(10) NOT NULL DEFAULT 'PENDING', "error_message" text, "category_id" character varying(36), "is_current" boolean NOT NULL DEFAULT true, "contact_functional_name" character varying(255) NOT NULL DEFAULT '', "contact_functional_entity" character varying(255) NOT NULL DEFAULT '', "contact_functional_email" character varying(255) NOT NULL DEFAULT '', "contact_technical_name" character varying(255) NOT NULL DEFAULT '', "contact_technical_entity" character varying(255) NOT NULL DEFAULT '', "contact_technical_email" character varying(255) NOT NULL DEFAULT '', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_d7b668541d2539a0c212b198d32" PRIMARY KEY ("id"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "api_entries"`);
await queryRunner.query(`DROP TABLE "categories"`);
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail, MaxLength } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiType, ApiConvention, API_CONVENTIONS, API_TYPES } from '@datacat/shared';
@@ -8,10 +8,12 @@ export class CreateApiEntryDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
name!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
provider!: string;
@IsInt()
@@ -46,23 +48,29 @@ export class CreateApiEntryDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
contactFunctionalName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
contactFunctionalEntity!: string;
@IsEmail()
@MaxLength(255)
contactFunctionalEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
contactTechnicalName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
contactTechnicalEntity!: string;
@IsEmail()
@MaxLength(255)
contactTechnicalEmail!: string;
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { CreateApiUploadDto } from './create-api-upload.dto';
function validate(payload: Record<string, unknown>) {
const dto = plainToInstance(CreateApiUploadDto, payload, { enableImplicitConversion: true });
return validateSync(dto, { whitelist: true });
}
describe('CreateApiUploadDto', () => {
it('accepte un payload minimal (name seul requis)', () => {
expect(validate({ name: 'Mon API' })).toHaveLength(0);
});
it('rejette un name manquant', () => {
expect(validate({}).some((e) => e.property === 'name')).toBe(true);
});
it('accepte des emails de contact vides (@ValidateIf)', () => {
expect(
validate({ name: 'X', contactFunctionalEmail: '', contactTechnicalEmail: '' }),
).toHaveLength(0);
});
it('rejette un email renseigné mais invalide', () => {
expect(
validate({ name: 'X', contactFunctionalEmail: 'pas-un-email' }).some(
(e) => e.property === 'contactFunctionalEmail',
),
).toBe(true);
});
it('accepte un email valide', () => {
expect(validate({ name: 'X', contactFunctionalEmail: 'a@b.fr' })).toHaveLength(0);
});
it('rejette une convention hors énumération', () => {
expect(
validate({ name: 'X', convention: 'FOO' }).some((e) => e.property === 'convention'),
).toBe(true);
});
it('rejette un name trop long (>255)', () => {
expect(
validate({ name: 'a'.repeat(256) }).some((e) => e.property === 'name'),
).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { ApiConvention, ApiType, API_CONVENTIONS, API_TYPES } from '@datacat/shared';
/**
* Corps du multipart POST /uploads. Les valeurs arrivent en `string` (form-data)
* et sont transformées par le ValidationPipe global (transform:true).
*
* Strictness alignée sur le comportement existant pour ne pas casser le front :
* seul `name` est réellement requis ; provider/contacts peuvent être vides ; les
* emails ne sont validés que s'ils sont renseignés (@ValidateIf).
*/
export class CreateApiUploadDto {
@IsIn([...API_CONVENTIONS])
@IsOptional()
convention?: ApiConvention;
@IsString()
@IsNotEmpty()
@MaxLength(255)
name!: string;
@IsString()
@IsOptional()
@MaxLength(255)
provider?: string;
@Type(() => Number)
@IsInt()
@Min(0)
@IsOptional()
versionMajor?: number;
@Type(() => Number)
@IsInt()
@Min(0)
@IsOptional()
versionMinor?: number;
@Type(() => Number)
@IsInt()
@Min(0)
@IsOptional()
versionPatch?: number;
@IsString()
@IsOptional()
description?: string;
@IsIn([...API_TYPES])
@IsOptional()
type?: ApiType;
@IsUUID()
@IsOptional()
categoryId?: string;
@IsString()
@IsOptional()
@MaxLength(255)
contactFunctionalName?: string;
@IsString()
@IsOptional()
@MaxLength(255)
contactFunctionalEntity?: string;
@ValidateIf((o: CreateApiUploadDto) => !!o.contactFunctionalEmail)
@IsEmail()
contactFunctionalEmail?: string;
@IsString()
@IsOptional()
@MaxLength(255)
contactTechnicalName?: string;
@IsString()
@IsOptional()
@MaxLength(255)
contactTechnicalEntity?: string;
@ValidateIf((o: CreateApiUploadDto) => !!o.contactTechnicalEmail)
@IsEmail()
contactTechnicalEmail?: string;
}

View File

@@ -28,6 +28,7 @@ const execEnv = {
import { ApisService } from '../apis/apis.service';
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
import { ApiType } from '@datacat/shared';
import { CreateApiUploadDto } from './dto/create-api-upload.dto';
@Controller('uploads')
export class UploadsController {
@@ -176,28 +177,8 @@ export class UploadsController {
)
async upload(
@UploadedFiles() files: Express.Multer.File[],
@Body()
body: {
convention: string;
name: string;
provider: string;
versionMajor: string;
versionMinor: string;
versionPatch: string;
description?: string;
type?: ApiType;
categoryId?: string;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
contactTechnicalName: string;
contactTechnicalEntity: string;
contactTechnicalEmail: string;
},
@Body() body: CreateApiUploadDto,
) {
if (!body.name) throw new BadRequestException('name is required');
if (!body.convention) throw new BadRequestException('convention is required');
/* Crée le répertoire si absent (mkdir recursive est idempotent) */
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
await fsPromises.mkdir(uploadDir, { recursive: true });
@@ -232,22 +213,22 @@ export class UploadsController {
}
const entry = await this.apisService.create({
convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER',
convention: body.convention ?? 'CONSULTER',
name: body.name,
provider: body.provider,
versionMajor: parseInt(body.versionMajor, 10) || 0,
versionMinor: parseInt(body.versionMinor, 10) || 0,
versionPatch: parseInt(body.versionPatch, 10) || 0,
provider: body.provider ?? '',
versionMajor: body.versionMajor ?? 0,
versionMinor: body.versionMinor ?? 0,
versionPatch: body.versionPatch ?? 0,
description: body.description,
type: apiType,
yamlContent,
categoryId: body.categoryId || undefined,
contactFunctionalName: body.contactFunctionalName,
contactFunctionalEntity: body.contactFunctionalEntity,
contactFunctionalEmail: body.contactFunctionalEmail,
contactTechnicalName: body.contactTechnicalName,
contactTechnicalEntity: body.contactTechnicalEntity,
contactTechnicalEmail: body.contactTechnicalEmail,
contactFunctionalName: body.contactFunctionalName ?? '',
contactFunctionalEntity: body.contactFunctionalEntity ?? '',
contactFunctionalEmail: body.contactFunctionalEmail ?? '',
contactTechnicalName: body.contactTechnicalName ?? '',
contactTechnicalEntity: body.contactTechnicalEntity ?? '',
contactTechnicalEmail: body.contactTechnicalEmail ?? '',
});
/* Fire-and-forget : génération asynchrone (seulement si YAML fourni) */

3
back/vitest-setup.ts Normal file
View File

@@ -0,0 +1,3 @@
// Requis pour que les décorateurs class-validator / class-transformer
// (Reflect.getMetadata) fonctionnent dans les tests, comme dans l'app (main.ts).
import 'reflect-metadata';

View File

@@ -4,6 +4,20 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./vitest-setup.ts'],
include: ['src/**/*.spec.ts'],
coverage: {
provider: 'v8',
reportsDirectory: './coverage',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.spec.ts',
'src/**/*.module.ts',
'src/**/*.entity.ts',
'src/main.ts',
'src/**/*.dto.ts',
],
},
},
});

View File

@@ -0,0 +1,38 @@
// @ts-check
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
module.exports = tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', '.angular/**', 'coverage/**'],
},
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{ type: 'attribute', prefix: 'app', style: 'camelCase' },
],
'@angular-eslint/component-selector': [
'error',
{ type: 'element', prefix: 'app', style: 'kebab-case' },
],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
{
files: ['**/*.html'],
extends: [
...angular.configs.templateRecommended,
...angular.configs.templateAccessibility,
],
rules: {},
},
);

View File

@@ -6,12 +6,12 @@
"start": "ng serve",
"build": "ng build",
"build:prod": "ng build --configuration production",
"test": "ng test --watch=false --browsers=ChromeHeadless",
"lint": "ng lint"
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@datacat/shared": "workspace:*",
"@gouvfr/dsfr": "^1.13.0",
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",
"@angular/compiler": "^19.0.0",
@@ -20,6 +20,8 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@datacat/shared": "workspace:*",
"@gouvfr/dsfr": "^1.13.0",
"rxjs": "^7.8.0",
"tslib": "^2.8.0",
"zone.js": "^0.15.0"
@@ -28,8 +30,14 @@
"@angular-devkit/build-angular": "^19.0.0",
"@angular/cli": "^19.0.0",
"@angular/compiler-cli": "^19.0.0",
"@eslint/js": "^9.39.4",
"@types/node": "^22.0.0",
"angular-eslint": "^19.8.1",
"eslint": "^9.39.4",
"jsdom": "^29.1.1",
"sass": "^1.83.0",
"typescript": "~5.8.3",
"sass": "^1.83.0"
"typescript-eslint": "^8.62.0",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorService } from './error.service';
describe('ErrorService.toMessage', () => {
const svc = new ErrorService();
it('retourne un body string', () => {
const err = new HttpErrorResponse({ error: 'Boom', status: 500 });
expect(svc.toMessage(err)).toBe('Boom');
});
it('extrait body.message', () => {
const err = new HttpErrorResponse({ error: { message: 'Champ invalide' }, status: 400 });
expect(svc.toMessage(err)).toBe('Champ invalide');
});
it('joint un body.message tableau', () => {
const err = new HttpErrorResponse({ error: { message: ['a', 'b'] }, status: 400 });
expect(svc.toMessage(err)).toBe('a, b');
});
it('status 0 -> serveur injoignable', () => {
const err = new HttpErrorResponse({ status: 0 });
expect(svc.toMessage(err)).toContain('injoignable');
});
it('Error standard -> son message', () => {
expect(svc.toMessage(new Error('oops'))).toBe('oops');
});
it('valeur inconnue -> fallback', () => {
expect(svc.toMessage(null)).toBe('Une erreur est survenue.');
expect(svc.toMessage(undefined, 'custom')).toBe('custom');
});
});

View File

@@ -0,0 +1,369 @@
<div class="fr-container fr-mt-4w fr-mb-4w">
<app-breadcrumb [items]="breadcrumbItems()" />
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (error()) {
<div class="fr-alert fr-alert--error fr-mb-4w">
<p class="fr-alert__title">Erreur</p>
<p>{{ error() }}</p>
</div>
}
@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 -->
<div class="fr-mb-3w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<!-- Gauche : titre + version -->
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;min-width:0;">
<h1 class="fr-h2 fr-mb-0">{{ entry.title }}</h1>
@if (versions().length <= 1) {
<span class="fr-tag">v{{ entry.version }}</span>
} @else {
<select class="fr-select" id="version-select" style="width:auto;" (change)="onVersionChange($event)">
@for (v of versions(); track v.id) {
<option [value]="v.id" [selected]="v.id === entry.id">
{{ v.version }}{{ v.isCurrent ? ' (courante)' : '' }}
</option>
}
</select>
}
</div>
<!-- Droite : bouton Voir doc + Actions dropdown -->
<div style="display:flex;align-items:center;gap:0.5rem;flex-shrink:0;">
<a class="fr-btn fr-btn--icon-left fr-icon-eye-line"
[routerLink]="entry.status === 'GENERATED' ? ['/catalog', entry.id, 'docs'] : null"
[style.opacity]="entry.status !== 'GENERATED' ? '0.5' : '1'"
[style.pointer-events]="entry.status !== 'GENERATED' ? 'none' : 'auto'">
Voir la documentation
</a>
<div style="position:relative;">
<button class="fr-btn fr-btn--secondary fr-btn--icon-right fr-icon-arrow-down-s-line"
(click)="actionsOpen.set(!actionsOpen())">
Actions
</button>
@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;">
<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>
<!-- Navigation onglets -->
<div style="display:flex;border-bottom:2px solid #e5e5e5;margin-bottom:1.5rem;">
<button (click)="activeTab.set('info')"
[style.border-bottom]="activeTab()==='info' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='info' ? '#000091' : '#3a3a3a'"
[style.font-weight]="activeTab()==='info' ? '700' : '400'"
style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
Informations
</button>
<button (click)="activeTab.set('contacts')"
[style.border-bottom]="activeTab()==='contacts' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='contacts' ? '#000091' : '#3a3a3a'"
[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;">
Contacts
</button>
</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>

View File

@@ -3,412 +3,26 @@ import {
signal,
computed,
inject,
DestroyRef,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
@Component({
selector: 'app-api-detail',
standalone: true,
imports: [CommonModule, RouterLink, ReactiveFormsModule],
imports: [CommonModule, RouterLink, ReactiveFormsModule, BreadcrumbComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
<!-- Fil d'Ariane -->
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li>
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</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>
<a class="fr-breadcrumb__link" aria-current="page">
{{ api()?.title ?? 'Chargement...' }}
</a>
</li>
</ol>
</nav>
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (error()) {
<div class="fr-alert fr-alert--error fr-mb-4w">
<p class="fr-alert__title">Erreur</p>
<p>{{ error() }}</p>
</div>
}
@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 -->
<div class="fr-mb-3w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<!-- Gauche : titre + version -->
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;min-width:0;">
<h1 class="fr-h2 fr-mb-0">{{ entry.title }}</h1>
@if (versions().length <= 1) {
<span class="fr-tag">v{{ entry.version }}</span>
} @else {
<select class="fr-select" id="version-select" style="width:auto;" (change)="onVersionChange($event)">
@for (v of versions(); track v.id) {
<option [value]="v.id" [selected]="v.id === entry.id">
{{ v.version }}{{ v.isCurrent ? ' (courante)' : '' }}
</option>
}
</select>
}
</div>
<!-- Droite : bouton Voir doc + Actions dropdown -->
<div style="display:flex;align-items:center;gap:0.5rem;flex-shrink:0;">
<a class="fr-btn fr-btn--icon-left fr-icon-eye-line"
[routerLink]="entry.status === 'GENERATED' ? ['/catalog', entry.id, 'docs'] : null"
[style.opacity]="entry.status !== 'GENERATED' ? '0.5' : '1'"
[style.pointer-events]="entry.status !== 'GENERATED' ? 'none' : 'auto'">
Voir la documentation
</a>
<div style="position:relative;">
<button class="fr-btn fr-btn--secondary fr-btn--icon-right fr-icon-arrow-down-s-line"
(click)="actionsOpen.set(!actionsOpen())">
Actions
</button>
@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;">
<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>
<!-- Navigation onglets -->
<div style="display:flex;border-bottom:2px solid #e5e5e5;margin-bottom:1.5rem;">
<button (click)="activeTab.set('info')"
[style.border-bottom]="activeTab()==='info' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='info' ? '#000091' : '#3a3a3a'"
[style.font-weight]="activeTab()==='info' ? '700' : '400'"
style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
Informations
</button>
<button (click)="activeTab.set('contacts')"
[style.border-bottom]="activeTab()==='contacts' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='contacts' ? '#000091' : '#3a3a3a'"
[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;">
Contacts
</button>
</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>
`,
templateUrl: "./api-detail.component.html",
})
export class ApiDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
@@ -416,6 +30,7 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private fb = inject(FormBuilder);
private destroyRef = inject(DestroyRef);
api = signal<ApiEntry | null>(null);
versions = signal<ApiEntryListItem[]>([]);
@@ -434,6 +49,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
return ['/browse'];
});
breadcrumbItems = computed<BreadcrumbItem[]>(() => {
const crumbs = this.categoryBreadcrumb();
const items: BreadcrumbItem[] = [{ label: 'Accueil', link: '/' }];
if (crumbs.length === 0) items.push({ label: 'Parcourir', link: '/browse' });
for (const crumb of crumbs) items.push({ label: crumb.name, link: ['/browse', crumb.id] });
items.push({ label: this.api()?.title ?? 'Chargement...' });
return items;
});
/* Onglets */
activeTab = signal<'info' | 'contacts'>('info');
editingInfo = signal(false);
@@ -462,12 +86,16 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
});
private pollInterval: ReturnType<typeof setInterval> | null = null;
private routeSub: Subscription | null = null;
private id = '';
ngOnInit() {
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
this.routeSub = this.route.paramMap.subscribe((params) => {
this.categoryService
.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((res) => this.categories.set(res.items));
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
this.id = params.get('id') ?? '';
this.api.set(null);
this.versions.set([]);
@@ -481,7 +109,6 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
}
ngOnDestroy() {
this.routeSub?.unsubscribe();
this.clearPoll();
}
@@ -498,11 +125,17 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
onSetCurrent() {
this.settingCurrent.set(true);
this.apiService.setCurrent(this.id).subscribe({
this.apiService
.setCurrent(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
this.settingCurrent.set(false);
this.apiService.getVersions(this.id).subscribe({
this.apiService
.getVersions(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
@@ -513,7 +146,10 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
onRegenerate() {
this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({
this.apiService
.regenerate(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
this.regenerating.set(false);
@@ -568,7 +204,9 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
versionPatch: raw.versionPatch ?? 0,
categoryId: raw.categoryId || null,
description: raw.description || null,
}).subscribe({
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
error: () => this.savingInfo.set(false),
});
@@ -587,7 +225,9 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
contactTechnicalName: raw.contactTechnicalName ?? '',
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
}).subscribe({
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
error: () => this.savingContacts.set(false),
});
@@ -599,7 +239,10 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
/* Calculer la cible de redirection avant la suppression */
const otherVersions = this.versions().filter((v) => v.id !== this.id);
const redirectTarget = otherVersions.find((v) => v.isCurrent) ?? otherVersions[0];
this.apiService.delete(this.id).subscribe({
this.apiService
.delete(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
if (redirectTarget) {
this.router.navigate(['/catalog', redirectTarget.id]);
@@ -627,17 +270,26 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private loadApi() {
this.loading.set(true);
this.apiService.get(this.id).subscribe({
this.apiService
.get(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
this.loading.set(false);
this.startPollIfPending();
this.apiService.getVersions(this.id).subscribe({
this.apiService
.getVersions(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
if (entry.categoryId) {
this.categoryService.browse(entry.categoryId).subscribe({
this.categoryService
.browse(entry.categoryId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
error: () => {},
});
@@ -658,7 +310,10 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
}
private pollStatus() {
this.apiService.get(this.id).subscribe({
this.apiService
.get(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
if (entry.status !== 'PENDING') this.clearPoll();

View File

@@ -17,32 +17,16 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem
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';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
@Component({
selector: 'app-browse',
standalone: true,
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent],
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent, BreadcrumbComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
<!-- Fil d'Ariane -->
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li>
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</li>
@for (crumb of ancestors(); track crumb.id) {
<li>
<a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a>
</li>
}
@if (browseData()?.category) {
<li>
<a class="fr-breadcrumb__link" aria-current="page">{{ browseData()!.category!.name }}</a>
</li>
}
</ol>
</nav>
<app-breadcrumb [items]="breadcrumbItems()" />
<!-- En-tête -->
@if (categoryId()) {
@@ -309,6 +293,16 @@ export class BrowseComponent implements OnInit {
return d.breadcrumb.slice(0, -1);
});
breadcrumbItems = computed<BreadcrumbItem[]>(() => {
const items: BreadcrumbItem[] = [{ label: 'Accueil', link: '/' }];
for (const crumb of this.ancestors()) {
items.push({ label: crumb.name, link: ['/browse', crumb.id] });
}
const current = this.browseData()?.category;
if (current) items.push({ label: current.name });
return items;
});
backUrl = computed<string[]>(() => {
const anc = this.ancestors();
if (anc.length > 0) return ['/browse', anc[anc.length - 1].id];
@@ -327,7 +321,10 @@ export class BrowseComponent implements OnInit {
loadBrowse() {
this.loading.set(true);
this.error.set(null);
this.categoryService.browse(this.categoryId()).subscribe({
this.categoryService
.browse(this.categoryId())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => {
this.browseData.set(data);
this.loading.set(false);
@@ -385,7 +382,9 @@ export class BrowseComponent implements OnInit {
forkJoin({
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
categories: this.categoryService.list(raw || undefined),
}).subscribe({
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: ({ apis, categories }) => {
this.searchResults.set(apis.items);
this.searchTotal.set(apis.total);

View File

@@ -4,9 +4,11 @@ import {
computed,
effect,
inject,
DestroyRef,
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
@@ -154,6 +156,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared';
})
export class CatalogComponent implements OnInit {
private apiService = inject(ApiService);
private destroyRef = inject(DestroyRef);
apis = signal<ApiEntryListItem[]>([]);
loading = signal(false);
@@ -173,9 +176,9 @@ export class CatalogComponent implements OnInit {
private searchTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
/* Rechargement si page change */
/* Rechargement si page change (lecture du signal = dépendance de l'effet) */
effect(() => {
const _ = this.page();
this.page();
this.loadApis();
});
}
@@ -221,6 +224,7 @@ export class CatalogComponent implements OnInit {
page: this.page(),
limit: this.limit,
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res) => {
this.apis.set(res.items);

View File

@@ -0,0 +1,472 @@
<div class="fr-container fr-mt-4w fr-mb-4w">
<app-breadcrumb [items]="breadcrumbItems" />
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
<!-- Stepper numéroté -->
<div class="fr-mb-4w" style="display:flex;align-items:flex-start;">
@for (title of stepTitles; track $index) {
<div style="display:flex;flex-direction:column;align-items:center;flex:none;min-width:6rem;">
<div
[style.background]="step() > $index + 1 ? '#1f8d49' : step() === $index + 1 ? '#000091' : '#e5e5e5'"
[style.color]="step() >= $index + 1 ? 'white' : '#666'"
style="width:2rem;height:2rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:0.875rem;transition:background 0.2s;">
@if (step() > $index + 1) {
<span class="fr-icon-check-line" aria-hidden="true" style="font-size:0.875rem"></span>
} @else {
{{ $index + 1 }}
}
</div>
<span style="font-size:0.75rem;margin-top:0.4rem;text-align:center;line-height:1.2;"
[style.font-weight]="step() === $index + 1 ? '700' : '400'"
[style.color]="step() === $index + 1 ? '#000091' : step() > $index + 1 ? '#1f8d49' : '#666'">
{{ title }}
</span>
</div>
@if ($index < stepTitles.length - 1) {
<div style="flex:1;height:2px;margin-top:1rem;transition:background 0.2s;"
[style.background]="step() > $index + 1 ? '#1f8d49' : '#e5e5e5'">
</div>
}
}
</div>
<!-- Étape 1 : Fichier(s) YAML -->
@if (step() === 1) {
<div class="fr-upload-group fr-mb-4w">
<label class="fr-label" for="file-input">
Fichier(s) YAML
<span class="fr-hint-text">
Formats acceptés : .yaml, .yml — Sélectionnez plusieurs fichiers si votre spec utilise des $ref
</span>
</label>
<input
id="file-input"
class="fr-upload"
type="file"
accept=".yaml,.yml"
multiple
(change)="onFileChange($event)"
/>
</div>
@if (fileError()) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p>{{ fileError() }}</p>
</div>
}
@if (selectedFiles().length > 0) {
<div class="fr-mb-3w">
@for (file of selectedFiles(); track file.name) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-1w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
<span>
<strong>{{ file.name }}</strong>
({{ (file.size / 1024).toFixed(1) }} Ko)
</span>
@if (file.name === detectedMainFilename()) {
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
} @else if (selectedFiles().length > 1) {
<span class="fr-badge fr-badge--blue-cumulus fr-badge--sm">Secondaire</span>
}
</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">
<button
class="fr-btn"
(click)="goToStep2()"
[disabled]="!canProceedFromStep1()"
>
Suivant
</button>
<a routerLink="/catalog" class="fr-btn fr-btn--secondary">Annuler</a>
</div>
}
<!-- Étape 2 : Informations -->
@if (step() === 2) {
<!-- Bannière nouvelle version -->
@if (fromApi(); as origin) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-3w">
<p class="fr-callout__text">
Nouvelle version de : <strong>{{ origin.title }}</strong> (v{{ origin.version }})
— le nom et la convention sont verrouillés.
</p>
</div>
}
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
<!-- Titre : convention + nom -->
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-select-group" [class.fr-select-group--error]="conventionInvalid()">
<label class="fr-label" for="convention-input">Convention</label>
<select
id="convention-input"
class="fr-select"
[class.fr-select--error]="conventionInvalid()"
formControlName="convention"
>
<option value="CONSULTER">Consulter</option>
<option value="ENREGISTRER">Enregistrer</option>
<option value="ETRE_NOTIFIE">Être notifié</option>
</select>
@if (conventionInvalid()) {
<p class="fr-error-text">La convention est obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-8">
<div class="fr-input-group" [class.fr-input-group--error]="nameInvalid()">
<label class="fr-label" for="name-input">Nom de l'API</label>
<input
id="name-input"
class="fr-input"
[class.fr-input--error]="nameInvalid()"
formControlName="name"
type="text"
placeholder="Référentiel Adresses"
/>
@if (nameInvalid()) {
<p class="fr-error-text">Le nom est obligatoire.</p>
}
</div>
</div>
</div>
<p class="fr-hint-text fr-mb-3w">Titre complet : <strong>{{ previewTitle() }}</strong></p>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="category-input">
Catégorie
</label>
<select id="category-input" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ categoryLabel(cat) }}</option>
}
</select>
</div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="providerInvalid()">
<label class="fr-label" for="provider-input">
Fournisseur <span style="color:var(--text-default-error)">*</span>
</label>
<input
id="provider-input"
class="fr-input"
[class.fr-input--error]="providerInvalid()"
formControlName="provider"
type="text"
placeholder="DINUM, Direction XYZ..."
/>
@if (providerInvalid()) {
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
}
</div>
<!-- Type -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="type-input">
Type <span style="color:var(--text-default-error)">*</span>
</label>
<select id="type-input" class="fr-select" formControlName="type">
<option value="ASYNCAPI">AsyncAPI</option>
<option value="OPENAPI">OpenAPI</option>
</select>
@if (detectedType()) {
<p class="fr-hint-text">Détecté automatiquement depuis le fichier YAML</p>
}
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="description-input">
Description
</label>
<textarea
id="description-input"
class="fr-input"
formControlName="description"
rows="3"
placeholder="Description de l'API..."
></textarea>
</div>
<!-- Version X.Y.Z -->
<p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
<label class="fr-label" for="version-major-input">Majeure (X)</label>
<input
id="version-major-input"
class="fr-input"
[class.fr-input--error]="versionMajorInvalid()"
formControlName="versionMajor"
type="number"
min="0"
placeholder="1"
/>
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionMinorInvalid()">
<label class="fr-label" for="version-minor-input">Mineure (Y)</label>
<input
id="version-minor-input"
class="fr-input"
[class.fr-input--error]="versionMinorInvalid()"
formControlName="versionMinor"
type="number"
min="0"
placeholder="0"
/>
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionPatchInvalid()">
<label class="fr-label" for="version-patch-input">Correctif (Z)</label>
<input
id="version-patch-input"
class="fr-input"
[class.fr-input--error]="versionPatchInvalid()"
formControlName="versionPatch"
type="number"
min="0"
placeholder="0"
/>
</div>
</div>
</div>
@if (versionAlreadyExists()) {
<div class="fr-alert fr-alert--warning fr-mb-3w">
<p class="fr-alert__title">Version déjà existante</p>
<p>
La version {{ formValues().versionMajor }}.{{ formValues().versionMinor }}.{{ formValues().versionPatch }}
existe déjà pour cette API. Choisissez un numéro de version différent.
</p>
</div>
}
<div class="fr-btns-group fr-btns-group--inline-sm">
<button type="submit" class="fr-btn" [disabled]="versionAlreadyExists()">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
Retour
</button>
</div>
</form>
}
<!-- Étape 3 : Contacts -->
@if (step() === 3) {
<form [formGroup]="metaForm" (ngSubmit)="goToStep4()" novalidate>
<!-- Contact métier -->
<p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalName')">
<label class="fr-label" for="cf-name-input">Nom et prénom</label>
<input id="cf-name-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalName')"
formControlName="contactFunctionalName" type="text" placeholder="Prénom Nom" />
@if (fieldInvalid('contactFunctionalName')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEntity')">
<label class="fr-label" for="cf-entity-input">Entité</label>
<input id="cf-entity-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalEntity')"
formControlName="contactFunctionalEntity" type="text" placeholder="Direction XYZ" />
@if (fieldInvalid('contactFunctionalEntity')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEmail')">
<label class="fr-label" for="cf-email-input">Email</label>
<input id="cf-email-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalEmail')"
formControlName="contactFunctionalEmail" type="email" placeholder="prenom.nom@example.fr" />
@if (fieldInvalid('contactFunctionalEmail')) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<!-- Contact technique -->
<p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalName')">
<label class="fr-label" for="ct-name-input">Nom et prénom</label>
<input id="ct-name-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalName')"
formControlName="contactTechnicalName" type="text" placeholder="Prénom Nom" />
@if (fieldInvalid('contactTechnicalName')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEntity')">
<label class="fr-label" for="ct-entity-input">Entité</label>
<input id="ct-entity-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalEntity')"
formControlName="contactTechnicalEntity" type="text" placeholder="Équipe Technique" />
@if (fieldInvalid('contactTechnicalEntity')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEmail')">
<label class="fr-label" for="ct-email-input">Email</label>
<input id="ct-email-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalEmail')"
formControlName="contactTechnicalEmail" type="email" placeholder="tech@example.fr" />
@if (fieldInvalid('contactTechnicalEmail')) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--inline-sm">
<button type="submit" class="fr-btn">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(2)">
Retour
</button>
</div>
</form>
}
<!-- Étape 4 : Confirmation -->
@if (step() === 4) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-3w">Récapitulatif</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
<dd>
@if (selectedFiles().length === 0) {
<span class="fr-badge fr-badge--warning fr-badge--sm">Aucun fichier</span>
} @else if (selectedFiles().length === 1) {
{{ selectedFiles()[0].name }}
} @else {
@for (f of selectedFiles(); track f.name) {
<div style="display:flex;align-items:center;gap:0.5rem;">
{{ f.name }}
@if (f.name === detectedMainFilename()) {
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
}
</div>
}
}
</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Type</dt>
<dd>{{ displayType() }}</dd>
</div>
<div class="fr-col-12">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ previewTitle() }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Version</dt>
<dd>{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Fournisseur</dt>
<dd>{{ metaForm.value.provider }}</dd>
</div>
@if (metaForm.value.categoryId) {
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Catégorie</dt>
<dd>{{ selectedCategoryName() }}</dd>
</div>
}
@if (metaForm.value.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ metaForm.value.description }}</dd>
</div>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact métier</dt>
<dd>
{{ metaForm.value.contactFunctionalName }}<br>
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEntity }}</span><br>
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEmail }}</span>
</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact technique</dt>
<dd>
{{ metaForm.value.contactTechnicalName }}<br>
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEntity }}</span><br>
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEmail }}</span>
</dd>
</div>
</dl>
</div>
</div>
</div>
@if (uploadError()) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Erreur lors de l'import</p>
<p>{{ uploadError() }}</p>
</div>
}
<div class="fr-btns-group fr-btns-group--inline-sm">
<button
class="fr-btn"
(click)="onSubmit()"
[disabled]="uploading()"
>
{{ uploading() ? 'Import en cours...' : 'Importer' }}
</button>
<button class="fr-btn fr-btn--secondary" (click)="step.set(3)" [disabled]="uploading()">
Retour
</button>
</div>
}
</div>

View File

@@ -3,503 +3,25 @@ import {
signal,
computed,
inject,
DestroyRef,
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
import { ApiEntry, ApiEntryListItem, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
@Component({
selector: 'app-upload',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink],
imports: [CommonModule, ReactiveFormsModule, RouterLink, BreadcrumbComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
<!-- Fil d'Ariane -->
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li><a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a></li>
<li><a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a></li>
<li><a class="fr-breadcrumb__link" aria-current="page">Importer une API</a></li>
</ol>
</nav>
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
<!-- Stepper numéroté -->
<div class="fr-mb-4w" style="display:flex;align-items:flex-start;">
@for (title of stepTitles; track $index) {
<div style="display:flex;flex-direction:column;align-items:center;flex:none;min-width:6rem;">
<div
[style.background]="step() > $index + 1 ? '#1f8d49' : step() === $index + 1 ? '#000091' : '#e5e5e5'"
[style.color]="step() >= $index + 1 ? 'white' : '#666'"
style="width:2rem;height:2rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:0.875rem;transition:background 0.2s;">
@if (step() > $index + 1) {
<span class="fr-icon-check-line" aria-hidden="true" style="font-size:0.875rem"></span>
} @else {
{{ $index + 1 }}
}
</div>
<span style="font-size:0.75rem;margin-top:0.4rem;text-align:center;line-height:1.2;"
[style.font-weight]="step() === $index + 1 ? '700' : '400'"
[style.color]="step() === $index + 1 ? '#000091' : step() > $index + 1 ? '#1f8d49' : '#666'">
{{ title }}
</span>
</div>
@if ($index < stepTitles.length - 1) {
<div style="flex:1;height:2px;margin-top:1rem;transition:background 0.2s;"
[style.background]="step() > $index + 1 ? '#1f8d49' : '#e5e5e5'">
</div>
}
}
</div>
<!-- Étape 1 : Fichier(s) YAML -->
@if (step() === 1) {
<div class="fr-upload-group fr-mb-4w">
<label class="fr-label" for="file-input">
Fichier(s) YAML
<span class="fr-hint-text">
Formats acceptés : .yaml, .yml — Sélectionnez plusieurs fichiers si votre spec utilise des $ref
</span>
</label>
<input
id="file-input"
class="fr-upload"
type="file"
accept=".yaml,.yml"
multiple
(change)="onFileChange($event)"
/>
</div>
@if (fileError()) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p>{{ fileError() }}</p>
</div>
}
@if (selectedFiles().length > 0) {
<div class="fr-mb-3w">
@for (file of selectedFiles(); track file.name) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-1w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
<span>
<strong>{{ file.name }}</strong>
({{ (file.size / 1024).toFixed(1) }} Ko)
</span>
@if (file.name === detectedMainFilename()) {
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
} @else if (selectedFiles().length > 1) {
<span class="fr-badge fr-badge--blue-cumulus fr-badge--sm">Secondaire</span>
}
</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">
<button
class="fr-btn"
(click)="goToStep2()"
[disabled]="!canProceedFromStep1()"
>
Suivant
</button>
<a routerLink="/catalog" class="fr-btn fr-btn--secondary">Annuler</a>
</div>
}
<!-- Étape 2 : Informations -->
@if (step() === 2) {
<!-- Bannière nouvelle version -->
@if (fromApi(); as origin) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-3w">
<p class="fr-callout__text">
Nouvelle version de : <strong>{{ origin.title }}</strong> (v{{ origin.version }})
— le nom et la convention sont verrouillés.
</p>
</div>
}
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
<!-- Titre : convention + nom -->
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-select-group" [class.fr-select-group--error]="conventionInvalid()">
<label class="fr-label" for="convention-input">Convention</label>
<select
id="convention-input"
class="fr-select"
[class.fr-select--error]="conventionInvalid()"
formControlName="convention"
>
<option value="CONSULTER">Consulter</option>
<option value="ENREGISTRER">Enregistrer</option>
<option value="ETRE_NOTIFIE">Être notifié</option>
</select>
@if (conventionInvalid()) {
<p class="fr-error-text">La convention est obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-8">
<div class="fr-input-group" [class.fr-input-group--error]="nameInvalid()">
<label class="fr-label" for="name-input">Nom de l'API</label>
<input
id="name-input"
class="fr-input"
[class.fr-input--error]="nameInvalid()"
formControlName="name"
type="text"
placeholder="Référentiel Adresses"
/>
@if (nameInvalid()) {
<p class="fr-error-text">Le nom est obligatoire.</p>
}
</div>
</div>
</div>
<p class="fr-hint-text fr-mb-3w">Titre complet : <strong>{{ previewTitle() }}</strong></p>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="category-input">
Catégorie
</label>
<select id="category-input" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ categoryLabel(cat) }}</option>
}
</select>
</div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="providerInvalid()">
<label class="fr-label" for="provider-input">
Fournisseur <span style="color:var(--text-default-error)">*</span>
</label>
<input
id="provider-input"
class="fr-input"
[class.fr-input--error]="providerInvalid()"
formControlName="provider"
type="text"
placeholder="DINUM, Direction XYZ..."
/>
@if (providerInvalid()) {
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
}
</div>
<!-- Type -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="type-input">
Type <span style="color:var(--text-default-error)">*</span>
</label>
<select id="type-input" class="fr-select" formControlName="type">
<option value="ASYNCAPI">AsyncAPI</option>
<option value="OPENAPI">OpenAPI</option>
</select>
@if (detectedType()) {
<p class="fr-hint-text">Détecté automatiquement depuis le fichier YAML</p>
}
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="description-input">
Description
</label>
<textarea
id="description-input"
class="fr-input"
formControlName="description"
rows="3"
placeholder="Description de l'API..."
></textarea>
</div>
<!-- Version X.Y.Z -->
<p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
<label class="fr-label" for="version-major-input">Majeure (X)</label>
<input
id="version-major-input"
class="fr-input"
[class.fr-input--error]="versionMajorInvalid()"
formControlName="versionMajor"
type="number"
min="0"
placeholder="1"
/>
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionMinorInvalid()">
<label class="fr-label" for="version-minor-input">Mineure (Y)</label>
<input
id="version-minor-input"
class="fr-input"
[class.fr-input--error]="versionMinorInvalid()"
formControlName="versionMinor"
type="number"
min="0"
placeholder="0"
/>
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionPatchInvalid()">
<label class="fr-label" for="version-patch-input">Correctif (Z)</label>
<input
id="version-patch-input"
class="fr-input"
[class.fr-input--error]="versionPatchInvalid()"
formControlName="versionPatch"
type="number"
min="0"
placeholder="0"
/>
</div>
</div>
</div>
@if (versionAlreadyExists()) {
<div class="fr-alert fr-alert--warning fr-mb-3w">
<p class="fr-alert__title">Version déjà existante</p>
<p>
La version {{ formValues().versionMajor }}.{{ formValues().versionMinor }}.{{ formValues().versionPatch }}
existe déjà pour cette API. Choisissez un numéro de version différent.
</p>
</div>
}
<div class="fr-btns-group fr-btns-group--inline-sm">
<button type="submit" class="fr-btn" [disabled]="versionAlreadyExists()">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
Retour
</button>
</div>
</form>
}
<!-- Étape 3 : Contacts -->
@if (step() === 3) {
<form [formGroup]="metaForm" (ngSubmit)="goToStep4()" novalidate>
<!-- Contact métier -->
<p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalName')">
<label class="fr-label" for="cf-name-input">Nom et prénom</label>
<input id="cf-name-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalName')"
formControlName="contactFunctionalName" type="text" placeholder="Prénom Nom" />
@if (fieldInvalid('contactFunctionalName')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEntity')">
<label class="fr-label" for="cf-entity-input">Entité</label>
<input id="cf-entity-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalEntity')"
formControlName="contactFunctionalEntity" type="text" placeholder="Direction XYZ" />
@if (fieldInvalid('contactFunctionalEntity')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEmail')">
<label class="fr-label" for="cf-email-input">Email</label>
<input id="cf-email-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalEmail')"
formControlName="contactFunctionalEmail" type="email" placeholder="prenom.nom@example.fr" />
@if (fieldInvalid('contactFunctionalEmail')) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<!-- Contact technique -->
<p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalName')">
<label class="fr-label" for="ct-name-input">Nom et prénom</label>
<input id="ct-name-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalName')"
formControlName="contactTechnicalName" type="text" placeholder="Prénom Nom" />
@if (fieldInvalid('contactTechnicalName')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEntity')">
<label class="fr-label" for="ct-entity-input">Entité</label>
<input id="ct-entity-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalEntity')"
formControlName="contactTechnicalEntity" type="text" placeholder="Équipe Technique" />
@if (fieldInvalid('contactTechnicalEntity')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEmail')">
<label class="fr-label" for="ct-email-input">Email</label>
<input id="ct-email-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalEmail')"
formControlName="contactTechnicalEmail" type="email" placeholder="tech@example.fr" />
@if (fieldInvalid('contactTechnicalEmail')) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--inline-sm">
<button type="submit" class="fr-btn">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(2)">
Retour
</button>
</div>
</form>
}
<!-- Étape 4 : Confirmation -->
@if (step() === 4) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-3w">Récapitulatif</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
<dd>
@if (selectedFiles().length === 0) {
<span class="fr-badge fr-badge--warning fr-badge--sm">Aucun fichier</span>
} @else if (selectedFiles().length === 1) {
{{ selectedFiles()[0].name }}
} @else {
@for (f of selectedFiles(); track f.name) {
<div style="display:flex;align-items:center;gap:0.5rem;">
{{ f.name }}
@if (f.name === detectedMainFilename()) {
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
}
</div>
}
}
</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Type</dt>
<dd>{{ displayType() }}</dd>
</div>
<div class="fr-col-12">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ previewTitle() }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Version</dt>
<dd>{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Fournisseur</dt>
<dd>{{ metaForm.value.provider }}</dd>
</div>
@if (metaForm.value.categoryId) {
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Catégorie</dt>
<dd>{{ selectedCategoryName() }}</dd>
</div>
}
@if (metaForm.value.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ metaForm.value.description }}</dd>
</div>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact métier</dt>
<dd>
{{ metaForm.value.contactFunctionalName }}<br>
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEntity }}</span><br>
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEmail }}</span>
</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact technique</dt>
<dd>
{{ metaForm.value.contactTechnicalName }}<br>
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEntity }}</span><br>
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEmail }}</span>
</dd>
</div>
</dl>
</div>
</div>
</div>
@if (uploadError()) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Erreur lors de l'import</p>
<p>{{ uploadError() }}</p>
</div>
}
<div class="fr-btns-group fr-btns-group--inline-sm">
<button
class="fr-btn"
(click)="onSubmit()"
[disabled]="uploading()"
>
{{ uploading() ? 'Import en cours...' : 'Importer' }}
</button>
<button class="fr-btn fr-btn--secondary" (click)="step.set(3)" [disabled]="uploading()">
Retour
</button>
</div>
}
</div>
`,
templateUrl: "./upload.component.html",
})
export class UploadComponent implements OnInit {
private fb = inject(FormBuilder);
@@ -507,8 +29,16 @@ export class UploadComponent implements OnInit {
private categoryService = inject(CategoryService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private destroyRef = inject(DestroyRef);
step = signal(1);
readonly breadcrumbItems: BreadcrumbItem[] = [
{ label: 'Parcourir', link: '/browse' },
{ label: 'Catalogue', link: '/catalog' },
{ label: 'Importer une API' },
];
private readonly maxFileSize = 5 * 1024 * 1024; // 5 Mo
selectedFiles = signal<File[]>([]);
detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null);
detectedMainFilename = signal<string | null>(null);
@@ -628,7 +158,10 @@ export class UploadComponent implements OnInit {
const preselectedCategoryId = params.get('categoryId');
const fromId = params.get('from');
this.categoryService.list().subscribe({
this.categoryService
.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res) => {
this.categories.set(res.items);
if (preselectedCategoryId) {
@@ -638,11 +171,17 @@ export class UploadComponent implements OnInit {
});
if (fromId) {
this.apiService.get(fromId).subscribe({
this.apiService
.get(fromId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (api) => this.prefillFromApi(api),
error: () => {},
});
this.apiService.getVersions(fromId).subscribe({
this.apiService
.getVersions(fromId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (versions) => this.existingVersions.set(versions),
error: () => {},
});
@@ -706,6 +245,15 @@ export class UploadComponent implements OnInit {
return;
}
const tooLarge = files.find((f) => f.size > this.maxFileSize);
if (tooLarge) {
this.fileError.set(
`Fichier trop volumineux (${tooLarge.name}). Taille max : ${this.maxFileSize / (1024 * 1024)} Mo.`,
);
this.selectedFiles.set([]);
return;
}
this.selectedFiles.set(files);
/* Détection côté client du fichier principal (pré-remplissage du type) */
@@ -748,7 +296,10 @@ export class UploadComponent implements OnInit {
formData.append('files', f);
}
this.apiService.validate(formData).subscribe({
this.apiService
.validate(formData)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
this.validating.set(false);
this.validationDone.set(true);
@@ -826,7 +377,10 @@ export class UploadComponent implements OnInit {
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
this.apiService.upload(formData).subscribe({
this.apiService
.upload(formData)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.uploading.set(false);
this.router.navigate(['/catalog', entry.id]);

View File

@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
/** Un maillon du fil d'Ariane. `link` absent => élément courant (aria-current). */
export interface BreadcrumbItem {
label: string;
link?: string | unknown[];
}
/**
* Fil d'Ariane DSFR générique (présentationnel). Chaque page construit sa liste
* d'items et la passe ; le dernier item sans `link` est marqué comme courant.
*/
@Component({
selector: 'app-breadcrumb',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
@for (item of items(); track $index) {
<li>
@if (item.link) {
<a class="fr-breadcrumb__link" [routerLink]="item.link">{{ item.label }}</a>
} @else {
<a class="fr-breadcrumb__link" aria-current="page">{{ item.label }}</a>
}
</li>
}
</ol>
</nav>
`,
})
export class BreadcrumbComponent {
items = input.required<BreadcrumbItem[]>();
}

View File

@@ -1,4 +1,5 @@
import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core';
import { Component, signal, inject, DestroyRef, ChangeDetectionStrategy, input, output } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { CategoryWithCounts } from '@datacat/shared';
@@ -49,6 +50,7 @@ import { CategoryWithCounts } from '@datacat/shared';
})
export class CategoryDeleteModalComponent {
private categoryService = inject(CategoryService);
private destroyRef = inject(DestroyRef);
category = input.required<CategoryWithCounts>();
confirmed = output<void>();
@@ -65,7 +67,10 @@ export class CategoryDeleteModalComponent {
const cat = this.category();
this.deleteLoading.set(true);
this.deleteError.set(null);
this.categoryService.delete(cat.id).subscribe({
this.categoryService
.delete(cat.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.deleteLoading.set(false);
this.confirmed.emit();

View File

@@ -3,11 +3,13 @@ import {
signal,
computed,
inject,
DestroyRef,
OnInit,
ChangeDetectionStrategy,
input,
output,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared';
@@ -79,6 +81,7 @@ import { Category } from '@datacat/shared';
})
export class CategoryFormModalComponent implements OnInit {
private categoryService = inject(CategoryService);
private destroyRef = inject(DestroyRef);
mode = input<'create' | 'edit'>('create');
category = input<Category | null>(null);
@@ -133,7 +136,10 @@ export class CategoryFormModalComponent implements OnInit {
this.formDescription.set(cat.description ?? '');
} else {
this.formParentId.set(this.parentId());
this.categoryService.list().subscribe({
this.categoryService
.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res) => this.allCategories.set(res.items),
error: () => this.allCategories.set([]),
});
@@ -159,7 +165,7 @@ export class CategoryFormModalComponent implements OnInit {
? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined })
: this.categoryService.update(this.editingCategoryId()!, { name, description });
obs.subscribe({
obs.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: () => {
this.formLoading.set(false);
this.saved.emit();

View File

@@ -0,0 +1,3 @@
// Charge le compilateur Angular pour permettre le fallback JIT en test
// (sinon l'import de symboles @angular/* partiellement compilés échoue).
import '@angular/compiler';

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
// Vitest pour les tests de logique/services purs (cohérent avec le back).
// Les tests de composants avec TestBed nécessiteraient un plugin Angular dédié
// (ex. @analogjs/vitest-angular) — hors périmètre pour l'instant.
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./vitest-setup.ts'],
include: ['src/**/*.spec.ts'],
},
});

1003
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff