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

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

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

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

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

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

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