docs : add custom fields simplification design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user