Files
Inventory/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
2026-04-04 12:37:15 +02:00

32 KiB

Custom Fields Simplification — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace 3 parallel custom fields frontend implementations (~2900 lines, 9 files) with a single unified module (~400 lines, 2 files), then migrate all consumers.

Architecture: One pure-logic module (customFields.ts) with types + helpers, one reactive composable (useCustomFieldInputs.ts) wrapping it. The existing API composable useCustomFields.ts stays as-is (it's the HTTP layer). The backend already returns a consistent format — only one minor fix needed (add defaultValue to serialization groups).

Tech Stack: TypeScript, Vue 3 Composition API, Nuxt 4 auto-imports

Spec: docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md


File Map

New files

  • frontend/app/shared/utils/customFields.ts — types + pure helpers (merge, filter, format, sort)
  • frontend/app/composables/useCustomFieldInputs.ts — reactive composable wrapping pure helpers + API

Files to delete (end of migration)

  • frontend/app/shared/utils/entityCustomFieldLogic.ts
  • frontend/app/shared/utils/customFieldUtils.ts
  • frontend/app/shared/utils/customFieldFormUtils.ts
  • frontend/app/composables/useEntityCustomFields.ts

Backend file (minor fix)

  • src/Entity/CustomField.php — add defaultValue to serialization groups

Files to refactor (update imports)

  • frontend/app/composables/useComponentEdit.ts
  • frontend/app/composables/useComponentCreate.ts
  • frontend/app/composables/usePieceEdit.ts
  • frontend/app/composables/useMachineDetailCustomFields.ts
  • frontend/app/components/ComponentItem.vue
  • frontend/app/components/PieceItem.vue
  • frontend/app/components/common/CustomFieldDisplay.vue
  • frontend/app/components/common/CustomFieldInputGrid.vue
  • frontend/app/components/machine/MachineCustomFieldsCard.vue
  • frontend/app/components/machine/MachineInfoCard.vue
  • frontend/app/pages/pieces/create.vue
  • frontend/app/pages/product/create.vue
  • frontend/app/pages/product/[id]/edit.vue
  • frontend/app/pages/product/[id]/index.vue
  • frontend/app/shared/model/componentStructure.ts
  • frontend/app/shared/model/componentStructureSanitize.ts
  • frontend/app/shared/model/componentStructureHydrate.ts

Task 1: Backend — Add defaultValue to serialization groups

Files:

  • Modify: src/Entity/CustomField.php:62-63

  • Step 1: Add Groups attribute to defaultValue

In src/Entity/CustomField.php, the defaultValue property (line 62-63) currently has no #[Groups] attribute. Add it so API Platform includes defaultValue in all read responses, matching what MachineStructureController already returns.

#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?string $defaultValue = null;
  • Step 2: Run linter
make php-cs-fixer-allow-risky
  • Step 3: Commit
git add src/Entity/CustomField.php
git commit -m "fix(api) : expose defaultValue in custom field serialization groups"

Task 2: Create unified pure-logic module customFields.ts

Files:

  • Create: frontend/app/shared/utils/customFields.ts

This replaces all the types and pure functions from entityCustomFieldLogic.ts (335 lines), customFieldUtils.ts (440 lines), and customFieldFormUtils.ts (404 lines).

  • Step 1: Write the types and all pure helper functions
/**
 * Unified custom field types and pure helpers.
 *
 * Replaces: entityCustomFieldLogic.ts, customFieldUtils.ts, customFieldFormUtils.ts
 */

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** A custom field definition (from ModelType structure or CustomField entity) */
export interface CustomFieldDefinition {
  id: string | null
  name: string
  type: string
  required: boolean
  options: string[]
  defaultValue: string | null
  orderIndex: number
  machineContextOnly: boolean
}

/** A persisted custom field value (from CustomFieldValue entity via API) */
export interface CustomFieldValue {
  id: string
  value: string
  customField: CustomFieldDefinition
}

/** Merged definition + value for form display and editing */
export interface CustomFieldInput {
  customFieldId: string | null
  customFieldValueId: string | null
  name: string
  type: string
  required: boolean
  options: string[]
  defaultValue: string | null
  orderIndex: number
  machineContextOnly: boolean
  value: string
}

// ---------------------------------------------------------------------------
// Normalization — accept any shape, return canonical types
// ---------------------------------------------------------------------------

const ALLOWED_TYPES = ['text', 'number', 'select', 'boolean', 'date'] as const

/**
 * Normalize any raw field definition object into a CustomFieldDefinition.
 * Handles both standard `{name, type}` and legacy `{key, value: {type}}` formats.
 */
export function normalizeDefinition(raw: any, fallbackIndex = 0): CustomFieldDefinition | null {
  if (!raw || typeof raw !== 'object') return null

  // Resolve name: standard → legacy key → label
  const name = (
    typeof raw.name === 'string' ? raw.name.trim() :
    typeof raw.key === 'string' ? raw.key.trim() :
    typeof raw.label === 'string' ? raw.label.trim() :
    ''
  )
  if (!name) return null

  // Resolve type: standard → nested in value → fallback
  const rawType = (
    typeof raw.type === 'string' ? raw.type :
    typeof raw.value?.type === 'string' ? raw.value.type :
    'text'
  ).toLowerCase()
  const type = ALLOWED_TYPES.includes(rawType as any) ? rawType : 'text'

  // Resolve required
  const required = typeof raw.required === 'boolean' ? raw.required
    : typeof raw.value?.required === 'boolean' ? raw.value.required
    : false

  // Resolve options
  const optionSource = Array.isArray(raw.options) ? raw.options
    : Array.isArray(raw.value?.options) ? raw.value.options
    : []
  const options = optionSource
    .map((o: any) => typeof o === 'string' ? o.trim() : typeof o?.value === 'string' ? o.value.trim() : String(o ?? '').trim())
    .filter((o: string) => o.length > 0 && o !== '[object Object]')

  // Resolve defaultValue
  const dv = raw.defaultValue ?? raw.value?.defaultValue ?? null
  const defaultValue = dv !== null && dv !== undefined && dv !== '' ? String(dv) : null

  // Resolve orderIndex
  const orderIndex = typeof raw.orderIndex === 'number' ? raw.orderIndex : fallbackIndex

  // Resolve machineContextOnly
  const machineContextOnly = !!raw.machineContextOnly

  // Resolve id
  const id = typeof raw.id === 'string' ? raw.id
    : typeof raw.customFieldId === 'string' ? raw.customFieldId
    : null

  return { id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }
}

/**
 * Normalize a raw value entry into a CustomFieldValue.
 * Accepts the API format: `{ id, value, customField: {...} }`
 */
export function normalizeValue(raw: any): CustomFieldValue | null {
  if (!raw || typeof raw !== 'object') return null
  const cf = raw.customField
  const definition = normalizeDefinition(cf)
  if (!definition) return null
  const id = typeof raw.id === 'string' ? raw.id : ''
  const value = raw.value !== null && raw.value !== undefined ? String(raw.value) : ''
  return { id, value, customField: definition }
}

/**
 * Normalize an array of raw definitions into CustomFieldDefinition[].
 */
export function normalizeDefinitions(raw: any): CustomFieldDefinition[] {
  if (!Array.isArray(raw)) return []
  return raw
    .map((item: any, i: number) => normalizeDefinition(item, i))
    .filter((d: any): d is CustomFieldDefinition => d !== null)
    .sort((a, b) => a.orderIndex - b.orderIndex)
}

/**
 * Normalize an array of raw values into CustomFieldValue[].
 */
export function normalizeValues(raw: any): CustomFieldValue[] {
  if (!Array.isArray(raw)) return []
  return raw
    .map((item: any) => normalizeValue(item))
    .filter((v: any): v is CustomFieldValue => v !== null)
}

// ---------------------------------------------------------------------------
// Merge — THE one merge function
// ---------------------------------------------------------------------------

/**
 * Merge definitions from a ModelType with persisted values from an entity.
 * Returns a CustomFieldInput[] ready for form display.
 *
 * Match strategy: by customField.id first, then by name (case-sensitive).
 * When no value exists for a definition, uses defaultValue as initial value.
 */
export function mergeDefinitionsWithValues(
  rawDefinitions: any,
  rawValues: any,
): CustomFieldInput[] {
  const definitions = normalizeDefinitions(rawDefinitions)
  const values = normalizeValues(rawValues)

  // Build lookup maps for values
  const valueById = new Map<string, CustomFieldValue>()
  const valueByName = new Map<string, CustomFieldValue>()
  for (const v of values) {
    if (v.customField.id) valueById.set(v.customField.id, v)
    valueByName.set(v.customField.name, v)
  }

  const matchedValueIds = new Set<string>()
  const matchedNames = new Set<string>()

  // 1. Map definitions to inputs, matching values
  const result: CustomFieldInput[] = definitions.map((def) => {
    const matched = (def.id ? valueById.get(def.id) : undefined) ?? valueByName.get(def.name)

    if (matched) {
      if (matched.id) matchedValueIds.add(matched.id)
      matchedNames.add(def.name)
      return {
        customFieldId: def.id,
        customFieldValueId: matched.id || null,
        name: def.name,
        type: def.type,
        required: def.required,
        options: def.options,
        defaultValue: def.defaultValue,
        orderIndex: def.orderIndex,
        machineContextOnly: def.machineContextOnly,
        value: matched.value,
      }
    }

    // No value found — use defaultValue
    return {
      customFieldId: def.id,
      customFieldValueId: null,
      name: def.name,
      type: def.type,
      required: def.required,
      options: def.options,
      defaultValue: def.defaultValue,
      orderIndex: def.orderIndex,
      machineContextOnly: def.machineContextOnly,
      value: def.defaultValue ?? '',
    }
  })

  // 2. Add orphan values (have a value but no matching definition)
  for (const v of values) {
    if (matchedValueIds.has(v.id)) continue
    if (matchedNames.has(v.customField.name)) continue

    result.push({
      customFieldId: v.customField.id,
      customFieldValueId: v.id || null,
      name: v.customField.name,
      type: v.customField.type,
      required: v.customField.required,
      options: v.customField.options,
      defaultValue: v.customField.defaultValue,
      orderIndex: v.customField.orderIndex,
      machineContextOnly: v.customField.machineContextOnly,
      value: v.value,
    })
  }

  return result.sort((a, b) => a.orderIndex - b.orderIndex)
}

// ---------------------------------------------------------------------------
// Filter & sort
// ---------------------------------------------------------------------------

/** Filter fields by context: standalone hides machineContextOnly, machine shows only machineContextOnly */
export function filterByContext(
  fields: CustomFieldInput[],
  context: 'standalone' | 'machine',
): CustomFieldInput[] {
  if (context === 'machine') return fields.filter((f) => f.machineContextOnly)
  return fields.filter((f) => !f.machineContextOnly)
}

/** Sort fields by orderIndex */
export function sortByOrder(fields: CustomFieldInput[]): CustomFieldInput[] {
  return [...fields].sort((a, b) => a.orderIndex - b.orderIndex)
}

// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------

/** Format a field value for display (e.g. boolean → Oui/Non) */
export function formatValueForDisplay(field: CustomFieldInput): string {
  const raw = field.value ?? ''
  if (field.type === 'boolean') {
    const normalized = String(raw).toLowerCase()
    if (normalized === 'true' || normalized === '1') return 'Oui'
    if (normalized === 'false' || normalized === '0') return 'Non'
  }
  return raw || 'Non défini'
}

/** Whether a field has a displayable value */
export function hasDisplayableValue(field: CustomFieldInput): boolean {
  if (field.type === 'boolean') return field.value !== undefined && field.value !== null && field.value !== ''
  return typeof field.value === 'string' && field.value.trim().length > 0
}

/** Stable key for v-for rendering */
export function fieldKey(field: CustomFieldInput, index: number): string {
  return field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
}

// ---------------------------------------------------------------------------
// Persistence helpers
// ---------------------------------------------------------------------------

/** Whether a field should be persisted (non-empty value) */
export function shouldPersist(field: CustomFieldInput): boolean {
  if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
  return typeof field.value === 'string' && field.value.trim() !== ''
}

/** Format value for save (trim, boolean coercion) */
export function formatValueForSave(field: CustomFieldInput): string {
  if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
  return typeof field.value === 'string' ? field.value.trim() : ''
}

/** Check if all required fields are filled */
export function requiredFieldsFilled(fields: CustomFieldInput[]): boolean {
  return fields.every((field) => {
    if (!field.required) return true
    return shouldPersist(field)
  })
}
  • Step 2: Run lint
cd frontend && npm run lint:fix
  • Step 3: Commit
git add frontend/app/shared/utils/customFields.ts
git commit -m "feat(custom-fields) : add unified pure-logic custom fields module"

Task 3: Create unified composable useCustomFieldInputs.ts

Files:

  • Create: frontend/app/composables/useCustomFieldInputs.ts

This replaces useEntityCustomFields.ts (181 lines) and the custom field parts of useMachineDetailCustomFields.ts.

  • Step 1: Write the composable
/**
 * Unified reactive custom field management composable.
 *
 * Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts,
 * and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit.
 */

import { computed, type MaybeRef, toValue } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import {
  mergeDefinitionsWithValues,
  filterByContext,
  formatValueForSave,
  shouldPersist,
  requiredFieldsFilled,
  type CustomFieldDefinition,
  type CustomFieldValue,
  type CustomFieldInput,
} from '~/shared/utils/customFields'

export type { CustomFieldDefinition, CustomFieldValue, CustomFieldInput }

export type CustomFieldEntityType =
  | 'machine'
  | 'composant'
  | 'piece'
  | 'product'
  | 'machineComponentLink'
  | 'machinePieceLink'

export interface UseCustomFieldInputsOptions {
  /** Custom field definitions (from ModelType structure or machine.customFields) */
  definitions: MaybeRef<any[]>
  /** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
  values: MaybeRef<any[]>
  /** Entity type for API upsert calls */
  entityType: CustomFieldEntityType
  /** Entity ID for API upsert calls */
  entityId: MaybeRef<string | null>
  /** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
  context?: 'standalone' | 'machine'
}

export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
  const { entityType, context } = options
  const {
    updateCustomFieldValue: updateApi,
    upsertCustomFieldValue,
  } = useCustomFields()
  const { showSuccess, showError } = useToast()

  // Merged fields: definitions + values
  const allFields = computed<CustomFieldInput[]>(() => {
    const defs = toValue(options.definitions)
    const vals = toValue(options.values)
    return mergeDefinitionsWithValues(defs, vals)
  })

  // Filtered by context (standalone vs machine)
  const fields = computed<CustomFieldInput[]>(() => {
    if (!context) return allFields.value
    return filterByContext(allFields.value, context)
  })

  // Validation
  const requiredFilled = computed(() => requiredFieldsFilled(fields.value))

  // Update a single field value
  const update = async (field: CustomFieldInput): Promise<boolean> => {
    const id = toValue(options.entityId)
    if (!id) {
      showError(`Impossible de sauvegarder le champ "${field.name}"`)
      return false
    }

    const value = formatValueForSave(field)

    // Update existing value
    if (field.customFieldValueId) {
      const result: any = await updateApi(field.customFieldValueId, { value })
      if (result.success) {
        showSuccess(`Champ "${field.name}" mis à jour`)
        return true
      }
      showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
      return false
    }

    // Create new value via upsert
    if (!field.customFieldId) {
      showError(`Impossible de sauvegarder le champ "${field.name}" (identifiant manquant)`)
      return false
    }

    const result: any = await upsertCustomFieldValue(
      field.customFieldId,
      entityType,
      id,
      value,
    )

    if (result.success) {
      // Update field with the newly created value ID
      if (result.data?.id) {
        field.customFieldValueId = result.data.id
      }
      showSuccess(`Champ "${field.name}" enregistré`)
      return true
    }

    showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
    return false
  }

  // Save all fields that have values
  const saveAll = async (): Promise<string[]> => {
    const id = toValue(options.entityId)
    if (!id) return ['(entity ID missing)']

    const failed: string[] = []

    for (const field of fields.value) {
      if (!shouldPersist(field)) continue

      const value = formatValueForSave(field)

      if (field.customFieldValueId) {
        const result: any = await updateApi(field.customFieldValueId, { value })
        if (!result.success) failed.push(field.name)
        continue
      }

      if (!field.customFieldId) {
        failed.push(field.name)
        continue
      }

      const result: any = await upsertCustomFieldValue(
        field.customFieldId,
        entityType,
        id,
        value,
      )

      if (result.success) {
        if (result.data?.id) {
          field.customFieldValueId = result.data.id
        }
      } else {
        failed.push(field.name)
      }
    }

    return failed
  }

  return {
    /** All merged fields filtered by context */
    fields,
    /** All merged fields (unfiltered) */
    allFields,
    /** Whether all required fields have values */
    requiredFilled,
    /** Update a single field value via API */
    update,
    /** Save all fields with values, returns list of failed field names */
    saveAll,
  }
}
  • Step 2: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 3: Commit
git add frontend/app/composables/useCustomFieldInputs.ts
git commit -m "feat(custom-fields) : add unified useCustomFieldInputs composable"

Task 4: Migrate CustomFieldInputGrid.vue and CustomFieldDisplay.vue

These are shared components used everywhere. Migrate them first so downstream consumers can use the new types.

Files:

  • Modify: frontend/app/components/common/CustomFieldInputGrid.vue

  • Modify: frontend/app/components/common/CustomFieldDisplay.vue

  • Step 1: Migrate CustomFieldInputGrid.vue

Replace the import from customFieldFormUtils:

// OLD
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
// NEW
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
  • Step 2: Migrate CustomFieldDisplay.vue

Replace all imports from entityCustomFieldLogic with the new module. Read the file first to identify the exact imports, then replace:

// OLD
import {
  resolveFieldKey,
  resolveFieldName,
  resolveFieldType,
  resolveFieldOptions,
  resolveFieldReadOnly,
  formatFieldDisplayValue,
  resolveCustomFieldId,
} from '~/shared/utils/entityCustomFieldLogic'

// NEW
import {
  fieldKey,
  formatValueForDisplay,
  hasDisplayableValue,
  type CustomFieldInput,
} from '~/shared/utils/customFields'

Note: CustomFieldDisplay.vue accesses field properties directly (.name, .type, .options, .value). With the new typed CustomFieldInput, direct property access replaces the resolveFieldXxx() wrapper functions. Read the full file and adapt the template accordingly.

  • Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 4: Commit
git add frontend/app/components/common/CustomFieldInputGrid.vue frontend/app/components/common/CustomFieldDisplay.vue
git commit -m "refactor(custom-fields) : migrate shared display components to unified module"

Task 5: Migrate standalone composables (useComponentEdit, useComponentCreate, usePieceEdit)

Files:

  • Modify: frontend/app/composables/useComponentEdit.ts
  • Modify: frontend/app/composables/useComponentCreate.ts
  • Modify: frontend/app/composables/usePieceEdit.ts

For each file:

  1. Read the full file to understand the current custom field logic
  2. Replace imports from customFieldFormUtils with the new module
  3. Replace buildCustomFieldInputs(structure, values) with mergeDefinitionsWithValues(structure?.customFields, values)
  4. Replace requiredCustomFieldsFilled(customFieldInputs.value) with requiredFieldsFilled(fields)
  5. Replace saveCustomFieldValues(...) with saveAll() from useCustomFieldInputs
  6. Replace normalizeCustomFieldInputs(structure) with normalizeDefinitions(structure?.customFields)
  • Step 1: Migrate useComponentEdit.ts

Read the file. Replace the customFieldFormUtils imports and the refreshCustomFieldInputs / buildCustomFieldInputs calls with useCustomFieldInputs. The composable should receive selectedTypeStructure.value?.customFields as definitions and component.value?.customFieldValues as values.

  • Step 2: Migrate useComponentCreate.ts

Same pattern. Definitions come from selectedType.value?.structure?.customFields, values are empty [] for creation.

  • Step 3: Migrate usePieceEdit.ts

Same pattern. Definitions come from the piece type structure, values from piece.customFieldValues.

  • Step 4: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 5: Verify

Open each page in the browser:

  • /component/{id} — check custom fields display and edit

  • /component/create — check custom fields with default values

  • /pieces/{id}/edit — check custom fields display and edit

  • Step 6: Commit

git add frontend/app/composables/useComponentEdit.ts frontend/app/composables/useComponentCreate.ts frontend/app/composables/usePieceEdit.ts
git commit -m "refactor(custom-fields) : migrate standalone composables to unified module"

Task 6: Migrate standalone pages (product + piece create)

Files:

  • Modify: frontend/app/pages/pieces/create.vue
  • Modify: frontend/app/pages/product/create.vue
  • Modify: frontend/app/pages/product/[id]/edit.vue
  • Modify: frontend/app/pages/product/[id]/index.vue

These pages import directly from customFieldFormUtils. Replace with the new module.

  • Step 1: Read each file and identify all customFieldFormUtils imports

  • Step 2: For each page, replace imports

Replace:

import { CustomFieldInput, normalizeCustomFieldInputs, buildCustomFieldInputs, requiredCustomFieldsFilled, saveCustomFieldValues } from '~/shared/utils/customFieldFormUtils'

With:

import { type CustomFieldInput, normalizeDefinitions, mergeDefinitionsWithValues, requiredFieldsFilled } from '~/shared/utils/customFields'
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'

Adapt the logic in each page to use useCustomFieldInputs or the pure helpers as appropriate.

  • Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 4: Verify

Open each page in the browser:

  • /pieces/create — check custom fields appear when selecting a type

  • /product/create — same

  • /product/{id}/edit — check fields display with values

  • /product/{id} — check read-only display

  • Step 5: Commit

git add frontend/app/pages/pieces/create.vue frontend/app/pages/product/create.vue frontend/app/pages/product/\[id\]/edit.vue frontend/app/pages/product/\[id\]/index.vue
git commit -m "refactor(custom-fields) : migrate product and piece pages to unified module"

Task 7: Migrate machine page — MachineCustomFieldsCard, MachineInfoCard, useMachineDetailCustomFields

Files:

  • Modify: frontend/app/components/machine/MachineCustomFieldsCard.vue

  • Modify: frontend/app/components/machine/MachineInfoCard.vue

  • Modify: frontend/app/composables/useMachineDetailCustomFields.ts

  • Step 1: Migrate MachineCustomFieldsCard.vue and MachineInfoCard.vue

Replace:

import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'

With:

import { formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'

Replace all calls to formatCustomFieldValue(field) with formatValueForDisplay(field).

  • Step 2: Migrate useMachineDetailCustomFields.ts

This is the biggest migration. Read the full file. The custom field logic needs to be replaced:

  1. Replace imports from customFieldUtils with the new module
  2. Replace syncMachineCustomFields() — use mergeDefinitionsWithValues(machine.customFields, machine.customFieldValues)
  3. Replace transformComponentCustomFields() and transformCustomFields() — for each component/piece in the hierarchy, use mergeDefinitionsWithValues(type.customFields, entity.customFieldValues) then filterByContext(fields, 'standalone')
  4. For context fields on links: mergeDefinitionsWithValues(link.contextCustomFields, link.contextCustomFieldValues)
  5. Replace updateMachineCustomField(), saveAllMachineCustomFields(), saveAllContextCustomFields() with useCustomFieldInputs instances or direct API calls from useCustomFields

Keep the non-custom-field logic (constructeurs, products, transforms) in this file.

  • Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 4: Verify

Open a machine page (/machine/{id}) that has:

  • Machine-level custom fields

  • Components with regular custom fields

  • Components with machineContextOnly fields Check display, edit, and save for all three.

  • Step 5: Commit

git add frontend/app/components/machine/MachineCustomFieldsCard.vue frontend/app/components/machine/MachineInfoCard.vue frontend/app/composables/useMachineDetailCustomFields.ts
git commit -m "refactor(custom-fields) : migrate machine page to unified module"

Task 8: Migrate hierarchy components (ComponentItem.vue, PieceItem.vue)

Files:

  • Modify: frontend/app/components/ComponentItem.vue

  • Modify: frontend/app/components/PieceItem.vue

  • Step 1: Migrate ComponentItem.vue

Read the file. Replace:

import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCustomFieldLogic'

With:

import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
import { fieldKey, formatValueForDisplay, hasDisplayableValue, type CustomFieldInput } from '~/shared/utils/customFields'

Replace useEntityCustomFields({ entity: () => props.component, entityType: 'composant' }) with useCustomFieldInputs(...), passing the type's customFields as definitions and the entity's customFieldValues as values.

Update the template to use the new field properties directly instead of resolveFieldXxx() functions.

  • Step 2: Migrate PieceItem.vue

Same pattern as ComponentItem but with entityType: 'piece' and piece type fields.

  • Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 4: Verify

Open a machine page and expand components/pieces in the hierarchy. Check custom fields display correctly.

  • Step 5: Commit
git add frontend/app/components/ComponentItem.vue frontend/app/components/PieceItem.vue
git commit -m "refactor(custom-fields) : migrate ComponentItem and PieceItem to unified module"

Task 9: Clean category editor files (componentStructure*.ts)

Files:

  • Modify: frontend/app/shared/model/componentStructure.ts

  • Modify: frontend/app/shared/model/componentStructureSanitize.ts

  • Modify: frontend/app/shared/model/componentStructureHydrate.ts

  • Step 1: Read the three files and identify custom field code

The custom field code in these files handles the category editor (defining fields on a ModelType skeleton). It's the sanitizeCustomFields and hydrateCustomFields functions, plus custom field handling in normalizeStructureForEditor and normalizeStructureForSave.

  • Step 2: Replace custom field sanitize/hydrate with normalizeDefinitions from the unified module

In componentStructure.ts, replace the custom field handling in normalizeStructureForEditor:

// OLD
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
const customFields = sanitizedCustomFields.map((field) => { ... })
// NEW
import { normalizeDefinitions } from '~/shared/utils/customFields'
const customFields = normalizeDefinitions(source.customFields)

Note: The category editor needs additional properties (optionsText for textarea display). These can be computed in the editor component itself rather than in the structure normalization.

  • Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 4: Verify

Open /component-category/{id}/edit — check that custom fields are displayed, can be added/removed/reordered, and save correctly.

  • Step 5: Commit
git add frontend/app/shared/model/componentStructure.ts frontend/app/shared/model/componentStructureSanitize.ts frontend/app/shared/model/componentStructureHydrate.ts
git commit -m "refactor(custom-fields) : clean category editor structure files"

Task 10: Delete old files + final cleanup

Files:

  • Delete: frontend/app/shared/utils/entityCustomFieldLogic.ts

  • Delete: frontend/app/shared/utils/customFieldUtils.ts

  • Delete: frontend/app/shared/utils/customFieldFormUtils.ts

  • Delete: frontend/app/composables/useEntityCustomFields.ts

  • Delete or rewrite: frontend/tests/shared/customFieldFormUtils.test.ts

  • Step 1: Verify no remaining imports of old files

cd frontend && grep -r "entityCustomFieldLogic\|customFieldUtils\|customFieldFormUtils\|useEntityCustomFields" app/ --include="*.ts" --include="*.vue" -l

Expected: no results (0 files).

  • Step 2: Delete old files
rm frontend/app/shared/utils/entityCustomFieldLogic.ts
rm frontend/app/shared/utils/customFieldUtils.ts
rm frontend/app/shared/utils/customFieldFormUtils.ts
rm frontend/app/composables/useEntityCustomFields.ts
rm frontend/tests/shared/customFieldFormUtils.test.ts
  • Step 3: Run full lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck

Expected: 0 errors.

  • Step 4: Final smoke test

Test all 4 contexts in the browser:

  1. Machine fields/machine/{id} → machine-level custom fields
  2. Standalone entity/component/{id} → custom fields display and edit
  3. Machine context/machine/{id} → expand a component → machineContextOnly fields
  4. Category editor/component-category/{id}/edit → custom field definitions
  • Step 5: Commit
git add -A
git commit -m "refactor(custom-fields) : delete old parallel custom field modules"