refactor: fix bugs et réduire duplication + extraire modales BrowseComponent
- fix(dto): ajouter @IsEmail() sur contactFunctionalEmail/TechnicalEmail dans UpdateApiEntryDto - fix(docs): corriger stringify des erreurs (String(error) → error.message) - refactor(mapper): extraire toApiEntryListItem() dans api-entry.mapper.ts partagé - refactor(categories): supprimer CONVENTION_LABELS et toApiEntryListItem locaux dupliqués - refactor(browse): extraire CategoryFormModalComponent et CategoryDeleteModalComponent - refactor(api-detail): convertir editPreviewTitle() en computed() Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
19
back/src/modules/apis/api-entry.mapper.ts
Normal file
19
back/src/modules/apis/api-entry.mapper.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiEntryEntity } from './api-entry.entity';
|
||||||
|
import { ApiEntryListItem, API_CONVENTION_LABELS } from '@datacat/shared';
|
||||||
|
|
||||||
|
export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
title: `${API_CONVENTION_LABELS[entity.convention]} ${entity.name}`,
|
||||||
|
name: entity.name,
|
||||||
|
convention: entity.convention,
|
||||||
|
version: `${entity.versionMajor}.${entity.versionMinor}.${entity.versionPatch}`,
|
||||||
|
description: entity.description,
|
||||||
|
type: entity.type,
|
||||||
|
provider: entity.provider,
|
||||||
|
status: entity.status,
|
||||||
|
categoryId: entity.categoryId,
|
||||||
|
createdAt: entity.createdAt.toISOString(),
|
||||||
|
updatedAt: entity.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import { Repository } from 'typeorm';
|
|||||||
import { ApiEntryEntity } from './api-entry.entity';
|
import { ApiEntryEntity } from './api-entry.entity';
|
||||||
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
||||||
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
||||||
import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus, 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';
|
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||||
|
|
||||||
/** Champs internes mis à jour par DocsGenerationService */
|
/** 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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { Type } from 'class-transformer';
|
||||||
import { ApiConvention } from '@datacat/shared';
|
import { ApiConvention } from '@datacat/shared';
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export class UpdateApiEntryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
contactFunctionalEntity?: string;
|
contactFunctionalEntity?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEmail()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
contactFunctionalEmail?: string;
|
contactFunctionalEmail?: string;
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export class UpdateApiEntryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
contactTechnicalEntity?: string;
|
contactTechnicalEntity?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEmail()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
contactTechnicalEmail?: string;
|
contactTechnicalEmail?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ import {
|
|||||||
CategoryWithCounts,
|
CategoryWithCounts,
|
||||||
CategoryBrowseResponse,
|
CategoryBrowseResponse,
|
||||||
CategoryListResponse,
|
CategoryListResponse,
|
||||||
ApiEntryListItem,
|
|
||||||
ApiType,
|
|
||||||
ApiStatus,
|
|
||||||
ApiConvention,
|
|
||||||
} from '@datacat/shared';
|
} from '@datacat/shared';
|
||||||
|
import { toApiEntryListItem } from '../apis/api-entry.mapper';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
@@ -151,25 +148,3 @@ function toCategory(entity: CategoryEntity): Category {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONVENTION_LABELS: Record<ApiConvention, string> = {
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class DocsGenerationService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.apisService.updateInternal(entryId, {
|
await this.apisService.updateInternal(entryId, {
|
||||||
status: 'ERROR',
|
status: 'ERROR',
|
||||||
errorMessage: String(error),
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
signal,
|
signal,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
@@ -381,10 +382,10 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
editContactTechnicalEntity = signal('');
|
editContactTechnicalEntity = signal('');
|
||||||
editContactTechnicalEmail = signal('');
|
editContactTechnicalEmail = signal('');
|
||||||
|
|
||||||
editPreviewTitle() {
|
editPreviewTitle = computed(() => {
|
||||||
const labels: Record<ApiConvention, string> = { CONSULTER: 'Consulter', ENREGISTRER: 'Enregistrer', ETRE_NOTIFIE: 'Être notifié' };
|
const labels: Record<ApiConvention, string> = { CONSULTER: 'Consulter', ENREGISTRER: 'Enregistrer', ETRE_NOTIFIE: 'Être notifié' };
|
||||||
return `${labels[this.editConvention()]} ${this.editName()}`.trim();
|
return `${labels[this.editConvention()]} ${this.editName()}`.trim();
|
||||||
}
|
});
|
||||||
|
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private id = '';
|
private id = '';
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import { CategoryService } from '../../core/category.service';
|
|||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared';
|
import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
|
import { CategoryFormModalComponent } from '../../shared/category-form-modal/category-form-modal.component';
|
||||||
|
import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal/category-delete-modal.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-browse',
|
selector: 'app-browse',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink],
|
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="fr-container fr-mt-4w fr-mb-4w">
|
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||||
@@ -238,110 +240,27 @@ import { forkJoin } from 'rxjs';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Modale de suppression -->
|
|
||||||
@if (showDeleteModal()) {
|
|
||||||
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
|
|
||||||
role="dialog" aria-modal="true" aria-labelledby="delete-modal-title">
|
|
||||||
<div style="background:white;width:calc(100% - 2rem);max-width:480px;margin:0 1rem 2rem">
|
|
||||||
<div class="fr-p-4w">
|
|
||||||
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
|
||||||
<div class="fr-col">
|
|
||||||
<h2 id="delete-modal-title" class="fr-h3 fr-mb-0">Supprimer le dossier</h2>
|
|
||||||
</div>
|
|
||||||
<div class="fr-col-auto">
|
|
||||||
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
|
|
||||||
(click)="showDeleteModal.set(false)">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="fr-text--lead fr-mb-1w">
|
|
||||||
Êtes-vous sûr de vouloir supprimer le dossier
|
|
||||||
<strong>« {{ deletingCategory()?.name }} »</strong> ?
|
|
||||||
</p>
|
|
||||||
<p class="fr-text--sm fr-text--mention-grey fr-mb-3w">
|
|
||||||
Cette action est irréversible.
|
|
||||||
</p>
|
|
||||||
@if (deleteError()) {
|
|
||||||
<div class="fr-alert fr-alert--error fr-mb-2w">
|
|
||||||
<p>{{ deleteError() }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
|
|
||||||
<button class="fr-btn fr-btn--secondary" (click)="showDeleteModal.set(false)"
|
|
||||||
[disabled]="deleteLoading()">Annuler</button>
|
|
||||||
<button class="fr-btn fr-btn--error" (click)="confirmDelete()"
|
|
||||||
[disabled]="deleteLoading()">
|
|
||||||
{{ deleteLoading() ? 'Suppression...' : 'Supprimer' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Modale création / édition (overlay Angular, sans fr-modal pour éviter l'interférence du JS DSFR) -->
|
|
||||||
@if (showFormModal()) {
|
|
||||||
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
|
|
||||||
role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
|
||||||
<div style="background:white;width:calc(100% - 2rem);max-width:540px;margin:0 1rem 2rem">
|
|
||||||
<div class="fr-p-4w">
|
|
||||||
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
|
||||||
<div class="fr-col">
|
|
||||||
<h2 id="modal-title" class="fr-h3 fr-mb-0">
|
|
||||||
{{ formMode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="fr-col-auto">
|
|
||||||
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
|
|
||||||
(click)="closeModal()">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (formError()) {
|
|
||||||
<div class="fr-alert fr-alert--error fr-mb-2w">
|
|
||||||
<p>{{ formError() }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (formMode() === 'create') {
|
|
||||||
<div class="fr-select-group fr-mb-2w">
|
|
||||||
<label class="fr-label" for="form-parent">Emplacement</label>
|
|
||||||
<select id="form-parent" class="fr-select"
|
|
||||||
(change)="formParentId.set($any($event.target).value || null)">
|
|
||||||
<option value="" [selected]="!formParentId()"></option>
|
|
||||||
@for (opt of categoriesForSelect(); track opt.id) {
|
|
||||||
<option [value]="opt.id" [selected]="opt.id === formParentId()">{{ opt.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="formNameError()">
|
|
||||||
<label class="fr-label" for="form-name">
|
|
||||||
Nom <span style="color:var(--text-default-error)">*</span>
|
|
||||||
</label>
|
|
||||||
<input id="form-name" class="fr-input" [class.fr-input--error]="formNameError()" type="text"
|
|
||||||
[value]="formName()"
|
|
||||||
(input)="formName.set($any($event.target).value); formNameError.set(false)"
|
|
||||||
(blur)="formNameError.set(!formName().trim())" />
|
|
||||||
@if (formNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
|
|
||||||
</div>
|
|
||||||
<div class="fr-input-group fr-mb-3w">
|
|
||||||
<label class="fr-label" for="form-desc">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea id="form-desc" class="fr-input" rows="3"
|
|
||||||
[value]="formDescription()"
|
|
||||||
(input)="formDescription.set($any($event.target).value)"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
|
|
||||||
<button class="fr-btn fr-btn--secondary" (click)="closeModal()">Annuler</button>
|
|
||||||
<button class="fr-btn" (click)="submitForm()" [disabled]="formLoading()">
|
|
||||||
{{ formMode() === 'create' ? 'Créer' : 'Enregistrer' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale de suppression -->
|
||||||
|
@if (deletingCategory()) {
|
||||||
|
<app-category-delete-modal
|
||||||
|
[category]="deletingCategory()!"
|
||||||
|
(confirmed)="onDeleteConfirmed()"
|
||||||
|
(cancelled)="deletingCategory.set(null)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Modale création / édition -->
|
||||||
|
@if (showFormModal()) {
|
||||||
|
<app-category-form-modal
|
||||||
|
[mode]="formMode()"
|
||||||
|
[category]="editingCategory()"
|
||||||
|
[parentId]="categoryId()"
|
||||||
|
(saved)="onFormSaved()"
|
||||||
|
(closed)="showFormModal.set(false)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class BrowseComponent implements OnInit {
|
export class BrowseComponent implements OnInit {
|
||||||
@@ -369,64 +288,18 @@ export class BrowseComponent implements OnInit {
|
|||||||
|
|
||||||
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
/* Signaux pour la modale création / édition */
|
/* Signaux pour les modales */
|
||||||
showFormModal = signal(false);
|
showFormModal = signal(false);
|
||||||
formMode = signal<'create' | 'edit'>('create');
|
formMode = signal<'create' | 'edit'>('create');
|
||||||
editingCategoryId = signal<string | null>(null);
|
editingCategory = signal<Category | null>(null);
|
||||||
formName = signal('');
|
|
||||||
formDescription = signal('');
|
|
||||||
formParentId = signal<string | null>(null);
|
|
||||||
formError = signal<string | null>(null);
|
|
||||||
formNameError = signal(false);
|
|
||||||
formLoading = signal(false);
|
|
||||||
allCategories = signal<Category[]>([]);
|
|
||||||
|
|
||||||
/* Signaux pour la modale de suppression */
|
|
||||||
showDeleteModal = signal(false);
|
|
||||||
deletingCategory = signal<CategoryWithCounts | null>(null);
|
deletingCategory = signal<CategoryWithCounts | null>(null);
|
||||||
deleteLoading = signal(false);
|
|
||||||
deleteError = signal<string | null>(null);
|
|
||||||
|
|
||||||
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
|
|
||||||
ancestors = computed<Category[]>(() => {
|
ancestors = computed<Category[]>(() => {
|
||||||
const d = this.browseData();
|
const d = this.browseData();
|
||||||
if (!d || !d.category) return [];
|
if (!d || !d.category) return [];
|
||||||
return d.breadcrumb.slice(0, -1);
|
return d.breadcrumb.slice(0, -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* 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() {
|
ngOnInit() {
|
||||||
this.route.paramMap
|
this.route.paramMap
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
@@ -453,92 +326,28 @@ export class BrowseComponent implements OnInit {
|
|||||||
|
|
||||||
openCreate() {
|
openCreate() {
|
||||||
this.formMode.set('create');
|
this.formMode.set('create');
|
||||||
this.editingCategoryId.set(null);
|
this.editingCategory.set(null);
|
||||||
this.formName.set('');
|
this.showFormModal.set(true);
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openEdit(cat: Category) {
|
openEdit(cat: Category) {
|
||||||
this.formMode.set('edit');
|
this.formMode.set('edit');
|
||||||
this.editingCategoryId.set(cat.id);
|
this.editingCategory.set(cat);
|
||||||
this.formName.set(cat.name);
|
|
||||||
this.formDescription.set(cat.description ?? '');
|
|
||||||
this.formError.set(null);
|
|
||||||
this.formNameError.set(false);
|
|
||||||
this.showFormModal.set(true);
|
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) {
|
openDelete(cat: CategoryWithCounts) {
|
||||||
this.deletingCategory.set(cat);
|
this.deletingCategory.set(cat);
|
||||||
this.deleteError.set(null);
|
|
||||||
this.showDeleteModal.set(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmDelete() {
|
onFormSaved() {
|
||||||
const cat = this.deletingCategory();
|
this.showFormModal.set(false);
|
||||||
if (!cat) return;
|
this.loadBrowse();
|
||||||
this.deleteLoading.set(true);
|
}
|
||||||
this.categoryService.delete(cat.id).subscribe({
|
|
||||||
next: () => {
|
onDeleteConfirmed() {
|
||||||
this.deleteLoading.set(false);
|
this.deletingCategory.set(null);
|
||||||
this.showDeleteModal.set(false);
|
this.loadBrowse();
|
||||||
this.loadBrowse();
|
|
||||||
},
|
|
||||||
error: (err: { error?: { message?: string } }) => {
|
|
||||||
this.deleteLoading.set(false);
|
|
||||||
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearchChange(value: string) {
|
onSearchChange(value: string) {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CategoryService } from '../../core/category.service';
|
||||||
|
import { CategoryWithCounts } from '@datacat/shared';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-category-delete-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
|
||||||
|
role="dialog" aria-modal="true" aria-labelledby="delete-modal-title">
|
||||||
|
<div style="background:white;width:calc(100% - 2rem);max-width:480px;margin:0 1rem 2rem">
|
||||||
|
<div class="fr-p-4w">
|
||||||
|
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
||||||
|
<div class="fr-col">
|
||||||
|
<h2 id="delete-modal-title" class="fr-h3 fr-mb-0">Supprimer le dossier</h2>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-auto">
|
||||||
|
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
|
||||||
|
(click)="cancel()">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="fr-text--lead fr-mb-1w">
|
||||||
|
Êtes-vous sûr de vouloir supprimer le dossier
|
||||||
|
<strong>« {{ category().name }} »</strong> ?
|
||||||
|
</p>
|
||||||
|
<p class="fr-text--sm fr-text--mention-grey fr-mb-3w">
|
||||||
|
Cette action est irréversible.
|
||||||
|
</p>
|
||||||
|
@if (deleteError()) {
|
||||||
|
<div class="fr-alert fr-alert--error fr-mb-2w">
|
||||||
|
<p>{{ deleteError() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
|
||||||
|
<button class="fr-btn fr-btn--secondary" (click)="cancel()"
|
||||||
|
[disabled]="deleteLoading()">Annuler</button>
|
||||||
|
<button class="fr-btn fr-btn--error" (click)="confirmDelete()"
|
||||||
|
[disabled]="deleteLoading()">
|
||||||
|
{{ deleteLoading() ? 'Suppression...' : 'Supprimer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class CategoryDeleteModalComponent {
|
||||||
|
private categoryService = inject(CategoryService);
|
||||||
|
|
||||||
|
category = input.required<CategoryWithCounts>();
|
||||||
|
confirmed = output<void>();
|
||||||
|
cancelled = output<void>();
|
||||||
|
|
||||||
|
deleteLoading = signal(false);
|
||||||
|
deleteError = signal<string | null>(null);
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.cancelled.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete() {
|
||||||
|
const cat = this.category();
|
||||||
|
this.deleteLoading.set(true);
|
||||||
|
this.deleteError.set(null);
|
||||||
|
this.categoryService.delete(cat.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.deleteLoading.set(false);
|
||||||
|
this.confirmed.emit();
|
||||||
|
},
|
||||||
|
error: (err: { error?: { message?: string } }) => {
|
||||||
|
this.deleteLoading.set(false);
|
||||||
|
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CategoryService } from '../../core/category.service';
|
||||||
|
import { Category } from '@datacat/shared';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-category-form-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
|
||||||
|
role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
|
<div style="background:white;width:calc(100% - 2rem);max-width:540px;margin:0 1rem 2rem">
|
||||||
|
<div class="fr-p-4w">
|
||||||
|
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
||||||
|
<div class="fr-col">
|
||||||
|
<h2 id="modal-title" class="fr-h3 fr-mb-0">
|
||||||
|
{{ mode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-auto">
|
||||||
|
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
|
||||||
|
(click)="close()">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (formError()) {
|
||||||
|
<div class="fr-alert fr-alert--error fr-mb-2w">
|
||||||
|
<p>{{ formError() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (mode() === 'create') {
|
||||||
|
<div class="fr-select-group fr-mb-2w">
|
||||||
|
<label class="fr-label" for="form-parent">Emplacement</label>
|
||||||
|
<select id="form-parent" class="fr-select"
|
||||||
|
(change)="formParentId.set($any($event.target).value || null)">
|
||||||
|
<option value="" [selected]="!formParentId()"></option>
|
||||||
|
@for (opt of categoriesForSelect(); track opt.id) {
|
||||||
|
<option [value]="opt.id" [selected]="opt.id === formParentId()">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="formNameError()">
|
||||||
|
<label class="fr-label" for="form-name">
|
||||||
|
Nom <span style="color:var(--text-default-error)">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="form-name" class="fr-input" [class.fr-input--error]="formNameError()" type="text"
|
||||||
|
[value]="formName()"
|
||||||
|
(input)="formName.set($any($event.target).value); formNameError.set(false)"
|
||||||
|
(blur)="formNameError.set(!formName().trim())" />
|
||||||
|
@if (formNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
|
||||||
|
</div>
|
||||||
|
<div class="fr-input-group fr-mb-3w">
|
||||||
|
<label class="fr-label" for="form-desc">Description</label>
|
||||||
|
<textarea id="form-desc" class="fr-input" rows="3"
|
||||||
|
[value]="formDescription()"
|
||||||
|
(input)="formDescription.set($any($event.target).value)"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
|
||||||
|
<button class="fr-btn fr-btn--secondary" (click)="close()">Annuler</button>
|
||||||
|
<button class="fr-btn" (click)="submit()" [disabled]="formLoading()">
|
||||||
|
{{ mode() === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class CategoryFormModalComponent implements OnInit {
|
||||||
|
private categoryService = inject(CategoryService);
|
||||||
|
|
||||||
|
mode = input<'create' | 'edit'>('create');
|
||||||
|
category = input<Category | null>(null);
|
||||||
|
parentId = input<string | null>(null);
|
||||||
|
saved = output<void>();
|
||||||
|
closed = output<void>();
|
||||||
|
|
||||||
|
formName = signal('');
|
||||||
|
formDescription = signal('');
|
||||||
|
formParentId = signal<string | null>(null);
|
||||||
|
formError = signal<string | null>(null);
|
||||||
|
formNameError = signal(false);
|
||||||
|
formLoading = signal(false);
|
||||||
|
allCategories = signal<Category[]>([]);
|
||||||
|
editingCategoryId = signal<string | null>(null);
|
||||||
|
|
||||||
|
categoriesForSelect = computed<{ id: string; label: string }[]>(() => {
|
||||||
|
const cats = this.allCategories();
|
||||||
|
const map = new Map(cats.map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
const getSortPath = (id: string, depth = 0): string => {
|
||||||
|
if (depth > 10) return '';
|
||||||
|
const cat = map.get(id);
|
||||||
|
if (!cat) return '';
|
||||||
|
if (!cat.parentId) return cat.name;
|
||||||
|
return `${getSortPath(cat.parentId, depth + 1)} / ${cat.name}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDepth = (id: string, depth = 0): number => {
|
||||||
|
if (depth > 10) return depth;
|
||||||
|
const cat = map.get(id);
|
||||||
|
if (!cat?.parentId) return depth;
|
||||||
|
return getDepth(cat.parentId, depth + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return cats
|
||||||
|
.map((c) => {
|
||||||
|
const depth = getDepth(c.id);
|
||||||
|
const indent = '\u00a0\u00a0'.repeat(depth);
|
||||||
|
const prefix = depth > 0 ? `${indent}└\u00a0` : '';
|
||||||
|
return { id: c.id, label: `${prefix}${c.name}`, _sort: getSortPath(c.id) };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a._sort.localeCompare(b._sort, 'fr'))
|
||||||
|
.map(({ id, label }) => ({ id, label }));
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const cat = this.category();
|
||||||
|
if (this.mode() === 'edit' && cat) {
|
||||||
|
this.editingCategoryId.set(cat.id);
|
||||||
|
this.formName.set(cat.name);
|
||||||
|
this.formDescription.set(cat.description ?? '');
|
||||||
|
} else {
|
||||||
|
this.formParentId.set(this.parentId());
|
||||||
|
this.categoryService.list().subscribe({
|
||||||
|
next: (res) => this.allCategories.set(res.items),
|
||||||
|
error: () => this.allCategories.set([]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (!this.formName().trim()) {
|
||||||
|
this.formNameError.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.formNameError.set(false);
|
||||||
|
this.formLoading.set(true);
|
||||||
|
const name = this.formName().trim();
|
||||||
|
const description = this.formDescription().trim() || undefined;
|
||||||
|
|
||||||
|
const obs =
|
||||||
|
this.mode() === 'create'
|
||||||
|
? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined })
|
||||||
|
: this.categoryService.update(this.editingCategoryId()!, { name, description });
|
||||||
|
|
||||||
|
obs.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.formLoading.set(false);
|
||||||
|
this.saved.emit();
|
||||||
|
},
|
||||||
|
error: (err: { error?: { message?: string } }) => {
|
||||||
|
this.formLoading.set(false);
|
||||||
|
this.formError.set(err?.error?.message ?? 'Une erreur est survenue.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user