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>
This commit is contained in:
Matthieu
2026-02-09 14:19:08 +01:00
parent 634184c2be
commit 67af3c9c46
28 changed files with 2287 additions and 42 deletions

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

@@ -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

@@ -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)