368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
|
||
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<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
|
||
}
|