normalizeStructureForEditor is used by useMachineDetailCustomFields. Must clean it (Task 6) before migrating the machine page (Task 7). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
36 KiB
Custom Fields Simplification — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace 3 parallel custom fields frontend implementations (~2900 lines, 9 files) with a single unified module (~400 lines, 2 files), then migrate all consumers.
Architecture: One pure-logic module (customFields.ts) with types + helpers, one reactive composable (useCustomFieldInputs.ts) wrapping it. The existing API composable useCustomFields.ts stays as-is (it's the HTTP layer). The backend already returns a consistent format — only one minor fix needed (add defaultValue to serialization groups).
Tech Stack: TypeScript, Vue 3 Composition API, Nuxt 4 auto-imports
Spec: docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md
File Map
New files
frontend/app/shared/utils/customFields.ts— types + pure helpers (merge, filter, format, sort)frontend/app/composables/useCustomFieldInputs.ts— reactive composable wrapping pure helpers + API
Files to delete (end of migration)
frontend/app/shared/utils/entityCustomFieldLogic.tsfrontend/app/shared/utils/customFieldUtils.tsfrontend/app/shared/utils/customFieldFormUtils.tsfrontend/app/composables/useEntityCustomFields.ts
Backend file (minor fix)
src/Entity/CustomField.php— adddefaultValueto serialization groups
Files to refactor (update imports)
frontend/app/composables/useComponentEdit.tsfrontend/app/composables/useComponentCreate.tsfrontend/app/composables/usePieceEdit.tsfrontend/app/composables/useMachineDetailCustomFields.tsfrontend/app/components/ComponentItem.vuefrontend/app/components/PieceItem.vuefrontend/app/components/common/CustomFieldDisplay.vuefrontend/app/components/common/CustomFieldInputGrid.vuefrontend/app/components/machine/MachineCustomFieldsCard.vuefrontend/app/components/machine/MachineInfoCard.vuefrontend/app/pages/pieces/create.vuefrontend/app/pages/product/create.vuefrontend/app/pages/product/[id]/edit.vuefrontend/app/pages/product/[id]/index.vuefrontend/app/shared/model/componentStructure.tsfrontend/app/shared/model/componentStructureSanitize.tsfrontend/app/shared/model/componentStructureHydrate.ts
Task 1: Backend — Add defaultValue to serialization groups
Files:
-
Modify:
src/Entity/CustomField.php:62-63 -
Step 1: Add Groups attribute to defaultValue
In src/Entity/CustomField.php, the defaultValue property (line 62-63) currently has no #[Groups] attribute. Add it so API Platform includes defaultValue in all read responses, matching what MachineStructureController already returns.
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?string $defaultValue = null;
- Step 2: Run linter
make php-cs-fixer-allow-risky
- Step 3: Commit
git add src/Entity/CustomField.php
git commit -m "fix(api) : expose defaultValue in custom field serialization groups"
Task 2: Create unified pure-logic module customFields.ts
Files:
- Create:
frontend/app/shared/utils/customFields.ts
This replaces all the types and pure functions from entityCustomFieldLogic.ts (335 lines), customFieldUtils.ts (440 lines), and customFieldFormUtils.ts (404 lines).
- Step 1: Write the types and all pure helper functions
/**
* 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)
})
}
- Step 2: Run lint
cd frontend && npm run lint:fix
- Step 3: Commit
git add frontend/app/shared/utils/customFields.ts
git commit -m "feat(custom-fields) : add unified pure-logic custom fields module"
Task 3: Create unified composable useCustomFieldInputs.ts
Files:
- Create:
frontend/app/composables/useCustomFieldInputs.ts
This replaces useEntityCustomFields.ts (181 lines) and the custom field parts of useMachineDetailCustomFields.ts.
- Step 1: Write the composable
/**
* Unified reactive custom field management composable.
*
* Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts,
* and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit.
*
* DESIGN NOTE: Uses an internal mutable `ref` (not a `computed`) so that
* save operations can update `customFieldValueId` in place without being
* overwritten on the next reactivity cycle. Call `refresh()` to re-merge
* from the source definitions + values (e.g. after fetching fresh data).
*/
import { ref, watch, computed, type MaybeRef, toValue } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import {
mergeDefinitionsWithValues,
filterByContext,
formatValueForSave,
shouldPersist,
requiredFieldsFilled,
type CustomFieldDefinition,
type CustomFieldValue,
type CustomFieldInput,
} from '~/shared/utils/customFields'
export type { CustomFieldDefinition, CustomFieldValue, CustomFieldInput }
export type CustomFieldEntityType =
| 'machine'
| 'composant'
| 'piece'
| 'product'
| 'machineComponentLink'
| 'machinePieceLink'
export interface UseCustomFieldInputsOptions {
/** Custom field definitions (from ModelType structure or machine.customFields) */
definitions: MaybeRef<any[]>
/** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
values: MaybeRef<any[]>
/** Entity type for API upsert calls */
entityType: CustomFieldEntityType
/** Entity ID for API upsert calls */
entityId: MaybeRef<string | null>
/** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
context?: 'standalone' | 'machine'
/** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */
onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void
}
export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
const { entityType, context } = options
const {
updateCustomFieldValue: updateApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Internal mutable state — NOT a computed, so save can mutate in place
const _allFields = ref<CustomFieldInput[]>([])
// Re-merge from source definitions + values
const refresh = () => {
const defs = toValue(options.definitions)
const vals = toValue(options.values)
_allFields.value = mergeDefinitionsWithValues(defs, vals)
}
// Auto-refresh when reactive sources change
watch(
() => [toValue(options.definitions), toValue(options.values)],
() => refresh(),
{ immediate: true, deep: true },
)
// Filtered by context (standalone vs machine)
const fields = computed<CustomFieldInput[]>(() => {
if (!context) return _allFields.value
return filterByContext(_allFields.value, context)
})
// Validation
const requiredFilled = computed(() => requiredFieldsFilled(fields.value))
// Build metadata for upsert when no customFieldId is available (legacy fallback)
const _buildMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
})
// Update a single field value
const update = async (field: CustomFieldInput): Promise<boolean> => {
const id = toValue(options.entityId)
if (!id) {
showError(`Impossible de sauvegarder le champ "${field.name}"`)
return false
}
const value = formatValueForSave(field)
// Update existing value
if (field.customFieldValueId) {
const result: any = await updateApi(field.customFieldValueId, { value })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour`)
return true
}
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
return false
}
// Create new value via upsert — with metadata fallback when no ID
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
metadata,
)
if (result.success) {
// Mutate in place (safe — _allFields is a ref, not computed)
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
if (result.data?.customField?.id) {
field.customFieldId = result.data.customField.id
}
// Notify parent to update its reactive source
if (options.onValueCreated && result.data) {
options.onValueCreated(result.data)
}
showSuccess(`Champ "${field.name}" enregistré`)
return true
}
showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
return false
}
// Save all fields that have values
const saveAll = async (): Promise<string[]> => {
const id = toValue(options.entityId)
if (!id) return ['(entity ID missing)']
const failed: string[] = []
for (const field of fields.value) {
if (!shouldPersist(field)) continue
const value = formatValueForSave(field)
if (field.customFieldValueId) {
const result: any = await updateApi(field.customFieldValueId, { value })
if (!result.success) failed.push(field.name)
continue
}
// Upsert with metadata fallback when no customFieldId
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
metadata,
)
if (result.success) {
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
if (result.data?.customField?.id) {
field.customFieldId = result.data.customField.id
}
if (options.onValueCreated && result.data) {
options.onValueCreated(result.data)
}
} else {
failed.push(field.name)
}
}
return failed
}
return {
/** All merged fields filtered by context */
fields,
/** All merged fields (unfiltered) */
allFields: _allFields,
/** Whether all required fields have values */
requiredFilled,
/** Update a single field value via API */
update,
/** Save all fields with values, returns list of failed field names */
saveAll,
/** Re-merge from source definitions + values (call after fetching fresh data) */
refresh,
}
}
- Step 2: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 3: Commit
git add frontend/app/composables/useCustomFieldInputs.ts
git commit -m "feat(custom-fields) : add unified useCustomFieldInputs composable"
Task 4: Migrate shared components + standalone composables (atomic batch)
Why batched: CustomFieldInputGrid.vue and CustomFieldDisplay.vue receive fields from the composables. Migrating them separately would cause TypeScript errors in the intermediate state. Migrate all together.
Files:
-
Modify:
frontend/app/components/common/CustomFieldInputGrid.vue -
Modify:
frontend/app/components/common/CustomFieldDisplay.vue -
Modify:
frontend/app/composables/useComponentEdit.ts -
Modify:
frontend/app/composables/useComponentCreate.ts -
Modify:
frontend/app/composables/usePieceEdit.ts -
Step 1: Migrate
CustomFieldInputGrid.vue
Replace the import:
// OLD
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
// NEW
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
- Step 2: Migrate
CustomFieldDisplay.vue
Replace all imports from entityCustomFieldLogic. With the new typed CustomFieldInput, direct property access (.name, .type, .options, .value, .readOnly) replaces the resolveFieldXxx() wrapper functions. Read the full file and adapt the template accordingly.
- Step 3: Migrate
useComponentEdit.ts
Read the file. Key changes:
- Replace
customFieldFormUtilsimports with the new module - Replace the imperative
refreshCustomFieldInputs+buildCustomFieldInputspattern withuseCustomFieldInputs - Pass
selectedTypeStructure.value?.customFieldsas definitions andcomponent.value?.customFieldValuesas values - Use the
onValueCreatedcallback to push new values intocomponent.value.customFieldValuesso the reactive source stays in sync - Replace calls to
refreshCustomFieldInputs()in watchers/fetch with calls torefresh()from the composable
- Step 4: Migrate
useComponentCreate.ts
Same pattern. Definitions come from selectedType.value?.structure?.customFields, values are empty [] for creation.
- Step 5: Migrate
usePieceEdit.ts
Same pattern. Definitions from piece type structure, values from piece.customFieldValues.
- Step 6: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 7: Verify
Open each page in the browser:
-
/component/{id}— check custom fields display and edit -
/component/create— check custom fields with default values -
/pieces/{id}/edit— check custom fields display and edit -
Step 8: Commit
git add frontend/app/components/common/CustomFieldInputGrid.vue frontend/app/components/common/CustomFieldDisplay.vue frontend/app/composables/useComponentEdit.ts frontend/app/composables/useComponentCreate.ts frontend/app/composables/usePieceEdit.ts
git commit -m "refactor(custom-fields) : migrate shared components and standalone composables to unified module"
Task 5: Migrate standalone pages (product + piece create)
Files:
- Modify:
frontend/app/pages/pieces/create.vue - Modify:
frontend/app/pages/product/create.vue - Modify:
frontend/app/pages/product/[id]/edit.vue - Modify:
frontend/app/pages/product/[id]/index.vue
These pages import directly from customFieldFormUtils. Replace with the new module.
-
Step 1: Read each file and identify all
customFieldFormUtilsimports -
Step 2: For each page, replace imports
Replace:
import { CustomFieldInput, normalizeCustomFieldInputs, buildCustomFieldInputs, requiredCustomFieldsFilled, saveCustomFieldValues } from '~/shared/utils/customFieldFormUtils'
With:
import { type CustomFieldInput, normalizeDefinitions, mergeDefinitionsWithValues, requiredFieldsFilled } from '~/shared/utils/customFields'
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
Adapt the logic in each page to use useCustomFieldInputs or the pure helpers as appropriate.
- Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 4: Verify
Open each page in the browser:
-
/pieces/create— check custom fields appear when selecting a type -
/product/create— same -
/product/{id}/edit— check fields display with values -
/product/{id}— check read-only display -
Step 5: Commit
git add frontend/app/pages/pieces/create.vue frontend/app/pages/product/create.vue frontend/app/pages/product/\[id\]/edit.vue frontend/app/pages/product/\[id\]/index.vue
git commit -m "refactor(custom-fields) : migrate product and piece pages to unified module"
Task 6: Clean category editor files (componentStructure*.ts)
WHY BEFORE MACHINE PAGE: normalizeStructureForEditor is used by useMachineDetailCustomFields.ts. If we change it after migrating the machine page, the machine page would break. So clean this first.
Files:
-
Modify:
frontend/app/shared/model/componentStructure.ts -
Modify:
frontend/app/shared/model/componentStructureSanitize.ts -
Modify:
frontend/app/shared/model/componentStructureHydrate.ts -
Step 1: Read the three files and identify custom field code
The custom field code in these files handles the category editor (defining fields on a ModelType skeleton). It's the sanitizeCustomFields and hydrateCustomFields functions, plus custom field handling in normalizeStructureForEditor and normalizeStructureForSave.
- Step 2: Replace custom field sanitize/hydrate with the unified module
In componentStructure.ts, replace the custom field handling in normalizeStructureForEditor:
// OLD
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
const customFields = sanitizedCustomFields.map((field) => { ... })
// NEW
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
const customFields = mergeDefinitionsWithValues(source.customFields, [])
optionsText is now included in CustomFieldInput (added in the type definition). mergeDefinitionsWithValues already computes optionsText from options.join('\n'), so all category editor textareas (v-model="field.optionsText") will work without changes.
- Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 4: Verify TWO things
- Open
/component-category/{id}/edit— check that custom fields are displayed, can be added/removed/reordered, and save correctly. - Open
/machine/{id}— check that the machine page still works (it usesnormalizeStructureForEditorviauseMachineDetailCustomFields.ts).
- Step 5: Commit
git add frontend/app/shared/model/componentStructure.ts frontend/app/shared/model/componentStructureSanitize.ts frontend/app/shared/model/componentStructureHydrate.ts
git commit -m "refactor(custom-fields) : clean category editor structure files"
Task 7: Migrate machine page — MachineCustomFieldsCard, MachineInfoCard, useMachineDetailCustomFields
Depends on: Task 6 (category editor cleaned — normalizeStructureForEditor already uses new types)
Files:
-
Modify:
frontend/app/components/machine/MachineCustomFieldsCard.vue -
Modify:
frontend/app/components/machine/MachineInfoCard.vue -
Modify:
frontend/app/composables/useMachineDetailCustomFields.ts -
Step 1: Migrate
MachineCustomFieldsCard.vueandMachineInfoCard.vue
Replace:
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
With:
import { formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'
Replace all calls to formatCustomFieldValue(field) with formatValueForDisplay(field).
- Step 2: Migrate
useMachineDetailCustomFields.ts— pure custom field functions
Replace the following pure-CF functions (~168 lines) with the new module:
| Old function (lines) | Replacement |
|---|---|
syncMachineCustomFields (269-289) |
mergeDefinitionsWithValues(machine.customFields, machine.customFieldValues) |
setMachineCustomFieldValue (291-299) |
Direct property mutation on the mutable CustomFieldInput |
updateMachineCustomField (302-363) |
useCustomFieldInputs.update() |
saveAllMachineCustomFields (461-511) |
useCustomFieldInputs.saveAll() |
saveAllContextCustomFields (430-459) |
Loop over link-level useCustomFieldInputs instances |
- Step 3: Migrate
useMachineDetailCustomFields.ts— mixed transform functions
transformCustomFields (lines 71-158) and transformComponentCustomFields (lines 161-263) mix custom field merging with constructeur/product/document logic in a single map(). Refactor surgically:
Inside transformCustomFields map callback, replace lines 82-106 (valueEntries + merge + dedupe + filter):
// OLD: ~25 lines of valueEntries building, normalizeCustomFieldValueEntry, mergeCustomFieldValuesWithDefinitions, dedupeCustomFieldEntries
// NEW: 2 lines
const customFields = filterByContext(
mergeDefinitionsWithValues(type.customFields ?? typePiece.customFields ?? [], piece.customFieldValues ?? []),
'standalone',
)
Keep the rest of the map callback (constructeurs lines 108-133, product lines 119-120, assembly lines 135-158) unchanged.
Inside transformComponentCustomFields map callback, replace lines 175-199 (same pattern):
const customFields = filterByContext(
mergeDefinitionsWithValues(type.customFields ?? [], component.customFieldValues ?? actualComponent?.customFieldValues ?? []),
'standalone',
)
Keep recursive calls (lines 201-209) and constructeur/product/document logic (lines 212-263) unchanged.
- Step 4: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 5: Verify
Open a machine page (/machine/{id}) that has:
-
Machine-level custom fields
-
Components with regular custom fields
-
Components with machineContextOnly fields Check display, edit, and save for all three.
-
Step 6: Commit
git add frontend/app/components/machine/MachineCustomFieldsCard.vue frontend/app/components/machine/MachineInfoCard.vue frontend/app/composables/useMachineDetailCustomFields.ts
git commit -m "refactor(custom-fields) : migrate machine page to unified module"
Task 8: Migrate hierarchy components (ComponentItem.vue, PieceItem.vue)
Depends on: Task 7 (machine page — parent pre-merges custom fields into CustomFieldInput[])
Data contract: The parent (useMachineDetailCustomFields.transformComponentCustomFields) already pre-merges custom fields into component.customFields (a CustomFieldInput[]). The Item components should NOT re-merge — they display the pre-merged data directly and use the API composable only for saves/updates.
Files:
-
Modify:
frontend/app/components/ComponentItem.vue -
Modify:
frontend/app/components/PieceItem.vue -
Step 1: Migrate
ComponentItem.vue
Read the file. Replace:
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCustomFieldLogic'
With:
import { useCustomFields } from '~/composables/useCustomFields'
import { fieldKey, formatValueForDisplay, hasDisplayableValue, mergeDefinitionsWithValues, type CustomFieldInput } from '~/shared/utils/customFields'
Key changes:
- Remove
useEntityCustomFields— the parent already pre-merges. Useprops.component.customFieldsdirectly (alreadyCustomFieldInput[]after Task 7) - For display: use
hasDisplayableValue(field)andformatValueForDisplay(field)instead ofresolveFieldXxx()wrappers - For edits/saves: use
useCustomFields()directly (the HTTP layer) instead ofuseEntityCustomFields().updateCustomField - For context fields (
component.contextCustomFields+component.contextCustomFieldValues): merge locally withmergeDefinitionsWithValues— these are NOT pre-merged by the parent since they come as separate arrays - Replace
resolveCustomFieldId(field)withfield.customFieldId(direct property access onCustomFieldInput) - Replace
resolveFieldId(field)withfield.customFieldValueId
- Step 2: Migrate
PieceItem.vue
Same pattern as ComponentItem but with entityType: 'piece' and props.piece.customFields.
- Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 4: Verify
Open a machine page and expand components/pieces in the hierarchy. Check custom fields display correctly.
- Step 5: Commit
git add frontend/app/components/ComponentItem.vue frontend/app/components/PieceItem.vue
git commit -m "refactor(custom-fields) : migrate ComponentItem and PieceItem to unified module"
Task 9: Delete old files + final cleanup
Files:
-
Delete:
frontend/app/shared/utils/entityCustomFieldLogic.ts -
Delete:
frontend/app/shared/utils/customFieldUtils.ts -
Delete:
frontend/app/shared/utils/customFieldFormUtils.ts -
Delete:
frontend/app/composables/useEntityCustomFields.ts -
Delete or rewrite:
frontend/tests/shared/customFieldFormUtils.test.ts -
Step 1: Verify no remaining imports of old files
cd frontend && grep -r "entityCustomFieldLogic\|customFieldUtils\|customFieldFormUtils\|useEntityCustomFields" app/ --include="*.ts" --include="*.vue" -l
Expected: no results (0 files).
- Step 2: Delete old files
rm frontend/app/shared/utils/entityCustomFieldLogic.ts
rm frontend/app/shared/utils/customFieldUtils.ts
rm frontend/app/shared/utils/customFieldFormUtils.ts
rm frontend/app/composables/useEntityCustomFields.ts
rm frontend/tests/shared/customFieldFormUtils.test.ts
- Step 3: Run full lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
Expected: 0 errors.
- Step 4: Final smoke test
Test all 4 contexts in the browser:
- Machine fields —
/machine/{id}→ machine-level custom fields - Standalone entity —
/component/{id}→ custom fields display and edit - Machine context —
/machine/{id}→ expand a component → machineContextOnly fields - Category editor —
/component-category/{id}/edit→ custom field definitions
- Step 5: Commit
git add -A
git commit -m "refactor(custom-fields) : delete old parallel custom field modules"