7 Commits

Author SHA1 Message Date
Matthieu
79d2df8bc6 perf(composables) : add smart cache to usePieces and useComposants
Align with useProducts pattern: loaded flag, cache-first return,
loading guard, and clearCache helper to avoid redundant API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:18:42 +01:00
Matthieu
23da4ba4c7 style(theme) : apply Malio brand colors
Primary #304998 (bleu Malio), base #FBFAFA (gris), accent #ED8521
(orange), secondary #A5ACD0 (lavande). Focus ring updated to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:06:20 +01:00
Matthieu
635b8f0461 feat(activity-log) : add global activity log page with filters and pagination
New /activity-log page showing all audit entries across pieces, products
and composants. Includes entity type and action filters, expandable
diffs, clickable entity links and pagination. Navbar link added under
Ressources liées.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:12 +01:00
Matthieu
bf74a50f57 feat(catalog) : make category types clickable in catalog pages
Type columns in piece, component and product catalogs now link
directly to the category edit page for quick access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:07 +01:00
Matthieu
7c44778f25 fix(edit-pages): resolve custom field display race condition
The init watcher destructured currentType/currentStructure before
setting selectedTypeId, so the values were stale (null). This caused
refreshCustomFieldInputs to receive null structure → empty definitions,
permanently wiping custom field display on piece and component edit pages.

Read selectedType.value / selectedTypeStructure.value after setting the
ID so the computed is already updated. Also remove the guard on the
piece selectedType watcher that prevented recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:47:54 +01:00
Matthieu
9f7dd12b34 perf(edit-pages) : reduce blocking API calls on edit pages
- Remove redundant getCustomFieldValuesByEntity() calls (use entity response)
- Remove redundant refreshDocuments() from onMounted (docs already in entity)
- Make loadHistory() non-blocking (fire-and-forget)
- Defer bulk catalog loads on component edit (pieces/products/composants)
- Use pieceTypes cache instead of separate getModelType() call on piece edit
- Try embedded typeProduct from entity response on product edit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:36 +01:00
Matthieu
67af3c9c46 feat: add API optimizations, cache invalidation and comprehensive test suite
- Add abort controllers and request deduplication to composables
- Add entity type cache invalidation on create/update/delete flows
- Add 179 new tests (utilities, services, composables, components)
- Fix Vue runtime warnings in structure editors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:19:08 +01:00
40 changed files with 2829 additions and 130 deletions

View File

@@ -6,26 +6,31 @@
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
color-scheme: light; /* color of browser-provided UI */
--color-base-100: oklch(98% 0.02 240);
--color-base-200: oklch(95% 0.03 240);
--color-base-300: oklch(92% 0.04 240);
--color-base-content: oklch(20% 0.05 240);
--color-primary: oklch(55% 0.3 240);
--color-primary-content: oklch(98% 0.01 240);
--color-secondary: oklch(70% 0.25 200);
--color-secondary-content: oklch(98% 0.01 200);
--color-accent: oklch(65% 0.25 160);
--color-accent-content: oklch(98% 0.01 160);
--color-neutral: oklch(50% 0.05 240);
--color-neutral-content: oklch(98% 0.01 240);
--color-info: oklch(70% 0.2 220);
--color-info-content: oklch(98% 0.01 220);
--color-success: oklch(65% 0.25 140);
--color-success-content: oklch(98% 0.01 140);
--color-warning: oklch(80% 0.25 80);
--color-warning-content: oklch(20% 0.05 80);
--color-error: oklch(65% 0.3 30);
--color-error-content: oklch(98% 0.01 30);
/* #FBFAFA — gris clair */
--color-base-100: oklch(98% 0.003 0);
--color-base-200: oklch(94% 0.01 262);
--color-base-300: oklch(90% 0.02 262);
--color-base-content: oklch(20% 0.03 262);
/* #304998 — bleu Malio */
--color-primary: oklch(37% 0.15 262);
--color-primary-content: oklch(98% 0.005 262);
/* #A5ACD0 — lavande */
--color-secondary: oklch(75% 0.055 270);
--color-secondary-content: oklch(20% 0.03 270);
/* #ED8521 — orange */
--color-accent: oklch(71% 0.17 58);
--color-accent-content: oklch(98% 0.005 58);
/* neutral dérivé du bleu Malio */
--color-neutral: oklch(37% 0.08 262);
--color-neutral-content: oklch(98% 0.005 262);
--color-info: oklch(55% 0.12 262);
--color-info-content: oklch(98% 0.005 262);
--color-success: oklch(65% 0.2 145);
--color-success-content: oklch(98% 0.005 145);
--color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(20% 0.05 70);
--color-error: oklch(60% 0.25 25);
--color-error-content: oklch(98% 0.005 25);
/* border radius */
--radius-selector: 1rem;
@@ -114,7 +119,7 @@
/* Focus visible pour l'accessibilité */
*:focus-visible {
outline: 2px solid #3b82f6;
outline: 2px solid #304998;
outline-offset: 2px;
}

View File

@@ -118,7 +118,6 @@
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
:disabled="isFieldLocked(field)"
>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<option value="text">

View File

@@ -114,7 +114,6 @@
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
:disabled="isCustomFieldLocked(index)"
/>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
<option value="text">Texte</option>

View File

@@ -275,11 +275,12 @@ const navGroups: NavGroup[] = [
{
id: 'resources',
label: 'Ressources liées',
activePaths: ['/sites', '/documents', '/constructeurs'],
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
children: [
{ to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' },
{ to: '/activity-log', label: 'Journal d\'activité' },
],
},
]

View File

@@ -106,6 +106,7 @@ import {
type ModelTypeListResponse,
} from "~/services/modelTypes";
import { useToast } from "~/composables/useToast";
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
const DEFAULT_DESCRIPTION =
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
@@ -305,6 +306,7 @@ const confirmDelete = async (item: ModelType) => {
try {
await deleteModelType(item.id);
invalidateEntityTypeCache(item.category);
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
if (items.value.length === 1 && offset.value >= limit.value) {

View File

@@ -0,0 +1,70 @@
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type ActivityLogActor = {
id: string
label: string
}
export type ActivityLogEntry = {
id: string
entityType: string
entityId: string
entityName: string | null
entityRef: string | null
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: ActivityLogActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
interface LoadActivityLogOptions {
page?: number
itemsPerPage?: number
entityType?: string
action?: string
}
export function useActivityLog() {
const { get } = useApi()
const entries = ref<ActivityLogEntry[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const loadActivityLog = async (options: LoadActivityLogOptions = {}) => {
loading.value = true
error.value = null
try {
const params = new URLSearchParams()
params.set('page', String(options.page ?? 1))
params.set('itemsPerPage', String(options.itemsPerPage ?? 30))
if (options.entityType) params.set('entityType', options.entityType)
if (options.action) params.set('action', options.action)
const result = await get(`/activity-logs?${params.toString()}`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger le journal d\'activité.'
entries.value = []
return result
}
const data = result.data as any
entries.value = Array.isArray(data?.items) ? data.items : []
total.value = typeof data?.total === 'number' ? data.total : entries.value.length
return { success: true, data: entries.value }
} catch (err: any) {
const message = err?.message ?? 'Erreur inconnue'
error.value = message
entries.value = []
return { success: false, error: message }
} finally {
loading.value = false
}
}
return { entries, total, loading, error, loadActivityLog }
}

View File

@@ -80,9 +80,9 @@ export function useCategoryEditGuard (config: GuardConfig) {
return ''
}
if (linkedCount.value === 1) {
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés, mais pas modifier ou supprimer les existants.`
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
}
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés, mais pas modifier ou supprimer les existants.`
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
})
const submitBlockMessage = computed(() => {

View File

@@ -40,11 +40,13 @@ interface LoadComposantsOptions {
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
force?: boolean
}
const composants = ref<Composant[]>([])
const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
@@ -98,15 +100,31 @@ export function useComposants() {
}
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
force = false,
} = options
if (!force && loaded.value && !search && page === 1) {
return {
success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage },
}
}
if (loading.value) {
return {
success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage },
}
}
loading.value = true
try {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
} = options
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
@@ -124,6 +142,7 @@ export function useComposants() {
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
composants.value = enrichedItems
total.value = extractTotal(result.data, items.length)
loaded.value = true
return {
success: true,
data: {
@@ -216,15 +235,23 @@ export function useComposants() {
const getComposants = () => composants.value
const isLoading = () => loading.value
const clearComposantsCache = () => {
composants.value = []
total.value = 0
loaded.value = false
}
return {
composants,
total,
loading,
loaded,
loadComposants,
createComposant,
updateComposant: updateComposantData,
deleteComposant,
getComposants,
isLoading,
clearComposantsCache,
}
}

View File

@@ -18,6 +18,7 @@ interface ConstructeurResult {
const constructeurs = ref<Constructeur[]>([])
const loading = ref(false)
const loaded = ref(false)
const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
const map = new Map<string, Constructeur>()
@@ -59,7 +60,10 @@ export function useConstructeurs() {
const { get, post, patch, delete: del } = useApi()
const { showSuccess, showError } = useToast()
const loadConstructeurs = async (search = ''): Promise<ConstructeurResult> => {
const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise<ConstructeurResult> => {
if (!search && !options.force && loaded.value) {
return { success: true, data: constructeurs.value }
}
loading.value = true
try {
const query = search ? `?search=${encodeURIComponent(search)}` : ''
@@ -67,6 +71,7 @@ export function useConstructeurs() {
if (result.success) {
const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items)
if (!search) loaded.value = true
}
return result as ConstructeurResult
} catch (error) {

View File

@@ -50,18 +50,31 @@ const generateCodeFromName = (name: string): string => {
}
// Shared state per category (module-level singletons)
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean> }> = {}
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean>; loaded: Ref<boolean> }> = {}
function getOrCreateState(category: ModelCategory) {
if (!stateByCategory[category]) {
stateByCategory[category] = {
types: ref<EntityType[]>([]),
loading: ref(false),
loaded: ref(false),
}
}
return stateByCategory[category]
}
/**
* Marks the cache for a given category as stale.
* Next call to `loadTypes()` (without `force`) will refetch from the API.
* Safe to call from event handlers (no setup context required).
*/
export function invalidateEntityTypeCache(category: ModelCategory) {
const state = stateByCategory[category]
if (state) {
state.loaded.value = false
}
}
export function useEntityTypes(config: EntityTypeConfig) {
const { category, label } = config
const { showSuccess, showError } = useToast()
@@ -72,7 +85,10 @@ export function useEntityTypes(config: EntityTypeConfig) {
description: item.description ?? item.notes ?? null,
})
const loadTypes = async (): Promise<EntityTypeResult> => {
const loadTypes = async (options: { force?: boolean } = {}): Promise<EntityTypeResult> => {
if (!options.force && state.loaded.value) {
return { success: true, data: state.types.value }
}
state.loading.value = true
try {
const data = await listModelTypes({
@@ -82,6 +98,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
limit: 200,
})
state.types.value = data.items.map(normalizeItem)
state.loaded.value = true
return { success: true, data: state.types.value }
} catch (error) {
const err = error as Error & { message?: string }

View File

@@ -1198,6 +1198,11 @@ export function useMachineDetailData(machineId: string) {
syncMachineCustomFields()
initMachineFields()
// Start document loading early (independent of products/links)
const documentPromise = !machineDocumentsLoaded.value
? refreshMachineDocuments()
: Promise.resolve()
if (!(productInventory.value as AnyRecord[]).length) {
try {
await loadProducts()
@@ -1228,9 +1233,8 @@ export function useMachineDetailData(machineId: string) {
collapseAllComponents()
if (!machineDocumentsLoaded.value) {
await refreshMachineDocuments()
}
// Wait for documents if still loading
await documentPromise
} catch (error) {
console.error('Erreur lors du chargement des données:', error)
} finally {
@@ -1238,10 +1242,12 @@ export function useMachineDetailData(machineId: string) {
}
}
const loadInitialData = () => {
if (!constructeurs.value.length) loadConstructeurs()
if (!componentTypes.value.length) loadComponentTypes()
if (!pieceTypes.value.length) loadPieceTypes()
const loadInitialData = (): Promise<unknown[]> => {
return Promise.all([
loadConstructeurs(),
loadComponentTypes(),
loadPieceTypes(),
])
}
// ---------------------------------------------------------------------------

View File

@@ -24,6 +24,7 @@ export interface MachineType {
const machineTypes = ref<MachineType[]>([])
const loading = ref(false)
const loaded = ref(false)
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
if (!Array.isArray(value)) {
@@ -59,10 +60,11 @@ const normalizeMachineType = (type: Record<string, unknown>): MachineType | null
}
export function useMachineTypesApi() {
const { showSuccess, showInfo } = useToast()
const { showSuccess } = useToast()
const { get, post, put, delete: del } = useApi()
const loadMachineTypes = async (): Promise<void> => {
const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true
try {
const result = await get('/type_machines')
@@ -71,7 +73,7 @@ export function useMachineTypesApi() {
machineTypes.value = items
.map((item) => normalizeMachineType(item as Record<string, unknown>))
.filter((item): item is MachineType => item !== null)
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
loaded.value = true
}
} catch (error) {
console.error('Erreur lors du chargement des types de machines:', error)

View File

@@ -17,6 +17,7 @@ export interface Machine {
const machines = ref<Machine[]>([])
const loading = ref(false)
const loaded = ref(false)
const resolveLinkCollection = (source: Record<string, unknown>, keys: string[]): unknown[] | undefined => {
if (!source || typeof source !== 'object') {
@@ -73,10 +74,11 @@ const normalizeMachineResponse = (payload: unknown): Machine | null => {
}
export function useMachines() {
const { showSuccess, showError, showInfo } = useToast()
const { showSuccess, showError } = useToast()
const { get, post, patch, delete: del } = useApi()
const loadMachines = async (): Promise<void> => {
const loadMachines = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true
try {
const result = await get('/machines')
@@ -86,7 +88,7 @@ export function useMachines() {
.map((item) => normalizeMachineResponse(item))
.filter((item): item is Machine => item !== null)
machines.value = normalized
showInfo(`Chargement de ${normalized.length} machine(s) réussi`)
loaded.value = true
}
} catch (error) {
console.error('Erreur lors du chargement des machines:', error)

View File

@@ -41,11 +41,13 @@ interface LoadPiecesOptions {
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
force?: boolean
}
const pieces = ref<Piece[]>([])
const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
@@ -108,15 +110,31 @@ export function usePieces() {
}
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
force = false,
} = options
if (!force && loaded.value && !search && page === 1) {
return {
success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage },
}
}
if (loading.value) {
return {
success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage },
}
}
loading.value = true
try {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
} = options
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
@@ -134,6 +152,7 @@ export function usePieces() {
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
pieces.value = enrichedItems
total.value = extractTotal(result.data, items.length)
loaded.value = true
return {
success: true,
data: {
@@ -226,15 +245,23 @@ export function usePieces() {
const getPieces = () => pieces.value
const isLoading = () => loading.value
const clearPiecesCache = () => {
pieces.value = []
total.value = 0
loaded.value = false
}
return {
pieces,
total,
loading,
loaded,
loadPieces,
createPiece,
updatePiece: updatePieceData,
deletePiece,
getPieces,
isLoading,
clearPiecesCache,
}
}

View File

@@ -115,8 +115,16 @@ export function useProducts() {
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
force = false,
} = options
if (!force && loaded.value && !search && page === 1) {
return {
success: true,
data: { items: products.value, total: total.value, page, itemsPerPage },
}
}
if (loading.value) {
return {
success: true,

View File

@@ -24,19 +24,21 @@ interface SiteResult {
const sites = ref<Site[]>([])
const loading = ref(false)
const loaded = ref(false)
export function useSites() {
const { showSuccess, showInfo } = useToast()
const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi()
const loadSites = async (): Promise<void> => {
const loadSites = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true
try {
const result = await get('/sites')
if (result.success) {
const collection = extractCollection(result.data)
sites.value = collection
showInfo(`Chargement de ${collection.length} site(s) réussi`)
loaded.value = true
}
} catch (error) {
console.error('Erreur lors du chargement des sites:', error)

274
app/pages/activity-log.vue Normal file
View File

@@ -0,0 +1,274 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header>
<h1 class="text-3xl font-semibold text-base-content">Journal d'activité</h1>
<p class="text-sm text-gray-500">
Historique des modifications sur l'ensemble des pièces, produits et composants.
</p>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-entity-type"
>
Type
</label>
<select
id="activity-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option value="">Tous</option>
<option value="piece">Pièce</option>
<option value="product">Produit</option>
<option value="composant">Composant</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-action"
>
Action
</label>
<select
id="activity-action"
v-model="actionFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option value="">Toutes</option>
<option value="create">Création</option>
<option value="update">Modification</option>
<option value="delete">Suppression</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-per-page"
>
Par page
</label>
<select
id="activity-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/50 lg:text-right">
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loading" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!total" class="text-sm text-base-content/70">
Aucune activité enregistrée.
</p>
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
Aucune activité ne correspond à vos filtres.
</p>
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Type</th>
<th>Entité</th>
<th>Auteur</th>
<th>Détails</th>
</tr>
</thead>
<tbody>
<template v-for="entry in entries" :key="entry.id">
<tr>
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
<td>
<span
class="badge badge-sm"
:class="actionBadgeClass(entry.action)"
>
{{ historyActionLabel(entry.action) }}
</span>
</td>
<td>
<span class="badge badge-ghost badge-sm">
{{ entityTypeLabel(entry.entityType) }}
</span>
</td>
<td>
<NuxtLink
v-if="entry.action !== 'delete'"
:to="entityEditLink(entry)"
class="link link-hover link-primary"
>
{{ entry.entityName || 'Sans nom' }}
</NuxtLink>
<span v-else class="text-base-content/50 line-through">
{{ entry.entityName || 'Sans nom' }}
</span>
<span
v-if="entry.entityRef"
class="text-xs text-base-content/50 ml-1"
>
({{ entry.entityRef }})
</span>
</td>
<td>{{ entry.actor?.label || '—' }}</td>
<td>
<button
v-if="hasDiff(entry)"
type="button"
class="btn btn-ghost btn-xs"
@click="toggleExpanded(entry.id)"
>
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
</button>
<span v-else class="text-xs text-base-content/50"></span>
</td>
</tr>
<tr v-if="expandedIds.has(entry.id)">
<td colspan="6" class="bg-base-200/50 p-4">
<div class="space-y-1 text-sm">
<div
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
:key="diffEntry.field"
class="flex gap-2"
>
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
<span></span>
<span class="text-success">{{ diffEntry.toLabel }}</span>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useActivityLog } from '~/composables/useActivityLog'
import type { ActivityLogEntry } from '~/composables/useActivityLog'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
import Pagination from '~/components/common/Pagination.vue'
const { entries, total, loading, loadActivityLog } = useActivityLog()
const currentPage = ref(1)
const itemsPerPage = ref(50)
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
const entityTypeFilter = ref('')
const actionFilter = ref('')
const expandedIds = reactive(new Set<string>())
const toggleExpanded = (id: string) => {
if (expandedIds.has(id)) expandedIds.delete(id)
else expandedIds.add(id)
}
const hasDiff = (entry: ActivityLogEntry) =>
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
const fetchLog = () => {
loadActivityLog({
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
entityType: entityTypeFilter.value || undefined,
action: actionFilter.value || undefined,
})
}
const handleFilterChange = () => {
currentPage.value = 1
fetchLog()
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchLog()
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
piece: 'Pièce',
product: 'Produit',
composant: 'Composant',
}
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
const ENTITY_EDIT_ROUTES: Record<string, string> = {
piece: '/pieces',
product: '/product',
composant: '/component',
}
const entityEditLink = (entry: ActivityLogEntry) => {
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
return base ? `${base}/${entry.entityId}/edit` : '#'
}
const actionBadgeClass = (action: string) => {
if (action === 'create') return 'badge-success'
if (action === 'delete') return 'badge-error'
return 'badge-warning'
}
const globalFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
supplierPrice: 'Prix fournisseur',
typePiece: 'Type de pièce',
typeProduct: 'Type de produit',
typeComposant: 'Type de composant',
product: 'Produit',
productIds: 'Produits',
constructeurIds: 'Fournisseurs',
structure: 'Structure',
}
onMounted(fetchLog)
</script>

View File

@@ -130,7 +130,16 @@
</td>
<td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.reference || '—' }}</td>
<td>{{ resolveComponentType(component) }}</td>
<td>
<NuxtLink
v-if="component.typeComposant?.id"
:to="`/component-category/${component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(component) }}</span>
</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink

View File

@@ -44,11 +44,13 @@ import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const { loadComponentTypes } = useComponentTypes()
const loading = ref(true)
const saving = ref(false)
@@ -137,6 +139,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
} catch (error) {

View File

@@ -32,6 +32,7 @@ import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
useHead(() => ({
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
invalidateEntityTypeCache('COMPONENT')
showSuccess('Catégorie de composant créée avec succès.')
await router.push('/component-category')
} catch (error: any) {

View File

@@ -576,7 +576,7 @@ const { updateComposant, loadComposants, composants: componentCatalogRef } = use
const { pieces, loadPieces } = usePieces()
const { products, loadProducts } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const {
@@ -764,12 +764,10 @@ const fetchComponent = async () => {
component.value = result.data
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) {
component.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
await loadHistory(result.data.id)
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
loadHistory(result.data.id).catch(() => {})
} else {
component.value = null
componentDocuments.value = []
@@ -805,7 +803,9 @@ watch(
void ensureConstructeurs(editionForm.constructeurIds)
}
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues)
// After setting selectedTypeId, read selectedTypeStructure.value (now updated) instead of
// the stale destructured currentStructure which was captured before the ID change.
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
initialized = true
},
@@ -1130,14 +1130,15 @@ onMounted(async () => {
loadComponentTypes(),
loadPieceTypes(),
loadProductTypes(),
loadPieces({ itemsPerPage: 500 }),
loadProducts({ itemsPerPage: 500, force: true }),
loadComposants({ itemsPerPage: 500 }),
fetchComponent(),
])
loading.value = false
if (component.value?.id) {
await refreshDocuments()
}
// Defer bulk catalog loads — not needed for initial render
Promise.allSettled([
loadPieces({ itemsPerPage: 500 }),
loadProducts({ itemsPerPage: 500 }),
loadComposants({ itemsPerPage: 500 }),
]).catch(() => {})
})
</script>

View File

@@ -185,6 +185,7 @@
</template>
<script setup>
import { proxyRefs } from 'vue'
import { useMachineCreatePage } from '~/composables/useMachineCreatePage'
import SearchSelect from '~/components/common/SearchSelect.vue'
import RequirementComponentSelector from '~/components/machine/create/RequirementComponentSelector.vue'
@@ -192,5 +193,5 @@ import RequirementPieceSelector from '~/components/machine/create/RequirementPie
import RequirementProductSelector from '~/components/machine/create/RequirementProductSelector.vue'
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
const c = useMachineCreatePage()
const c = proxyRefs(useMachineCreatePage())
</script>

View File

@@ -44,11 +44,13 @@ import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import type { PieceModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const { loadPieceTypes } = usePieceTypes()
const loading = ref(true)
const saving = ref(false)
@@ -135,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
} catch (error) {

View File

@@ -32,6 +32,7 @@ import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
useHead(() => ({
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
invalidateEntityTypeCache('PIECE')
showSuccess('Catégorie de pièce créée avec succès.')
await router.push('/piece-category')
} catch (error: any) {

View File

@@ -152,7 +152,16 @@
</div>
<span v-else></span>
</td>
<td>{{ resolvePieceType(row.piece) }}</td>
<td>
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink

View File

@@ -516,7 +516,7 @@ const router = useRouter()
const { get } = useApi()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { updatePiece } = usePieces()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
@@ -750,20 +750,23 @@ const fetchPiece = async () => {
if (result.success) {
piece.value = result.data
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) {
piece.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
await loadPieceTypeDetails(result.data)
await loadHistory(result.data.id)
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
// Use cached type from loadPieceTypes() instead of separate getModelType() call
loadPieceTypeDetailsFromCache(result.data)
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
} else {
piece.value = null
pieceDocuments.value = []
}
}
const loadPieceTypeDetails = async (currentPiece: any) => {
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
const typeId = currentPiece?.typePieceId
|| extractRelationId(currentPiece?.typePiece)
|| ''
@@ -771,15 +774,22 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
pieceTypeDetails.value = null
return
}
try {
const type = await getModelType(typeId)
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
if (cachedType) {
pieceTypeDetails.value = cachedType
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
return
}
// Fallback: fetch if not in cache (edge case)
getModelType(typeId).then((type) => {
if (type && typeof type === 'object') {
pieceTypeDetails.value = type
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
}
} catch (_error) {
}).catch(() => {
pieceTypeDetails.value = null
}
})
}
let initialized = false
@@ -827,7 +837,10 @@ watch(
pendingProductIds = []
}
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
// After setting selectedTypeId, read selectedType.value (now updated) instead of
// the stale destructured currentType which was captured before the ID change.
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
initialized = true
},
@@ -838,9 +851,7 @@ watch(selectedType, (currentType) => {
if (!piece.value || !currentType) {
return
}
if (!pieceTypeDetails.value) {
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
}
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
})
watch(resolvedStructure, (currentStructure) => {
@@ -920,8 +931,5 @@ const submitEdition = async () => {
onMounted(async () => {
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
loading.value = false
if (piece.value?.id) {
await refreshDocuments()
}
})
</script>

View File

@@ -110,7 +110,16 @@
</td>
<td class="font-medium">{{ row.product.name }}</td>
<td>{{ row.product.reference || '—' }}</td>
<td>{{ row.product.typeProduct?.name || '—' }}</td>
<td>
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '' }}</span>
</td>
<td>
<div
v-if="row.suppliers.visible.length"

View File

@@ -44,11 +44,13 @@ import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const { loadProductTypes } = useProductTypes()
const loading = ref(true)
const saving = ref(false)
@@ -135,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
} catch (error) {

View File

@@ -32,6 +32,7 @@ import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
useHead(() => ({
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
invalidateEntityTypeCache('PRODUCT')
showSuccess('Catégorie de produit créée avec succès.')
await router.push('/product-category')
} catch (error: any) {

View File

@@ -428,7 +428,7 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
@@ -520,15 +520,17 @@ const loadProduct = async () => {
if (result.success && result.data) {
product.value = result.data
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
await loadProductType()
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) {
product.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
await hydrateForm()
await refreshDocuments()
await loadHistory(result.data.id)
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
hydrateForm()
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
} else {
product.value = null
}
@@ -587,9 +589,20 @@ const handleFilesAdded = async (files: File[]) => {
}
const loadProductType = async () => {
// Try using the expanded typeProduct from entity response first
const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null
if (embeddedStructure) {
productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure)
return
}
}
if (!product.value?.typeProductId) {
productType.value = product.value?.typeProduct ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
return
}
try {
@@ -598,12 +611,12 @@ const loadProductType = async () => {
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = product.value?.typeProduct ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
}
}
const hydrateForm = async () => {
const hydrateForm = () => {
if (!product.value) {
return
}
@@ -618,7 +631,8 @@ const hydrateForm = async () => {
: ''
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
if (editionForm.constructeurIds.length) {
await ensureConstructeurs(editionForm.constructeurIds)
// Smart-cached + deduped — fire-and-forget, ConstructeurSelect handles its own loading
ensureConstructeurs(editionForm.constructeurIds).catch(() => {})
}
}

23
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0",
@@ -3255,9 +3256,9 @@
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.29",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
"integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==",
"version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
"license": "MIT"
},
"node_modules/@rollup/plugin-alias": {
@@ -4683,18 +4684,18 @@
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
"integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==",
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
"integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-beta.29"
"@rolldown/pluginutils": "1.0.0-rc.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
"vue": "^3.2.25"
}
},
@@ -4718,12 +4719,6 @@
"vue": "^3.0.0"
}
},
"node_modules/@vitejs/plugin-vue-jsx/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.40",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.40.tgz",
"integrity": "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==",
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",

View File

@@ -30,6 +30,7 @@
"@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0",

View File

@@ -0,0 +1,12 @@
/**
* Stub for ~icons/* imports (Unplugin Icons).
* Returns a minimal Vue component that renders a <span>.
*/
import { defineComponent, h } from 'vue'
export default defineComponent({
name: 'IconStub',
render() {
return h('span', { class: 'icon-stub' })
},
})

View File

@@ -0,0 +1,361 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
// ---------------------------------------------------------------------------
// Mocks — modelUtils (structure functions)
// ---------------------------------------------------------------------------
vi.mock('~/shared/modelUtils', () => ({
normalizeStructureForEditor: (s: any) => s ?? { customFields: [] },
normalizeStructureForSave: (s: any) => s ?? { customFields: [] },
normalizePieceStructureForSave: (s: any) => s ?? { customFields: [], products: [] },
normalizeProductStructureForSave: (s: any) => s ?? { customFields: [], products: [] },
formatStructurePreview: () => 'Component preview',
formatPieceStructurePreview: () => 'Piece preview',
formatProductStructurePreview: () => 'Product preview',
defaultStructure: () => ({ customFields: [] }),
defaultPieceStructure: () => ({ customFields: [], products: [] }),
defaultProductStructure: () => ({ customFields: [], products: [] }),
cloneStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [] })),
clonePieceStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [], products: [] })),
cloneProductStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [], products: [] })),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const globalStubs = {
ComponentModelStructureEditor: { template: '<div data-testid="comp-editor" />' },
PieceModelStructureEditor: { template: '<div data-testid="piece-editor" />' },
}
function mountForm(props: Record<string, any> = {}) {
return shallowMount(ModelTypeForm, {
props: {
mode: 'create',
initialCategory: 'COMPONENT',
lockCategory: true,
...props,
},
global: {
stubs: globalStubs,
},
})
}
function getNameInput(wrapper: VueWrapper) {
return wrapper.find('input[name="name"]')
}
function getCategorySelect(wrapper: VueWrapper) {
return wrapper.find('select[name="category"]')
}
function getNotesTextarea(wrapper: VueWrapper) {
return wrapper.find('textarea[name="notes"]')
}
function getSubmitButton(wrapper: VueWrapper) {
return wrapper.find('button[type="submit"]')
}
function getCancelButton(wrapper: VueWrapper) {
return wrapper.find('button[type="button"]')
}
async function submitForm(wrapper: VueWrapper) {
await wrapper.find('form').trigger('submit.prevent')
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
describe('rendering', () => {
it('renders form with name, category, and notes fields', () => {
const wrapper = mountForm()
expect(getNameInput(wrapper).exists()).toBe(true)
expect(getCategorySelect(wrapper).exists()).toBe(true)
expect(getNotesTextarea(wrapper).exists()).toBe(true)
})
it('shows "Créer" button in create mode', () => {
const wrapper = mountForm({ mode: 'create' })
expect(getSubmitButton(wrapper).text()).toBe('Créer')
})
it('shows "Enregistrer" button in edit mode', () => {
const wrapper = mountForm({ mode: 'edit' })
expect(getSubmitButton(wrapper).text()).toBe('Enregistrer')
})
it('populates form from initialData in edit mode', async () => {
const wrapper = mountForm({
mode: 'edit',
initialData: {
name: 'Existing Type',
code: 'existing-type',
category: 'COMPONENT',
notes: 'Some notes',
},
})
await nextTick()
expect((getNameInput(wrapper).element as HTMLInputElement).value).toBe('Existing Type')
expect((getNotesTextarea(wrapper).element as HTMLTextAreaElement).value).toBe('Some notes')
})
})
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
describe('validation', () => {
it('shows error for name shorter than 2 characters', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('A')
await submitForm(wrapper)
expect(wrapper.text()).toContain('au moins 2 caractères')
expect(wrapper.emitted('submit')).toBeUndefined()
})
it('shows error for name longer than 120 characters', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('A'.repeat(121))
await submitForm(wrapper)
expect(wrapper.text()).toContain('ne peut pas dépasser 120')
expect(wrapper.emitted('submit')).toBeUndefined()
})
it('submits with valid name', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('Mon Type')
await submitForm(wrapper)
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')![0]).toBeTruthy()
})
it('does not show error with valid name', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('Valid Name')
await submitForm(wrapper)
expect(wrapper.text()).not.toContain('au moins 2')
expect(wrapper.text()).not.toContain('ne peut pas dépasser')
})
})
// ---------------------------------------------------------------------------
// Code generation
// ---------------------------------------------------------------------------
describe('code generation', () => {
it('auto-generates code from name in create mode', async () => {
const wrapper = mountForm({ mode: 'create' })
await getNameInput(wrapper).setValue('Ma Catégorie')
await nextTick()
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.code).toBe('ma-categorie')
})
it('preserves initialData code in edit mode', async () => {
const wrapper = mountForm({
mode: 'edit',
initialData: {
name: 'Type',
code: 'custom-code',
category: 'COMPONENT',
},
})
await nextTick()
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.code).toBe('custom-code')
})
})
// ---------------------------------------------------------------------------
// Category-based structure editor rendering
// ---------------------------------------------------------------------------
describe('structure editor rendering', () => {
it('renders ComponentModelStructureEditor for COMPONENT', () => {
const wrapper = mountForm({ initialCategory: 'COMPONENT' })
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(false)
})
it('renders PieceModelStructureEditor for PIECE', () => {
const wrapper = mountForm({ initialCategory: 'PIECE' })
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(false)
})
it('renders PieceModelStructureEditor for PRODUCT', () => {
const wrapper = mountForm({ initialCategory: 'PRODUCT' })
// PRODUCT reuses PieceModelStructureEditor
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Category lock
// ---------------------------------------------------------------------------
describe('category lock', () => {
it('disables category select when lockCategory is true', () => {
const wrapper = mountForm({ lockCategory: true })
expect((getCategorySelect(wrapper).element as HTMLSelectElement).disabled).toBe(true)
})
it('enables category select when lockCategory is false', () => {
const wrapper = mountForm({ lockCategory: false })
expect((getCategorySelect(wrapper).element as HTMLSelectElement).disabled).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Restricted mode
// ---------------------------------------------------------------------------
describe('restricted mode', () => {
it('shows restricted mode message', () => {
const wrapper = mountForm({
restrictedMode: true,
restrictedModeMessage: 'Mode restreint actif',
})
expect(wrapper.text()).toContain('Mode restreint actif')
expect(wrapper.find('.alert-info').exists()).toBe(true)
})
it('does not show restricted mode message when not restricted', () => {
const wrapper = mountForm({
restrictedMode: false,
})
expect(wrapper.find('.alert-info').exists()).toBe(false)
})
it('disables name input in restricted mode', () => {
const wrapper = mountForm({ restrictedMode: true })
expect((getNameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Submit disabled
// ---------------------------------------------------------------------------
describe('submit disabled', () => {
it('disables submit button when disableSubmit is true', () => {
const wrapper = mountForm({ disableSubmit: true })
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
it('shows warning alert when disableSubmit is true', () => {
const wrapper = mountForm({
disableSubmit: true,
disableSubmitMessage: 'Cannot save now',
})
expect(wrapper.find('.alert-warning').exists()).toBe(true)
expect(wrapper.text()).toContain('Cannot save now')
})
it('does not show warning when disableSubmit is false', () => {
const wrapper = mountForm({ disableSubmit: false })
expect(wrapper.find('.alert-warning').exists()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Saving state
// ---------------------------------------------------------------------------
describe('saving state', () => {
it('disables submit button when saving', () => {
const wrapper = mountForm({ saving: true })
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
it('disables cancel button when saving', () => {
const wrapper = mountForm({ saving: true })
expect((getCancelButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
it('shows spinner when saving', () => {
const wrapper = mountForm({ saving: true })
expect(wrapper.find('.loading-spinner').exists()).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Cancel
// ---------------------------------------------------------------------------
describe('cancel', () => {
it('emits cancel on cancel button click', async () => {
const wrapper = mountForm()
await getCancelButton(wrapper).trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
})
// ---------------------------------------------------------------------------
// Submit payload
// ---------------------------------------------------------------------------
describe('submit payload', () => {
it('emits payload with COMPONENT category and structure', async () => {
const wrapper = mountForm({ initialCategory: 'COMPONENT' })
await getNameInput(wrapper).setValue('Test Component')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.category).toBe('COMPONENT')
expect(payload.name).toBe('Test Component')
expect(payload.structure).toBeDefined()
})
it('emits payload with PIECE category', async () => {
const wrapper = mountForm({ initialCategory: 'PIECE' })
await getNameInput(wrapper).setValue('Test Piece')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.category).toBe('PIECE')
})
it('emits payload with PRODUCT category', async () => {
const wrapper = mountForm({ initialCategory: 'PRODUCT' })
await getNameInput(wrapper).setValue('Test Product')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.category).toBe('PRODUCT')
})
it('includes trimmed notes in payload', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('Test')
await getNotesTextarea(wrapper).setValue(' Some notes ')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.notes).toBe('Some notes')
})
it('omits empty notes from payload', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('Test')
await getNotesTextarea(wrapper).setValue('')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.notes).toBeUndefined()
})
})

View File

@@ -0,0 +1,396 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('~/composables/useProductTypes', () => ({
useProductTypes: () => ({
productTypes: ref([]),
loadingProductTypes: ref(false),
loadProductTypes: vi.fn().mockResolvedValue({ success: true }),
createProductType: vi.fn(),
updateProductType: vi.fn(),
deleteProductType: vi.fn(),
getProductTypes: () => [],
isProductTypeLoading: () => false,
}),
}))
vi.mock('~/shared/modelUtils', () => ({
normalizePieceStructureForSave: (s: any) => s ?? { customFields: [] },
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mountEditor(props: Record<string, any> = {}) {
return mount(PieceModelStructureEditor, {
props: {
modelValue: { customFields: [], products: [] },
...props,
},
})
}
function getAddFieldButton(wrapper: VueWrapper) {
// The second "Ajouter" button is for custom fields
const buttons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
return buttons[buttons.length - 1]
}
function getAddProductButton(wrapper: VueWrapper) {
// The first "Ajouter" button is for products
const buttons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
return buttons[0]
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
describe('empty state', () => {
it('renders with no fields and shows empty message', () => {
const wrapper = mountEditor()
expect(wrapper.text()).toContain('Aucun champ personnalisé')
})
it('renders with no products and shows empty message', () => {
const wrapper = mountEditor()
expect(wrapper.text()).toContain('Aucun produit défini')
})
it('shows add field button', () => {
const wrapper = mountEditor()
expect(getAddFieldButton(wrapper).exists()).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Add custom field
// ---------------------------------------------------------------------------
describe('add custom field', () => {
it('adds a new empty field on button click', async () => {
const wrapper = mountEditor()
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
const nameInputs = wrapper.findAll('input[type="text"]')
expect(nameInputs.length).toBeGreaterThanOrEqual(1)
expect(wrapper.text()).not.toContain('Aucun champ personnalisé')
})
it('new field has type "text" by default', async () => {
const wrapper = mountEditor()
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
const selects = wrapper.findAll('select')
// The last select should be the type select for the new field
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).value).toBe('text')
})
it('emits update:modelValue after adding field', async () => {
const wrapper = mountEditor()
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
})
})
// ---------------------------------------------------------------------------
// Remove custom field
// ---------------------------------------------------------------------------
describe('remove custom field', () => {
it('removes field on delete button click', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Weight', type: 'number', required: false, orderIndex: 0 }],
products: [],
},
})
// Find the delete button (btn-error)
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(true)
await deleteBtn.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('Aucun champ personnalisé')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
})
})
// ---------------------------------------------------------------------------
// Field name editing
// ---------------------------------------------------------------------------
describe('field name editing', () => {
it('updates field name on input', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Old Name', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const nameInput = wrapper.find('input[type="text"]')
await nameInput.setValue('New Name')
await nextTick()
const events = wrapper.emitted('update:modelValue')
expect(events).toBeTruthy()
const lastPayload = events![events!.length - 1][0] as any
expect(lastPayload.customFields[0].name).toBe('New Name')
})
})
// ---------------------------------------------------------------------------
// Field type change
// ---------------------------------------------------------------------------
describe('field type change', () => {
it('changes field type via select', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Size', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
// Find the type select (not the product type select)
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
await typeSelect.setValue('number')
await nextTick()
const events = wrapper.emitted('update:modelValue')
expect(events).toBeTruthy()
const lastPayload = events![events!.length - 1][0] as any
expect(lastPayload.customFields[0].type).toBe('number')
})
it('shows options textarea for select type', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Color', type: 'select', required: false, orderIndex: 0, optionsText: 'Red\nBlue' }],
products: [],
},
})
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
})
it('hides options textarea for non-select types', () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Weight', type: 'number', required: false, orderIndex: 0 }],
products: [],
},
})
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Required checkbox
// ---------------------------------------------------------------------------
describe('required checkbox', () => {
it('toggles required on checkbox change', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Test', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.setValue(true)
await nextTick()
const events = wrapper.emitted('update:modelValue')
expect(events).toBeTruthy()
const lastPayload = events![events!.length - 1][0] as any
expect(lastPayload.customFields[0].required).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Restricted mode
// ---------------------------------------------------------------------------
describe('restricted mode', () => {
it('allows editing name of pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked Field', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const nameInput = wrapper.find('input[type="text"]')
expect((nameInput.element as HTMLInputElement).disabled).toBe(false)
})
it('disables type select for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(true)
})
it('disables required checkbox for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const checkbox = wrapper.find('input[type="checkbox"]')
expect((checkbox.element as HTMLInputElement).disabled).toBe(true)
})
it('hides delete button for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
// btn-error should not exist for locked fields
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(false)
})
it('allows full editing of newly added field', async () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [],
products: [],
},
})
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
// New field should have an editable type select (not disabled)
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(false)
// Delete button should exist for new field
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(true)
})
it('hides product add button in restricted mode', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: { customFields: [], products: [] },
})
const addButtons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
// Only the "add field" button should be visible, not the product one
expect(addButtons.length).toBe(1)
})
})
// ---------------------------------------------------------------------------
// Add product
// ---------------------------------------------------------------------------
describe('add product', () => {
it('adds a product entry on button click', async () => {
const wrapper = mountEditor()
await getAddProductButton(wrapper).trigger('click')
await nextTick()
expect(wrapper.text()).not.toContain('Aucun produit défini')
// Should have a product type select
expect(wrapper.findAll('select').length).toBeGreaterThanOrEqual(1)
})
})
// ---------------------------------------------------------------------------
// Options parsing
// ---------------------------------------------------------------------------
describe('options parsing', () => {
it('parses multiline options text into array', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Color', type: 'select', required: false, orderIndex: 0, optionsText: '' }],
products: [],
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Red\nGreen\nBlue')
await nextTick()
const events = wrapper.emitted('update:modelValue')
expect(events).toBeTruthy()
const lastPayload = events![events!.length - 1][0] as any
expect(lastPayload.customFields[0].options).toEqual(['Red', 'Green', 'Blue'])
})
})
// ---------------------------------------------------------------------------
// Hydration from modelValue
// ---------------------------------------------------------------------------
describe('hydration', () => {
it('renders existing fields from modelValue', () => {
const wrapper = mountEditor({
modelValue: {
customFields: [
{ name: 'Weight', type: 'number', required: true, orderIndex: 0 },
{ name: 'Color', type: 'text', required: false, orderIndex: 1 },
],
products: [],
},
})
const nameInputs = wrapper.findAll('input[type="text"]')
expect(nameInputs.length).toBe(2)
expect((nameInputs[0].element as HTMLInputElement).value).toBe('Weight')
expect((nameInputs[1].element as HTMLInputElement).value).toBe('Color')
})
it('sorts fields by orderIndex', () => {
const wrapper = mountEditor({
modelValue: {
customFields: [
{ name: 'Second', type: 'text', required: false, orderIndex: 1 },
{ name: 'First', type: 'text', required: false, orderIndex: 0 },
],
products: [],
},
})
const nameInputs = wrapper.findAll('input[type="text"]')
expect((nameInputs[0].element as HTMLInputElement).value).toBe('First')
expect((nameInputs[1].element as HTMLInputElement).value).toBe('Second')
})
})

View File

@@ -0,0 +1,269 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockShowInfo = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: vi.fn(),
patch: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
apiCall: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showInfo: mockShowInfo,
showSuccess: vi.fn(),
showError: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
const GUARD_CONFIG = {
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
describe('initial state', () => {
it('has linkedCount 0 and restrictedMode false', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.linkedLoading.value).toBe(false)
})
it('has empty messages when no linked items', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.restrictedModeMessage.value).toBe('')
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// loadLinkedCount
// ---------------------------------------------------------------------------
describe('loadLinkedCount', () => {
it('sets linkedCount from API totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(5)
expect(guard.isRestrictedMode.value).toBe(true)
expect(mockGet).toHaveBeenCalledWith(
expect.stringContaining('/composants?'),
)
})
it('sets linkedCount 0 when API returns 0 items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('extracts totalItems from hydra:totalItems format', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:totalItems': 3, 'hydra:member': [{}, {}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(3)
})
it('falls back to member.length when no totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { member: [{}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(2)
})
it('falls back to hydra:member.length', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:member': [{}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(1)
})
it('sets linkedCount 0 on API failure', async () => {
mockGet.mockResolvedValue({ success: false })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('sets linkedCount 0 on exception', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
})
it('sends correct filter parameters', async () => {
mockGet.mockResolvedValue({ success: true, data: { totalItems: 0 } })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('abc-123')
const callUrl = mockGet.mock.calls[0][0] as string
expect(callUrl).toContain('itemsPerPage=1')
expect(callUrl).toContain('typeComposant=%2Fapi%2Fmodel_types%2Fabc-123')
})
})
// ---------------------------------------------------------------------------
// restrictedModeMessage
// ---------------------------------------------------------------------------
describe('restrictedModeMessage', () => {
it('shows singular message for 1 linked item', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 1 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('1 composant')
expect(guard.restrictedModeMessage.value).toContain('Mode restreint')
})
it('shows plural message for multiple linked items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('5 composants')
expect(guard.restrictedModeMessage.value).toContain('renommer les existants')
})
it('uses custom labels from config', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 3 },
})
const guard = useCategoryEditGuard({
endpoint: '/pieces',
filterKey: 'typePiece',
labels: { singular: 'pièce', plural: 'pièces', verifying: 'Vérification...' },
})
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('3 pièces')
})
})
// ---------------------------------------------------------------------------
// isSubmitBlocked & submitBlockMessage
// ---------------------------------------------------------------------------
describe('submit blocking', () => {
it('blocks submit during loading', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading state by starting a load without awaiting
mockGet.mockReturnValue(new Promise(() => {})) // Never resolves
guard.loadLinkedCount('mt-1')
expect(guard.linkedLoading.value).toBe(true)
expect(guard.isSubmitBlocked.value).toBe(true)
expect(guard.submitBlockMessage.value).toBe(GUARD_CONFIG.labels.verifying)
})
it('unblocks submit after loading completes', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// guardSubmitOrNotify
// ---------------------------------------------------------------------------
describe('guardSubmitOrNotify', () => {
it('returns false when not blocked', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(false)
expect(mockShowInfo).not.toHaveBeenCalled()
})
it('returns true and shows info when blocked', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading
mockGet.mockReturnValue(new Promise(() => {}))
guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(true)
expect(mockShowInfo).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,412 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useEntityTypes, invalidateEntityTypeCache } from '~/composables/useEntityTypes'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockShowSuccess = vi.fn()
const mockShowError = vi.fn()
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: mockShowSuccess,
showError: mockShowError,
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
const mockListModelTypes = vi.fn()
const mockCreateModelType = vi.fn()
const mockUpdateModelType = vi.fn()
const mockDeleteModelType = vi.fn()
vi.mock('~/services/modelTypes', () => ({
listModelTypes: (...args: any[]) => mockListModelTypes(...args),
createModelType: (...args: any[]) => mockCreateModelType(...args),
updateModelType: (...args: any[]) => mockUpdateModelType(...args),
deleteModelType: (...args: any[]) => mockDeleteModelType(...args),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const fakeItem = (id: string, name: string) => ({
id,
name,
code: name.toLowerCase(),
category: 'COMPONENT' as const,
structure: null,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
notes: null,
description: null,
})
function resetState() {
// Reset singleton state via public APIs
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
comp.types.value = []
invalidateEntityTypeCache('COMPONENT')
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
piece.types.value = []
invalidateEntityTypeCache('PIECE')
const product = useEntityTypes({ category: 'PRODUCT', label: 'produit' })
product.types.value = []
invalidateEntityTypeCache('PRODUCT')
}
beforeEach(() => {
vi.clearAllMocks()
resetState()
})
// ---------------------------------------------------------------------------
// loadTypes
// ---------------------------------------------------------------------------
describe('loadTypes', () => {
it('fetches from API and stores in types', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Type A'), fakeItem('2', 'Type B')],
total: 2,
offset: 0,
limit: 200,
})
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await loadTypes()
expect(result.success).toBe(true)
expect(types.value).toHaveLength(2)
expect(types.value[0].name).toBe('Type A')
expect(mockListModelTypes).toHaveBeenCalledOnce()
expect(mockListModelTypes).toHaveBeenCalledWith({
category: 'COMPONENT',
sort: 'name',
dir: 'asc',
limit: 200,
})
})
it('returns cached data on second call without force', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Cached')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
// Second call — should return cache
const result = await loadTypes()
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(mockListModelTypes).toHaveBeenCalledTimes(1) // NOT called again
})
it('refetches with force: true', async () => {
mockListModelTypes
.mockResolvedValueOnce({ items: [fakeItem('1', 'Old')], total: 1, offset: 0, limit: 200 })
.mockResolvedValueOnce({ items: [fakeItem('1', 'New')], total: 1, offset: 0, limit: 200 })
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(types.value[0].name).toBe('Old')
await loadTypes({ force: true })
expect(types.value[0].name).toBe('New')
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
})
it('sets loading during fetch', async () => {
let resolvePromise: (value: any) => void
mockListModelTypes.mockReturnValue(new Promise((resolve) => { resolvePromise = resolve }))
const { loadTypes, loading } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
expect(loading.value).toBe(false)
const promise = loadTypes()
expect(loading.value).toBe(true)
resolvePromise!({ items: [], total: 0, offset: 0, limit: 200 })
await promise
expect(loading.value).toBe(false)
})
it('shows error on API failure', async () => {
mockListModelTypes.mockRejectedValue(new Error('Network error'))
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await loadTypes()
expect(result.success).toBe(false)
expect(result.error).toBe('Network error')
expect(types.value).toEqual([])
expect(mockShowError).toHaveBeenCalledWith(
expect.stringContaining('Network error'),
)
})
it('normalizes items with description fallback', async () => {
mockListModelTypes.mockResolvedValue({
items: [{ ...fakeItem('1', 'Test'), notes: 'From notes', description: null }],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(types.value[0].description).toBe('From notes')
})
})
// ---------------------------------------------------------------------------
// createType
// ---------------------------------------------------------------------------
describe('createType', () => {
it('creates and pushes to types array', async () => {
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Created'))
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await createType({ name: 'Created' })
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(types.value[0].name).toBe('Created')
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Created'))
})
it('sends correct payload with auto-generated code', async () => {
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Ma Catégorie'))
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await createType({ name: 'Ma Catégorie' })
expect(mockCreateModelType).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Ma Catégorie',
code: 'ma-categorie',
category: 'COMPONENT',
}),
)
})
it('uses provided code if available', async () => {
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Test'))
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await createType({ name: 'Test', code: 'custom-code' })
expect(mockCreateModelType).toHaveBeenCalledWith(
expect.objectContaining({ code: 'custom-code' }),
)
})
it('shows error on API failure without modifying types', async () => {
mockCreateModelType.mockRejectedValue(new Error('Create failed'))
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await createType({ name: 'Fail' })
expect(result.success).toBe(false)
expect(types.value).toEqual([])
expect(mockShowError).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// updateType
// ---------------------------------------------------------------------------
describe('updateType', () => {
it('updates the existing item in types array', async () => {
// Pre-populate
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Original')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
mockUpdateModelType.mockResolvedValue(fakeItem('1', 'Updated'))
const result = await updateType('1', { name: 'Updated' })
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(types.value[0].name).toBe('Updated')
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Updated'))
})
it('shows error on API failure without modifying types', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Original')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
mockUpdateModelType.mockRejectedValue(new Error('Update failed'))
const result = await updateType('1', { name: 'Bad' })
expect(result.success).toBe(false)
expect(types.value[0].name).toBe('Original')
expect(mockShowError).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// deleteType
// ---------------------------------------------------------------------------
describe('deleteType', () => {
it('removes item from types array', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'A'), fakeItem('2', 'B')],
total: 2,
offset: 0,
limit: 200,
})
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(types.value).toHaveLength(2)
mockDeleteModelType.mockResolvedValue(undefined)
const result = await deleteType('1')
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(types.value[0].id).toBe('2')
expect(mockShowSuccess).toHaveBeenCalled()
})
it('shows error on API failure without removing item', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Keep')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
mockDeleteModelType.mockRejectedValue(new Error('Delete failed'))
const result = await deleteType('1')
expect(result.success).toBe(false)
expect(types.value).toHaveLength(1)
expect(mockShowError).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// invalidateEntityTypeCache
// ---------------------------------------------------------------------------
describe('invalidateEntityTypeCache', () => {
it('forces next loadTypes to refetch', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Cached')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
// Without invalidation, wouldn't refetch
invalidateEntityTypeCache('COMPONENT')
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Fresh')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes: loadAgain, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadAgain()
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
expect(types.value[0].name).toBe('Fresh')
})
it('does not crash for unknown category', () => {
expect(() => invalidateEntityTypeCache('COMPONENT')).not.toThrow()
})
})
// ---------------------------------------------------------------------------
// Singleton per category
// ---------------------------------------------------------------------------
describe('singleton per category', () => {
it('shares state between same category calls', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Shared')],
total: 1,
offset: 0,
limit: 200,
})
const a = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await a.loadTypes()
const b = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
expect(b.types.value).toHaveLength(1)
expect(b.types.value[0].name).toBe('Shared')
})
it('isolates state between different categories', async () => {
mockListModelTypes
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Component')], total: 1, offset: 0, limit: 200 })
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
await comp.loadTypes()
await piece.loadTypes()
expect(comp.types.value).toHaveLength(1)
expect(comp.types.value[0].name).toBe('Component')
expect(piece.types.value).toHaveLength(1)
expect(piece.types.value[0].name).toBe('Piece')
})
it('invalidateEntityTypeCache only affects target category', async () => {
mockListModelTypes
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Comp')], total: 1, offset: 0, limit: 200 })
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
await comp.loadTypes()
await piece.loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
// Invalidate only COMPONENT
invalidateEntityTypeCache('COMPONENT')
// PIECE should still use cache
await piece.loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(2) // No extra call
// COMPONENT should refetch
mockListModelTypes.mockResolvedValueOnce({ items: [fakeItem('c1', 'Refreshed')], total: 1, offset: 0, limit: 200 })
await comp.loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(3)
})
})

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Import AFTER mocking
import {
createModelType,
updateModelType,
deleteModelType,
getModelType,
type ModelType,
type ModelTypePayload,
} from '~/services/modelTypes'
// The service uses useRequestFetch from #imports (explicit import)
// AND useRuntimeConfig as a Nuxt auto-import (bare global).
const mockFetch = vi.fn()
// Mock the explicit #imports module
vi.mock('#imports', () => ({
useRuntimeConfig: () => ({
public: { apiBaseUrl: 'http://test-api:8081/api' },
}),
useRequestFetch: () => mockFetch,
}))
// Also stub the global auto-import (Nuxt makes these globally available)
vi.stubGlobal('useRuntimeConfig', () => ({
public: { apiBaseUrl: 'http://test-api:8081/api' },
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const fakeModelType = (overrides: Partial<ModelType> = {}): ModelType => ({
id: 'mt-1',
name: 'Test Type',
code: 'test-type',
category: 'COMPONENT',
structure: null,
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
...overrides,
})
beforeEach(() => {
mockFetch.mockReset()
})
// ---------------------------------------------------------------------------
// normalizeModelType (tested via getModelType which calls .then(normalizeModelType))
// ---------------------------------------------------------------------------
describe('normalizeModelType (via getModelType)', () => {
it('maps componentSkeleton to structure for COMPONENT', async () => {
const skeleton = { customFields: [{ name: 'Weight' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT',
structure: null,
componentSkeleton: skeleton as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
})
it('maps pieceSkeleton to structure for PIECE', async () => {
const skeleton = { customFields: [{ name: 'Size' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'PIECE',
structure: null,
pieceSkeleton: skeleton as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
})
it('maps productSkeleton to structure for PRODUCT', async () => {
const skeleton = { customFields: [{ name: 'Brand' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'PRODUCT',
structure: null,
productSkeleton: skeleton as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
})
it('does not override existing structure', async () => {
const existing = { customFields: [{ name: 'Existing' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT',
structure: existing as any,
componentSkeleton: { customFields: [{ name: 'Skeleton' }] } as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(existing)
})
})
// ---------------------------------------------------------------------------
// createModelType — maps structure to skeleton
// ---------------------------------------------------------------------------
describe('createModelType', () => {
it('sends POST with componentSkeleton for COMPONENT', async () => {
const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType())
const payload: ModelTypePayload = {
name: 'New Type',
code: 'new-type',
category: 'COMPONENT',
structure: structure as any,
}
await createModelType(payload)
expect(mockFetch).toHaveBeenCalledOnce()
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types')
expect(options.method).toBe('POST')
expect(options.body.componentSkeleton).toEqual(structure)
expect(options.body.structure).toBeUndefined()
})
it('sends POST with pieceSkeleton for PIECE', async () => {
const structure = { customFields: [], products: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PIECE' }))
await createModelType({
name: 'Piece Type',
code: 'piece-type',
category: 'PIECE',
structure: structure as any,
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.pieceSkeleton).toEqual(structure)
expect(options.body.structure).toBeUndefined()
})
it('sends POST with productSkeleton for PRODUCT', async () => {
const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PRODUCT' }))
await createModelType({
name: 'Product Type',
code: 'product-type',
category: 'PRODUCT',
structure: structure as any,
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.productSkeleton).toEqual(structure)
})
})
// ---------------------------------------------------------------------------
// updateModelType — maps structure to skeleton
// ---------------------------------------------------------------------------
describe('updateModelType', () => {
it('sends PATCH with correct endpoint and skeleton', async () => {
const structure = { customFields: [{ name: 'Updated' }] }
mockFetch.mockResolvedValue(fakeModelType())
await updateModelType('mt-1', {
name: 'Updated',
code: 'updated',
category: 'COMPONENT',
structure: structure as any,
})
expect(mockFetch).toHaveBeenCalledOnce()
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types/mt-1')
expect(options.method).toBe('PATCH')
expect(options.headers['Content-Type']).toBe('application/merge-patch+json')
expect(options.body.componentSkeleton).toEqual(structure)
})
it('sends payload without skeleton when no structure', async () => {
mockFetch.mockResolvedValue(fakeModelType())
await updateModelType('mt-1', {
name: 'Just Name',
code: 'just-name',
category: 'COMPONENT',
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.componentSkeleton).toBeUndefined()
expect(options.body.name).toBe('Just Name')
})
})
// ---------------------------------------------------------------------------
// deleteModelType
// ---------------------------------------------------------------------------
describe('deleteModelType', () => {
it('sends DELETE to correct endpoint', async () => {
mockFetch.mockResolvedValue(undefined)
await deleteModelType('mt-42')
expect(mockFetch).toHaveBeenCalledOnce()
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types/mt-42')
expect(options.method).toBe('DELETE')
})
})
// ---------------------------------------------------------------------------
// getModelType
// ---------------------------------------------------------------------------
describe('getModelType', () => {
it('sends GET to correct endpoint', async () => {
mockFetch.mockResolvedValue(fakeModelType({ id: 'mt-99' }))
const result = await getModelType('mt-99')
expect(mockFetch).toHaveBeenCalledOnce()
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types/mt-99')
expect(options.method).toBe('GET')
expect(result.id).toBe('mt-99')
})
})

View File

@@ -0,0 +1,508 @@
import { describe, it, expect } from 'vitest'
import {
toFieldString,
fieldKey,
resolveFieldName,
resolveFieldType,
resolveRequiredFlag,
resolveOptions,
resolveDefaultValue,
formatDefaultValue,
normalizeCustomField,
normalizeCustomFieldInputs,
extractStoredCustomFieldValue,
buildCustomFieldInputs,
requiredCustomFieldsFilled,
shouldPersistField,
formatValueForPersistence,
buildCustomFieldMetadata,
type CustomFieldInput,
} from '~/shared/utils/customFieldFormUtils'
// ---------------------------------------------------------------------------
// toFieldString
// ---------------------------------------------------------------------------
describe('toFieldString', () => {
it('returns empty string for null', () => {
expect(toFieldString(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(toFieldString(undefined)).toBe('')
})
it('returns string as-is', () => {
expect(toFieldString('hello')).toBe('hello')
})
it('converts number to string', () => {
expect(toFieldString(42)).toBe('42')
})
it('converts boolean to string', () => {
expect(toFieldString(true)).toBe('true')
expect(toFieldString(false)).toBe('false')
})
it('returns empty string for objects', () => {
expect(toFieldString({ foo: 'bar' })).toBe('')
})
})
// ---------------------------------------------------------------------------
// fieldKey
// ---------------------------------------------------------------------------
describe('fieldKey', () => {
it('prefers customFieldValueId', () => {
const field = { customFieldValueId: 'cfv-1', id: 'id-1', name: 'field' } as CustomFieldInput
expect(fieldKey(field, 0)).toBe('cfv-1')
})
it('falls back to id', () => {
const field = { customFieldValueId: null, id: 'id-1', name: 'field' } as CustomFieldInput
expect(fieldKey(field, 0)).toBe('id-1')
})
it('falls back to name-index', () => {
const field = { customFieldValueId: null, id: null, name: 'weight' } as CustomFieldInput
expect(fieldKey(field, 3)).toBe('weight-3')
})
})
// ---------------------------------------------------------------------------
// resolveFieldName
// ---------------------------------------------------------------------------
describe('resolveFieldName', () => {
it('resolves from name property', () => {
expect(resolveFieldName({ name: 'Poids' })).toBe('Poids')
})
it('falls back to key', () => {
expect(resolveFieldName({ key: 'poids' })).toBe('poids')
})
it('falls back to label', () => {
expect(resolveFieldName({ label: 'Poids' })).toBe('Poids')
})
it('returns empty string for empty object', () => {
expect(resolveFieldName({})).toBe('')
})
it('returns empty string for null', () => {
expect(resolveFieldName(null)).toBe('')
})
it('trims whitespace', () => {
expect(resolveFieldName({ name: ' Poids ' })).toBe('Poids')
})
})
// ---------------------------------------------------------------------------
// resolveFieldType
// ---------------------------------------------------------------------------
describe('resolveFieldType', () => {
it('resolves valid type', () => {
expect(resolveFieldType({ type: 'number' })).toBe('number')
})
it('resolves case-insensitive', () => {
expect(resolveFieldType({ type: 'SELECT' })).toBe('select')
})
it('falls back to text for unknown type', () => {
expect(resolveFieldType({ type: 'blob' })).toBe('text')
})
it('resolves nested value.type', () => {
expect(resolveFieldType({ value: { type: 'date' } })).toBe('date')
})
it('returns text for missing type', () => {
expect(resolveFieldType({})).toBe('text')
})
it('handles all allowed types', () => {
for (const type of ['text', 'number', 'select', 'boolean', 'date']) {
expect(resolveFieldType({ type })).toBe(type)
}
})
})
// ---------------------------------------------------------------------------
// resolveRequiredFlag
// ---------------------------------------------------------------------------
describe('resolveRequiredFlag', () => {
it('resolves boolean true', () => {
expect(resolveRequiredFlag({ required: true })).toBe(true)
})
it('resolves boolean false', () => {
expect(resolveRequiredFlag({ required: false })).toBe(false)
})
it('resolves nested value.required', () => {
expect(resolveRequiredFlag({ value: { required: true } })).toBe(true)
})
it('resolves string "true"', () => {
expect(resolveRequiredFlag({ value: { required: 'true' } })).toBe(true)
})
it('resolves string "1"', () => {
expect(resolveRequiredFlag({ value: { required: '1' } })).toBe(true)
})
it('defaults to false', () => {
expect(resolveRequiredFlag({})).toBe(false)
})
})
// ---------------------------------------------------------------------------
// resolveOptions
// ---------------------------------------------------------------------------
describe('resolveOptions', () => {
it('resolves array of strings', () => {
expect(resolveOptions({ options: ['A', 'B'] })).toEqual(['A', 'B'])
})
it('resolves array of objects with value key', () => {
expect(resolveOptions({ options: [{ value: 'A' }, { value: 'B' }] })).toEqual(['A', 'B'])
})
it('resolves array of objects with label key', () => {
expect(resolveOptions({ options: [{ label: 'Foo' }] })).toEqual(['Foo'])
})
it('falls back to value.options', () => {
expect(resolveOptions({ value: { options: ['X'] } })).toEqual(['X'])
})
it('falls back to value.choices', () => {
expect(resolveOptions({ value: { choices: ['Y'] } })).toEqual(['Y'])
})
it('returns empty array for no options', () => {
expect(resolveOptions({})).toEqual([])
})
it('filters out empty strings', () => {
expect(resolveOptions({ options: ['A', '', 'B'] })).toEqual(['A', 'B'])
})
it('filters out null values', () => {
expect(resolveOptions({ options: [null, 'A'] })).toEqual(['A'])
})
})
// ---------------------------------------------------------------------------
// resolveDefaultValue
// ---------------------------------------------------------------------------
describe('resolveDefaultValue', () => {
it('returns null for null input', () => {
expect(resolveDefaultValue(null)).toBeNull()
})
it('resolves defaultValue', () => {
expect(resolveDefaultValue({ defaultValue: 'hello' })).toBe('hello')
})
it('resolves value (non-object)', () => {
expect(resolveDefaultValue({ value: 42 })).toBe(42)
})
it('resolves nested value.defaultValue', () => {
expect(resolveDefaultValue({ value: { defaultValue: 'nested' } })).toBe('nested')
})
it('returns null when nothing found', () => {
expect(resolveDefaultValue({})).toBeNull()
})
})
// ---------------------------------------------------------------------------
// formatDefaultValue
// ---------------------------------------------------------------------------
describe('formatDefaultValue', () => {
it('returns empty string for null', () => {
expect(formatDefaultValue('text', null)).toBe('')
})
it('converts number to string', () => {
expect(formatDefaultValue('number', 42)).toBe('42')
})
it('handles boolean type with true', () => {
expect(formatDefaultValue('boolean', 'true')).toBe('true')
expect(formatDefaultValue('boolean', true)).toBe('true')
expect(formatDefaultValue('boolean', '1')).toBe('true')
})
it('handles boolean type with false', () => {
expect(formatDefaultValue('boolean', 'false')).toBe('false')
expect(formatDefaultValue('boolean', false)).toBe('false')
expect(formatDefaultValue('boolean', '0')).toBe('false')
})
it('unwraps nested defaultValue object', () => {
expect(formatDefaultValue('text', { defaultValue: 'inner' })).toBe('inner')
})
})
// ---------------------------------------------------------------------------
// normalizeCustomField
// ---------------------------------------------------------------------------
describe('normalizeCustomField', () => {
it('normalizes a complete field', () => {
const result = normalizeCustomField({
id: 'cf-1',
name: 'Weight',
type: 'number',
required: true,
options: [],
orderIndex: 2,
})
expect(result).toEqual({
id: 'cf-1',
name: 'Weight',
type: 'number',
required: true,
options: [],
value: '',
customFieldId: 'cf-1',
customFieldValueId: null,
orderIndex: 2,
})
})
it('returns null for null input', () => {
expect(normalizeCustomField(null)).toBeNull()
})
it('returns null for field without name', () => {
expect(normalizeCustomField({ type: 'text' })).toBeNull()
})
it('uses fallback index', () => {
const result = normalizeCustomField({ name: 'Test' }, 5)
expect(result?.orderIndex).toBe(5)
})
it('defaults type to text', () => {
const result = normalizeCustomField({ name: 'Field' })
expect(result?.type).toBe('text')
})
})
// ---------------------------------------------------------------------------
// normalizeCustomFieldInputs
// ---------------------------------------------------------------------------
describe('normalizeCustomFieldInputs', () => {
it('returns empty array for null structure', () => {
expect(normalizeCustomFieldInputs(null)).toEqual([])
})
it('returns empty array for structure without customFields', () => {
expect(normalizeCustomFieldInputs({})).toEqual([])
})
it('normalizes and sorts fields by orderIndex', () => {
const result = normalizeCustomFieldInputs({
customFields: [
{ name: 'B', orderIndex: 2 },
{ name: 'A', orderIndex: 1 },
],
})
expect(result).toHaveLength(2)
expect(result[0].name).toBe('A')
expect(result[1].name).toBe('B')
})
it('filters out invalid fields', () => {
const result = normalizeCustomFieldInputs({
customFields: [{ name: 'Valid' }, null, { type: 'text' }],
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('Valid')
})
})
// ---------------------------------------------------------------------------
// extractStoredCustomFieldValue
// ---------------------------------------------------------------------------
describe('extractStoredCustomFieldValue', () => {
it('returns string directly', () => {
expect(extractStoredCustomFieldValue('hello')).toBe('hello')
})
it('returns number directly', () => {
expect(extractStoredCustomFieldValue(42)).toBe(42)
})
it('returns empty string for null', () => {
expect(extractStoredCustomFieldValue(null)).toBe('')
})
it('extracts .value from object', () => {
expect(extractStoredCustomFieldValue({ value: 'test' })).toBe('test')
})
it('extracts nested .value.value', () => {
expect(extractStoredCustomFieldValue({ value: { value: 'deep' } })).toBe('deep')
})
it('extracts customFieldValue.value', () => {
expect(extractStoredCustomFieldValue({ customFieldValue: { value: 'cfv' } })).toBe('cfv')
})
})
// ---------------------------------------------------------------------------
// buildCustomFieldInputs
// ---------------------------------------------------------------------------
describe('buildCustomFieldInputs', () => {
const definitions = {
customFields: [
{ name: 'Weight', type: 'number', required: true, id: 'cf-1', orderIndex: 0 },
{ name: 'Color', type: 'select', options: ['Red', 'Blue'], id: 'cf-2', orderIndex: 1 },
],
}
it('builds inputs from definitions without values', () => {
const result = buildCustomFieldInputs(definitions, null)
expect(result).toHaveLength(2)
expect(result[0].name).toBe('Weight')
expect(result[0].value).toBe('')
expect(result[1].name).toBe('Color')
})
it('merges stored values by id', () => {
const values = [
{ customField: { id: 'cf-1', name: 'Weight' }, id: 'cfv-1', value: '42' },
]
const result = buildCustomFieldInputs(definitions, values)
expect(result[0].value).toBe('42')
expect(result[0].customFieldValueId).toBe('cfv-1')
})
it('merges stored values by name fallback', () => {
const values = [
{ name: 'Color', id: 'cfv-2', value: 'Blue' },
]
const result = buildCustomFieldInputs(definitions, values)
expect(result[1].value).toBe('Blue')
})
it('returns empty array for null structure', () => {
expect(buildCustomFieldInputs(null, [])).toEqual([])
})
})
// ---------------------------------------------------------------------------
// requiredCustomFieldsFilled
// ---------------------------------------------------------------------------
describe('requiredCustomFieldsFilled', () => {
it('returns true when all required fields are filled', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'A', type: 'text', required: true, options: [], value: 'hello', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
})
it('returns false when required field is empty', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'A', type: 'text', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
})
it('returns true for non-required empty field', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'A', type: 'text', required: false, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
})
it('treats boolean "false" as filled', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'false', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
})
it('treats boolean "true" as filled', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'true', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
})
it('treats boolean empty as not filled', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
})
it('returns true for empty array', () => {
expect(requiredCustomFieldsFilled([])).toBe(true)
})
})
// ---------------------------------------------------------------------------
// shouldPersistField & formatValueForPersistence
// ---------------------------------------------------------------------------
describe('shouldPersistField', () => {
it('returns true for non-empty text field', () => {
expect(shouldPersistField({ value: 'hello' } as CustomFieldInput)).toBe(true)
})
it('returns false for empty text field', () => {
expect(shouldPersistField({ value: '', type: 'text' } as CustomFieldInput)).toBe(false)
})
it('returns true for boolean "true"', () => {
expect(shouldPersistField({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe(true)
})
it('returns true for boolean "false"', () => {
expect(shouldPersistField({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe(true)
})
it('returns false for boolean empty', () => {
expect(shouldPersistField({ value: '', type: 'boolean' } as CustomFieldInput)).toBe(false)
})
})
describe('formatValueForPersistence', () => {
it('trims text value', () => {
expect(formatValueForPersistence({ value: ' hello ', type: 'text' } as CustomFieldInput)).toBe('hello')
})
it('returns "true" for boolean true', () => {
expect(formatValueForPersistence({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe('true')
})
it('returns "false" for boolean non-true', () => {
expect(formatValueForPersistence({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe('false')
expect(formatValueForPersistence({ value: '', type: 'boolean' } as CustomFieldInput)).toBe('false')
})
})
// ---------------------------------------------------------------------------
// buildCustomFieldMetadata
// ---------------------------------------------------------------------------
describe('buildCustomFieldMetadata', () => {
it('builds metadata from field', () => {
const field: CustomFieldInput = {
id: null, name: 'Color', type: 'select', required: true,
options: ['Red', 'Blue'], value: 'Red',
customFieldId: null, customFieldValueId: null, orderIndex: 0,
}
expect(buildCustomFieldMetadata(field)).toEqual({
customFieldName: 'Color',
customFieldType: 'select',
customFieldRequired: true,
customFieldOptions: ['Red', 'Blue'],
})
})
})

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
const iconStub = resolve(__dirname, 'tests/__mocks__/iconStub.ts')
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'happy-dom',
@@ -9,9 +13,10 @@ export default defineConfig({
include: ['tests/**/*.test.ts'],
},
resolve: {
alias: {
'~': resolve(__dirname, 'app'),
'#imports': resolve(__dirname, 'tests/__mocks__/imports.ts'),
},
alias: [
{ find: /^~icons\/.*/, replacement: iconStub },
{ find: '~', replacement: resolve(__dirname, 'app') },
{ find: '#imports', replacement: resolve(__dirname, 'tests/__mocks__/imports.ts') },
],
},
})