Files
Inventory/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
r-dev f2eff89e00 docs : fix task ordering — category editor before machine page
normalizeStructureForEditor is used by useMachineDetailCustomFields.
Must clean it (Task 6) before migrating the machine page (Task 7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:49:41 +02:00

36 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
  readOnly?: boolean
  /** options joined by newline — used by category editor textareas (v-model) */
  optionsText?: 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)

    const optionsText = def.options.length ? def.options.join('\n') : undefined

    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,
        optionsText,
      }
    }

    // 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 ?? '',
      optionsText,
    }
  })

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

    const orphanOptionsText = v.customField.options.length ? v.customField.options.join('\n') : undefined
    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,
      optionsText: orphanOptionsText,
    })
  }

  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 (readOnly fields always display) */
export function hasDisplayableValue(field: CustomFieldInput): boolean {
  if (field.readOnly) return true
  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.
 *
 * DESIGN NOTE: Uses an internal mutable `ref` (not a `computed`) so that
 * save operations can update `customFieldValueId` in place without being
 * overwritten on the next reactivity cycle. Call `refresh()` to re-merge
 * from the source definitions + values (e.g. after fetching fresh data).
 */

import { ref, watch, 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'
  /** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */
  onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void
}

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

  // Internal mutable state — NOT a computed, so save can mutate in place
  const _allFields = ref<CustomFieldInput[]>([])

  // Re-merge from source definitions + values
  const refresh = () => {
    const defs = toValue(options.definitions)
    const vals = toValue(options.values)
    _allFields.value = mergeDefinitionsWithValues(defs, vals)
  }

  // Auto-refresh when reactive sources change
  watch(
    () => [toValue(options.definitions), toValue(options.values)],
    () => refresh(),
    { immediate: true, deep: true },
  )

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

  // Build metadata for upsert when no customFieldId is available (legacy fallback)
  const _buildMetadata = (field: CustomFieldInput) => ({
    customFieldName: field.name,
    customFieldType: field.type,
    customFieldRequired: field.required,
    customFieldOptions: field.options,
  })

  // 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 — with metadata fallback when no ID
    const metadata = field.customFieldId ? undefined : _buildMetadata(field)
    const result: any = await upsertCustomFieldValue(
      field.customFieldId,
      entityType,
      id,
      value,
      metadata,
    )

    if (result.success) {
      // Mutate in place (safe — _allFields is a ref, not computed)
      if (result.data?.id) {
        field.customFieldValueId = result.data.id
      }
      if (result.data?.customField?.id) {
        field.customFieldId = result.data.customField.id
      }
      // Notify parent to update its reactive source
      if (options.onValueCreated && result.data) {
        options.onValueCreated(result.data)
      }
      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
      }

      // Upsert with metadata fallback when no customFieldId
      const metadata = field.customFieldId ? undefined : _buildMetadata(field)
      const result: any = await upsertCustomFieldValue(
        field.customFieldId,
        entityType,
        id,
        value,
        metadata,
      )

      if (result.success) {
        if (result.data?.id) {
          field.customFieldValueId = result.data.id
        }
        if (result.data?.customField?.id) {
          field.customFieldId = result.data.customField.id
        }
        if (options.onValueCreated && result.data) {
          options.onValueCreated(result.data)
        }
      } else {
        failed.push(field.name)
      }
    }

    return failed
  }

  return {
    /** All merged fields filtered by context */
    fields,
    /** All merged fields (unfiltered) */
    allFields: _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,
    /** Re-merge from source definitions + values (call after fetching fresh data) */
    refresh,
  }
}
  • 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 shared components + standalone composables (atomic batch)

Why batched: CustomFieldInputGrid.vue and CustomFieldDisplay.vue receive fields from the composables. Migrating them separately would cause TypeScript errors in the intermediate state. Migrate all together.

Files:

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

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

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

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

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

  • Step 1: Migrate CustomFieldInputGrid.vue

Replace the import:

// 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 typed CustomFieldInput, direct property access (.name, .type, .options, .value, .readOnly) replaces the resolveFieldXxx() wrapper functions. Read the full file and adapt the template accordingly.

  • Step 3: Migrate useComponentEdit.ts

Read the file. Key changes:

  1. Replace customFieldFormUtils imports with the new module
  2. Replace the imperative refreshCustomFieldInputs + buildCustomFieldInputs pattern with useCustomFieldInputs
  3. Pass selectedTypeStructure.value?.customFields as definitions and component.value?.customFieldValues as values
  4. Use the onValueCreated callback to push new values into component.value.customFieldValues so the reactive source stays in sync
  5. Replace calls to refreshCustomFieldInputs() in watchers/fetch with calls to refresh() from the composable
  • Step 4: Migrate useComponentCreate.ts

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

  • Step 5: Migrate usePieceEdit.ts

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

  • Step 6: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 7: 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 8: Commit

git add frontend/app/components/common/CustomFieldInputGrid.vue frontend/app/components/common/CustomFieldDisplay.vue frontend/app/composables/useComponentEdit.ts frontend/app/composables/useComponentCreate.ts frontend/app/composables/usePieceEdit.ts
git commit -m "refactor(custom-fields) : migrate shared components and standalone composables to unified module"

Task 5: 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 6: Clean category editor files (componentStructure*.ts)

WHY BEFORE MACHINE PAGE: normalizeStructureForEditor is used by useMachineDetailCustomFields.ts. If we change it after migrating the machine page, the machine page would break. So clean this first.

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 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 { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
const customFields = mergeDefinitionsWithValues(source.customFields, [])

optionsText is now included in CustomFieldInput (added in the type definition). mergeDefinitionsWithValues already computes optionsText from options.join('\n'), so all category editor textareas (v-model="field.optionsText") will work without changes.

  • Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 4: Verify TWO things
  1. Open /component-category/{id}/edit — check that custom fields are displayed, can be added/removed/reordered, and save correctly.
  2. Open /machine/{id} — check that the machine page still works (it uses normalizeStructureForEditor via useMachineDetailCustomFields.ts).
  • 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 7: Migrate machine page — MachineCustomFieldsCard, MachineInfoCard, useMachineDetailCustomFields

Depends on: Task 6 (category editor cleaned — normalizeStructureForEditor already uses new types)

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 — pure custom field functions

Replace the following pure-CF functions (~168 lines) with the new module:

Old function (lines) Replacement
syncMachineCustomFields (269-289) mergeDefinitionsWithValues(machine.customFields, machine.customFieldValues)
setMachineCustomFieldValue (291-299) Direct property mutation on the mutable CustomFieldInput
updateMachineCustomField (302-363) useCustomFieldInputs.update()
saveAllMachineCustomFields (461-511) useCustomFieldInputs.saveAll()
saveAllContextCustomFields (430-459) Loop over link-level useCustomFieldInputs instances
  • Step 3: Migrate useMachineDetailCustomFields.ts — mixed transform functions

transformCustomFields (lines 71-158) and transformComponentCustomFields (lines 161-263) mix custom field merging with constructeur/product/document logic in a single map(). Refactor surgically:

Inside transformCustomFields map callback, replace lines 82-106 (valueEntries + merge + dedupe + filter):

// OLD: ~25 lines of valueEntries building, normalizeCustomFieldValueEntry, mergeCustomFieldValuesWithDefinitions, dedupeCustomFieldEntries
// NEW: 2 lines
const customFields = filterByContext(
  mergeDefinitionsWithValues(type.customFields ?? typePiece.customFields ?? [], piece.customFieldValues ?? []),
  'standalone',
)

Keep the rest of the map callback (constructeurs lines 108-133, product lines 119-120, assembly lines 135-158) unchanged.

Inside transformComponentCustomFields map callback, replace lines 175-199 (same pattern):

const customFields = filterByContext(
  mergeDefinitionsWithValues(type.customFields ?? [], component.customFieldValues ?? actualComponent?.customFieldValues ?? []),
  'standalone',
)

Keep recursive calls (lines 201-209) and constructeur/product/document logic (lines 212-263) unchanged.

  • Step 4: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 5: 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 6: 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)

Depends on: Task 7 (machine page — parent pre-merges custom fields into CustomFieldInput[])

Data contract: The parent (useMachineDetailCustomFields.transformComponentCustomFields) already pre-merges custom fields into component.customFields (a CustomFieldInput[]). The Item components should NOT re-merge — they display the pre-merged data directly and use the API composable only for saves/updates.

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 { useCustomFields } from '~/composables/useCustomFields'
import { fieldKey, formatValueForDisplay, hasDisplayableValue, mergeDefinitionsWithValues, type CustomFieldInput } from '~/shared/utils/customFields'

Key changes:

  1. Remove useEntityCustomFields — the parent already pre-merges. Use props.component.customFields directly (already CustomFieldInput[] after Task 7)
  2. For display: use hasDisplayableValue(field) and formatValueForDisplay(field) instead of resolveFieldXxx() wrappers
  3. For edits/saves: use useCustomFields() directly (the HTTP layer) instead of useEntityCustomFields().updateCustomField
  4. For context fields (component.contextCustomFields + component.contextCustomFieldValues): merge locally with mergeDefinitionsWithValues — these are NOT pre-merged by the parent since they come as separate arrays
  5. Replace resolveCustomFieldId(field) with field.customFieldId (direct property access on CustomFieldInput)
  6. Replace resolveFieldId(field) with field.customFieldValueId
  • Step 2: Migrate PieceItem.vue

Same pattern as ComponentItem but with entityType: 'piece' and props.piece.customFields.

  • 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: 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"