From 353d7e938e778ec5ba5dbe118e09ec6139efb6b0 Mon Sep 17 00:00:00 2001 From: r-dev Date: Sat, 4 Apr 2026 12:32:11 +0200 Subject: [PATCH] docs : add custom fields simplification design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-04-custom-fields-simplification-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md diff --git a/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md b/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md new file mode 100644 index 0000000..230cc0b --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md @@ -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 + 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