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>
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
}
|