# 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 1. **Machine** — fields defined directly on the machine (`CustomField.machineid` FK), values on machine 2. **Standalone entity** — fields defined in ModelType (category), values on composant/piece/product. Visible when opening the entity directly 3. **Machine context** — fields with `machineContextOnly=true` defined in ModelType, values stored on `MachineComponentLink`/`MachinePieceLink`. Visible only from the machine detail page 4. **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: ```json // 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 } } ``` ```json // 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 to `machineContextOnly=true` - `contextCustomFieldValues` — values stored on `MachineComponentLink`/`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'])]` to `defaultValue` ### 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 by `customField.id` then by `name`. When no value exists for a definition, uses `defaultValue` as initial value. - `filterByContext(fields, context: 'standalone' | 'machine')` — filters on `machineContextOnly` - `sortByOrder(fields)` — sorts by `orderIndex` - `formatValueForSave(field)` / `shouldPersist(field)` — persistence helpers - `formatValueForDisplay(field)` — display helper (e.g. boolean → `Oui/Non`), replaces `formatCustomFieldValue` from `customFieldUtils.ts` - `fieldKey(field, index)` — stable key for v-for, replaces `fieldKey` from `customFieldFormUtils.ts` **`composables/useCustomFieldInputs.ts`** (~220 lines) — Reactive, wraps pure helpers ```ts function useCustomFieldInputs(options: { definitions: MaybeRef values: MaybeRef entityType: 'machine' | 'composant' | 'piece' | 'product' | 'machineComponentLink' | 'machinePieceLink' entityId: MaybeRef context?: 'standalone' | 'machine' // defaults to 'standalone' }): { fields: ComputedRef update: (field: CustomFieldInput) => Promise saveAll: () => Promise // returns failed field names requiredFilled: ComputedRef } ``` **Usage for context 3 (machine context fields on links):** ```ts // 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 removed - `shared/model/componentStructureHydrate.ts` — custom fields hydrate code removed ### All consuming files to migrate **Composables:** - `composables/useComponentEdit.ts` — use `useCustomFieldInputs` - `composables/useComponentCreate.ts` — use `useCustomFieldInputs` - `composables/usePieceEdit.ts` — use `useCustomFieldInputs` - `composables/useMachineDetailCustomFields.ts` — use `useCustomFieldInputs` for all 3 machine sub-cases **Pages:** - `pages/component/[id]/index.vue` — already uses composable, minimal changes - `pages/component/[id]/edit.vue` — already uses composable, minimal changes - `pages/component/create.vue` — already uses composable, minimal changes - `pages/pieces/create.vue` — imports from `customFieldFormUtils`, migrate to new types - `pages/pieces/[id]/edit.vue` — already uses composable, minimal changes - `pages/product/create.vue` — imports from `customFieldFormUtils`, migrate to new types - `pages/product/[id]/edit.vue` — imports from `customFieldFormUtils`, migrate to new types - `pages/product/[id]/index.vue` — imports from `customFieldFormUtils`, migrate to new types **Shared components:** - `components/common/CustomFieldDisplay.vue` — imports 7 functions from `entityCustomFieldLogic`, rewrite with unified `CustomFieldInput` type - `components/common/CustomFieldInputGrid.vue` — imports `fieldKey` + `CustomFieldInput` from `customFieldFormUtils`, update imports - `components/ComponentItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate - `components/PieceItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate - `components/machine/MachineCustomFieldsCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay` - `components/machine/MachineInfoCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay` - `components/model-types/ModelTypeForm.vue` — use `shared/utils/customFields.ts` types **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 `defaultValue` to serialization groups in `CustomField.php` - Check DB for legacy `{key, value}` format in `model_types.structure` — write migration if needed - **Verify:** call `/api/composants/{id}`, confirm `defaultValue` appears in `customField` objects ### Step 2: Create new module - Write `shared/utils/customFields.ts` and `composables/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` — use `useCustomFieldInputs` for: - Machine direct fields (definitions from `machine.customFields`, values from `machine.customFieldValues`) - Standalone component/piece fields (definitions from `type.customFields`, values from entity's `customFieldValues`, filtered `machineContextOnly=false`) - Machine context fields (definitions from `link.contextCustomFields`, values from `link.contextCustomFieldValues`) - Refactor `ComponentItem.vue`, `PieceItem.vue` — use `useCustomFieldInputs` instead of `useEntityCustomFields` - Refactor `MachineCustomFieldsCard.vue`, `MachineInfoCard.vue` — use `formatValueForDisplay` - **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 from `customFields.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 - **`defaultValue` properly handled** across all contexts - **Legacy format eliminated** from DB and code