# 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<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):**
```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
