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:
@@ -335,13 +335,8 @@ import {
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
import {
|
||||
mergeFieldDefinitionsWithValues,
|
||||
dedupeMergedFields,
|
||||
resolveCustomFieldId,
|
||||
resolveFieldId,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
|
||||
|
||||
const props = defineProps({
|
||||
component: { type: Object, required: true },
|
||||
@@ -377,25 +372,81 @@ const {
|
||||
} = useEntityProductDisplay({ entity: () => props.component })
|
||||
|
||||
const {
|
||||
displayedCustomFields,
|
||||
updateCustomField: updateComponentCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||
upsertCustomFieldValue,
|
||||
} = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
// Parent already pre-merges standalone custom fields into props.component.customFields
|
||||
const displayedCustomFields = computed(() => {
|
||||
const fields = props.component?.customFields
|
||||
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
|
||||
})
|
||||
|
||||
const updateComponentCustomField = async (field) => {
|
||||
if (!field || field.readOnly) return
|
||||
|
||||
const e = props.component
|
||||
const fieldValueId = field.customFieldValueId
|
||||
|
||||
if (fieldValueId) {
|
||||
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||
if (result.success) {
|
||||
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!e?.id) {
|
||||
showError('Impossible de créer la valeur pour ce champ')
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = field.customFieldId ? undefined : {
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
customFieldRequired: field.required,
|
||||
customFieldOptions: field.options,
|
||||
}
|
||||
const result = await upsertCustomFieldValue(
|
||||
field.customFieldId,
|
||||
'composant',
|
||||
e.id,
|
||||
field.value ?? '',
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
const newValue = result.data
|
||||
if (newValue?.id) {
|
||||
field.customFieldValueId = newValue.id
|
||||
field.value = newValue.value ?? field.value ?? ''
|
||||
if (newValue.customField?.id) {
|
||||
field.customFieldId = newValue.customField.id
|
||||
}
|
||||
}
|
||||
showSuccess(`Champ "${field.name}" créé avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Context fields are NOT pre-merged — merge locally
|
||||
const mergedContextFields = computed(() => {
|
||||
const definitions = props.component?.contextCustomFields ?? []
|
||||
const values = props.component?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
return mergeDefinitionsWithValues(definitions, values)
|
||||
})
|
||||
|
||||
const queueContextCustomFieldUpdate = (field, value) => {
|
||||
const linkId = props.component?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = resolveCustomFieldId(field)
|
||||
const customFieldValueId = resolveFieldId(field)
|
||||
const customFieldId = field.customFieldId
|
||||
const customFieldValueId = field.customFieldValueId
|
||||
if (!customFieldId && !customFieldValueId) return
|
||||
|
||||
field.value = value
|
||||
@@ -405,7 +456,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
|
||||
fieldId: customFieldId,
|
||||
customFieldValueId,
|
||||
value: value ?? '',
|
||||
fieldName: field.name || field.customField?.name || 'Champ contextuel',
|
||||
fieldName: field.name || 'Champ contextuel',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -319,16 +319,10 @@ import {
|
||||
uniqueConstructeurIds,
|
||||
parseConstructeurLinksFromApi,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
resolveFieldId,
|
||||
resolveFieldReadOnly,
|
||||
resolveCustomFieldId,
|
||||
mergeFieldDefinitionsWithValues,
|
||||
dedupeMergedFields,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
|
||||
const props = defineProps({
|
||||
piece: { type: Object, required: true },
|
||||
@@ -392,25 +386,81 @@ const {
|
||||
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
|
||||
|
||||
const {
|
||||
displayedCustomFields,
|
||||
updateCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||
upsertCustomFieldValue,
|
||||
} = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
// Parent already pre-merges standalone custom fields into props.piece.customFields
|
||||
const displayedCustomFields = computed(() => {
|
||||
const fields = props.piece?.customFields
|
||||
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
|
||||
})
|
||||
|
||||
const updateCustomField = async (field) => {
|
||||
if (!field || field.readOnly) return
|
||||
|
||||
const e = props.piece
|
||||
const fieldValueId = field.customFieldValueId
|
||||
|
||||
if (fieldValueId) {
|
||||
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||
if (result.success) {
|
||||
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!e?.id) {
|
||||
showError('Impossible de créer la valeur pour ce champ')
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = field.customFieldId ? undefined : {
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
customFieldRequired: field.required,
|
||||
customFieldOptions: field.options,
|
||||
}
|
||||
const result = await upsertCustomFieldValue(
|
||||
field.customFieldId,
|
||||
'piece',
|
||||
e.id,
|
||||
field.value ?? '',
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
const newValue = result.data
|
||||
if (newValue?.id) {
|
||||
field.customFieldValueId = newValue.id
|
||||
field.value = newValue.value ?? field.value ?? ''
|
||||
if (newValue.customField?.id) {
|
||||
field.customFieldId = newValue.customField.id
|
||||
}
|
||||
}
|
||||
showSuccess(`Champ "${field.name}" créé avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Context fields are NOT pre-merged — merge locally
|
||||
const mergedContextFields = computed(() => {
|
||||
const definitions = props.piece?.contextCustomFields ?? []
|
||||
const values = props.piece?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
return mergeDefinitionsWithValues(definitions, values)
|
||||
})
|
||||
|
||||
const queueContextCustomFieldUpdate = (field, value) => {
|
||||
const linkId = props.piece?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = resolveCustomFieldId(field)
|
||||
const customFieldValueId = resolveFieldId(field)
|
||||
const customFieldId = field.customFieldId
|
||||
const customFieldValueId = field.customFieldValueId
|
||||
if (!customFieldId && !customFieldValueId) return
|
||||
|
||||
field.value = value
|
||||
@@ -420,7 +470,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
|
||||
fieldId: customFieldId,
|
||||
customFieldValueId,
|
||||
value: value ?? '',
|
||||
fieldName: field.name || field.customField?.name || 'Champ contextuel',
|
||||
fieldName: field.name || 'Champ contextuel',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -544,8 +594,8 @@ const handleProductChange = async (value) => {
|
||||
|
||||
// --- Custom field event handlers ---
|
||||
const handleCustomFieldInput = (field, value) => {
|
||||
if (resolveFieldReadOnly(field)) return
|
||||
const fieldValueId = resolveFieldId(field)
|
||||
if (field.readOnly) return
|
||||
const fieldValueId = field.customFieldValueId
|
||||
if (!fieldValueId) return
|
||||
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
||||
if (fieldValue) fieldValue.value = value
|
||||
@@ -553,7 +603,7 @@ const handleCustomFieldInput = (field, value) => {
|
||||
|
||||
const handleCustomFieldBlur = async (field) => {
|
||||
await updateCustomField(field)
|
||||
const cfId = field?.customFieldId || field?.customField?.id || null
|
||||
const cfId = field?.customFieldId || null
|
||||
if (cfId || field?.customFieldValueId) {
|
||||
emit('custom-field-update', {
|
||||
fieldId: cfId,
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
<div :class="layoutClass">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="resolveFieldKey(field, index)"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{
|
||||
resolveFieldName(field)
|
||||
field.name
|
||||
}}</span>
|
||||
<span
|
||||
v-if="resolveFieldRequired(field)"
|
||||
v-if="field.required"
|
||||
class="label-text-alt text-error"
|
||||
>*</span>
|
||||
</label>
|
||||
@@ -26,32 +26,32 @@
|
||||
<template v-if="isFieldEditable(field)">
|
||||
<!-- Champ de type TEXT -->
|
||||
<input
|
||||
v-if="resolveFieldType(field) === 'text'"
|
||||
v-if="field.type === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type NUMBER -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'number'"
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type SELECT -->
|
||||
<select
|
||||
v-else-if="resolveFieldType(field) === 'select'"
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
@@ -59,7 +59,7 @@
|
||||
Sélectionner...
|
||||
</option>
|
||||
<option
|
||||
v-for="option in resolveFieldOptions(field)"
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
<!-- Champ de type BOOLEAN -->
|
||||
<div
|
||||
v-else-if="resolveFieldType(field) === 'boolean'"
|
||||
v-else-if="field.type === 'boolean'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -85,21 +85,21 @@
|
||||
|
||||
<!-- Champ de type DATE -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'date'"
|
||||
v-else-if="field.type === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type TEXTAREA -->
|
||||
<textarea
|
||||
v-else-if="resolveFieldType(field) === 'textarea'"
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:value="field.value ?? ''"
|
||||
class="textarea textarea-bordered textarea-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
/>
|
||||
@@ -110,7 +110,7 @@
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
@@ -119,7 +119,7 @@
|
||||
<!-- Mode lecture seule -->
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatFieldDisplayValue(field) }}
|
||||
{{ formatValueForDisplay(field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -128,18 +128,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
resolveFieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldOptions,
|
||||
resolveFieldRequired,
|
||||
resolveFieldReadOnly,
|
||||
formatFieldDisplayValue,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { fieldKey, formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: any[]
|
||||
fields: CustomFieldInput[]
|
||||
isEditMode: boolean
|
||||
columns?: 1 | 2
|
||||
title?: string
|
||||
@@ -150,8 +142,8 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'field-input': [field: any, value: string]
|
||||
'field-blur': [field: any]
|
||||
'field-input': [field: CustomFieldInput, value: string]
|
||||
'field-blur': [field: CustomFieldInput]
|
||||
}>()
|
||||
|
||||
const layoutClass = computed(() =>
|
||||
@@ -170,16 +162,16 @@ const containerClass = computed(() =>
|
||||
const editable = computed(() => props.editable ?? true)
|
||||
const emitBlur = computed(() => props.emitBlur ?? true)
|
||||
|
||||
function isFieldEditable(field: any) {
|
||||
return props.isEditMode && editable.value && !resolveFieldReadOnly(field)
|
||||
function isFieldEditable(field: CustomFieldInput) {
|
||||
return props.isEditMode && editable.value && !field.readOnly
|
||||
}
|
||||
|
||||
function onInput(field: any, value: string) {
|
||||
function onInput(field: CustomFieldInput, value: string) {
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
}
|
||||
|
||||
function onBooleanChange(field: any, checked: boolean) {
|
||||
function onBooleanChange(field: CustomFieldInput, checked: boolean) {
|
||||
const value = checked ? 'true' : 'false'
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
@@ -188,7 +180,7 @@ function onBooleanChange(field: any, checked: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur(field: any) {
|
||||
function onBlur(field: CustomFieldInput) {
|
||||
if (emitBlur.value) {
|
||||
emit('field-blur', field)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
|
||||
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
|
||||
|
||||
defineProps<{
|
||||
fields: CustomFieldInput[]
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatCustomFieldValue(field) }}
|
||||
{{ formatValueForDisplay(field) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
import { formatValueForDisplay } from '~/shared/utils/customFields'
|
||||
|
||||
defineProps<{
|
||||
customFields: any[]
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatCustomFieldValue(field) }}
|
||||
{{ formatValueForDisplay(field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@ import { watch } from 'vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
||||
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
import { formatValueForDisplay } from '~/shared/utils/customFields'
|
||||
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user