refactor(custom-fields) : unify 3 parallel implementations into 1 module

Replace ~2900 lines across 9 files with ~400 lines in 2 files:
- shared/utils/customFields.ts (types + pure helpers)
- composables/useCustomFieldInputs.ts (reactive composable)

Migrated all consumers:
- Backend: add defaultValue to API Platform serialization groups
- Standalone pages: component edit/create, piece edit/create, product edit/create/detail
- Machine page: MachineCustomFieldsCard, MachineInfoCard, useMachineDetailCustomFields
- Hierarchy: ComponentItem, PieceItem
- Shared: CustomFieldDisplay, CustomFieldInputGrid
- Category editor: componentStructure.ts

Deleted:
- entityCustomFieldLogic.ts (335 lines)
- customFieldUtils.ts (440 lines)
- customFieldFormUtils.ts (404 lines)
- useEntityCustomFields.ts (181 lines)
- customFieldFormUtils.test.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 13:09:27 +02:00
parent f2eff89e00
commit 894d522036
25 changed files with 861 additions and 2279 deletions

View File

@@ -336,7 +336,7 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">

View File

@@ -278,7 +278,7 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">

View File

@@ -225,7 +225,6 @@ import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useConstructeurs } from '~/composables/useConstructeurs'
@@ -243,12 +242,7 @@ import {
applyProductSelection,
collectNormalizedProductIds,
} from '~/shared/utils/pieceProductSelectionUtils'
import {
type CustomFieldInput,
normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null
@@ -261,7 +255,6 @@ const router = useRouter()
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
const { createPiece } = usePieces()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
@@ -281,7 +274,14 @@ const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const productSelections = ref<(string | null)[]>([])
const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([])
const cfDefinitions = ref<any[]>([])
const createdEntityId = ref<string | null>(null)
const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, saveAll: saveAllCustomFields } = useCustomFieldInputs({
definitions: cfDefinitions,
values: [] as any[],
entityType: 'piece' as CustomFieldEntityType,
entityId: createdEntityId,
})
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
@@ -360,21 +360,17 @@ watch(structureProducts, (products) => {
watch(selectedType, (type) => {
if (!type) {
clearCreationForm()
customFieldInputs.value = []
cfDefinitions.value = []
return
}
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
creationForm.name = type.name
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
cfDefinitions.value = type.structure?.customFields ?? []
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() =>
Boolean(
canEdit.value &&
@@ -450,14 +446,11 @@ const submitCreation = async () => {
const result = await createPiece(payload)
if (result.success && result.data) {
const createdPiece = result.data as Record<string, any>
await _saveCustomFieldValues(
'piece',
createdPiece.id,
[
createdPiece?.typePiece?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
createdEntityId.value = createdPiece.id
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Pièce créée, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
}
// Sync constructeur links after creation
if (constructeurLinks.value.length) {
await syncLinks('piece', createdPiece.id, [], constructeurLinks.value)

View File

@@ -237,7 +237,6 @@ import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
@@ -250,12 +249,7 @@ import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
const { canEdit } = usePermissions()
const versionRefreshKey = ref(0)
@@ -263,7 +257,6 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
@@ -282,7 +275,19 @@ const {
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const customFieldInputs = ref<CustomFieldInput[]>([])
const cfDefinitions = ref<any[]>([])
const cfValues = ref<any[]>([])
const entityId = computed(() => product.value?.id ?? null)
const {
fields: customFieldInputs,
requiredFilled: requiredCustomFieldsFilled,
saveAll: saveAllCustomFields,
} = useCustomFieldInputs({
definitions: cfDefinitions,
values: cfValues,
entityType: 'product' as CustomFieldEntityType,
entityId,
})
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
@@ -311,7 +316,8 @@ const refreshCustomFieldInputs = (
) => {
const nextStructure = structureOverride ?? structure.value ?? null
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
cfDefinitions.value = nextStructure?.customFields ?? []
cfValues.value = Array.isArray(nextValues) ? nextValues : []
}
const editionForm = reactive({
@@ -321,9 +327,7 @@ const editionForm = reactive({
supplierPrice: '' as string,
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
const canSubmit = computed(() =>
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
@@ -517,12 +521,7 @@ const submitEdition = async () => {
const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) {
product.value = result.data
const failedFields = await _saveCustomFieldValues(
'product',
result.data.id,
[result.data?.typeProduct?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return

View File

@@ -194,7 +194,7 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
@@ -309,7 +309,6 @@ import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
@@ -323,19 +322,13 @@ import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
@@ -359,7 +352,19 @@ const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const customFieldInputs = ref<CustomFieldInput[]>([])
const cfDefinitions = ref<any[]>([])
const cfValues = ref<any[]>([])
const entityId = computed(() => product.value?.id ?? null)
const {
fields: customFieldInputs,
requiredFilled: requiredCustomFieldsFilled,
saveAll: saveAllCustomFields,
} = useCustomFieldInputs({
definitions: cfDefinitions,
values: cfValues,
entityType: 'product' as CustomFieldEntityType,
entityId,
})
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
@@ -385,7 +390,8 @@ const refreshCustomFieldInputs = (
) => {
const nextStructure = structureOverride ?? structure.value ?? null
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
cfDefinitions.value = nextStructure?.customFields ?? []
cfValues.value = Array.isArray(nextValues) ? nextValues : []
}
const editionForm = reactive({
@@ -395,9 +401,7 @@ const editionForm = reactive({
supplierPrice: '' as string,
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
const canSubmit = computed(() =>
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
@@ -595,12 +599,7 @@ const submitEdition = async () => {
const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) {
product.value = result.data
const failedFields = await _saveCustomFieldValues(
'product',
result.data.id,
[result.data?.typeProduct?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return

View File

@@ -177,7 +177,6 @@ import DocumentUpload from '~/components/DocumentUpload.vue'
import { useProductTypes } from '~/composables/useProductTypes'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
@@ -186,10 +185,7 @@ import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useConstructeurs } from '~/composables/useConstructeurs'
import type { ProductModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
normalizeCustomFieldInputs as normalizeCustomFieldInputsFromUtils,
} from '~/shared/utils/customFieldFormUtils'
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
interface ProductCatalogType extends ModelType {
structure: ProductModelStructure | null
@@ -202,7 +198,6 @@ const router = useRouter()
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
const { createProduct } = useProducts()
const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
const { syncLinks } = useConstructeurLinks()
@@ -221,7 +216,14 @@ const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
const customFieldInputs = ref<CustomFieldInput[]>([])
const cfDefinitions = ref<any[]>([])
const createdEntityId = ref<string | null>(null)
const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, saveAll: saveAllCustomFields } = useCustomFieldInputs({
definitions: cfDefinitions,
values: [] as any[],
entityType: 'product' as CustomFieldEntityType,
entityId: createdEntityId,
})
const productTypeList = computed<ProductCatalogType[]>(() =>
(productTypes.value || []) as ProductCatalogType[],
@@ -264,26 +266,17 @@ watch(selectedTypeId, (id) => {
watch(selectedType, (type) => {
if (!type) {
clearForm()
customFieldInputs.value = []
cfDefinitions.value = []
return
}
if (!creationForm.name) {
creationForm.name = type.name
}
customFieldInputs.value = normalizeCustomFieldInputsFromUtils(normalizeProductStructureForSave(type.structure))
const normalized = normalizeProductStructureForSave(type.structure)
cfDefinitions.value = normalized?.customFields ?? []
})
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim().length > 0
}),
)
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
const canSubmit = computed(() => Boolean(
canEdit.value &&
@@ -336,7 +329,8 @@ const submitCreation = async () => {
const result = await createProduct(payload)
if (result.success && result.data?.id) {
const productId = result.data.id
const failedFields = await saveCustomFieldValues(result.data.id)
createdEntityId.value = productId
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
await router.replace(`/product/${result.data.id}?edit=true`)
@@ -375,39 +369,6 @@ const submitCreation = async () => {
}
}
const saveCustomFieldValues = async (productId: string) => {
const failed: string[] = []
for (const field of customFieldInputs.value) {
if (!field.name) {
continue
}
const value = field.value ?? ''
const metadata = field.customFieldId
? undefined
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
metadata,
)
if (!result.success) {
failed.push(field.name)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || field.customFieldId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
return failed
}
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.constructeurIds,