refactor(front): extract shared utils and rewire pages
This commit is contained in:
367
app/shared/utils/customFieldFormUtils.ts
Normal file
367
app/shared/utils/customFieldFormUtils.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user