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>
306 lines
11 KiB
TypeScript
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)
|
|
})
|
|
}
|