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:
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user