32 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
}
// ---------------------------------------------------------------------------
// 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)
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,
}
}
// 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 ?? '',
}
})
// 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
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,
})
}
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 */
export function hasDisplayableValue(field: CustomFieldInput): boolean {
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.
*/
import { 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'
}
export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
const { entityType, context } = options
const {
updateCustomFieldValue: updateApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Merged fields: definitions + values
const allFields = computed<CustomFieldInput[]>(() => {
const defs = toValue(options.definitions)
const vals = toValue(options.values)
return mergeDefinitionsWithValues(defs, vals)
})
// 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))
// 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
if (!field.customFieldId) {
showError(`Impossible de sauvegarder le champ "${field.name}" (identifiant manquant)`)
return false
}
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
)
if (result.success) {
// Update field with the newly created value ID
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
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
}
if (!field.customFieldId) {
failed.push(field.name)
continue
}
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
)
if (result.success) {
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
} else {
failed.push(field.name)
}
}
return failed
}
return {
/** All merged fields filtered by context */
fields,
/** All merged fields (unfiltered) */
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,
}
}
- 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 CustomFieldInputGrid.vue and CustomFieldDisplay.vue
These are shared components used everywhere. Migrate them first so downstream consumers can use the new types.
Files:
-
Modify:
frontend/app/components/common/CustomFieldInputGrid.vue -
Modify:
frontend/app/components/common/CustomFieldDisplay.vue -
Step 1: Migrate
CustomFieldInputGrid.vue
Replace the import from customFieldFormUtils:
// 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 module. Read the file first to identify the exact imports, then replace:
// OLD
import {
resolveFieldKey,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldReadOnly,
formatFieldDisplayValue,
resolveCustomFieldId,
} from '~/shared/utils/entityCustomFieldLogic'
// NEW
import {
fieldKey,
formatValueForDisplay,
hasDisplayableValue,
type CustomFieldInput,
} from '~/shared/utils/customFields'
Note: CustomFieldDisplay.vue accesses field properties directly (.name, .type, .options, .value). With the new typed CustomFieldInput, direct property access replaces the resolveFieldXxx() wrapper functions. Read the full file and adapt the template accordingly.
- Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 4: Commit
git add frontend/app/components/common/CustomFieldInputGrid.vue frontend/app/components/common/CustomFieldDisplay.vue
git commit -m "refactor(custom-fields) : migrate shared display components to unified module"
Task 5: Migrate standalone composables (useComponentEdit, useComponentCreate, usePieceEdit)
Files:
- Modify:
frontend/app/composables/useComponentEdit.ts - Modify:
frontend/app/composables/useComponentCreate.ts - Modify:
frontend/app/composables/usePieceEdit.ts
For each file:
- Read the full file to understand the current custom field logic
- Replace imports from
customFieldFormUtilswith the new module - Replace
buildCustomFieldInputs(structure, values)withmergeDefinitionsWithValues(structure?.customFields, values) - Replace
requiredCustomFieldsFilled(customFieldInputs.value)withrequiredFieldsFilled(fields) - Replace
saveCustomFieldValues(...)withsaveAll()fromuseCustomFieldInputs - Replace
normalizeCustomFieldInputs(structure)withnormalizeDefinitions(structure?.customFields)
- Step 1: Migrate
useComponentEdit.ts
Read the file. Replace the customFieldFormUtils imports and the refreshCustomFieldInputs / buildCustomFieldInputs calls with useCustomFieldInputs. The composable should receive selectedTypeStructure.value?.customFields as definitions and component.value?.customFieldValues as values.
- Step 2: Migrate
useComponentCreate.ts
Same pattern. Definitions come from selectedType.value?.structure?.customFields, values are empty [] for creation.
- Step 3: Migrate
usePieceEdit.ts
Same pattern. Definitions come from the piece type structure, values from piece.customFieldValues.
- Step 4: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 5: 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 6: Commit
git add frontend/app/composables/useComponentEdit.ts frontend/app/composables/useComponentCreate.ts frontend/app/composables/usePieceEdit.ts
git commit -m "refactor(custom-fields) : migrate standalone composables to unified module"
Task 6: 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 7: Migrate machine page — MachineCustomFieldsCard, MachineInfoCard, useMachineDetailCustomFields
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
This is the biggest migration. Read the full file. The custom field logic needs to be replaced:
- Replace imports from
customFieldUtilswith the new module - Replace
syncMachineCustomFields()— usemergeDefinitionsWithValues(machine.customFields, machine.customFieldValues) - Replace
transformComponentCustomFields()andtransformCustomFields()— for each component/piece in the hierarchy, usemergeDefinitionsWithValues(type.customFields, entity.customFieldValues)thenfilterByContext(fields, 'standalone') - For context fields on links:
mergeDefinitionsWithValues(link.contextCustomFields, link.contextCustomFieldValues) - Replace
updateMachineCustomField(),saveAllMachineCustomFields(),saveAllContextCustomFields()withuseCustomFieldInputsinstances or direct API calls fromuseCustomFields
Keep the non-custom-field logic (constructeurs, products, transforms) in this file.
- Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 4: 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 5: 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)
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 { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
import { fieldKey, formatValueForDisplay, hasDisplayableValue, type CustomFieldInput } from '~/shared/utils/customFields'
Replace useEntityCustomFields({ entity: () => props.component, entityType: 'composant' }) with useCustomFieldInputs(...), passing the type's customFields as definitions and the entity's customFieldValues as values.
Update the template to use the new field properties directly instead of resolveFieldXxx() functions.
- Step 2: Migrate
PieceItem.vue
Same pattern as ComponentItem but with entityType: 'piece' and piece type fields.
- 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: Clean category editor files (componentStructure*.ts)
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
normalizeDefinitionsfrom 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 { normalizeDefinitions } from '~/shared/utils/customFields'
const customFields = normalizeDefinitions(source.customFields)
Note: The category editor needs additional properties (optionsText for textarea display). These can be computed in the editor component itself rather than in the structure normalization.
- Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 4: Verify
Open /component-category/{id}/edit — check that custom fields are displayed, can be added/removed/reordered, and save correctly.
- 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 10: 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"