11 KiB
Custom Fields Simplification — Design Spec
Date: 2026-04-04 Scope: Backend minor cleanup + Frontend unification of the custom fields system Constraint: Everything must work after — progressive migration with verification at each step
Problem
The custom fields system has grown into 3 parallel frontend implementations (~2900 lines across 9 files) due to accumulated defensive code. This caused data bugs (orphaned fields, lost linkage) and makes every change risky.
4 Custom Field Contexts
- Machine — fields defined directly on the machine (
CustomField.machineidFK), values on machine - Standalone entity — fields defined in ModelType (category), values on composant/piece/product. Visible when opening the entity directly
- Machine context — fields with
machineContextOnly=truedefined in ModelType, values stored onMachineComponentLink/MachinePieceLink. Visible only from the machine detail page - Category editor — UI for defining/editing custom fields in a ModelType skeleton
Backend Changes
Minor — format already consistent
After review, MachineStructureController already serializes custom fields in the same format as API Platform:
// CustomFieldValue (from normalizeCustomFieldValues)
{
"id": "cfv-123",
"value": "USOCOME",
"customField": {
"id": "cf-456",
"name": "MARQUE",
"type": "text",
"required": false,
"options": [],
"defaultValue": null,
"orderIndex": 0,
"machineContextOnly": false
}
}
// CustomField definition (from normalizeCustomFieldDefinitions)
{
"id": "cf-456",
"name": "MARQUE",
"type": "text",
"required": false,
"options": [],
"defaultValue": null,
"orderIndex": 0,
"machineContextOnly": false
}
The only backend task is adding defaultValue to the API Platform serialization groups on CustomField.php so that both API Platform and the custom controller return it.
Context fields on links are returned as two separate arrays:
contextCustomFields— definitions filtered tomachineContextOnly=truecontextCustomFieldValues— values stored onMachineComponentLink/MachinePieceLink
This format stays as-is. The frontend unified module handles the merge.
Files:
src/Entity/CustomField.php— add#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]todefaultValue
Legacy {key, value} format in DB
SkeletonStructureService::normalizeCustomFieldData() accepts two formats:
- Legacy:
{key: "name", value: {type, required, options?, defaultValue?}} - Standard:
{name, type, required, options?, defaultValue?}
Pre-migration check required: verify if any ModelType rows still have the legacy format in their structure data. If yes, write a one-time DB migration to normalize them before removing the frontend parsing code in Step 5. If no legacy data exists, the parsing code can be safely removed.
Frontend Changes
New Unified Module (2 files, ~400 lines total)
shared/utils/customFields.ts (~180 lines) — Pure logic, zero Vue dependency
Types:
CustomFieldDefinition—{ id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }CustomFieldValue—{ id, value, customField: CustomFieldDefinition }CustomFieldInput—{ ...CustomFieldDefinition, value, customFieldId, customFieldValueId }(the merged type used by forms)
Functions:
mergeDefinitionsWithValues(definitions, values)→CustomFieldInput[]— the ONE merge function replacing the 3 current ones. Matches bycustomField.idthen byname. When no value exists for a definition, usesdefaultValueas initial value.filterByContext(fields, context: 'standalone' | 'machine')— filters onmachineContextOnlysortByOrder(fields)— sorts byorderIndexformatValueForSave(field)/shouldPersist(field)— persistence helpersformatValueForDisplay(field)— display helper (e.g. boolean →Oui/Non), replacesformatCustomFieldValuefromcustomFieldUtils.tsfieldKey(field, index)— stable key for v-for, replacesfieldKeyfromcustomFieldFormUtils.ts
composables/useCustomFieldInputs.ts (~220 lines) — Reactive, wraps pure helpers
function useCustomFieldInputs(options: {
definitions: MaybeRef<CustomFieldDefinition[]>
values: MaybeRef<CustomFieldValue[]>
entityType: 'machine' | 'composant' | 'piece' | 'product' | 'machineComponentLink' | 'machinePieceLink'
entityId: MaybeRef<string | null>
context?: 'standalone' | 'machine' // defaults to 'standalone'
}): {
fields: ComputedRef<CustomFieldInput[]>
update: (field: CustomFieldInput) => Promise<void>
saveAll: () => Promise<string[]> // returns failed field names
requiredFilled: ComputedRef<boolean>
}
Usage for context 3 (machine context fields on links):
// For each MachineComponentLink, instantiate with:
const contextFields = useCustomFieldInputs({
definitions: link.contextCustomFields, // from MachineStructureController
values: link.contextCustomFieldValues, // from MachineStructureController
entityType: 'machineComponentLink',
entityId: link.id,
context: 'machine',
})
Files Deleted After Migration
| File | Lines | Replaced by |
|---|---|---|
shared/utils/entityCustomFieldLogic.ts |
335 | shared/utils/customFields.ts |
shared/utils/customFieldUtils.ts |
440 | shared/utils/customFields.ts |
shared/utils/customFieldFormUtils.ts |
404 | shared/utils/customFields.ts + composables/useCustomFieldInputs.ts |
composables/useEntityCustomFields.ts |
181 | composables/useCustomFieldInputs.ts |
Additionally refactored (not deleted):
composables/useMachineDetailCustomFields.ts— custom fields code extracted, uses new module (keeps non-CF logic: constructeurs, products, transforms)shared/model/componentStructure.ts— custom fields code removed (kept: structure/skeleton logic)shared/model/componentStructureSanitize.ts— custom fields sanitize code removedshared/model/componentStructureHydrate.ts— custom fields hydrate code removed
All consuming files to migrate
Composables:
composables/useComponentEdit.ts— useuseCustomFieldInputscomposables/useComponentCreate.ts— useuseCustomFieldInputscomposables/usePieceEdit.ts— useuseCustomFieldInputscomposables/useMachineDetailCustomFields.ts— useuseCustomFieldInputsfor all 3 machine sub-cases
Pages:
pages/component/[id]/index.vue— already uses composable, minimal changespages/component/[id]/edit.vue— already uses composable, minimal changespages/component/create.vue— already uses composable, minimal changespages/pieces/create.vue— imports fromcustomFieldFormUtils, migrate to new typespages/pieces/[id]/edit.vue— already uses composable, minimal changespages/product/create.vue— imports fromcustomFieldFormUtils, migrate to new typespages/product/[id]/edit.vue— imports fromcustomFieldFormUtils, migrate to new typespages/product/[id]/index.vue— imports fromcustomFieldFormUtils, migrate to new types
Shared components:
components/common/CustomFieldDisplay.vue— imports 7 functions fromentityCustomFieldLogic, rewrite with unifiedCustomFieldInputtypecomponents/common/CustomFieldInputGrid.vue— importsfieldKey+CustomFieldInputfromcustomFieldFormUtils, update importscomponents/ComponentItem.vue— imports fromentityCustomFieldLogic+useEntityCustomFields, migratecomponents/PieceItem.vue— imports fromentityCustomFieldLogic+useEntityCustomFields, migratecomponents/machine/MachineCustomFieldsCard.vue— importsformatCustomFieldValuefromcustomFieldUtils, useformatValueForDisplaycomponents/machine/MachineInfoCard.vue— importsformatCustomFieldValuefromcustomFieldUtils, useformatValueForDisplaycomponents/model-types/ModelTypeForm.vue— useshared/utils/customFields.tstypes
Tests:
tests/shared/customFieldFormUtils.test.ts— rewrite for new module or delete
Migration Strategy — Progressive (6 steps)
Step 1: Backend minor fix + DB check
- Add
defaultValueto serialization groups inCustomField.php - Check DB for legacy
{key, value}format inmodel_types.structure— write migration if needed - Verify: call
/api/composants/{id}, confirmdefaultValueappears incustomFieldobjects
Step 2: Create new module
- Write
shared/utils/customFields.tsandcomposables/useCustomFieldInputs.ts - Port existing test to new module
- Verify: import in a test page, confirm merge/filter/sort/defaultValue work with real data
Step 3: Migrate standalone pages (composant/piece/product)
- Refactor composables:
useComponentEdit.ts,useComponentCreate.ts,usePieceEdit.ts - Refactor pages:
pieces/create.vue,product/create.vue,product/[id]/edit.vue,product/[id]/index.vue - Refactor shared components:
CustomFieldInputGrid.vue,CustomFieldDisplay.vue - Verify per page: open entity, check fields display with values (including defaultValue on new entities), modify a value, confirm save works
Step 4: Migrate machine page + hierarchy components
- Refactor
useMachineDetailCustomFields.ts— useuseCustomFieldInputsfor:- Machine direct fields (definitions from
machine.customFields, values frommachine.customFieldValues) - Standalone component/piece fields (definitions from
type.customFields, values from entity'scustomFieldValues, filteredmachineContextOnly=false) - Machine context fields (definitions from
link.contextCustomFields, values fromlink.contextCustomFieldValues)
- Machine direct fields (definitions from
- Refactor
ComponentItem.vue,PieceItem.vue— useuseCustomFieldInputsinstead ofuseEntityCustomFields - Refactor
MachineCustomFieldsCard.vue,MachineInfoCard.vue— useformatValueForDisplay - Verify: open a machine with components that have both normal AND machine-context custom fields, check both display and save correctly
Step 5: Migrate category editor
- Check DB for legacy
{key, value}format — run migration if needed - Clean
componentStructure.ts,componentStructureSanitize.ts,componentStructureHydrate.ts— remove custom fields code, use unified types fromcustomFields.ts - Refactor
ModelTypeForm.vue - Verify: edit a component category, modify skeleton custom fields, save, check linked components see changes
Step 6: Cleanup
- Delete the 4 old files
- Delete or rewrite
tests/shared/customFieldFormUtils.test.ts npm run lint:fix+npx nuxi typecheck= 0 errors- Final smoke test of all 4 contexts
Result
- ~2900 lines → ~400 lines + simplified consumers
- 9 custom fields files → 2
- 3 parallel systems → 1
- 1 unified data format understood by all pages
defaultValueproperly handled across all contexts- Legacy format eliminated from DB and code