From 1adb6820dd345631471f31d3bb29e3757d36af91 Mon Sep 17 00:00:00 2001 From: z3n Date: Thu, 11 Jun 2026 15:10:38 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20fix=20bugs=20et=20r=C3=A9duire=20du?= =?UTF-8?q?plication=20+=20extraire=20modales=20BrowseComponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(dto): ajouter @IsEmail() sur contactFunctionalEmail/TechnicalEmail dans UpdateApiEntryDto - fix(docs): corriger stringify des erreurs (String(error) → error.message) - refactor(mapper): extraire toApiEntryListItem() dans api-entry.mapper.ts partagé - refactor(categories): supprimer CONVENTION_LABELS et toApiEntryListItem locaux dupliqués - refactor(browse): extraire CategoryFormModalComponent et CategoryDeleteModalComponent - refactor(api-detail): convertir editPreviewTitle() en computed() Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- back/src/modules/apis/api-entry.mapper.ts | 19 ++ back/src/modules/apis/apis.service.ts | 19 +- .../modules/apis/dto/update-api-entry.dto.ts | 6 +- .../modules/categories/categories.service.ts | 27 +- .../docs-generation.service.ts | 2 +- .../pages/api-detail/api-detail.component.ts | 5 +- .../src/app/pages/browse/browse.component.ts | 263 +++--------------- .../category-delete-modal.component.ts | 79 ++++++ .../category-form-modal.component.ts | 173 ++++++++++++ 9 files changed, 317 insertions(+), 276 deletions(-) create mode 100644 back/src/modules/apis/api-entry.mapper.ts create mode 100644 front-public/src/app/shared/category-delete-modal/category-delete-modal.component.ts create mode 100644 front-public/src/app/shared/category-form-modal/category-form-modal.component.ts diff --git a/back/src/modules/apis/api-entry.mapper.ts b/back/src/modules/apis/api-entry.mapper.ts new file mode 100644 index 0000000..60674dc --- /dev/null +++ b/back/src/modules/apis/api-entry.mapper.ts @@ -0,0 +1,19 @@ +import { ApiEntryEntity } from './api-entry.entity'; +import { ApiEntryListItem, API_CONVENTION_LABELS } from '@datacat/shared'; + +export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem { + return { + id: entity.id, + title: `${API_CONVENTION_LABELS[entity.convention]} ${entity.name}`, + name: entity.name, + convention: entity.convention, + version: `${entity.versionMajor}.${entity.versionMinor}.${entity.versionPatch}`, + description: entity.description, + type: entity.type, + provider: entity.provider, + status: entity.status, + categoryId: entity.categoryId, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + }; +} diff --git a/back/src/modules/apis/apis.service.ts b/back/src/modules/apis/apis.service.ts index 2a27340..31c1a2a 100644 --- a/back/src/modules/apis/apis.service.ts +++ b/back/src/modules/apis/apis.service.ts @@ -4,7 +4,8 @@ import { 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'; -import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus, ApiConvention, API_CONVENTION_LABELS } from '@datacat/shared'; +import { ApiListResponse, ApiListQuery, ApiEntry, ApiType, ApiConvention, API_CONVENTION_LABELS } from '@datacat/shared'; +import { toApiEntryListItem } from './api-entry.mapper'; import { DocsGenerationService } from '../docs-generation/docs-generation.service'; /** Champs internes mis à jour par DocsGenerationService */ @@ -151,19 +152,3 @@ function toApiEntry(entity: ApiEntryEntity): ApiEntry { }; } -function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem { - return { - id: entity.id, - title: buildTitle(entity.convention, entity.name), - name: entity.name, - convention: entity.convention, - version: buildVersion(entity.versionMajor, entity.versionMinor, entity.versionPatch), - description: entity.description, - type: entity.type, - provider: entity.provider, - status: entity.status, - categoryId: entity.categoryId, - createdAt: entity.createdAt.toISOString(), - updatedAt: entity.updatedAt.toISOString(), - }; -} diff --git a/back/src/modules/apis/dto/update-api-entry.dto.ts b/back/src/modules/apis/dto/update-api-entry.dto.ts index cb6f5e5..9e87bd9 100644 --- a/back/src/modules/apis/dto/update-api-entry.dto.ts +++ b/back/src/modules/apis/dto/update-api-entry.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min } from 'class-validator'; +import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiConvention } from '@datacat/shared'; @@ -49,7 +49,7 @@ export class UpdateApiEntryDto { @IsOptional() contactFunctionalEntity?: string; - @IsString() + @IsEmail() @IsOptional() contactFunctionalEmail?: string; @@ -61,7 +61,7 @@ export class UpdateApiEntryDto { @IsOptional() contactTechnicalEntity?: string; - @IsString() + @IsEmail() @IsOptional() contactTechnicalEmail?: string; } diff --git a/back/src/modules/categories/categories.service.ts b/back/src/modules/categories/categories.service.ts index c3280ef..3cf5153 100644 --- a/back/src/modules/categories/categories.service.ts +++ b/back/src/modules/categories/categories.service.ts @@ -10,11 +10,8 @@ import { CategoryWithCounts, CategoryBrowseResponse, CategoryListResponse, - ApiEntryListItem, - ApiType, - ApiStatus, - ApiConvention, } from '@datacat/shared'; +import { toApiEntryListItem } from '../apis/api-entry.mapper'; @Injectable() export class CategoriesService { @@ -151,25 +148,3 @@ function toCategory(entity: CategoryEntity): Category { }; } -const CONVENTION_LABELS: Record = { - CONSULTER: 'Consulter', - ENREGISTRER: 'Enregistrer', - ETRE_NOTIFIE: 'Être notifié', -}; - -function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem { - return { - id: entity.id, - title: `${CONVENTION_LABELS[entity.convention] ?? entity.convention} ${entity.name}`, - name: entity.name, - convention: entity.convention, - version: `${entity.versionMajor}.${entity.versionMinor}.${entity.versionPatch}`, - description: entity.description, - type: entity.type as ApiType, - provider: entity.provider, - status: entity.status as ApiStatus, - categoryId: entity.categoryId, - createdAt: entity.createdAt.toISOString(), - updatedAt: entity.updatedAt.toISOString(), - }; -} diff --git a/back/src/modules/docs-generation/docs-generation.service.ts b/back/src/modules/docs-generation/docs-generation.service.ts index 5dc806a..054ce98 100644 --- a/back/src/modules/docs-generation/docs-generation.service.ts +++ b/back/src/modules/docs-generation/docs-generation.service.ts @@ -46,7 +46,7 @@ export class DocsGenerationService { } catch (error) { await this.apisService.updateInternal(entryId, { status: 'ERROR', - errorMessage: String(error), + errorMessage: error instanceof Error ? error.message : String(error), }); } } diff --git a/front-public/src/app/pages/api-detail/api-detail.component.ts b/front-public/src/app/pages/api-detail/api-detail.component.ts index 3aa55ba..f026961 100644 --- a/front-public/src/app/pages/api-detail/api-detail.component.ts +++ b/front-public/src/app/pages/api-detail/api-detail.component.ts @@ -1,6 +1,7 @@ import { Component, signal, + computed, inject, OnInit, OnDestroy, @@ -381,10 +382,10 @@ export class ApiDetailComponent implements OnInit, OnDestroy { editContactTechnicalEntity = signal(''); editContactTechnicalEmail = signal(''); - editPreviewTitle() { + editPreviewTitle = computed(() => { const labels: Record = { CONSULTER: 'Consulter', ENREGISTRER: 'Enregistrer', ETRE_NOTIFIE: 'Être notifié' }; return `${labels[this.editConvention()]} ${this.editName()}`.trim(); - } + }); private pollInterval: ReturnType | null = null; private id = ''; diff --git a/front-public/src/app/pages/browse/browse.component.ts b/front-public/src/app/pages/browse/browse.component.ts index 3dbc414..1e5b379 100644 --- a/front-public/src/app/pages/browse/browse.component.ts +++ b/front-public/src/app/pages/browse/browse.component.ts @@ -15,11 +15,13 @@ import { CategoryService } from '../../core/category.service'; import { ApiService } from '../../core/api.service'; import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared'; import { forkJoin } from 'rxjs'; +import { CategoryFormModalComponent } from '../../shared/category-form-modal/category-form-modal.component'; +import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal/category-delete-modal.component'; @Component({ selector: 'app-browse', standalone: true, - imports: [CommonModule, RouterLink], + imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -238,110 +240,27 @@ import { forkJoin } from 'rxjs'; } } } - - - @if (showDeleteModal()) { -
-
-
-
-
-

Supprimer le dossier

-
-
- -
-
-

- Êtes-vous sûr de vouloir supprimer le dossier - « {{ deletingCategory()?.name }} » ? -

-

- Cette action est irréversible. -

- @if (deleteError()) { -
-

{{ deleteError() }}

-
- } -
- - -
-
-
-
- } - - - @if (showFormModal()) { -
-
-
-
-
- -
-
- -
-
- @if (formError()) { -
-

{{ formError() }}

-
- } - @if (formMode() === 'create') { -
- - -
- } -
- - - @if (formNameError()) {

Le nom est obligatoire.

} -
-
- - -
-
- - -
-
-
-
- }
+ + + @if (deletingCategory()) { + + } + + + @if (showFormModal()) { + + } `, }) export class BrowseComponent implements OnInit { @@ -369,64 +288,18 @@ export class BrowseComponent implements OnInit { private searchDebounce: ReturnType | null = null; - /* Signaux pour la modale création / édition */ + /* Signaux pour les modales */ showFormModal = signal(false); formMode = signal<'create' | 'edit'>('create'); - editingCategoryId = signal(null); - formName = signal(''); - formDescription = signal(''); - formParentId = signal(null); - formError = signal(null); - formNameError = signal(false); - formLoading = signal(false); - allCategories = signal([]); - - /* Signaux pour la modale de suppression */ - showDeleteModal = signal(false); + editingCategory = signal(null); deletingCategory = signal(null); - deleteLoading = signal(false); - deleteError = signal(null); - /* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */ ancestors = computed(() => { const d = this.browseData(); if (!d || !d.category) return []; return d.breadcrumb.slice(0, -1); }); - /* Options du select "Emplacement" avec indentation type └ similaire au formulaire d'import */ - categoriesForSelect = computed<{ id: string; label: string }[]>(() => { - const cats = this.allCategories(); - const map = new Map(cats.map((c) => [c.id, c])); - - /* Chemin complet pour le tri */ - const getSortPath = (id: string, depth = 0): string => { - if (depth > 10) return ''; - const cat = map.get(id); - if (!cat) return ''; - if (!cat.parentId) return cat.name; - return `${getSortPath(cat.parentId, depth + 1)} / ${cat.name}`; - }; - - /* Profondeur pour l'indentation */ - const getDepth = (id: string, depth = 0): number => { - if (depth > 10) return depth; - const cat = map.get(id); - if (!cat?.parentId) return depth; - return getDepth(cat.parentId, depth + 1); - }; - - return cats - .map((c) => { - const depth = getDepth(c.id); - const indent = '\u00a0\u00a0'.repeat(depth); - const prefix = depth > 0 ? `${indent}└\u00a0` : ''; - return { id: c.id, label: `${prefix}${c.name}`, _sort: getSortPath(c.id) }; - }) - .sort((a, b) => a._sort.localeCompare(b._sort, 'fr')) - .map(({ id, label }) => ({ id, label })); - }); - ngOnInit() { this.route.paramMap .pipe(takeUntilDestroyed(this.destroyRef)) @@ -453,92 +326,28 @@ export class BrowseComponent implements OnInit { openCreate() { this.formMode.set('create'); - this.editingCategoryId.set(null); - this.formName.set(''); - this.formDescription.set(''); - this.formParentId.set(this.categoryId()); - this.formError.set(null); - this.formNameError.set(false); - /* Charger les catégories d'abord, puis afficher la modale pour que la pré-sélection fonctionne */ - this.categoryService.list().subscribe({ - next: (res) => { - this.allCategories.set(res.items); - this.showFormModal.set(true); - }, - error: () => { - this.allCategories.set([]); - this.showFormModal.set(true); - }, - }); + this.editingCategory.set(null); + this.showFormModal.set(true); } openEdit(cat: Category) { this.formMode.set('edit'); - this.editingCategoryId.set(cat.id); - this.formName.set(cat.name); - this.formDescription.set(cat.description ?? ''); - this.formError.set(null); - this.formNameError.set(false); + this.editingCategory.set(cat); this.showFormModal.set(true); } - closeModal() { - this.showFormModal.set(false); - } - - submitForm() { - if (!this.formName().trim()) { - this.formNameError.set(true); - return; - } - this.formNameError.set(false); - this.formLoading.set(true); - const name = this.formName().trim(); - const description = this.formDescription().trim() || undefined; - - const obs = - this.formMode() === 'create' - ? this.categoryService.create({ - name, - description, - parentId: this.formParentId() ?? undefined, - }) - : this.categoryService.update(this.editingCategoryId()!, { name, description }); - - obs.subscribe({ - next: () => { - this.formLoading.set(false); - this.showFormModal.set(false); - this.loadBrowse(); - }, - error: (err: { error?: { message?: string } }) => { - this.formLoading.set(false); - this.formError.set(err?.error?.message ?? 'Une erreur est survenue.'); - }, - }); - } - openDelete(cat: CategoryWithCounts) { this.deletingCategory.set(cat); - this.deleteError.set(null); - this.showDeleteModal.set(true); } - confirmDelete() { - const cat = this.deletingCategory(); - if (!cat) return; - this.deleteLoading.set(true); - this.categoryService.delete(cat.id).subscribe({ - next: () => { - this.deleteLoading.set(false); - this.showDeleteModal.set(false); - this.loadBrowse(); - }, - error: (err: { error?: { message?: string } }) => { - this.deleteLoading.set(false); - this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.'); - }, - }); + onFormSaved() { + this.showFormModal.set(false); + this.loadBrowse(); + } + + onDeleteConfirmed() { + this.deletingCategory.set(null); + this.loadBrowse(); } onSearchChange(value: string) { diff --git a/front-public/src/app/shared/category-delete-modal/category-delete-modal.component.ts b/front-public/src/app/shared/category-delete-modal/category-delete-modal.component.ts new file mode 100644 index 0000000..d902f38 --- /dev/null +++ b/front-public/src/app/shared/category-delete-modal/category-delete-modal.component.ts @@ -0,0 +1,79 @@ +import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CategoryService } from '../../core/category.service'; +import { CategoryWithCounts } from '@datacat/shared'; + +@Component({ + selector: 'app-category-delete-modal', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+
+
+

Supprimer le dossier

+
+
+ +
+
+

+ Êtes-vous sûr de vouloir supprimer le dossier + « {{ category().name }} » ? +

+

+ Cette action est irréversible. +

+ @if (deleteError()) { +
+

{{ deleteError() }}

+
+ } +
+ + +
+
+
+
+ `, +}) +export class CategoryDeleteModalComponent { + private categoryService = inject(CategoryService); + + category = input.required(); + confirmed = output(); + cancelled = output(); + + deleteLoading = signal(false); + deleteError = signal(null); + + cancel() { + this.cancelled.emit(); + } + + confirmDelete() { + const cat = this.category(); + this.deleteLoading.set(true); + this.deleteError.set(null); + this.categoryService.delete(cat.id).subscribe({ + next: () => { + this.deleteLoading.set(false); + this.confirmed.emit(); + }, + error: (err: { error?: { message?: string } }) => { + this.deleteLoading.set(false); + this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.'); + }, + }); + } +} diff --git a/front-public/src/app/shared/category-form-modal/category-form-modal.component.ts b/front-public/src/app/shared/category-form-modal/category-form-modal.component.ts new file mode 100644 index 0000000..9d58d20 --- /dev/null +++ b/front-public/src/app/shared/category-form-modal/category-form-modal.component.ts @@ -0,0 +1,173 @@ +import { + Component, + signal, + computed, + inject, + OnInit, + ChangeDetectionStrategy, + input, + output, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CategoryService } from '../../core/category.service'; +import { Category } from '@datacat/shared'; + +@Component({ + selector: 'app-category-form-modal', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+
+
+ +
+
+ +
+
+ @if (formError()) { +
+

{{ formError() }}

+
+ } + @if (mode() === 'create') { +
+ + +
+ } +
+ + + @if (formNameError()) {

Le nom est obligatoire.

} +
+
+ + +
+
+ + +
+
+
+
+ `, +}) +export class CategoryFormModalComponent implements OnInit { + private categoryService = inject(CategoryService); + + mode = input<'create' | 'edit'>('create'); + category = input(null); + parentId = input(null); + saved = output(); + closed = output(); + + formName = signal(''); + formDescription = signal(''); + formParentId = signal(null); + formError = signal(null); + formNameError = signal(false); + formLoading = signal(false); + allCategories = signal([]); + editingCategoryId = signal(null); + + categoriesForSelect = computed<{ id: string; label: string }[]>(() => { + const cats = this.allCategories(); + const map = new Map(cats.map((c) => [c.id, c])); + + const getSortPath = (id: string, depth = 0): string => { + if (depth > 10) return ''; + const cat = map.get(id); + if (!cat) return ''; + if (!cat.parentId) return cat.name; + return `${getSortPath(cat.parentId, depth + 1)} / ${cat.name}`; + }; + + const getDepth = (id: string, depth = 0): number => { + if (depth > 10) return depth; + const cat = map.get(id); + if (!cat?.parentId) return depth; + return getDepth(cat.parentId, depth + 1); + }; + + return cats + .map((c) => { + const depth = getDepth(c.id); + const indent = '\u00a0\u00a0'.repeat(depth); + const prefix = depth > 0 ? `${indent}└\u00a0` : ''; + return { id: c.id, label: `${prefix}${c.name}`, _sort: getSortPath(c.id) }; + }) + .sort((a, b) => a._sort.localeCompare(b._sort, 'fr')) + .map(({ id, label }) => ({ id, label })); + }); + + ngOnInit() { + const cat = this.category(); + if (this.mode() === 'edit' && cat) { + this.editingCategoryId.set(cat.id); + this.formName.set(cat.name); + this.formDescription.set(cat.description ?? ''); + } else { + this.formParentId.set(this.parentId()); + this.categoryService.list().subscribe({ + next: (res) => this.allCategories.set(res.items), + error: () => this.allCategories.set([]), + }); + } + } + + close() { + this.closed.emit(); + } + + submit() { + if (!this.formName().trim()) { + this.formNameError.set(true); + return; + } + this.formNameError.set(false); + this.formLoading.set(true); + const name = this.formName().trim(); + const description = this.formDescription().trim() || undefined; + + const obs = + this.mode() === 'create' + ? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined }) + : this.categoryService.update(this.editingCategoryId()!, { name, description }); + + obs.subscribe({ + next: () => { + this.formLoading.set(false); + this.saved.emit(); + }, + error: (err: { error?: { message?: string } }) => { + this.formLoading.set(false); + this.formError.set(err?.error?.message ?? 'Une erreur est survenue.'); + }, + }); + } +}