Refonte qualité (shared / back / front) #1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -7,21 +7,26 @@ import { UploadsModule } from './modules/uploads/uploads.module';
|
||||
import { DocsGenerationModule } from './modules/docs-generation/docs-generation.module';
|
||||
import { CategoriesModule } from './modules/categories/categories.module';
|
||||
import { SeederModule } from './modules/seeder/seeder.module';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: config.get('DATABASE_HOST', 'localhost'),
|
||||
port: config.get<number>('DATABASE_PORT', 5432),
|
||||
username: config.get('DATABASE_USER', 'datacat'),
|
||||
password: config.get('DATABASE_PASSWORD', 'datacat'),
|
||||
database: config.get('DATABASE_NAME', 'datacat'),
|
||||
host: config.getOrThrow<string>('DATABASE_HOST'),
|
||||
port: config.getOrThrow<number>('DATABASE_PORT'),
|
||||
username: config.getOrThrow<string>('DATABASE_USER'),
|
||||
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',
|
||||
}),
|
||||
}),
|
||||
|
||||
33
back/src/config/env.validation.spec.ts
Normal file
33
back/src/config/env.validation.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
76
back/src/config/env.validation.ts
Normal file
76
back/src/config/env.validation.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { plainToInstance, Type } from 'class-transformer';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum NodeEnv {
|
||||
Development = 'development',
|
||||
Production = 'production',
|
||||
Test = 'test',
|
||||
}
|
||||
|
||||
/**
|
||||
* Schéma des variables d'environnement. Validé au démarrage via `validateEnv`
|
||||
* (branché sur `ConfigModule.forRoot({ validate })`) : l'app refuse de démarrer
|
||||
* si une variable requise manque ou est mal typée, plutôt que d'utiliser
|
||||
* silencieusement des valeurs par défaut (ex. credentials DB).
|
||||
*/
|
||||
export class EnvironmentVariables {
|
||||
@IsEnum(NodeEnv)
|
||||
@IsOptional()
|
||||
NODE_ENV: NodeEnv = NodeEnv.Development;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DATABASE_HOST!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
DATABASE_PORT!: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DATABASE_USER!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DATABASE_PASSWORD!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DATABASE_NAME!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
@IsOptional()
|
||||
PORT = 3000;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
DOCS_OUTPUT_DIR?: string;
|
||||
}
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>): EnvironmentVariables {
|
||||
const validated = plainToInstance(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
const errors = validateSync(validated, { skipMissingProperties: false });
|
||||
if (errors.length > 0) {
|
||||
const details = errors
|
||||
.map((e) => Object.values(e.constraints ?? {}).join(', '))
|
||||
.join('\n - ');
|
||||
throw new Error(`Configuration d'environnement invalide:\n - ${details}`);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
20
back/src/data-source.ts
Normal file
20
back/src/data-source.ts
Normal 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'],
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, RequestMethod } from '@nestjs/common';
|
||||
import { Logger, ValidationPipe, RequestMethod } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
|
||||
@@ -16,7 +16,7 @@ async function bootstrap() {
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
console.log(`Datacat API running on port ${port}`);
|
||||
Logger.log(`Datacat API running on port ${port}`, 'Bootstrap');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
16
back/src/migrations/1782543826810-InitialSchema.ts
Normal file
16
back/src/migrations/1782543826810-InitialSchema.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -170,11 +170,17 @@ const mockRepo = {
|
||||
|
||||
const mockDocsService = { generate: vi.fn() };
|
||||
|
||||
const mockDataSource = {
|
||||
transaction: vi.fn(async (cb: (m: unknown) => unknown) =>
|
||||
cb({ getRepository: () => mockRepo }),
|
||||
),
|
||||
};
|
||||
|
||||
let service: ApisService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new ApisService(mockRepo as any, mockDocsService as any);
|
||||
service = new ApisService(mockRepo as any, mockDataSource as any, mockDocsService as any);
|
||||
});
|
||||
|
||||
function makeEntity(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, Inject, forwardRef, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { ApiEntryEntity } from './api-entry.entity';
|
||||
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
||||
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
||||
@@ -17,9 +17,12 @@ interface InternalUpdateFields {
|
||||
|
||||
@Injectable()
|
||||
export class ApisService {
|
||||
private readonly logger = new Logger(ApisService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApiEntryEntity)
|
||||
private readonly repo: Repository<ApiEntryEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => DocsGenerationService))
|
||||
private readonly docsService: DocsGenerationService,
|
||||
) {}
|
||||
@@ -213,20 +216,24 @@ export class ApisService {
|
||||
|
||||
async setCurrent(id: string): Promise<ApiEntry> {
|
||||
const entry = await this.findOneOrThrow(id);
|
||||
// Mettre tous les siblings à false
|
||||
await this.repo.update(
|
||||
{ name: entry.name, convention: entry.convention },
|
||||
{ isCurrent: false },
|
||||
);
|
||||
// Mettre cet entry à true
|
||||
await this.repo.update({ id }, { isCurrent: true });
|
||||
// Transaction : éviter une fenêtre où aucun (ou deux) sibling n'est courant.
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(ApiEntryEntity);
|
||||
await repo.update(
|
||||
{ name: entry.name, convention: entry.convention },
|
||||
{ isCurrent: false },
|
||||
);
|
||||
await repo.update({ id }, { isCurrent: true });
|
||||
});
|
||||
return this.findOneOrThrow(id);
|
||||
}
|
||||
|
||||
async regenerate(id: string): Promise<ApiEntry> {
|
||||
await this.findOneOrThrow(id);
|
||||
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
||||
this.docsService.generate(id).catch(console.error);
|
||||
this.docsService
|
||||
.generate(id)
|
||||
.catch((err) => this.logger.error(`Génération docs échouée pour ${id}`, err));
|
||||
return this.findOneOrThrow(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
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 } from '@datacat/shared';
|
||||
import { ApiType, ApiConvention, API_CONVENTIONS, API_TYPES } from '@datacat/shared';
|
||||
|
||||
export class CreateApiEntryDto {
|
||||
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
|
||||
@IsIn([...API_CONVENTIONS])
|
||||
convention!: ApiConvention;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
provider!: string;
|
||||
|
||||
@IsInt()
|
||||
@@ -33,7 +35,7 @@ export class CreateApiEntryDto {
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsIn(['ASYNCAPI', 'OPENAPI'])
|
||||
@IsIn([...API_TYPES])
|
||||
type!: ApiType;
|
||||
|
||||
@IsString()
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiConvention } from '@datacat/shared';
|
||||
import { ApiConvention, API_CONVENTIONS } from '@datacat/shared';
|
||||
|
||||
export class UpdateApiEntryDto {
|
||||
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
|
||||
@IsIn([...API_CONVENTIONS])
|
||||
@IsOptional()
|
||||
convention?: ApiConvention;
|
||||
|
||||
|
||||
@@ -98,11 +98,51 @@ export class CategoriesService {
|
||||
await this.categoryRepo.delete(id);
|
||||
}
|
||||
|
||||
private async countApisRecursively(categoryId: string): Promise<number> {
|
||||
const direct = await this.apiRepo.count({ where: { categoryId } });
|
||||
const children = await this.categoryRepo.find({ where: { parentId: categoryId }, select: ['id'] });
|
||||
const childCounts = await Promise.all(children.map((c) => this.countApisRecursively(c.id)));
|
||||
return direct + childCounts.reduce((sum, n) => sum + n, 0);
|
||||
/**
|
||||
* Comptages pour TOUTES les catégories en 2 requêtes (au lieu d'un N+1
|
||||
* récursif par enfant) : la hiérarchie et les APIs directes sont chargées en
|
||||
* une fois, puis les compteurs récursifs sont calculés en mémoire.
|
||||
*/
|
||||
private async computeCategoryCounts(): Promise<{
|
||||
subcategoryCount: Map<string, number>;
|
||||
recursiveApiCount: Map<string, number>;
|
||||
}> {
|
||||
const categories = await this.categoryRepo.find({ select: ['id', 'parentId'] });
|
||||
const directRows = await this.apiRepo
|
||||
.createQueryBuilder('api')
|
||||
.select('api.categoryId', 'categoryId')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('api.categoryId IS NOT NULL')
|
||||
.groupBy('api.categoryId')
|
||||
.getRawMany<{ categoryId: string; count: string }>();
|
||||
|
||||
const directApiCount = new Map(directRows.map((r) => [r.categoryId, Number(r.count)]));
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const c of categories) {
|
||||
if (c.parentId) {
|
||||
const siblings = childrenOf.get(c.parentId) ?? [];
|
||||
siblings.push(c.id);
|
||||
childrenOf.set(c.parentId, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
const recursiveApiCount = new Map<string, number>();
|
||||
const computeRecursive = (id: string): number => {
|
||||
const cached = recursiveApiCount.get(id);
|
||||
if (cached !== undefined) return cached;
|
||||
let total = directApiCount.get(id) ?? 0;
|
||||
for (const childId of childrenOf.get(id) ?? []) total += computeRecursive(childId);
|
||||
recursiveApiCount.set(id, total);
|
||||
return total;
|
||||
};
|
||||
|
||||
const subcategoryCount = new Map<string, number>();
|
||||
for (const c of categories) {
|
||||
subcategoryCount.set(c.id, (childrenOf.get(c.id) ?? []).length);
|
||||
computeRecursive(c.id);
|
||||
}
|
||||
|
||||
return { subcategoryCount, recursiveApiCount };
|
||||
}
|
||||
|
||||
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
|
||||
@@ -132,13 +172,12 @@ export class CategoriesService {
|
||||
order: { orderIndex: 'ASC', name: 'ASC' },
|
||||
});
|
||||
|
||||
const subcategories: CategoryWithCounts[] = await Promise.all(
|
||||
directChildren.map(async (child) => {
|
||||
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
|
||||
const apiCount = await this.countApisRecursively(child.id);
|
||||
return { ...toCategory(child), subcategoryCount, apiCount };
|
||||
}),
|
||||
);
|
||||
const { subcategoryCount, recursiveApiCount } = await this.computeCategoryCounts();
|
||||
const subcategories: CategoryWithCounts[] = directChildren.map((child) => ({
|
||||
...toCategory(child),
|
||||
subcategoryCount: subcategoryCount.get(child.id) ?? 0,
|
||||
apiCount: recursiveApiCount.get(child.id) ?? 0,
|
||||
}));
|
||||
|
||||
/* 4. APIs directement dans cette catégorie */
|
||||
const apiEntities = await this.apiRepo.find({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import * as fs from 'fs/promises';
|
||||
@@ -587,6 +587,8 @@ components:
|
||||
|
||||
@Injectable()
|
||||
export class SeederService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SeederService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApiEntryEntity)
|
||||
private readonly apiRepo: Repository<ApiEntryEntity>,
|
||||
@@ -648,7 +650,7 @@ export class SeederService implements OnApplicationBootstrap {
|
||||
const docsDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
|
||||
await fs.rm(docsDir, { recursive: true, force: true });
|
||||
await fs.mkdir(docsDir, { recursive: true });
|
||||
console.log('[Seeder] Reset complet — données supprimées, docs nettoyées.');
|
||||
this.logger.log('Reset complet — données supprimées, docs nettoyées.');
|
||||
}
|
||||
|
||||
private async seed(): Promise<void> {
|
||||
@@ -864,11 +866,13 @@ export class SeederService implements OnApplicationBootstrap {
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log('[Seeder] 8 catégories et 7 APIs de démo créées — génération docs en cours...');
|
||||
this.logger.log('8 catégories et 7 APIs de démo créées — génération docs en cours...');
|
||||
|
||||
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
|
||||
for (const entry of entries) {
|
||||
this.docsService.generate(entry.id).catch(console.error);
|
||||
this.docsService
|
||||
.generate(entry.id)
|
||||
.catch((err) => this.logger.error(`Génération docs échouée pour ${entry.id}`, err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
back/src/modules/uploads/dto/create-api-upload.dto.spec.ts
Normal file
49
back/src/modules/uploads/dto/create-api-upload.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
96
back/src/modules/uploads/dto/create-api-upload.dto.ts
Normal file
96
back/src/modules/uploads/dto/create-api-upload.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import * as fs from 'fs';
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -29,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 {
|
||||
@@ -69,11 +69,13 @@ export class UploadsController {
|
||||
}
|
||||
|
||||
/* Lire tous les contenus en mémoire avant toute suppression */
|
||||
const fileContents = files.map((file) => ({
|
||||
name: file.originalname,
|
||||
path: file.path,
|
||||
content: fs.readFileSync(file.path, 'utf-8'),
|
||||
}));
|
||||
const fileContents = await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
name: file.originalname,
|
||||
path: file.path,
|
||||
content: await fsPromises.readFile(file.path, 'utf-8'),
|
||||
})),
|
||||
);
|
||||
|
||||
/* Nettoyage immédiat des fichiers temporaires */
|
||||
for (const file of files) {
|
||||
@@ -175,33 +177,11 @@ 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 */
|
||||
/* Crée le répertoire si absent (mkdir recursive est idempotent) */
|
||||
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
await fsPromises.mkdir(uploadDir, { recursive: true });
|
||||
|
||||
let yamlContent: string;
|
||||
let detectedType: ApiType | null = null;
|
||||
@@ -211,7 +191,7 @@ export class UploadsController {
|
||||
yamlContent = '';
|
||||
} else if (files.length === 1) {
|
||||
/* Cas simple : un seul fichier */
|
||||
yamlContent = fs.readFileSync(files[0].path, 'utf-8');
|
||||
yamlContent = await fsPromises.readFile(files[0].path, 'utf-8');
|
||||
|
||||
/* Validation syntaxique YAML */
|
||||
this.validateYamlSyntax(yamlContent, files[0].originalname);
|
||||
@@ -219,7 +199,7 @@ export class UploadsController {
|
||||
detectedType = this.detectTypeFromContent(yamlContent);
|
||||
} else {
|
||||
/* Cas multi-fichiers : détecter le fichier principal et bundler */
|
||||
const { mainFile, type } = this.detectMainFile(files);
|
||||
const { mainFile, type } = await this.detectMainFile(files);
|
||||
detectedType = type;
|
||||
|
||||
this.logger.log(`Multi-file upload: main=${mainFile.originalname}, type=${type}, total=${files.length} files`);
|
||||
@@ -233,27 +213,29 @@ 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) */
|
||||
if (yamlContent) {
|
||||
this.docsService.generate(entry.id).catch(console.error);
|
||||
this.docsService
|
||||
.generate(entry.id)
|
||||
.catch((err) => this.logger.error(`Génération docs échouée pour ${entry.id}`, err));
|
||||
}
|
||||
|
||||
return entry;
|
||||
@@ -308,9 +290,11 @@ export class UploadsController {
|
||||
return null;
|
||||
}
|
||||
|
||||
private detectMainFile(files: Express.Multer.File[]): { mainFile: Express.Multer.File; type: ApiType } {
|
||||
private async detectMainFile(
|
||||
files: Express.Multer.File[],
|
||||
): Promise<{ mainFile: Express.Multer.File; type: ApiType }> {
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file.path, 'utf-8');
|
||||
const content = await fsPromises.readFile(file.path, 'utf-8');
|
||||
const type = this.detectTypeFromContent(content);
|
||||
if (type) {
|
||||
return { mainFile: file, type };
|
||||
|
||||
3
back/vitest-setup.ts
Normal file
3
back/vitest-setup.ts
Normal 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';
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,7 +57,13 @@
|
||||
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
||||
],
|
||||
"outputHashing": "all"
|
||||
"outputHashing": "all",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
|
||||
38
front-public/eslint.config.js
Normal file
38
front-public/eslint.config.js
Normal 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: {},
|
||||
},
|
||||
);
|
||||
@@ -6,8 +6,10 @@
|
||||
"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": {
|
||||
"@angular/animations": "^19.0.0",
|
||||
@@ -18,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"
|
||||
@@ -26,9 +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",
|
||||
"@gouvfr/dsfr": "^1.13.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { routes } from './app.routes';
|
||||
import { errorInterceptor } from './core/error.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClient(withInterceptors([errorInterceptor])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery } from '@datacat/shared';
|
||||
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery, ApiType } from '@datacat/shared';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = '/api/apis';
|
||||
private baseUrl = `${environment.apiBaseUrl}/apis`;
|
||||
private uploadsUrl = `${environment.apiBaseUrl}/uploads`;
|
||||
|
||||
list(query?: ApiListQuery): Observable<ApiListResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
@@ -58,30 +60,23 @@ export class ApiService {
|
||||
}
|
||||
|
||||
upload(formData: FormData): Observable<ApiEntry> {
|
||||
return this.http.post<ApiEntry>('/api/uploads', formData);
|
||||
return this.http.post<ApiEntry>(this.uploadsUrl, formData);
|
||||
}
|
||||
|
||||
validate(formData: FormData): Observable<{
|
||||
valid: boolean;
|
||||
type: 'ASYNCAPI' | 'OPENAPI' | null;
|
||||
mainFile: string | null;
|
||||
name: string | null;
|
||||
versionMajor: number | null;
|
||||
versionMinor: number | null;
|
||||
versionPatch: number | null;
|
||||
description: string | null;
|
||||
errors: Array<{ file: string; message: string }>;
|
||||
}> {
|
||||
return this.http.post<{
|
||||
valid: boolean;
|
||||
type: 'ASYNCAPI' | 'OPENAPI' | null;
|
||||
mainFile: string | null;
|
||||
name: string | null;
|
||||
versionMajor: number | null;
|
||||
versionMinor: number | null;
|
||||
versionPatch: number | null;
|
||||
description: string | null;
|
||||
errors: Array<{ file: string; message: string }>;
|
||||
}>('/api/uploads/validate', formData);
|
||||
validate(formData: FormData): Observable<ValidateResponse> {
|
||||
return this.http.post<ValidateResponse>(`${this.uploadsUrl}/validate`, formData);
|
||||
}
|
||||
}
|
||||
|
||||
/** Réponse de l'endpoint de validation d'upload (POST /uploads/validate). */
|
||||
export interface ValidateResponse {
|
||||
valid: boolean;
|
||||
type: ApiType | null;
|
||||
mainFile: string | null;
|
||||
name: string | null;
|
||||
versionMajor: number | null;
|
||||
versionMinor: number | null;
|
||||
versionPatch: number | null;
|
||||
description: string | null;
|
||||
errors: Array<{ file: string; message: string }>;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
CategoryListResponse,
|
||||
CategoryBrowseResponse,
|
||||
} from '@datacat/shared';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CategoryService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = '/api/categories';
|
||||
private baseUrl = `${environment.apiBaseUrl}/categories`;
|
||||
|
||||
list(search?: string): Observable<CategoryListResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
18
front-public/src/app/core/error.interceptor.ts
Normal file
18
front-public/src/app/core/error.interceptor.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { ErrorService } from './error.service';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Interceptor HTTP fonctionnel : journalise et normalise les erreurs réseau en
|
||||
* un message lisible (via ErrorService), puis relaie l'erreur aux appelants.
|
||||
*/
|
||||
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const errorService = inject(ErrorService);
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
errorService.log(req.method, req.url, error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
};
|
||||
36
front-public/src/app/core/error.service.spec.ts
Normal file
36
front-public/src/app/core/error.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
32
front-public/src/app/core/error.service.ts
Normal file
32
front-public/src/app/core/error.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Centralise la traduction d'une erreur HTTP en message lisible et son
|
||||
* journal. Les composants appellent `toMessage(err)` plutôt que de
|
||||
* réimplémenter chacun leur extraction de message.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ErrorService {
|
||||
/** Extrait un message lisible depuis une erreur HTTP (ou autre). */
|
||||
toMessage(error: unknown, fallback = 'Une erreur est survenue.'): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const body = error.error;
|
||||
if (typeof body === 'string' && body.trim()) return body;
|
||||
if (body && typeof body === 'object') {
|
||||
const message = (body as { message?: unknown }).message;
|
||||
if (typeof message === 'string' && message) return message;
|
||||
if (Array.isArray(message) && message.length) return message.join(', ');
|
||||
}
|
||||
if (error.status === 0) return 'Serveur injoignable. Vérifiez votre connexion.';
|
||||
return error.message || fallback;
|
||||
}
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Journalise une erreur réseau (appelé par l'interceptor). */
|
||||
log(method: string, url: string, error: HttpErrorResponse): void {
|
||||
console.error(`[HTTP ${error.status}] ${method} ${url} — ${this.toMessage(error)}`);
|
||||
}
|
||||
}
|
||||
369
front-public/src/app/pages/api-detail/api-detail.component.html
Normal file
369
front-public/src/app/pages/api-detail/api-detail.component.html
Normal 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>
|
||||
@@ -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,26 +86,29 @@ 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.id = params.get('id') ?? '';
|
||||
this.api.set(null);
|
||||
this.versions.set([]);
|
||||
this.error.set(null);
|
||||
this.categoryBreadcrumb.set([]);
|
||||
this.editingInfo.set(false);
|
||||
this.editingContacts.set(false);
|
||||
this.clearPoll();
|
||||
this.loadApi();
|
||||
});
|
||||
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([]);
|
||||
this.error.set(null);
|
||||
this.categoryBreadcrumb.set([]);
|
||||
this.editingInfo.set(false);
|
||||
this.editingContacts.set(false);
|
||||
this.clearPoll();
|
||||
this.loadApi();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.routeSub?.unsubscribe();
|
||||
this.clearPoll();
|
||||
}
|
||||
|
||||
@@ -498,29 +125,38 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
onSetCurrent() {
|
||||
this.settingCurrent.set(true);
|
||||
this.apiService.setCurrent(this.id).subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
this.settingCurrent.set(false);
|
||||
this.apiService.getVersions(this.id).subscribe({
|
||||
next: (v) => this.versions.set(v),
|
||||
error: () => {},
|
||||
});
|
||||
},
|
||||
error: () => this.settingCurrent.set(false),
|
||||
});
|
||||
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)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (v) => this.versions.set(v),
|
||||
error: () => {},
|
||||
});
|
||||
},
|
||||
error: () => this.settingCurrent.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
onRegenerate() {
|
||||
this.regenerating.set(true);
|
||||
this.apiService.regenerate(this.id).subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
this.regenerating.set(false);
|
||||
this.startPollIfPending();
|
||||
},
|
||||
error: () => this.regenerating.set(false),
|
||||
});
|
||||
this.apiService
|
||||
.regenerate(this.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
this.regenerating.set(false);
|
||||
this.startPollIfPending();
|
||||
},
|
||||
error: () => this.regenerating.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
startEditInfo() {
|
||||
@@ -568,10 +204,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
versionPatch: raw.versionPatch ?? 0,
|
||||
categoryId: raw.categoryId || null,
|
||||
description: raw.description || null,
|
||||
}).subscribe({
|
||||
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
|
||||
error: () => this.savingInfo.set(false),
|
||||
});
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
|
||||
error: () => this.savingInfo.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
submitContacts() {
|
||||
@@ -587,10 +225,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
contactTechnicalName: raw.contactTechnicalName ?? '',
|
||||
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
|
||||
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
|
||||
}).subscribe({
|
||||
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
|
||||
error: () => this.savingContacts.set(false),
|
||||
});
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
|
||||
error: () => this.savingContacts.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
onDelete() {
|
||||
@@ -599,16 +239,19 @@ 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({
|
||||
next: () => {
|
||||
if (redirectTarget) {
|
||||
this.router.navigate(['/catalog', redirectTarget.id]);
|
||||
} else {
|
||||
this.router.navigate(this.backUrl());
|
||||
}
|
||||
},
|
||||
error: () => this.deleting.set(false),
|
||||
});
|
||||
this.apiService
|
||||
.delete(this.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
if (redirectTarget) {
|
||||
this.router.navigate(['/catalog', redirectTarget.id]);
|
||||
} else {
|
||||
this.router.navigate(this.backUrl());
|
||||
}
|
||||
},
|
||||
error: () => this.deleting.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
badgeClass(status: string): string {
|
||||
@@ -627,27 +270,36 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
private loadApi() {
|
||||
this.loading.set(true);
|
||||
this.apiService.get(this.id).subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
this.loading.set(false);
|
||||
this.startPollIfPending();
|
||||
this.apiService.getVersions(this.id).subscribe({
|
||||
next: (v) => this.versions.set(v),
|
||||
error: () => {},
|
||||
});
|
||||
if (entry.categoryId) {
|
||||
this.categoryService.browse(entry.categoryId).subscribe({
|
||||
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Erreur lors du chargement');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
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)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (v) => this.versions.set(v),
|
||||
error: () => {},
|
||||
});
|
||||
if (entry.categoryId) {
|
||||
this.categoryService
|
||||
.browse(entry.categoryId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Erreur lors du chargement');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private startPollIfPending() {
|
||||
@@ -658,12 +310,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private pollStatus() {
|
||||
this.apiService.get(this.id).subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
if (entry.status !== 'PENDING') this.clearPoll();
|
||||
},
|
||||
});
|
||||
this.apiService
|
||||
.get(this.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
if (entry.status !== 'PENDING') this.clearPoll();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private clearPoll() {
|
||||
|
||||
@@ -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,16 +321,19 @@ export class BrowseComponent implements OnInit {
|
||||
loadBrowse() {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.categoryService.browse(this.categoryId()).subscribe({
|
||||
next: (data) => {
|
||||
this.browseData.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('Erreur lors du chargement des données.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
this.categoryService
|
||||
.browse(this.categoryId())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.browseData.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('Erreur lors du chargement des données.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openCreate() {
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
472
front-public/src/app/pages/upload/upload.component.html
Normal file
472
front-public/src/app/pages/upload/upload.component.html
Normal 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>
|
||||
@@ -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);
|
||||
@@ -560,7 +90,7 @@ export class UploadComponent implements OnInit {
|
||||
});
|
||||
|
||||
/* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
|
||||
private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
|
||||
protected readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
|
||||
|
||||
previewTitle = computed(() => {
|
||||
const values = this.formValues();
|
||||
@@ -628,24 +158,33 @@ export class UploadComponent implements OnInit {
|
||||
const preselectedCategoryId = params.get('categoryId');
|
||||
const fromId = params.get('from');
|
||||
|
||||
this.categoryService.list().subscribe({
|
||||
next: (res) => {
|
||||
this.categories.set(res.items);
|
||||
if (preselectedCategoryId) {
|
||||
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
|
||||
}
|
||||
},
|
||||
});
|
||||
this.categoryService
|
||||
.list()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.categories.set(res.items);
|
||||
if (preselectedCategoryId) {
|
||||
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (fromId) {
|
||||
this.apiService.get(fromId).subscribe({
|
||||
next: (api) => this.prefillFromApi(api),
|
||||
error: () => {},
|
||||
});
|
||||
this.apiService.getVersions(fromId).subscribe({
|
||||
next: (versions) => this.existingVersions.set(versions),
|
||||
error: () => {},
|
||||
});
|
||||
this.apiService
|
||||
.get(fromId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (api) => this.prefillFromApi(api),
|
||||
error: () => {},
|
||||
});
|
||||
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,10 +296,13 @@ export class UploadComponent implements OnInit {
|
||||
formData.append('files', f);
|
||||
}
|
||||
|
||||
this.apiService.validate(formData).subscribe({
|
||||
next: (result) => {
|
||||
this.validating.set(false);
|
||||
this.validationDone.set(true);
|
||||
this.apiService
|
||||
.validate(formData)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.validating.set(false);
|
||||
this.validationDone.set(true);
|
||||
if (!result.valid) {
|
||||
this.validationErrors.set(result.errors);
|
||||
/* Ne pas réactiver le type s'il est verrouillé par fromApi */
|
||||
@@ -826,16 +377,19 @@ export class UploadComponent implements OnInit {
|
||||
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
|
||||
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
|
||||
|
||||
this.apiService.upload(formData).subscribe({
|
||||
next: (entry) => {
|
||||
this.uploading.set(false);
|
||||
this.router.navigate(['/catalog', entry.id]);
|
||||
},
|
||||
error: (err) => {
|
||||
this.uploading.set(false);
|
||||
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
|
||||
},
|
||||
});
|
||||
this.apiService
|
||||
.upload(formData)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (entry) => {
|
||||
this.uploading.set(false);
|
||||
this.router.navigate(['/catalog', entry.id]);
|
||||
},
|
||||
error: (err) => {
|
||||
this.uploading.set(false);
|
||||
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async detectMainFileClient(files: File[]): Promise<void> {
|
||||
|
||||
@@ -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[]>();
|
||||
}
|
||||
@@ -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,15 +67,18 @@ export class CategoryDeleteModalComponent {
|
||||
const cat = this.category();
|
||||
this.deleteLoading.set(true);
|
||||
this.deleteError.set(null);
|
||||
this.categoryService.delete(cat.id).subscribe({
|
||||
next: () => {
|
||||
this.deleteLoading.set(false);
|
||||
this.confirmed.emit();
|
||||
},
|
||||
error: (err: { error?: { message?: string } }) => {
|
||||
this.deleteLoading.set(false);
|
||||
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
|
||||
},
|
||||
});
|
||||
this.categoryService
|
||||
.delete(cat.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.deleteLoading.set(false);
|
||||
this.confirmed.emit();
|
||||
},
|
||||
error: (err: { error?: { message?: string } }) => {
|
||||
this.deleteLoading.set(false);
|
||||
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +136,13 @@ export class CategoryFormModalComponent implements OnInit {
|
||||
this.formDescription.set(cat.description ?? '');
|
||||
} else {
|
||||
this.formParentId.set(this.parentId());
|
||||
this.categoryService.list().subscribe({
|
||||
next: (res) => this.allCategories.set(res.items),
|
||||
error: () => this.allCategories.set([]),
|
||||
});
|
||||
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();
|
||||
|
||||
4
front-public/src/environments/environment.prod.ts
Normal file
4
front-public/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: '/api',
|
||||
};
|
||||
5
front-public/src/environments/environment.ts
Normal file
5
front-public/src/environments/environment.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
// Préfixe des appels API (proxifié vers le back en dev, cf. proxy.conf*.json).
|
||||
apiBaseUrl: '/api',
|
||||
};
|
||||
3
front-public/vitest-setup.ts
Normal file
3
front-public/vitest-setup.ts
Normal 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';
|
||||
13
front-public/vitest.config.ts
Normal file
13
front-public/vitest.config.ts
Normal 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
1003
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
31
shared/README.md
Normal file
31
shared/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @datacat/shared
|
||||
|
||||
Types et constantes partagés entre le **back** (NestJS) et le **front** (Angular).
|
||||
|
||||
## Contenu
|
||||
|
||||
- `src/types/` — interfaces et types (`ApiEntry`, `Category`, réponses d'API…).
|
||||
- `src/schemas/api-entry.schema.ts` — **source de vérité** des énumérations
|
||||
(`API_TYPES`, `API_STATUSES`, `API_CONVENTIONS`). Les types `ApiType` /
|
||||
`ApiStatus` / `ApiConvention` en sont **dérivés** (`typeof X[number]`), et la
|
||||
validation back (`@IsIn`) réutilise ces mêmes constantes. Ajouter une valeur
|
||||
dans le tableau suffit donc à propager type + validation.
|
||||
|
||||
## Double build (piège récurrent)
|
||||
|
||||
Ce paquet est consommé de **deux façons différentes** :
|
||||
|
||||
| Consommateur | Mécanisme | Config |
|
||||
|--------------|-----------|--------|
|
||||
| **front** (Angular) | alias TypeScript vers la **source** | `tsconfig.json` (ES2022) + `paths` dans `front-public/tsconfig.json` |
|
||||
| **back** (NestJS) | paquet **compilé** (`dist/`) en CommonJS | `tsconfig.build.json` (CommonJS) → `dist/` |
|
||||
|
||||
⚠️ Le back lit `dist/`, pas la source. Après toute modification de `shared`, il
|
||||
faut **rebuild** avant que le back voie le changement :
|
||||
|
||||
```sh
|
||||
pnpm --filter @datacat/shared build
|
||||
# (le docker-compose le fait déjà : voir infra/docker-compose.yml, étape tsc)
|
||||
```
|
||||
|
||||
Le front, lui, voit la source directement (pas de rebuild nécessaire).
|
||||
@@ -1,9 +1,17 @@
|
||||
{
|
||||
"name": "@datacat/shared",
|
||||
"version": "0.1.0",
|
||||
"description": "Types et constantes partagés entre le back (NestJS) et le front (Angular) de Datacat.",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Source de vérité unique pour les énumérations d'API.
|
||||
// Les types (`ApiType`, `ApiStatus`, `ApiConvention`) sont dérivés de ces
|
||||
// constantes dans `../types/api-entry.types.ts`, et la validation back
|
||||
// (class-validator `@IsIn`) les réutilise — pas de duplication.
|
||||
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
|
||||
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
|
||||
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR', 'NO_YAML'] as const;
|
||||
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export type ApiType = 'ASYNCAPI' | 'OPENAPI';
|
||||
import { API_TYPES, API_STATUSES, API_CONVENTIONS } from '../schemas/api-entry.schema';
|
||||
|
||||
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR' | 'NO_YAML';
|
||||
// Types dérivés des constantes (source de vérité dans api-entry.schema.ts) :
|
||||
// ajouter une valeur dans le tableau suffit à mettre à jour le type.
|
||||
export type ApiType = (typeof API_TYPES)[number];
|
||||
|
||||
export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE';
|
||||
export type ApiStatus = (typeof API_STATUSES)[number];
|
||||
|
||||
export type ApiConvention = (typeof API_CONVENTIONS)[number];
|
||||
|
||||
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
|
||||
CONSULTER: 'Consulter',
|
||||
|
||||
Reference in New Issue
Block a user