From 1348fa9963e18bf83f0f963b0c52a8e6936fe136 Mon Sep 17 00:00:00 2001 From: r-dev Date: Sat, 4 Apr 2026 12:46:31 +0200 Subject: [PATCH] docs : update implementation plan with review fixes Addresses 8 issues found by dual code review: - Add readOnly + optionsText to CustomFieldInput type - Replace computed with mutable ref + refresh() in composable - Add metadata fallback for fields without customFieldId - Add onValueCreated callback to keep parent reactive state in sync - Merge Task 4+5 to avoid type mismatch in intermediate state - Detail surgical refactoring of transformComponentCustomFields - Define data contract for ComponentItem/PieceItem (pre-merged) - Fix hasDisplayableValue to handle readOnly fields Co-Authored-By: Claude Opus 4.6 (1M context) --- ...2026-04-04-custom-fields-simplification.md | 254 ++++++++++-------- 1 file changed, 143 insertions(+), 111 deletions(-) diff --git a/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md index bcd70d8..9374ccb 100644 --- a/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md +++ b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md @@ -129,6 +129,9 @@ export interface CustomFieldInput { orderIndex: number machineContextOnly: boolean value: string + readOnly?: boolean + /** options joined by newline — used by category editor textareas (v-model) */ + optionsText?: string } // --------------------------------------------------------------------------- @@ -260,6 +263,8 @@ export function mergeDefinitionsWithValues( 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) @@ -274,6 +279,7 @@ export function mergeDefinitionsWithValues( orderIndex: def.orderIndex, machineContextOnly: def.machineContextOnly, value: matched.value, + optionsText, } } @@ -289,6 +295,7 @@ export function mergeDefinitionsWithValues( orderIndex: def.orderIndex, machineContextOnly: def.machineContextOnly, value: def.defaultValue ?? '', + optionsText, } }) @@ -297,6 +304,7 @@ export function mergeDefinitionsWithValues( 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, @@ -308,6 +316,7 @@ export function mergeDefinitionsWithValues( orderIndex: v.customField.orderIndex, machineContextOnly: v.customField.machineContextOnly, value: v.value, + optionsText: orphanOptionsText, }) } @@ -347,8 +356,9 @@ export function formatValueForDisplay(field: CustomFieldInput): string { return raw || 'Non défini' } -/** Whether a field has a displayable value */ +/** 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 } @@ -413,9 +423,14 @@ This replaces `useEntityCustomFields.ts` (181 lines) and the custom field parts * * 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 { computed, type MaybeRef, toValue } from 'vue' +import { ref, watch, computed, type MaybeRef, toValue } from 'vue' import { useCustomFields } from '~/composables/useCustomFields' import { useToast } from '~/composables/useToast' import { @@ -450,6 +465,8 @@ export interface UseCustomFieldInputsOptions { 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) { @@ -460,22 +477,40 @@ export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) { } = useCustomFields() const { showSuccess, showError } = useToast() - // Merged fields: definitions + values - const allFields = computed(() => { + // 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) - return mergeDefinitionsWithValues(defs, vals) - }) + _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) + 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) @@ -497,24 +532,28 @@ export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) { return false } - // Create new value via upsert - if (!field.customFieldId) { - showError(`Impossible de sauvegarder le champ "${field.name}" (identifiant manquant)`) - 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) { - // Update field with the newly created value ID + // 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 } @@ -541,22 +580,26 @@ export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) { continue } - if (!field.customFieldId) { - 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) } @@ -569,13 +612,15 @@ export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) { /** All merged fields filtered by context */ fields, /** All merged fields (unfiltered) */ - allFields, + 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, } } ``` @@ -595,18 +640,20 @@ git commit -m "feat(custom-fields) : add unified useCustomFieldInputs composable --- -## Task 4: Migrate `CustomFieldInputGrid.vue` and `CustomFieldDisplay.vue` +## Task 4: Migrate shared components + standalone composables (atomic batch) -These are shared components used everywhere. Migrate them first so downstream consumers can use the new types. +**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 from `customFieldFormUtils`: - +Replace the import: ```typescript // OLD import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils' @@ -616,97 +663,48 @@ 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: +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. -```typescript -// OLD -import { - resolveFieldKey, - resolveFieldName, - resolveFieldType, - resolveFieldOptions, - resolveFieldReadOnly, - formatFieldDisplayValue, - resolveCustomFieldId, -} from '~/shared/utils/entityCustomFieldLogic' +- [ ] **Step 3: Migrate `useComponentEdit.ts`** -// NEW -import { - fieldKey, - formatValueForDisplay, - hasDisplayableValue, - type CustomFieldInput, -} from '~/shared/utils/customFields' -``` +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 -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`** +- [ ] **Step 4: Migrate `useComponentCreate.ts`** Same pattern. Definitions come from `selectedType.value?.structure?.customFields`, values are empty `[]` for creation. -- [ ] **Step 3: Migrate `usePieceEdit.ts`** +- [ ] **Step 5: Migrate `usePieceEdit.ts`** -Same pattern. Definitions come from the piece type structure, values from `piece.customFieldValues`. +Same pattern. Definitions from piece type structure, values from `piece.customFieldValues`. -- [ ] **Step 4: Run lint + typecheck** +- [ ] **Step 6: Run lint + typecheck** ```bash cd frontend && npm run lint:fix && npx nuxi typecheck ``` -- [ ] **Step 5: Verify** +- [ ] **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 6: Commit** +- [ ] **Step 8: 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" +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 6: Migrate standalone pages (product + piece create) +## Task 5: Migrate standalone pages (product + piece create) **Files:** - Modify: `frontend/app/pages/pieces/create.vue` @@ -755,7 +753,7 @@ git commit -m "refactor(custom-fields) : migrate product and piece pages to unif --- -## Task 7: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields` +## Task 6: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields` **Files:** - Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue` @@ -775,17 +773,43 @@ import { formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/cus Replace all calls to `formatCustomFieldValue(field)` with `formatValueForDisplay(field)`. -- [ ] **Step 2: Migrate `useMachineDetailCustomFields.ts`** +- [ ] **Step 2: Migrate `useMachineDetailCustomFields.ts` — pure custom field functions** -This is the biggest migration. Read the full file. The custom field logic needs to be replaced: +Replace the following pure-CF functions (~168 lines) with the new module: -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` +| 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 | -Keep the non-custom-field logic (constructeurs, products, transforms) in this file. +- [ ] **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 3: Run lint + typecheck** @@ -810,7 +834,9 @@ git commit -m "refactor(custom-fields) : migrate machine page to unified module" --- -## Task 8: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`) +## Task 7: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`) + +**Data contract decision:** 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` @@ -825,17 +851,21 @@ import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCus ``` With: ```typescript -import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs' +import { useCustomFields } from '~/composables/useCustomFields' 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. +Key changes: +1. **Remove `useEntityCustomFields`** — the parent already pre-merges. Use `props.component.customFields` directly (already `CustomFieldInput[]` after Task 6) +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 piece type fields. +Same pattern as ComponentItem but with `entityType: 'piece'` and `props.piece.customFields`. - [ ] **Step 3: Run lint + typecheck** @@ -856,7 +886,7 @@ git commit -m "refactor(custom-fields) : migrate ComponentItem and PieceItem to --- -## Task 9: Clean category editor files (`componentStructure*.ts`) +## Task 8: Clean category editor files (`componentStructure*.ts`) **Files:** - Modify: `frontend/app/shared/model/componentStructure.ts` @@ -875,11 +905,13 @@ In `componentStructure.ts`, replace the custom field handling in `normalizeStruc const sanitizedCustomFields = sanitizeCustomFields(source.customFields) const customFields = sanitizedCustomFields.map((field) => { ... }) // NEW -import { normalizeDefinitions } from '~/shared/utils/customFields' -const customFields = normalizeDefinitions(source.customFields) +import { mergeDefinitionsWithValues } from '~/shared/utils/customFields' +const customFields = mergeDefinitionsWithValues(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. +**`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. + +**Important:** `normalizeStructureForEditor` is also used by `useMachineDetailCustomFields.ts` (imported as `normalizeStructureForEditor` from `~/shared/modelUtils`). Make sure the change doesn't break the machine detail page — it should be fine since the new output includes the same `options` array plus the `optionsText` string. - [ ] **Step 3: Run lint + typecheck** @@ -900,7 +932,7 @@ git commit -m "refactor(custom-fields) : clean category editor structure files" --- -## Task 10: Delete old files + final cleanup +## Task 9: Delete old files + final cleanup **Files:** - Delete: `frontend/app/shared/utils/entityCustomFieldLogic.ts`