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:
@@ -4,6 +4,7 @@ import {
|
||||
type ComponentModelStructure,
|
||||
type ComponentModelStructureNode,
|
||||
} from '../types/inventory'
|
||||
import { mergeDefinitionsWithValues } from '../utils/customFields'
|
||||
|
||||
// Import for internal use in this file
|
||||
import { sanitizeCustomFields, sanitizePieces, sanitizeProducts, sanitizeSubcomponents } from './componentStructureSanitize'
|
||||
@@ -86,30 +87,22 @@ export const cloneStructure = (input: any): ComponentModelStructure => {
|
||||
export const normalizeStructureForEditor = (input: any): ComponentModelStructure => {
|
||||
const source = cloneStructure(input)
|
||||
|
||||
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
|
||||
const customFields = sanitizedCustomFields.map((field) => {
|
||||
const options = Array.isArray(field.options) ? [...field.options] : []
|
||||
const optionsText = options.length ? options.join('\n') : ''
|
||||
const defaultValue =
|
||||
field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== ''
|
||||
? String(field.defaultValue)
|
||||
: null
|
||||
const copy: ComponentModelCustomField = {
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
machineContextOnly: !!field.machineContextOnly,
|
||||
options,
|
||||
defaultValue,
|
||||
optionsText,
|
||||
id: field.id,
|
||||
customFieldId: field.customFieldId,
|
||||
}
|
||||
return copy
|
||||
})
|
||||
const merged = mergeDefinitionsWithValues(source.customFields, [])
|
||||
const customFields: ComponentModelCustomField[] = merged.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type as ComponentModelCustomField['type'],
|
||||
required: field.required,
|
||||
machineContextOnly: field.machineContextOnly,
|
||||
options: field.options,
|
||||
defaultValue: field.defaultValue,
|
||||
optionsText: field.optionsText,
|
||||
id: field.customFieldId ?? undefined,
|
||||
customFieldId: field.customFieldId ?? undefined,
|
||||
orderIndex: field.orderIndex,
|
||||
}))
|
||||
|
||||
const result: ComponentModelStructure = {
|
||||
customFields: customFields as ComponentModelCustomField[],
|
||||
customFields,
|
||||
pieces: sanitizePieces(source.pieces),
|
||||
products: sanitizeProducts(source.products),
|
||||
subcomponents: hydrateSubcomponents(source.subcomponents),
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
/**
|
||||
* Custom field form normalization, merge, and persistence utilities.
|
||||
*
|
||||
* Extracted from pages/component/create.vue, component/[id]/edit.vue,
|
||||
* pieces/create.vue, pieces/[id]/edit.vue, product/[id]/edit.vue.
|
||||
*
|
||||
* Every create/edit page was shipping its own copy of these helpers –
|
||||
* this module unifies them behind a single, entity-agnostic API.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CustomFieldInput {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
value: string
|
||||
customFieldId: string | null
|
||||
customFieldValueId: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
export interface SaveCustomFieldDeps {
|
||||
customFieldInputs: { value: CustomFieldInput[] }
|
||||
upsertCustomFieldValue: (
|
||||
definitionId: string | null,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
value: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
) => Promise<{ success: boolean; data?: any }>
|
||||
updateCustomFieldValue: (
|
||||
id: string,
|
||||
payload: { value: string },
|
||||
) => Promise<{ success: boolean }>
|
||||
toast: { showError: (msg: string) => void }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
return ''
|
||||
}
|
||||
|
||||
export const fieldKey = (field: CustomFieldInput, index: number): string =>
|
||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field resolution helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolveFieldName = (field: any): string => {
|
||||
if (typeof field?.name === 'string' && field.name.trim()) return field.name.trim()
|
||||
if (typeof field?.key === 'string' && field.key.trim()) return field.key.trim()
|
||||
if (typeof field?.label === 'string' && field.label.trim()) return field.label.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
export const resolveFieldType = (field: any): string => {
|
||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const rawType =
|
||||
typeof field?.type === 'string'
|
||||
? field.type
|
||||
: typeof field?.value?.type === 'string'
|
||||
? field.value.type
|
||||
: ''
|
||||
const value = rawType.toLowerCase()
|
||||
return allowed.includes(value) ? value : 'text'
|
||||
}
|
||||
|
||||
export const resolveRequiredFlag = (field: any): boolean => {
|
||||
if (typeof field?.required === 'boolean') return field.required
|
||||
const nested = field?.value?.required
|
||||
if (typeof nested === 'boolean') return nested
|
||||
if (typeof nested === 'string') {
|
||||
const normalized = nested.toLowerCase()
|
||||
return normalized === 'true' || normalized === '1'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const resolveOptions = (field: any): string[] => {
|
||||
const sources = [field?.options, field?.value?.options, field?.value?.choices]
|
||||
for (const source of sources) {
|
||||
if (Array.isArray(source)) {
|
||||
const mapped = source
|
||||
.map((option: unknown) => {
|
||||
if (option === null || option === undefined) return ''
|
||||
if (typeof option === 'string') return option.trim()
|
||||
if (typeof option === 'object') {
|
||||
const record = (option || {}) as Record<string, unknown>
|
||||
for (const key of ['value', 'label', 'name']) {
|
||||
const candidate = record[key]
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
|
||||
}
|
||||
}
|
||||
const fallback = String(option).trim()
|
||||
return fallback === '[object Object]' ? '' : fallback
|
||||
})
|
||||
.filter((o) => o.length > 0)
|
||||
if (mapped.length) return mapped
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export const resolveDefaultValue = (field: any): any => {
|
||||
if (!field || typeof field !== 'object') return null
|
||||
if (field.defaultValue !== undefined && field.defaultValue !== null) return field.defaultValue
|
||||
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') return field.value
|
||||
if (field.default !== undefined && field.default !== null) return field.default
|
||||
if (field.value && typeof field.value === 'object') {
|
||||
if (field.value.defaultValue !== undefined && field.value.defaultValue !== null) return field.value.defaultValue
|
||||
if (field.value.value !== undefined && field.value.value !== null && typeof field.value.value !== 'object') return field.value.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
if (defaultValue === null || defaultValue === undefined) return ''
|
||||
if (typeof defaultValue === 'object') {
|
||||
if (defaultValue === null) return ''
|
||||
if ('defaultValue' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
|
||||
}
|
||||
if ('value' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(defaultValue).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return 'true'
|
||||
if (normalized === 'false' || normalized === '0') return 'false'
|
||||
return ''
|
||||
}
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize a single raw custom-field definition into CustomFieldInput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||
if (!rawField || typeof rawField !== 'object') return null
|
||||
const name = resolveFieldName(rawField)
|
||||
if (!name) return null
|
||||
const type = resolveFieldType(rawField)
|
||||
const required = resolveRequiredFlag(rawField)
|
||||
const options = resolveOptions(rawField)
|
||||
const defaultSource = resolveDefaultValue(rawField)
|
||||
const value = formatDefaultValue(type, defaultSource)
|
||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string' ? rawField.customFieldValueId : null
|
||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize ALL custom-field definitions from a structure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const normalizeCustomFieldInputs = (structure: any): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') return []
|
||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||
return fields
|
||||
.map((field: any, index: number) => normalizeCustomField(field, index))
|
||||
.filter((field: CustomFieldInput | null): field is CustomFieldInput => field !== null)
|
||||
.sort((a: CustomFieldInput, b: CustomFieldInput) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract stored value from a persisted custom-field entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const extractStoredCustomFieldValue = (entry: any): any => {
|
||||
if (entry === null || entry === undefined) return ''
|
||||
if (typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean') return entry
|
||||
if (typeof entry !== 'object') return String(entry)
|
||||
|
||||
const direct = entry.value
|
||||
if (direct !== undefined && direct !== null) {
|
||||
if (typeof direct === 'object') {
|
||||
if (direct === null) return ''
|
||||
if ('value' in direct && direct.value !== undefined && direct.value !== null) return direct.value
|
||||
if ('defaultValue' in direct && direct.defaultValue !== undefined && direct.defaultValue !== null) return direct.defaultValue
|
||||
return ''
|
||||
}
|
||||
return direct
|
||||
}
|
||||
if (entry.defaultValue !== undefined && entry.defaultValue !== null) return entry.defaultValue
|
||||
if (entry.customFieldValue?.value !== undefined && entry.customFieldValue?.value !== null) return entry.customFieldValue.value
|
||||
return ''
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build inputs for edit pages (merge definitions + stored values)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildCustomFieldInputs = (
|
||||
structure: any,
|
||||
values: any[] | null | undefined,
|
||||
): CustomFieldInput[] => {
|
||||
const definitions = normalizeCustomFieldInputs(structure)
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const mapById = new Map<string, any>()
|
||||
const mapByName = new Map<string, any>()
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return
|
||||
const fieldId = entry.customField?.id || entry.customFieldId || null
|
||||
if (fieldId) mapById.set(fieldId, entry)
|
||||
const fieldName = entry.customField?.name || entry.name || entry.key || null
|
||||
if (fieldName) mapByName.set(fieldName, entry)
|
||||
})
|
||||
|
||||
const matchedIds = new Set<string>()
|
||||
const matchedNames = new Set<string>()
|
||||
|
||||
const result = definitions
|
||||
.map((definition) => {
|
||||
const definitionId = definition.customFieldId || definition.id || null
|
||||
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: definition.customFieldId || definition.id,
|
||||
customFieldValueId: null,
|
||||
orderIndex: definition.orderIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const matchedFieldId = matched.customField?.id || matched.customFieldId || null
|
||||
if (matchedFieldId) matchedIds.add(matchedFieldId)
|
||||
const matchedFieldName = matched.customField?.name || matched.name || null
|
||||
if (matchedFieldName) matchedNames.add(matchedFieldName)
|
||||
|
||||
const resolvedValue = extractStoredCustomFieldValue(matched)
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
||||
customFieldValueId: matched.id ?? null,
|
||||
value: formatDefaultValue(definition.type, resolvedValue),
|
||||
orderIndex: Math.min(
|
||||
definition.orderIndex ?? 0,
|
||||
typeof matched.customField?.orderIndex === 'number'
|
||||
? matched.customField.orderIndex
|
||||
: definition.orderIndex ?? 0,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
// Include values with embedded definitions that didn't match any structure definition
|
||||
valueList.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') return
|
||||
const cf = entry.customField
|
||||
if (!cf || typeof cf !== 'object') return
|
||||
const fieldId = cf.id || entry.customFieldId || null
|
||||
const fieldName = cf.name || entry.name || null
|
||||
if (fieldId && matchedIds.has(fieldId)) return
|
||||
if (fieldName && matchedNames.has(fieldName)) return
|
||||
|
||||
const name = resolveFieldName(cf)
|
||||
if (!name) return
|
||||
|
||||
const type = resolveFieldType(cf)
|
||||
const resolvedValue = extractStoredCustomFieldValue(entry)
|
||||
result.push({
|
||||
id: fieldId,
|
||||
name,
|
||||
type,
|
||||
required: resolveRequiredFlag(cf),
|
||||
options: resolveOptions(cf),
|
||||
value: formatDefaultValue(type, resolvedValue),
|
||||
customFieldId: fieldId,
|
||||
customFieldValueId: entry.id ?? null,
|
||||
orderIndex: typeof cf.orderIndex === 'number' ? cf.orderIndex : definitions.length + index,
|
||||
})
|
||||
})
|
||||
|
||||
return result.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildCustomFieldMetadata = (field: CustomFieldInput): Record<string, unknown> => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
customFieldRequired: field.required,
|
||||
customFieldOptions: field.options,
|
||||
})
|
||||
|
||||
export const shouldPersistField = (field: CustomFieldInput): boolean => {
|
||||
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
export const formatValueForPersistence = (field: CustomFieldInput): string => {
|
||||
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
|
||||
export const requiredCustomFieldsFilled = (inputs: CustomFieldInput[]): boolean =>
|
||||
inputs.every((field) => {
|
||||
if (!field.required) return true
|
||||
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save custom-field values for an entity.
|
||||
*
|
||||
* @param entityType - API entity slug ('composant' | 'piece' | 'product')
|
||||
* @param entityId - ID of the created/updated entity
|
||||
* @param definitionSources - arrays of raw definition objects to build a name→id map
|
||||
* @param deps - injected composable references
|
||||
* @returns list of field names that failed to save (empty = all OK)
|
||||
*/
|
||||
export const saveCustomFieldValues = async (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
definitionSources: any[][],
|
||||
deps: SaveCustomFieldDeps,
|
||||
): Promise<string[]> => {
|
||||
if (!entityId) return []
|
||||
|
||||
const definitionMap = new Map<string, string>()
|
||||
const registerDefinitions = (fields: any[]) => {
|
||||
if (!Array.isArray(fields)) return
|
||||
fields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') return
|
||||
const name = typeof field.name === 'string' ? field.name : null
|
||||
const id = typeof field.id === 'string' ? field.id : null
|
||||
if (name && id && !definitionMap.has(name)) definitionMap.set(name, id)
|
||||
})
|
||||
}
|
||||
|
||||
definitionSources.forEach(registerDefinitions)
|
||||
|
||||
const resolveDefinitionId = (field: CustomFieldInput) => {
|
||||
if (field.customFieldId) return field.customFieldId
|
||||
if (field.id) return field.id
|
||||
return definitionMap.get(field.name) ?? null
|
||||
}
|
||||
|
||||
const failed: string[] = []
|
||||
|
||||
for (const field of deps.customFieldInputs.value) {
|
||||
if (!shouldPersistField(field)) continue
|
||||
|
||||
const definitionId = resolveDefinitionId(field)
|
||||
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
|
||||
const value = formatValueForPersistence(field)
|
||||
|
||||
if (field.customFieldValueId) {
|
||||
const result = await deps.updateCustomFieldValue(field.customFieldValueId, { value })
|
||||
if (!result.success) {
|
||||
deps.toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
|
||||
failed.push(field.name)
|
||||
} else if (definitionId && !field.customFieldId) {
|
||||
field.customFieldId = definitionId
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await deps.upsertCustomFieldValue(
|
||||
definitionId,
|
||||
entityType,
|
||||
entityId,
|
||||
value,
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
deps.toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
|
||||
failed.push(field.name)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) field.customFieldValueId = createdValue.id
|
||||
const resolvedId = createdValue?.customField?.id || definitionId
|
||||
if (resolvedId) field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
|
||||
return failed
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
/**
|
||||
* Custom field normalization, merging and display utilities.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue to be reusable across
|
||||
* machine detail, component, piece and product views.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const coerceValueForType = (type: string, rawValue: unknown): string => {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(rawValue).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return 'true'
|
||||
if (normalized === 'false' || normalized === '0') return 'false'
|
||||
return ''
|
||||
}
|
||||
return String(rawValue)
|
||||
}
|
||||
|
||||
export const formatCustomFieldValue = (field: Record<string, unknown> | null | undefined): string => {
|
||||
if (!field) return 'Non défini'
|
||||
|
||||
const value = (field.value ?? field.defaultValue ?? '') as string
|
||||
if (value === '' || value === null || value === undefined) return 'Non défini'
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
const normalized = String(value).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return 'Oui'
|
||||
if (normalized === 'false' || normalized === '0') return 'Non'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export const shouldDisplayCustomField = (field: Record<string, unknown> | null | undefined): boolean => {
|
||||
if (!field) return false
|
||||
if (field.readOnly) return true
|
||||
if (field.type === 'boolean') return field.value !== undefined && field.value !== null
|
||||
|
||||
const value = field.value
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === 'string') return value.trim().length > 0
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Definition extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const extractDefinitionName = (definition: Record<string, unknown> = {}): string => {
|
||||
if (typeof definition?.name === 'string' && (definition.name as string).trim()) {
|
||||
return (definition.name as string).trim()
|
||||
}
|
||||
if (typeof definition?.key === 'string' && (definition.key as string).trim()) {
|
||||
return (definition.key as string).trim()
|
||||
}
|
||||
if (typeof definition?.label === 'string' && (definition.label as string).trim()) {
|
||||
return (definition.label as string).trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const extractDefinitionType = (
|
||||
definition: Record<string, unknown> = {},
|
||||
fallback = 'text',
|
||||
): string => {
|
||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const rawType =
|
||||
typeof definition?.type === 'string'
|
||||
? definition.type
|
||||
: typeof (definition?.value as Record<string, unknown>)?.type === 'string'
|
||||
? (definition.value as Record<string, unknown>).type as string
|
||||
: typeof fallback === 'string'
|
||||
? fallback
|
||||
: 'text'
|
||||
const normalized = (rawType as string).toLowerCase()
|
||||
return allowed.includes(normalized) ? normalized : 'text'
|
||||
}
|
||||
|
||||
export const extractDefinitionRequired = (
|
||||
definition: Record<string, unknown> = {},
|
||||
fallback = false,
|
||||
): boolean => {
|
||||
if (typeof definition?.required === 'boolean') return definition.required
|
||||
const nested = (definition?.value as Record<string, unknown>)?.required
|
||||
if (typeof nested === 'boolean') return nested
|
||||
if (typeof nested === 'string') {
|
||||
const normalized = nested.toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return true
|
||||
if (normalized === 'false' || normalized === '0') return false
|
||||
}
|
||||
return !!fallback
|
||||
}
|
||||
|
||||
const extractOptionList = (input: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(input)) return undefined
|
||||
const mapped = input
|
||||
.map((option) => {
|
||||
if (option === null || option === undefined) return ''
|
||||
if (typeof option === 'string') return option.trim()
|
||||
if (typeof option === 'object') {
|
||||
const record = (option || {}) as Record<string, unknown>
|
||||
for (const key of ['value', 'label', 'name']) {
|
||||
const candidate = record[key]
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
|
||||
}
|
||||
}
|
||||
const fallback = String(option).trim()
|
||||
return fallback === '[object Object]' ? '' : fallback
|
||||
})
|
||||
.filter((option) => option.length > 0)
|
||||
return mapped.length ? mapped : undefined
|
||||
}
|
||||
|
||||
export const extractDefinitionOptions = (definition: Record<string, unknown> = {}): string[] => {
|
||||
const sources = [
|
||||
definition?.options,
|
||||
(definition?.value as Record<string, unknown>)?.options,
|
||||
(definition?.value as Record<string, unknown>)?.choices,
|
||||
]
|
||||
for (const source of sources) {
|
||||
const list = extractOptionList(source)
|
||||
if (list) return list
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export const extractDefinitionDefaultValue = (definition: Record<string, unknown> = {}): unknown => {
|
||||
const candidates = [
|
||||
definition?.defaultValue,
|
||||
(definition?.value as Record<string, unknown>)?.defaultValue,
|
||||
(definition?.value as Record<string, unknown>)?.value,
|
||||
definition?.value,
|
||||
definition?.default,
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === undefined || candidate === null || candidate === '') continue
|
||||
if (typeof candidate === 'object') {
|
||||
if (candidate === null) continue
|
||||
const nestedDefault =
|
||||
(candidate as Record<string, unknown>).defaultValue !== undefined &&
|
||||
(candidate as Record<string, unknown>).defaultValue !== null
|
||||
? (candidate as Record<string, unknown>).defaultValue
|
||||
: (candidate as Record<string, unknown>).value
|
||||
if (nestedDefault !== undefined && nestedDefault !== null && nestedDefault !== '') {
|
||||
return nestedDefault
|
||||
}
|
||||
continue
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NormalizedCustomFieldDefinition {
|
||||
id?: string
|
||||
customFieldId?: string
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
defaultValue?: unknown
|
||||
readOnly: boolean
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
export interface NormalizedCustomFieldEntry {
|
||||
customFieldValueId: unknown
|
||||
id: string | undefined
|
||||
customFieldId: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
optionsText: string
|
||||
defaultValue: unknown
|
||||
value: string
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
export const normalizeCustomFieldDefinitionEntry = (
|
||||
definition: Record<string, unknown> = {},
|
||||
fallbackIndex = 0,
|
||||
): NormalizedCustomFieldDefinition | null => {
|
||||
const name = extractDefinitionName(definition)
|
||||
if (!name) return null
|
||||
const type = extractDefinitionType(definition)
|
||||
const required = extractDefinitionRequired(definition)
|
||||
const options = extractDefinitionOptions(definition)
|
||||
const defaultValue = extractDefinitionDefaultValue(definition)
|
||||
const id = typeof definition?.id === 'string' ? definition.id : undefined
|
||||
const customFieldId = typeof definition?.customFieldId === 'string' ? definition.customFieldId : id
|
||||
const orderIndex = typeof definition?.orderIndex === 'number' ? definition.orderIndex : fallbackIndex
|
||||
return { id, customFieldId, name, type, required, options, defaultValue, readOnly: !!definition?.readOnly, orderIndex }
|
||||
}
|
||||
|
||||
export const normalizeExistingCustomFieldDefinitions = (
|
||||
fields: unknown,
|
||||
): NormalizedCustomFieldDefinition[] => {
|
||||
if (!Array.isArray(fields)) return []
|
||||
return fields
|
||||
.map((field, index) => normalizeCustomFieldDefinitionEntry(field as Record<string, unknown>, index))
|
||||
.filter((d): d is NormalizedCustomFieldDefinition => d !== null)
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom field value normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const normalizeCustomFieldValueEntry = (entry: Record<string, unknown> = {}): Record<string, unknown> | null => {
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const normalizedDefinition = normalizeCustomFieldDefinitionEntry(entry)
|
||||
if (!normalizedDefinition) return null
|
||||
|
||||
const value = coerceValueForType(
|
||||
normalizedDefinition.type,
|
||||
(entry?.value ?? entry?.defaultValue ?? normalizedDefinition.defaultValue ?? '') as string,
|
||||
)
|
||||
|
||||
return {
|
||||
id: (entry?.customFieldValueId ?? entry?.id ?? null) as string | null,
|
||||
customFieldId:
|
||||
(entry?.customFieldId ?? normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null) as string | null,
|
||||
customField: {
|
||||
id: normalizedDefinition.id ?? normalizedDefinition.customFieldId ?? null,
|
||||
name: normalizedDefinition.name,
|
||||
type: normalizedDefinition.type,
|
||||
required: normalizedDefinition.required,
|
||||
options: normalizedDefinition.options,
|
||||
defaultValue: normalizedDefinition.defaultValue ?? '',
|
||||
},
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge & dedup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const mergeCustomFieldValuesWithDefinitions = (
|
||||
valueEntries: Record<string, unknown>[] = [],
|
||||
...definitionSources: unknown[][]
|
||||
): Record<string, unknown>[] => {
|
||||
const normalizedValues: Record<string, unknown>[] = (Array.isArray(valueEntries) ? valueEntries : [])
|
||||
.map((entry): Record<string, unknown> | null => {
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const normalizedDefinition = normalizeCustomFieldDefinitionEntry(
|
||||
((entry as Record<string, unknown>).customField || entry) as Record<string, unknown>,
|
||||
)
|
||||
if (!normalizedDefinition) return null
|
||||
|
||||
const value = coerceValueForType(
|
||||
normalizedDefinition.type,
|
||||
((entry as Record<string, unknown>)?.value ??
|
||||
(entry as Record<string, unknown>)?.defaultValue ??
|
||||
normalizedDefinition.defaultValue ??
|
||||
'') as string,
|
||||
)
|
||||
|
||||
return {
|
||||
customFieldValueId: (entry as Record<string, unknown>)?.id ?? (entry as Record<string, unknown>)?.customFieldValueId ?? null,
|
||||
id: normalizedDefinition.id,
|
||||
customFieldId: normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null,
|
||||
name: normalizedDefinition.name,
|
||||
type: normalizedDefinition.type,
|
||||
required: normalizedDefinition.required,
|
||||
options: normalizedDefinition.options,
|
||||
optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '',
|
||||
defaultValue: normalizedDefinition.defaultValue ?? '',
|
||||
value,
|
||||
readOnly: !!(entry as Record<string, unknown>)?.readOnly,
|
||||
} as Record<string, unknown>
|
||||
})
|
||||
.filter((entry): entry is Record<string, unknown> => entry !== null)
|
||||
|
||||
const result = [...normalizedValues]
|
||||
const keyFor = (item: Record<string, unknown>) => (item?.id as string) ?? `${item?.name ?? ''}::${item?.type ?? ''}`
|
||||
const existingMap = new Map<string, Record<string, unknown>>()
|
||||
|
||||
result.forEach((item) => {
|
||||
const key = keyFor(item)
|
||||
if (key) existingMap.set(key, item)
|
||||
const fallbackKey = item?.name ? `${item.name}::${item.type ?? ''}` : null
|
||||
if (fallbackKey) existingMap.set(fallbackKey, item)
|
||||
})
|
||||
|
||||
const definitions = definitionSources
|
||||
.flatMap((source) => (Array.isArray(source) ? source : []))
|
||||
.map((definition) => normalizeCustomFieldDefinitionEntry(definition as Record<string, unknown>))
|
||||
.filter((d): d is NormalizedCustomFieldDefinition => d !== null)
|
||||
|
||||
definitions.forEach((normalizedDefinition) => {
|
||||
const key = normalizedDefinition.id ?? `${normalizedDefinition.name}::${normalizedDefinition.type}`
|
||||
if (!key) return
|
||||
|
||||
if (normalizedDefinition.id) {
|
||||
const fallbackKey = `${normalizedDefinition.name}::${normalizedDefinition.type}`
|
||||
if (existingMap.has(fallbackKey)) {
|
||||
const existingFallback = existingMap.get(fallbackKey)
|
||||
if (existingFallback) {
|
||||
existingFallback.id = existingFallback.id || normalizedDefinition.id
|
||||
existingFallback.customFieldId = normalizedDefinition.id
|
||||
existingFallback.readOnly = (existingFallback.readOnly as boolean) && normalizedDefinition.readOnly
|
||||
existingMap.delete(fallbackKey)
|
||||
existingMap.set(normalizedDefinition.id, existingFallback)
|
||||
existingMap.set(fallbackKey, existingFallback)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existing =
|
||||
existingMap.get(key) ||
|
||||
(normalizedDefinition.name ? existingMap.get(`${normalizedDefinition.name}::${normalizedDefinition.type}`) : null)
|
||||
|
||||
if (existing) {
|
||||
existing.name = existing.name || normalizedDefinition.name
|
||||
existing.type = existing.type || normalizedDefinition.type
|
||||
existing.required = (existing.required as boolean) || normalizedDefinition.required
|
||||
if (!(existing.options as string[])?.length && normalizedDefinition.options?.length) {
|
||||
existing.options = normalizedDefinition.options
|
||||
}
|
||||
if (!existing.defaultValue && normalizedDefinition.defaultValue) {
|
||||
existing.defaultValue = String(normalizedDefinition.defaultValue)
|
||||
if (!existing.value) {
|
||||
existing.value = coerceValueForType(existing.type as string, normalizedDefinition.defaultValue)
|
||||
}
|
||||
}
|
||||
existing.customFieldId = existing.customFieldId || normalizedDefinition.id
|
||||
existing.readOnly = (existing.readOnly as boolean) && normalizedDefinition.readOnly
|
||||
if (!existing.optionsText && normalizedDefinition.options?.length) {
|
||||
existing.optionsText = normalizedDefinition.options.join('\n')
|
||||
}
|
||||
if (normalizedDefinition.id) existingMap.set(normalizedDefinition.id, existing)
|
||||
if (normalizedDefinition.name) {
|
||||
existingMap.set(`${normalizedDefinition.name}::${normalizedDefinition.type}`, existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const entry: Record<string, unknown> = {
|
||||
customFieldValueId: null,
|
||||
id: normalizedDefinition.id,
|
||||
customFieldId: normalizedDefinition.id,
|
||||
name: normalizedDefinition.name,
|
||||
type: normalizedDefinition.type,
|
||||
required: normalizedDefinition.required,
|
||||
options: normalizedDefinition.options,
|
||||
optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '',
|
||||
defaultValue: normalizedDefinition.defaultValue ?? '',
|
||||
value: coerceValueForType(normalizedDefinition.type, (normalizedDefinition.defaultValue ?? '') as string),
|
||||
readOnly: false,
|
||||
}
|
||||
result.push(entry)
|
||||
existingMap.set(key, entry)
|
||||
const fallbackKey = entry.name ? `${entry.name}::${entry.type}` : null
|
||||
if (fallbackKey) existingMap.set(fallbackKey, entry)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const dedupeCustomFieldEntries = (fields: Record<string, unknown>[]): Record<string, unknown>[] => {
|
||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
||||
return Array.isArray(fields) ? fields : []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const result: Record<string, unknown>[] = []
|
||||
|
||||
for (const field of fields) {
|
||||
if (!field) continue
|
||||
|
||||
field.type = field.type || 'text'
|
||||
|
||||
let normalizedName = typeof field.name === 'string' ? (field.name as string).trim() : ''
|
||||
|
||||
if (!normalizedName && (field.customField as Record<string, unknown>)?.name) {
|
||||
normalizedName = String((field.customField as Record<string, unknown>).name).trim()
|
||||
field.name = normalizedName
|
||||
} else if (typeof field.name === 'string') {
|
||||
field.name = normalizedName
|
||||
}
|
||||
|
||||
const key =
|
||||
(field.customFieldId as string) ||
|
||||
(field.id as string) ||
|
||||
(normalizedName ? `${normalizedName}::${field.type || 'text'}` : null)
|
||||
|
||||
if (!key && !normalizedName) continue
|
||||
if (key && seen.has(key)) continue
|
||||
if (!normalizedName) continue
|
||||
|
||||
if (key) seen.add(key)
|
||||
if (normalizedName) seen.add(`${normalizedName}::${field.type || 'text'}`)
|
||||
result.push(field)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summarize for display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const summarizeCustomFields = (
|
||||
fields: Record<string, unknown>[] = [],
|
||||
): { key: string; label: string; value: string }[] => {
|
||||
const seen = new Set<string>()
|
||||
return fields
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
|
||||
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
|
||||
return (left as number) - (right as number)
|
||||
})
|
||||
.filter(shouldDisplayCustomField)
|
||||
.filter((field) => {
|
||||
const key = (field.customFieldId || field.id || field.name) as string
|
||||
if (!key) return true
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map((field, index) => ({
|
||||
key: ((field.customFieldId || field.id || field.name) as string) || `custom-field-${index}`,
|
||||
label: (field.name as string) || 'Champ',
|
||||
value: formatCustomFieldValue(field),
|
||||
}))
|
||||
}
|
||||
303
frontend/app/shared/utils/customFields.ts
Normal file
303
frontend/app/shared/utils/customFields.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Unified custom field types and pure helpers.
|
||||
*
|
||||
* Replaces: entityCustomFieldLogic.ts, customFieldUtils.ts, customFieldFormUtils.ts
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A custom field definition (from ModelType structure or CustomField entity) */
|
||||
export interface CustomFieldDefinition {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
defaultValue: string | null
|
||||
orderIndex: number
|
||||
machineContextOnly: boolean
|
||||
}
|
||||
|
||||
/** A persisted custom field value (from CustomFieldValue entity via API) */
|
||||
export interface CustomFieldValue {
|
||||
id: string
|
||||
value: string
|
||||
customField: CustomFieldDefinition
|
||||
}
|
||||
|
||||
/** Merged definition + value for form display and editing */
|
||||
export interface CustomFieldInput {
|
||||
customFieldId: string | null
|
||||
customFieldValueId: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
defaultValue: string | null
|
||||
orderIndex: number
|
||||
machineContextOnly: boolean
|
||||
value: string
|
||||
readOnly?: boolean
|
||||
/** options joined by newline — used by category editor textareas (v-model) */
|
||||
optionsText?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalization — accept any shape, return canonical types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALLOWED_TYPES = ['text', 'number', 'select', 'boolean', 'date'] as const
|
||||
|
||||
/**
|
||||
* Normalize any raw field definition object into a CustomFieldDefinition.
|
||||
* Handles both standard `{name, type}` and legacy `{key, value: {type}}` formats.
|
||||
*/
|
||||
export function normalizeDefinition(raw: any, fallbackIndex = 0): CustomFieldDefinition | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
|
||||
// Resolve name: standard → legacy key → label
|
||||
const name = (
|
||||
typeof raw.name === 'string' ? raw.name.trim()
|
||||
: typeof raw.key === 'string' ? raw.key.trim()
|
||||
: typeof raw.label === 'string' ? raw.label.trim()
|
||||
: ''
|
||||
)
|
||||
if (!name) return null
|
||||
|
||||
// Resolve type: standard → nested in value → fallback
|
||||
const rawType = (
|
||||
typeof raw.type === 'string' ? raw.type
|
||||
: typeof raw.value?.type === 'string' ? raw.value.type
|
||||
: 'text'
|
||||
).toLowerCase()
|
||||
const type = ALLOWED_TYPES.includes(rawType as any) ? rawType : 'text'
|
||||
|
||||
// Resolve required
|
||||
const required = typeof raw.required === 'boolean' ? raw.required
|
||||
: typeof raw.value?.required === 'boolean' ? raw.value.required
|
||||
: false
|
||||
|
||||
// Resolve options
|
||||
const optionSource = Array.isArray(raw.options) ? raw.options
|
||||
: Array.isArray(raw.value?.options) ? raw.value.options
|
||||
: []
|
||||
const options = optionSource
|
||||
.map((o: any) => typeof o === 'string' ? o.trim() : typeof o?.value === 'string' ? o.value.trim() : String(o ?? '').trim())
|
||||
.filter((o: string) => o.length > 0 && o !== '[object Object]')
|
||||
|
||||
// Resolve defaultValue
|
||||
const dv = raw.defaultValue ?? raw.value?.defaultValue ?? null
|
||||
const defaultValue = dv !== null && dv !== undefined && dv !== '' ? String(dv) : null
|
||||
|
||||
// Resolve orderIndex
|
||||
const orderIndex = typeof raw.orderIndex === 'number' ? raw.orderIndex : fallbackIndex
|
||||
|
||||
// Resolve machineContextOnly
|
||||
const machineContextOnly = !!raw.machineContextOnly
|
||||
|
||||
// Resolve id
|
||||
const id = typeof raw.id === 'string' ? raw.id
|
||||
: typeof raw.customFieldId === 'string' ? raw.customFieldId
|
||||
: null
|
||||
|
||||
return { id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw value entry into a CustomFieldValue.
|
||||
* Accepts the API format: `{ id, value, customField: {...} }`
|
||||
*/
|
||||
export function normalizeValue(raw: any): CustomFieldValue | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const cf = raw.customField
|
||||
const definition = normalizeDefinition(cf)
|
||||
if (!definition) return null
|
||||
const id = typeof raw.id === 'string' ? raw.id : ''
|
||||
const value = raw.value !== null && raw.value !== undefined ? String(raw.value) : ''
|
||||
return { id, value, customField: definition }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an array of raw definitions into CustomFieldDefinition[].
|
||||
*/
|
||||
export function normalizeDefinitions(raw: any): CustomFieldDefinition[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((item: any, i: number) => normalizeDefinition(item, i))
|
||||
.filter((d: any): d is CustomFieldDefinition => d !== null)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an array of raw values into CustomFieldValue[].
|
||||
*/
|
||||
export function normalizeValues(raw: any): CustomFieldValue[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((item: any) => normalizeValue(item))
|
||||
.filter((v: any): v is CustomFieldValue => v !== null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge — THE one merge function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merge definitions from a ModelType with persisted values from an entity.
|
||||
* Returns a CustomFieldInput[] ready for form display.
|
||||
*
|
||||
* Match strategy: by customField.id first, then by name (case-sensitive).
|
||||
* When no value exists for a definition, uses defaultValue as initial value.
|
||||
*/
|
||||
export function mergeDefinitionsWithValues(
|
||||
rawDefinitions: any,
|
||||
rawValues: any,
|
||||
): CustomFieldInput[] {
|
||||
const definitions = normalizeDefinitions(rawDefinitions)
|
||||
const values = normalizeValues(rawValues)
|
||||
|
||||
// Build lookup maps for values
|
||||
const valueById = new Map<string, CustomFieldValue>()
|
||||
const valueByName = new Map<string, CustomFieldValue>()
|
||||
for (const v of values) {
|
||||
if (v.customField.id) valueById.set(v.customField.id, v)
|
||||
valueByName.set(v.customField.name, v)
|
||||
}
|
||||
|
||||
const matchedValueIds = new Set<string>()
|
||||
const matchedNames = new Set<string>()
|
||||
|
||||
// 1. Map definitions to inputs, matching values
|
||||
const result: CustomFieldInput[] = definitions.map((def) => {
|
||||
const matched = (def.id ? valueById.get(def.id) : undefined) ?? valueByName.get(def.name)
|
||||
|
||||
const optionsText = def.options.length ? def.options.join('\n') : undefined
|
||||
|
||||
if (matched) {
|
||||
if (matched.id) matchedValueIds.add(matched.id)
|
||||
matchedNames.add(def.name)
|
||||
return {
|
||||
customFieldId: def.id,
|
||||
customFieldValueId: matched.id || null,
|
||||
name: def.name,
|
||||
type: def.type,
|
||||
required: def.required,
|
||||
options: def.options,
|
||||
defaultValue: def.defaultValue,
|
||||
orderIndex: def.orderIndex,
|
||||
machineContextOnly: def.machineContextOnly,
|
||||
value: matched.value,
|
||||
optionsText,
|
||||
}
|
||||
}
|
||||
|
||||
// No value found — use defaultValue
|
||||
return {
|
||||
customFieldId: def.id,
|
||||
customFieldValueId: null,
|
||||
name: def.name,
|
||||
type: def.type,
|
||||
required: def.required,
|
||||
options: def.options,
|
||||
defaultValue: def.defaultValue,
|
||||
orderIndex: def.orderIndex,
|
||||
machineContextOnly: def.machineContextOnly,
|
||||
value: def.defaultValue ?? '',
|
||||
optionsText,
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Add orphan values (have a value but no matching definition)
|
||||
for (const v of values) {
|
||||
if (matchedValueIds.has(v.id)) continue
|
||||
if (matchedNames.has(v.customField.name)) continue
|
||||
|
||||
const orphanOptionsText = v.customField.options.length ? v.customField.options.join('\n') : undefined
|
||||
result.push({
|
||||
customFieldId: v.customField.id,
|
||||
customFieldValueId: v.id || null,
|
||||
name: v.customField.name,
|
||||
type: v.customField.type,
|
||||
required: v.customField.required,
|
||||
options: v.customField.options,
|
||||
defaultValue: v.customField.defaultValue,
|
||||
orderIndex: v.customField.orderIndex,
|
||||
machineContextOnly: v.customField.machineContextOnly,
|
||||
value: v.value,
|
||||
optionsText: orphanOptionsText,
|
||||
})
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter & sort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Filter fields by context: standalone hides machineContextOnly, machine shows only machineContextOnly */
|
||||
export function filterByContext(
|
||||
fields: CustomFieldInput[],
|
||||
context: 'standalone' | 'machine',
|
||||
): CustomFieldInput[] {
|
||||
if (context === 'machine') return fields.filter((f) => f.machineContextOnly)
|
||||
return fields.filter((f) => !f.machineContextOnly)
|
||||
}
|
||||
|
||||
/** Sort fields by orderIndex */
|
||||
export function sortByOrder(fields: CustomFieldInput[]): CustomFieldInput[] {
|
||||
return [...fields].sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Format a field value for display (e.g. boolean → Oui/Non) */
|
||||
export function formatValueForDisplay(field: CustomFieldInput): string {
|
||||
const raw = field.value ?? ''
|
||||
if (field.type === 'boolean') {
|
||||
const normalized = String(raw).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return 'Oui'
|
||||
if (normalized === 'false' || normalized === '0') return 'Non'
|
||||
}
|
||||
return raw || 'Non défini'
|
||||
}
|
||||
|
||||
/** Whether a field has a displayable value (readOnly fields always display) */
|
||||
export function hasDisplayableValue(field: CustomFieldInput): boolean {
|
||||
if (field.readOnly) return true
|
||||
if (field.type === 'boolean') return field.value !== undefined && field.value !== null && field.value !== ''
|
||||
return typeof field.value === 'string' && field.value.trim().length > 0
|
||||
}
|
||||
|
||||
/** Stable key for v-for rendering */
|
||||
export function fieldKey(field: CustomFieldInput, index: number): string {
|
||||
return field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Whether a field should be persisted (non-empty value) */
|
||||
export function shouldPersist(field: CustomFieldInput): boolean {
|
||||
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
|
||||
return typeof field.value === 'string' && field.value.trim() !== ''
|
||||
}
|
||||
|
||||
/** Format value for save (trim, boolean coercion) */
|
||||
export function formatValueForSave(field: CustomFieldInput): string {
|
||||
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
|
||||
return typeof field.value === 'string' ? field.value.trim() : ''
|
||||
}
|
||||
|
||||
/** Check if all required fields are filled */
|
||||
export function requiredFieldsFilled(fields: CustomFieldInput[]): boolean {
|
||||
return fields.every((field) => {
|
||||
if (!field.required) return true
|
||||
return shouldPersist(field)
|
||||
})
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
/**
|
||||
* Pure functions for custom field resolution, merging, and deduplication.
|
||||
*
|
||||
* Extracted from ComponentItem.vue and PieceItem.vue which had ~350 LOC
|
||||
* of identical custom field logic duplicated between them.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field key / identity helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fieldKeyFromNameAndType(name: unknown, type: unknown): string | null {
|
||||
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''
|
||||
const normalizedType = typeof type === 'string' ? type.trim().toLowerCase() : ''
|
||||
return normalizedName ? `${normalizedName}::${normalizedType}` : null
|
||||
}
|
||||
|
||||
export function resolveOrderIndex(field: any): number {
|
||||
if (!field || typeof field !== 'object') return 0
|
||||
if (typeof field.orderIndex === 'number') return field.orderIndex
|
||||
if (field.customField && typeof field.customField.orderIndex === 'number') return field.customField.orderIndex
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field accessors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolveFieldKey(field: any, index: number): string {
|
||||
return field?.id ?? field?.customFieldValueId ?? field?.customFieldId ?? field?.name ?? `field-${index}`
|
||||
}
|
||||
|
||||
export function resolveFieldId(field: any): string | null {
|
||||
return field?.customFieldValueId ?? null
|
||||
}
|
||||
|
||||
export function resolveFieldName(field: any): string {
|
||||
return field?.name ?? 'Champ'
|
||||
}
|
||||
|
||||
export function resolveFieldType(field: any): string {
|
||||
return field?.type ?? 'text'
|
||||
}
|
||||
|
||||
export function resolveFieldOptions(field: any): string[] {
|
||||
return field?.options ?? []
|
||||
}
|
||||
|
||||
export function resolveFieldRequired(field: any): boolean {
|
||||
return !!field?.required
|
||||
}
|
||||
|
||||
export function resolveFieldReadOnly(field: any): boolean {
|
||||
return !!field?.readOnly
|
||||
}
|
||||
|
||||
export function resolveCustomFieldId(field: any): string | null {
|
||||
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
|
||||
}
|
||||
|
||||
export function buildCustomFieldMetadata(field: any) {
|
||||
return {
|
||||
customFieldName: resolveFieldName(field),
|
||||
customFieldType: resolveFieldType(field),
|
||||
customFieldRequired: resolveFieldRequired(field),
|
||||
customFieldOptions: resolveFieldOptions(field),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFieldDisplayValue(field: any): string {
|
||||
const type = resolveFieldType(field)
|
||||
const rawValue = field?.value ?? ''
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(rawValue).toLowerCase()
|
||||
if (normalized === 'true') return 'Oui'
|
||||
if (normalized === 'false') return 'Non'
|
||||
}
|
||||
return rawValue || 'Non défini'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom field ID resolution against candidate pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ensureCustomFieldId(field: any, candidateFields: any[]): string | null {
|
||||
const existingId = resolveCustomFieldId(field)
|
||||
if (existingId) return existingId
|
||||
|
||||
const name = resolveFieldName(field)
|
||||
if (!name || name === 'Champ') return null
|
||||
|
||||
const matches = candidateFields.filter((candidate) => {
|
||||
if (!candidate || typeof candidate !== 'object') return false
|
||||
const candidateId = candidate.id || candidate.customFieldId
|
||||
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) return true
|
||||
return typeof candidate.name === 'string' && candidate.name === name
|
||||
})
|
||||
|
||||
if (matches.length) {
|
||||
const withId = matches.find((c) => c?.id || c?.customFieldId) || matches[0]
|
||||
const id = withId?.id || withId?.customFieldId || null
|
||||
if (id) field.customFieldId = id
|
||||
if (!field.customField && typeof withId === 'object') field.customField = withId
|
||||
return id
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function extractStructureCustomFields(structure: any): any[] {
|
||||
if (!structure || typeof structure !== 'object') return []
|
||||
const customFields = structure.customFields
|
||||
return Array.isArray(customFields) ? customFields : []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deduplication & merge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function deduplicateFieldDefinitions(definitions: any[]): any[] {
|
||||
const result: any[] = []
|
||||
const seenIds = new Set<string>()
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
const orderedDefinitions = (Array.isArray(definitions) ? definitions.slice() : [])
|
||||
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
|
||||
orderedDefinitions.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') return
|
||||
const id = field.id ?? field.customFieldId ?? field.customField?.id ?? null
|
||||
const nameKey = fieldKeyFromNameAndType(field.name, field.type)
|
||||
// Deduplicate by name+type (primary) AND by id — a field with the same
|
||||
// name+type is the same field even when stored with different IDs.
|
||||
if (nameKey && seenNames.has(nameKey)) return
|
||||
if (id && seenIds.has(id)) return
|
||||
if (id) seenIds.add(id)
|
||||
if (nameKey) seenNames.add(nameKey)
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
result.push(field)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function mergeFieldDefinitionsWithValues(definitions: any[], values: any[]): any[] {
|
||||
const definitionList = Array.isArray(definitions) ? definitions : []
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const valueMap = new Map<string, any>()
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return
|
||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||
if (fieldId) valueMap.set(fieldId, entry)
|
||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||
if (nameKey) valueMap.set(nameKey, entry)
|
||||
})
|
||||
|
||||
const merged = definitionList.map((field) => {
|
||||
if (!field || typeof field !== 'object') return field
|
||||
|
||||
const fieldId = resolveCustomFieldId(field)
|
||||
const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
|
||||
const matchedValue = (fieldId ? valueMap.get(fieldId) : undefined) ?? (nameKey ? valueMap.get(nameKey) : undefined)
|
||||
|
||||
if (!matchedValue) {
|
||||
return { ...field, value: field?.value ?? '', orderIndex: resolveOrderIndex(field) }
|
||||
}
|
||||
|
||||
const resolvedOrder = Math.min(resolveOrderIndex(field), resolveOrderIndex(matchedValue.customField))
|
||||
return {
|
||||
...field,
|
||||
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
||||
customFieldId: matchedValue.customField?.id ?? matchedValue.customFieldId ?? fieldId ?? null,
|
||||
customField: matchedValue.customField ?? field.customField ?? null,
|
||||
value: matchedValue.value ?? field.value ?? '',
|
||||
orderIndex: resolvedOrder,
|
||||
}
|
||||
})
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return
|
||||
|
||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||
|
||||
const exists = merged.some((field) => {
|
||||
if (!field || typeof field !== 'object') return false
|
||||
if (field.customFieldValueId && field.customFieldValueId === entry.id) return true
|
||||
const existingId = resolveCustomFieldId(field)
|
||||
if (fieldId && existingId && existingId === fieldId) return true
|
||||
if (!fieldId && nameKey) {
|
||||
return fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (!exists) {
|
||||
merged.push({
|
||||
customFieldValueId: entry.id ?? null,
|
||||
customFieldId: fieldId,
|
||||
name: entry.customField?.name ?? '',
|
||||
type: entry.customField?.type ?? 'text',
|
||||
required: entry.customField?.required ?? false,
|
||||
options: entry.customField?.options ?? [],
|
||||
value: entry.value ?? '',
|
||||
customField: entry.customField ?? null,
|
||||
orderIndex: resolveOrderIndex(entry.customField),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
}
|
||||
|
||||
export function dedupeMergedFields(fields: any[]): any[] {
|
||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
||||
return Array.isArray(fields)
|
||||
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
: []
|
||||
}
|
||||
|
||||
const seenById = new Map<string, any>()
|
||||
const seenByName = new Map<string, any>()
|
||||
const result: any[] = []
|
||||
|
||||
const orderedFields = fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
|
||||
orderedFields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') return
|
||||
|
||||
const rawName = resolveFieldName(field)
|
||||
const normalizedName = typeof rawName === 'string' ? rawName.trim() : ''
|
||||
if (!normalizedName) return
|
||||
|
||||
field.name = normalizedName
|
||||
field.type = field.type || resolveFieldType(field)
|
||||
|
||||
const fieldId = resolveCustomFieldId(field)
|
||||
const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
|
||||
|
||||
// Check duplicates by name+type first (same field can have different IDs)
|
||||
const existing = (nameKey ? seenByName.get(nameKey) : undefined) ?? (fieldId ? seenById.get(fieldId) : undefined)
|
||||
|
||||
if (!existing) {
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
if (fieldId) seenById.set(fieldId, field)
|
||||
if (nameKey) seenByName.set(nameKey, field)
|
||||
result.push(field)
|
||||
return
|
||||
}
|
||||
|
||||
const existingHasValue = existing.value !== undefined && existing.value !== null && String(existing.value).trim().length > 0
|
||||
const incomingHasValue = field.value !== undefined && field.value !== null && String(field.value).trim().length > 0
|
||||
|
||||
if (!existingHasValue && incomingHasValue) {
|
||||
Object.assign(existing, field)
|
||||
existing.orderIndex = Math.min(resolveOrderIndex(existing), resolveOrderIndex(field))
|
||||
if (fieldId) seenById.set(fieldId, existing)
|
||||
if (nameKey) seenByName.set(nameKey, existing)
|
||||
}
|
||||
})
|
||||
|
||||
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Definition sources builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildDefinitionSources(entity: any, entityType: 'composant' | 'piece'): any[] {
|
||||
const definitions: any[] = []
|
||||
const pushFields = (collection: any) => {
|
||||
if (Array.isArray(collection)) definitions.push(...collection)
|
||||
}
|
||||
|
||||
if (entityType === 'composant') {
|
||||
const type = entity.typeComposant || {}
|
||||
|
||||
pushFields(entity.customFields)
|
||||
pushFields(entity.definition?.customFields)
|
||||
pushFields(type.customFields)
|
||||
|
||||
;[
|
||||
entity.definition?.structure,
|
||||
type.structure,
|
||||
].forEach((structure) => {
|
||||
const fields = extractStructureCustomFields(structure)
|
||||
if (fields.length) definitions.push(...fields)
|
||||
})
|
||||
} else {
|
||||
const type = entity.typePiece || {}
|
||||
|
||||
pushFields(entity.customFields)
|
||||
pushFields(entity.definition?.customFields)
|
||||
pushFields(type.customFields)
|
||||
|
||||
;[
|
||||
entity.definition?.structure,
|
||||
type.structure,
|
||||
].forEach((structure) => {
|
||||
const fields = extractStructureCustomFields(structure)
|
||||
if (fields.length) definitions.push(...fields)
|
||||
})
|
||||
}
|
||||
|
||||
return deduplicateFieldDefinitions(definitions)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Candidate fields builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildCandidateCustomFields(entity: any, definitionSources: any[]): any[] {
|
||||
const map = new Map<string, any>()
|
||||
const register = (collection: any[]) => {
|
||||
if (!Array.isArray(collection)) return
|
||||
collection.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') return
|
||||
const id = item.id || item.customFieldId
|
||||
const name = typeof item.name === 'string' ? item.name : null
|
||||
const key = id || (name ? `${name}::${item.type ?? ''}` : null)
|
||||
if (!key || map.has(key)) return
|
||||
map.set(key, item)
|
||||
})
|
||||
}
|
||||
|
||||
register((entity.customFieldValues || []).map((value: any) => value?.customField))
|
||||
register(definitionSources)
|
||||
|
||||
return Array.from(map.values())
|
||||
}
|
||||
Reference in New Issue
Block a user