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) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:46:31 +02:00
parent 875a34f169
commit 1348fa9963

View File

@@ -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<string | null>
/** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
context?: 'standalone' | 'machine'
/** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */
onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void
}
export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
@@ -460,22 +477,40 @@ export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Merged fields: definitions + values
const allFields = computed<CustomFieldInput[]>(() => {
// Internal mutable state — NOT a computed, so save can mutate in place
const _allFields = ref<CustomFieldInput[]>([])
// Re-merge from source definitions + values
const refresh = () => {
const defs = toValue(options.definitions)
const vals = toValue(options.values)
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<CustomFieldInput[]>(() => {
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<boolean> => {
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`