From 875a34f169a94c7192e862a6c23e92e4ed00094c Mon Sep 17 00:00:00 2001 From: r-dev Date: Sat, 4 Apr 2026 12:37:15 +0200 Subject: [PATCH] docs : add custom fields simplification implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- ...2026-04-04-custom-fields-simplification.md | 951 ++++++++++++++++++ 1 file changed, 951 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-04-custom-fields-simplification.md diff --git a/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md new file mode 100644 index 0000000..bcd70d8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md @@ -0,0 +1,951 @@ +# 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" +```