/** * 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, ) => 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 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)) { return formatDefaultValue(type, (defaultValue as Record).defaultValue) } if ('value' in (defaultValue as Record)) { return formatDefaultValue(type, (defaultValue as Record).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() const mapByName = new Map() 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) }) return 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 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, ), } }) .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) } // --------------------------------------------------------------------------- // Validation helpers // --------------------------------------------------------------------------- export const buildCustomFieldMetadata = (field: CustomFieldInput): Record => ({ 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 => { if (!entityId) return [] const definitionMap = new Map() 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 }