Files
Inventory/frontend/app/shared/utils/customFields.ts
r-dev c4ed8c8edc 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>
2026-04-06 11:20:08 +02:00

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)
})
}