Files
Inventory/frontend/app/shared/utils/customFields.ts
r-dev 201485552a fix(ui) : remove legacy edit pages and history composables, unify create/edit forms
Consolidate create and edit pages into single create pages with edit mode support.
Remove obsolete catalog pages, history composables, and fix remaining code review issues.
Include migration to relink orphaned custom fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:19:50 +02:00

306 lines
11 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'
if (typeof field.value === 'number') return !Number.isNaN(field.value)
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'
if (typeof field.value === 'number') return String(field.value)
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)
})
}