# 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. ```php #[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** ```bash make php-cs-fixer-allow-risky ``` - [ ] **Step 3: Commit** ```bash 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** ```typescript /** * 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() const valueByName = new Map() 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() const matchedNames = new Set() // 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** ```bash cd frontend && npm run lint:fix ``` - [ ] **Step 3: Commit** ```bash 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** ```typescript /** * 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 /** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */ values: MaybeRef /** Entity type for API upsert calls */ entityType: CustomFieldEntityType /** Entity ID for API upsert calls */ entityId: MaybeRef /** 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([]) // 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(() => { 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 => { 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 => { 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** ```bash cd frontend && npm run lint:fix && npx nuxi typecheck ``` - [ ] **Step 3: Commit** ```bash 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: ```typescript // 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** ```bash 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** ```bash 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: ```typescript import { CustomFieldInput, normalizeCustomFieldInputs, buildCustomFieldInputs, requiredCustomFieldsFilled, saveCustomFieldValues } from '~/shared/utils/customFieldFormUtils' ``` With: ```typescript 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** ```bash 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** ```bash 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`: ```typescript // 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** ```bash 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** ```bash 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: ```typescript import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils' ``` With: ```typescript 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): ```typescript // 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): ```typescript 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** ```bash 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** ```bash 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: ```typescript import { useEntityCustomFields } from '~/composables/useEntityCustomFields' import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCustomFieldLogic' ``` With: ```typescript 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** ```bash 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** ```bash 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** ```bash cd frontend && grep -r "entityCustomFieldLogic\|customFieldUtils\|customFieldFormUtils\|useEntityCustomFields" app/ --include="*.ts" --include="*.vue" -l ``` Expected: no results (0 files). - [ ] **Step 2: Delete old files** ```bash 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** ```bash 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** ```bash git add -A git commit -m "refactor(custom-fields) : delete old parallel custom field modules" ```