# 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 } // --------------------------------------------------------------------------- // 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) 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** ```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. */ 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 /** 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' } 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(() => { const defs = toValue(options.definitions) const vals = toValue(options.values) return mergeDefinitionsWithValues(defs, vals) }) // 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)) // 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 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 => { 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** ```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 `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`: ```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 module. Read the file first to identify the exact imports, then replace: ```typescript // 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** ```bash cd frontend && npm run lint:fix && npx nuxi typecheck ``` - [ ] **Step 4: Commit** ```bash 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** ```bash 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** ```bash 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: ```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 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: ```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`** 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** ```bash 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** ```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`) **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 { 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** ```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: 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`: ```typescript // 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** ```bash 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** ```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 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** ```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" ```