/** * 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() const valueByName = new Map() 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() const matchedNames = new Set() // 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' if (typeof field.value === 'number') return !Number.isNaN(field.value) 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' if (typeof field.value === 'number') return String(field.value) 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) }) }