Files
Inventory/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md
2026-04-06 11:20:08 +02:00

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

  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:

// 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 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

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 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