From 353d7e938e778ec5ba5dbe118e09ec6139efb6b0 Mon Sep 17 00:00:00 2001 From: r-dev Date: Sat, 4 Apr 2026 12:32:11 +0200 Subject: [PATCH 01/42] docs : add custom fields simplification design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-04-custom-fields-simplification-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md diff --git a/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md b/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md new file mode 100644 index 0000000..230cc0b --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md @@ -0,0 +1,214 @@ +# Custom Fields Simplification — Design Spec + +**Date:** 2026-04-04 +**Scope:** Backend minor cleanup + Frontend unification of the custom fields system +**Constraint:** Everything must work after — progressive migration with verification at each step + +## Problem + +The custom fields system has grown into 3 parallel frontend implementations (~2900 lines across 9 files) due to accumulated defensive code. This caused data bugs (orphaned fields, lost linkage) and makes every change risky. + +## 4 Custom Field Contexts + +1. **Machine** — fields defined directly on the machine (`CustomField.machineid` FK), values on machine +2. **Standalone entity** — fields defined in ModelType (category), values on composant/piece/product. Visible when opening the entity directly +3. **Machine context** — fields with `machineContextOnly=true` defined in ModelType, values stored on `MachineComponentLink`/`MachinePieceLink`. Visible only from the machine detail page +4. **Category editor** — UI for defining/editing custom fields in a ModelType skeleton + +## Backend Changes + +### Minor — format already consistent + +After review, `MachineStructureController` already serializes custom fields in the same format as API Platform: + +```json +// CustomFieldValue (from normalizeCustomFieldValues) +{ + "id": "cfv-123", + "value": "USOCOME", + "customField": { + "id": "cf-456", + "name": "MARQUE", + "type": "text", + "required": false, + "options": [], + "defaultValue": null, + "orderIndex": 0, + "machineContextOnly": false + } +} +``` + +```json +// CustomField definition (from normalizeCustomFieldDefinitions) +{ + "id": "cf-456", + "name": "MARQUE", + "type": "text", + "required": false, + "options": [], + "defaultValue": null, + "orderIndex": 0, + "machineContextOnly": false +} +``` + +The only backend task is adding `defaultValue` to the API Platform serialization groups on `CustomField.php` so that both API Platform and the custom controller return it. + +**Context fields on links** are returned as two separate arrays: +- `contextCustomFields` — definitions filtered to `machineContextOnly=true` +- `contextCustomFieldValues` — values stored on `MachineComponentLink`/`MachinePieceLink` + +This format stays as-is. The frontend unified module handles the merge. + +**Files:** +- `src/Entity/CustomField.php` — add `#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]` to `defaultValue` + +### Legacy `{key, value}` format in DB + +`SkeletonStructureService::normalizeCustomFieldData()` accepts two formats: +- Legacy: `{key: "name", value: {type, required, options?, defaultValue?}}` +- Standard: `{name, type, required, options?, defaultValue?}` + +**Pre-migration check required:** verify if any `ModelType` rows still have the legacy format in their structure data. If yes, write a one-time DB migration to normalize them before removing the frontend parsing code in Step 5. If no legacy data exists, the parsing code can be safely removed. + +## Frontend Changes + +### New Unified Module (2 files, ~400 lines total) + +**`shared/utils/customFields.ts`** (~180 lines) — Pure logic, zero Vue dependency + +Types: +- `CustomFieldDefinition` — `{ id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }` +- `CustomFieldValue` — `{ id, value, customField: CustomFieldDefinition }` +- `CustomFieldInput` — `{ ...CustomFieldDefinition, value, customFieldId, customFieldValueId }` (the merged type used by forms) + +Functions: +- `mergeDefinitionsWithValues(definitions, values)` → `CustomFieldInput[]` — the ONE merge function replacing the 3 current ones. Matches by `customField.id` then by `name`. When no value exists for a definition, uses `defaultValue` as initial value. +- `filterByContext(fields, context: 'standalone' | 'machine')` — filters on `machineContextOnly` +- `sortByOrder(fields)` — sorts by `orderIndex` +- `formatValueForSave(field)` / `shouldPersist(field)` — persistence helpers +- `formatValueForDisplay(field)` — display helper (e.g. boolean → `Oui/Non`), replaces `formatCustomFieldValue` from `customFieldUtils.ts` +- `fieldKey(field, index)` — stable key for v-for, replaces `fieldKey` from `customFieldFormUtils.ts` + +**`composables/useCustomFieldInputs.ts`** (~220 lines) — Reactive, wraps pure helpers + +```ts +function useCustomFieldInputs(options: { + definitions: MaybeRef + values: MaybeRef + entityType: 'machine' | 'composant' | 'piece' | 'product' | 'machineComponentLink' | 'machinePieceLink' + entityId: MaybeRef + context?: 'standalone' | 'machine' // defaults to 'standalone' +}): { + fields: ComputedRef + update: (field: CustomFieldInput) => Promise + saveAll: () => Promise // returns failed field names + requiredFilled: ComputedRef +} +``` + +**Usage for context 3 (machine context fields on links):** +```ts +// For each MachineComponentLink, instantiate with: +const contextFields = useCustomFieldInputs({ + definitions: link.contextCustomFields, // from MachineStructureController + values: link.contextCustomFieldValues, // from MachineStructureController + entityType: 'machineComponentLink', + entityId: link.id, + context: 'machine', +}) +``` + +### Files Deleted After Migration + +| File | Lines | Replaced by | +|------|-------|-------------| +| `shared/utils/entityCustomFieldLogic.ts` | 335 | `shared/utils/customFields.ts` | +| `shared/utils/customFieldUtils.ts` | 440 | `shared/utils/customFields.ts` | +| `shared/utils/customFieldFormUtils.ts` | 404 | `shared/utils/customFields.ts` + `composables/useCustomFieldInputs.ts` | +| `composables/useEntityCustomFields.ts` | 181 | `composables/useCustomFieldInputs.ts` | + +Additionally refactored (not deleted): +- `composables/useMachineDetailCustomFields.ts` — custom fields code extracted, uses new module (keeps non-CF logic: constructeurs, products, transforms) +- `shared/model/componentStructure.ts` — custom fields code removed (kept: structure/skeleton logic) +- `shared/model/componentStructureSanitize.ts` — custom fields sanitize code removed +- `shared/model/componentStructureHydrate.ts` — custom fields hydrate code removed + +### All consuming files to migrate + +**Composables:** +- `composables/useComponentEdit.ts` — use `useCustomFieldInputs` +- `composables/useComponentCreate.ts` — use `useCustomFieldInputs` +- `composables/usePieceEdit.ts` — use `useCustomFieldInputs` +- `composables/useMachineDetailCustomFields.ts` — use `useCustomFieldInputs` for all 3 machine sub-cases + +**Pages:** +- `pages/component/[id]/index.vue` — already uses composable, minimal changes +- `pages/component/[id]/edit.vue` — already uses composable, minimal changes +- `pages/component/create.vue` — already uses composable, minimal changes +- `pages/pieces/create.vue` — imports from `customFieldFormUtils`, migrate to new types +- `pages/pieces/[id]/edit.vue` — already uses composable, minimal changes +- `pages/product/create.vue` — imports from `customFieldFormUtils`, migrate to new types +- `pages/product/[id]/edit.vue` — imports from `customFieldFormUtils`, migrate to new types +- `pages/product/[id]/index.vue` — imports from `customFieldFormUtils`, migrate to new types + +**Shared components:** +- `components/common/CustomFieldDisplay.vue` — imports 7 functions from `entityCustomFieldLogic`, rewrite with unified `CustomFieldInput` type +- `components/common/CustomFieldInputGrid.vue` — imports `fieldKey` + `CustomFieldInput` from `customFieldFormUtils`, update imports +- `components/ComponentItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate +- `components/PieceItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate +- `components/machine/MachineCustomFieldsCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay` +- `components/machine/MachineInfoCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay` +- `components/model-types/ModelTypeForm.vue` — use `shared/utils/customFields.ts` types + +**Tests:** +- `tests/shared/customFieldFormUtils.test.ts` — rewrite for new module or delete + +## Migration Strategy — Progressive (6 steps) + +### Step 1: Backend minor fix + DB check +- Add `defaultValue` to serialization groups in `CustomField.php` +- Check DB for legacy `{key, value}` format in `model_types.structure` — write migration if needed +- **Verify:** call `/api/composants/{id}`, confirm `defaultValue` appears in `customField` objects + +### Step 2: Create new module +- Write `shared/utils/customFields.ts` and `composables/useCustomFieldInputs.ts` +- Port existing test to new module +- **Verify:** import in a test page, confirm merge/filter/sort/defaultValue work with real data + +### Step 3: Migrate standalone pages (composant/piece/product) +- Refactor composables: `useComponentEdit.ts`, `useComponentCreate.ts`, `usePieceEdit.ts` +- Refactor pages: `pieces/create.vue`, `product/create.vue`, `product/[id]/edit.vue`, `product/[id]/index.vue` +- Refactor shared components: `CustomFieldInputGrid.vue`, `CustomFieldDisplay.vue` +- **Verify per page:** open entity, check fields display with values (including defaultValue on new entities), modify a value, confirm save works + +### Step 4: Migrate machine page + hierarchy components +- Refactor `useMachineDetailCustomFields.ts` — use `useCustomFieldInputs` for: + - Machine direct fields (definitions from `machine.customFields`, values from `machine.customFieldValues`) + - Standalone component/piece fields (definitions from `type.customFields`, values from entity's `customFieldValues`, filtered `machineContextOnly=false`) + - Machine context fields (definitions from `link.contextCustomFields`, values from `link.contextCustomFieldValues`) +- Refactor `ComponentItem.vue`, `PieceItem.vue` — use `useCustomFieldInputs` instead of `useEntityCustomFields` +- Refactor `MachineCustomFieldsCard.vue`, `MachineInfoCard.vue` — use `formatValueForDisplay` +- **Verify:** open a machine with components that have both normal AND machine-context custom fields, check both display and save correctly + +### Step 5: Migrate category editor +- Check DB for legacy `{key, value}` format — run migration if needed +- Clean `componentStructure.ts`, `componentStructureSanitize.ts`, `componentStructureHydrate.ts` — remove custom fields code, use unified types from `customFields.ts` +- Refactor `ModelTypeForm.vue` +- **Verify:** edit a component category, modify skeleton custom fields, save, check linked components see changes + +### Step 6: Cleanup +- Delete the 4 old files +- Delete or rewrite `tests/shared/customFieldFormUtils.test.ts` +- `npm run lint:fix` + `npx nuxi typecheck` = 0 errors +- Final smoke test of all 4 contexts + +## Result + +- **~2900 lines → ~400 lines** + simplified consumers +- **9 custom fields files → 2** +- **3 parallel systems → 1** +- **1 unified data format** understood by all pages +- **`defaultValue` properly handled** across all contexts +- **Legacy format eliminated** from DB and code From 875a34f169a94c7192e862a6c23e92e4ed00094c Mon Sep 17 00:00:00 2001 From: r-dev Date: Sat, 4 Apr 2026 12:37:15 +0200 Subject: [PATCH 02/42] 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" +``` From 1348fa9963e18bf83f0f963b0c52a8e6936fe136 Mon Sep 17 00:00:00 2001 From: r-dev Date: Sat, 4 Apr 2026 12:46:31 +0200 Subject: [PATCH 03/42] 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` From f2eff89e00374960cac0abd205b2f5f79dd9dffe Mon Sep 17 00:00:00 2001 From: r-dev Date: Sat, 4 Apr 2026 12:49:41 +0200 Subject: [PATCH 04/42] =?UTF-8?q?docs=20:=20fix=20task=20ordering=20?= =?UTF-8?q?=E2=80=94=20category=20editor=20before=20machine=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeStructureForEditor is used by useMachineDetailCustomFields. Must clean it (Task 6) before migrating the machine page (Task 7). Co-Authored-By: Claude Opus 4.6 (1M context) --- ...2026-04-04-custom-fields-simplification.md | 113 +++++++++--------- 1 file changed, 59 insertions(+), 54 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 9374ccb..956abae 100644 --- a/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md +++ b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md @@ -753,7 +753,56 @@ git commit -m "refactor(custom-fields) : migrate product and piece pages to unif --- -## Task 6: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields` +## Task 6: Clean category editor files (`componentStructure*.ts`) + +**WHY BEFORE MACHINE PAGE:** `normalizeStructureForEditor` is used by `useMachineDetailCustomFields.ts`. If we change it after migrating the machine page, the machine page would break. So clean this first. + +**Files:** +- Modify: `frontend/app/shared/model/componentStructure.ts` +- Modify: `frontend/app/shared/model/componentStructureSanitize.ts` +- Modify: `frontend/app/shared/model/componentStructureHydrate.ts` + +- [ ] **Step 1: Read the three files and identify custom field code** + +The custom field code in these files handles the category editor (defining fields on a ModelType skeleton). It's the `sanitizeCustomFields` and `hydrateCustomFields` functions, plus custom field handling in `normalizeStructureForEditor` and `normalizeStructureForSave`. + +- [ ] **Step 2: Replace custom field sanitize/hydrate with the unified module** + +In `componentStructure.ts`, replace the custom field handling in `normalizeStructureForEditor`: +```typescript +// OLD +const sanitizedCustomFields = sanitizeCustomFields(source.customFields) +const customFields = sanitizedCustomFields.map((field) => { ... }) +// NEW +import { mergeDefinitionsWithValues } from '~/shared/utils/customFields' +const customFields = mergeDefinitionsWithValues(source.customFields, []) +``` + +**`optionsText` is now included** in `CustomFieldInput` (added in the type definition). `mergeDefinitionsWithValues` already computes `optionsText` from `options.join('\n')`, so all category editor textareas (`v-model="field.optionsText"`) will work without changes. + +- [ ] **Step 3: Run lint + typecheck** + +```bash +cd frontend && npm run lint:fix && npx nuxi typecheck +``` + +- [ ] **Step 4: Verify TWO things** + +1. Open `/component-category/{id}/edit` — check that custom fields are displayed, can be added/removed/reordered, and save correctly. +2. Open `/machine/{id}` — check that the machine page still works (it uses `normalizeStructureForEditor` via `useMachineDetailCustomFields.ts`). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/app/shared/model/componentStructure.ts frontend/app/shared/model/componentStructureSanitize.ts frontend/app/shared/model/componentStructureHydrate.ts +git commit -m "refactor(custom-fields) : clean category editor structure files" +``` + +--- + +## Task 7: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields` + +**Depends on:** Task 6 (category editor cleaned — `normalizeStructureForEditor` already uses new types) **Files:** - Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue` @@ -811,13 +860,13 @@ const customFields = filterByContext( Keep recursive calls (lines 201-209) and constructeur/product/document logic (lines 212-263) unchanged. -- [ ] **Step 3: Run lint + typecheck** +- [ ] **Step 4: Run lint + typecheck** ```bash cd frontend && npm run lint:fix && npx nuxi typecheck ``` -- [ ] **Step 4: Verify** +- [ ] **Step 5: Verify** Open a machine page (`/machine/{id}`) that has: - Machine-level custom fields @@ -825,7 +874,7 @@ Open a machine page (`/machine/{id}`) that has: - Components with machineContextOnly fields Check display, edit, and save for all three. -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit** ```bash git add frontend/app/components/machine/MachineCustomFieldsCard.vue frontend/app/components/machine/MachineInfoCard.vue frontend/app/composables/useMachineDetailCustomFields.ts @@ -834,9 +883,11 @@ git commit -m "refactor(custom-fields) : migrate machine page to unified module" --- -## Task 7: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`) +## Task 8: 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. +**Depends on:** Task 7 (machine page — parent pre-merges custom fields into `CustomFieldInput[]`) + +**Data contract:** The parent (`useMachineDetailCustomFields.transformComponentCustomFields`) already pre-merges custom fields into `component.customFields` (a `CustomFieldInput[]`). The Item components should NOT re-merge — they display the pre-merged data directly and use the API composable only for saves/updates. **Files:** - Modify: `frontend/app/components/ComponentItem.vue` @@ -852,11 +903,11 @@ import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCus With: ```typescript import { useCustomFields } from '~/composables/useCustomFields' -import { fieldKey, formatValueForDisplay, hasDisplayableValue, type CustomFieldInput } from '~/shared/utils/customFields' +import { fieldKey, formatValueForDisplay, hasDisplayableValue, mergeDefinitionsWithValues, type CustomFieldInput } from '~/shared/utils/customFields' ``` Key changes: -1. **Remove `useEntityCustomFields`** — the parent already pre-merges. Use `props.component.customFields` directly (already `CustomFieldInput[]` after Task 6) +1. **Remove `useEntityCustomFields`** — the parent already pre-merges. Use `props.component.customFields` directly (already `CustomFieldInput[]` after Task 7) 2. **For display:** use `hasDisplayableValue(field)` and `formatValueForDisplay(field)` instead of `resolveFieldXxx()` wrappers 3. **For edits/saves:** use `useCustomFields()` directly (the HTTP layer) instead of `useEntityCustomFields().updateCustomField` 4. **For context fields** (`component.contextCustomFields` + `component.contextCustomFieldValues`): merge locally with `mergeDefinitionsWithValues` — these are NOT pre-merged by the parent since they come as separate arrays @@ -886,52 +937,6 @@ git commit -m "refactor(custom-fields) : migrate ComponentItem and PieceItem to --- -## Task 8: 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 { mergeDefinitionsWithValues } from '~/shared/utils/customFields' -const customFields = mergeDefinitionsWithValues(source.customFields, []) -``` - -**`optionsText` is now included** in `CustomFieldInput` (added in the type definition). `mergeDefinitionsWithValues` already computes `optionsText` from `options.join('\n')`, so all category editor textareas (`v-model="field.optionsText"`) will work without changes. - -**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** - -```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 9: Delete old files + final cleanup **Files:** From 894d5220360d76d8f8cd4e1c0ddea0db243810fd Mon Sep 17 00:00:00 2001 From: r-dev Date: Sat, 4 Apr 2026 13:09:27 +0200 Subject: [PATCH 05/42] refactor(custom-fields) : unify 3 parallel implementations into 1 module Replace ~2900 lines across 9 files with ~400 lines in 2 files: - shared/utils/customFields.ts (types + pure helpers) - composables/useCustomFieldInputs.ts (reactive composable) Migrated all consumers: - Backend: add defaultValue to API Platform serialization groups - Standalone pages: component edit/create, piece edit/create, product edit/create/detail - Machine page: MachineCustomFieldsCard, MachineInfoCard, useMachineDetailCustomFields - Hierarchy: ComponentItem, PieceItem - Shared: CustomFieldDisplay, CustomFieldInputGrid - Category editor: componentStructure.ts Deleted: - entityCustomFieldLogic.ts (335 lines) - customFieldUtils.ts (440 lines) - customFieldFormUtils.ts (404 lines) - useEntityCustomFields.ts (181 lines) - customFieldFormUtils.test.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/app/components/ComponentItem.vue | 83 ++- frontend/app/components/PieceItem.vue | 90 +++- .../components/common/CustomFieldDisplay.vue | 60 +-- .../common/CustomFieldInputGrid.vue | 2 +- .../machine/MachineCustomFieldsCard.vue | 4 +- .../components/machine/MachineInfoCard.vue | 4 +- .../app/composables/useComponentCreate.ts | 43 +- frontend/app/composables/useComponentEdit.ts | 57 +- .../app/composables/useCustomFieldInputs.ts | 205 +++++++ .../app/composables/useEntityCustomFields.ts | 181 ------- .../useMachineDetailCustomFields.ts | 120 +---- frontend/app/composables/usePieceEdit.ts | 75 +-- frontend/app/pages/component/[id]/index.vue | 2 +- frontend/app/pages/piece/[id].vue | 2 +- frontend/app/pages/pieces/create.vue | 39 +- frontend/app/pages/product/[id]/edit.vue | 37 +- frontend/app/pages/product/[id]/index.vue | 39 +- frontend/app/pages/product/create.vue | 69 +-- .../app/shared/model/componentStructure.ts | 37 +- .../app/shared/utils/customFieldFormUtils.ts | 404 -------------- frontend/app/shared/utils/customFieldUtils.ts | 440 --------------- frontend/app/shared/utils/customFields.ts | 303 +++++++++++ .../shared/utils/entityCustomFieldLogic.ts | 335 ------------ .../tests/shared/customFieldFormUtils.test.ts | 508 ------------------ src/Entity/CustomField.php | 1 + 25 files changed, 861 insertions(+), 2279 deletions(-) create mode 100644 frontend/app/composables/useCustomFieldInputs.ts delete mode 100644 frontend/app/composables/useEntityCustomFields.ts delete mode 100644 frontend/app/shared/utils/customFieldFormUtils.ts delete mode 100644 frontend/app/shared/utils/customFieldUtils.ts create mode 100644 frontend/app/shared/utils/customFields.ts delete mode 100644 frontend/app/shared/utils/entityCustomFieldLogic.ts delete mode 100644 frontend/tests/shared/customFieldFormUtils.test.ts diff --git a/frontend/app/components/ComponentItem.vue b/frontend/app/components/ComponentItem.vue index d21fed9..cd324bf 100644 --- a/frontend/app/components/ComponentItem.vue +++ b/frontend/app/components/ComponentItem.vue @@ -335,13 +335,8 @@ import { } from '~/shared/utils/documentDisplayUtils' import { useEntityDocuments } from '~/composables/useEntityDocuments' import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay' -import { useEntityCustomFields } from '~/composables/useEntityCustomFields' -import { - mergeFieldDefinitionsWithValues, - dedupeMergedFields, - resolveCustomFieldId, - resolveFieldId, -} from '~/shared/utils/entityCustomFieldLogic' +import { useCustomFields } from '~/composables/useCustomFields' +import { mergeDefinitionsWithValues } from '~/shared/utils/customFields' const props = defineProps({ component: { type: Object, required: true }, @@ -377,25 +372,81 @@ const { } = useEntityProductDisplay({ entity: () => props.component }) const { - displayedCustomFields, - updateCustomField: updateComponentCustomField, -} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' }) + updateCustomFieldValue: updateCustomFieldValueApi, + upsertCustomFieldValue, +} = useCustomFields() +const { showSuccess, showError } = useToast() +// Parent already pre-merges standalone custom fields into props.component.customFields +const displayedCustomFields = computed(() => { + const fields = props.component?.customFields + return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : [] +}) + +const updateComponentCustomField = async (field) => { + if (!field || field.readOnly) return + + const e = props.component + const fieldValueId = field.customFieldValueId + + if (fieldValueId) { + const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' }) + if (result.success) { + showSuccess(`Champ "${field.name}" mis à jour avec succès`) + } else { + showError(`Erreur lors de la mise à jour du champ "${field.name}"`) + } + return + } + + if (!e?.id) { + showError('Impossible de créer la valeur pour ce champ') + return + } + + const metadata = field.customFieldId ? undefined : { + customFieldName: field.name, + customFieldType: field.type, + customFieldRequired: field.required, + customFieldOptions: field.options, + } + const result = await upsertCustomFieldValue( + field.customFieldId, + 'composant', + e.id, + field.value ?? '', + metadata, + ) + + if (result.success) { + const newValue = result.data + if (newValue?.id) { + field.customFieldValueId = newValue.id + field.value = newValue.value ?? field.value ?? '' + if (newValue.customField?.id) { + field.customFieldId = newValue.customField.id + } + } + showSuccess(`Champ "${field.name}" créé avec succès`) + } else { + showError(`Erreur lors de la sauvegarde du champ "${field.name}"`) + } +} + +// Context fields are NOT pre-merged — merge locally const mergedContextFields = computed(() => { const definitions = props.component?.contextCustomFields ?? [] const values = props.component?.contextCustomFieldValues ?? [] if (!definitions.length && !values.length) return [] - return dedupeMergedFields( - mergeFieldDefinitionsWithValues(definitions, values), - ) + return mergeDefinitionsWithValues(definitions, values) }) const queueContextCustomFieldUpdate = (field, value) => { const linkId = props.component?.linkId if (!linkId || !field) return - const customFieldId = resolveCustomFieldId(field) - const customFieldValueId = resolveFieldId(field) + const customFieldId = field.customFieldId + const customFieldValueId = field.customFieldValueId if (!customFieldId && !customFieldValueId) return field.value = value @@ -405,7 +456,7 @@ const queueContextCustomFieldUpdate = (field, value) => { fieldId: customFieldId, customFieldValueId, value: value ?? '', - fieldName: field.name || field.customField?.name || 'Champ contextuel', + fieldName: field.name || 'Champ contextuel', }) } diff --git a/frontend/app/components/PieceItem.vue b/frontend/app/components/PieceItem.vue index c715deb..1bb461d 100644 --- a/frontend/app/components/PieceItem.vue +++ b/frontend/app/components/PieceItem.vue @@ -319,16 +319,10 @@ import { uniqueConstructeurIds, parseConstructeurLinksFromApi, } from '~/shared/constructeurUtils' -import { - resolveFieldId, - resolveFieldReadOnly, - resolveCustomFieldId, - mergeFieldDefinitionsWithValues, - dedupeMergedFields, -} from '~/shared/utils/entityCustomFieldLogic' +import { mergeDefinitionsWithValues } from '~/shared/utils/customFields' +import { useCustomFields } from '~/composables/useCustomFields' import { useEntityDocuments } from '~/composables/useEntityDocuments' import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay' -import { useEntityCustomFields } from '~/composables/useEntityCustomFields' const props = defineProps({ piece: { type: Object, required: true }, @@ -392,25 +386,81 @@ const { } = useEntityProductDisplay({ entity: () => props.piece, selectedProduct }) const { - displayedCustomFields, - updateCustomField, -} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' }) + updateCustomFieldValue: updateCustomFieldValueApi, + upsertCustomFieldValue, +} = useCustomFields() +const { showSuccess, showError } = useToast() +// Parent already pre-merges standalone custom fields into props.piece.customFields +const displayedCustomFields = computed(() => { + const fields = props.piece?.customFields + return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : [] +}) + +const updateCustomField = async (field) => { + if (!field || field.readOnly) return + + const e = props.piece + const fieldValueId = field.customFieldValueId + + if (fieldValueId) { + const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' }) + if (result.success) { + showSuccess(`Champ "${field.name}" mis à jour avec succès`) + } else { + showError(`Erreur lors de la mise à jour du champ "${field.name}"`) + } + return + } + + if (!e?.id) { + showError('Impossible de créer la valeur pour ce champ') + return + } + + const metadata = field.customFieldId ? undefined : { + customFieldName: field.name, + customFieldType: field.type, + customFieldRequired: field.required, + customFieldOptions: field.options, + } + const result = await upsertCustomFieldValue( + field.customFieldId, + 'piece', + e.id, + field.value ?? '', + metadata, + ) + + if (result.success) { + const newValue = result.data + if (newValue?.id) { + field.customFieldValueId = newValue.id + field.value = newValue.value ?? field.value ?? '' + if (newValue.customField?.id) { + field.customFieldId = newValue.customField.id + } + } + showSuccess(`Champ "${field.name}" créé avec succès`) + } else { + showError(`Erreur lors de la sauvegarde du champ "${field.name}"`) + } +} + +// Context fields are NOT pre-merged — merge locally const mergedContextFields = computed(() => { const definitions = props.piece?.contextCustomFields ?? [] const values = props.piece?.contextCustomFieldValues ?? [] if (!definitions.length && !values.length) return [] - return dedupeMergedFields( - mergeFieldDefinitionsWithValues(definitions, values), - ) + return mergeDefinitionsWithValues(definitions, values) }) const queueContextCustomFieldUpdate = (field, value) => { const linkId = props.piece?.linkId if (!linkId || !field) return - const customFieldId = resolveCustomFieldId(field) - const customFieldValueId = resolveFieldId(field) + const customFieldId = field.customFieldId + const customFieldValueId = field.customFieldValueId if (!customFieldId && !customFieldValueId) return field.value = value @@ -420,7 +470,7 @@ const queueContextCustomFieldUpdate = (field, value) => { fieldId: customFieldId, customFieldValueId, value: value ?? '', - fieldName: field.name || field.customField?.name || 'Champ contextuel', + fieldName: field.name || 'Champ contextuel', }) } @@ -544,8 +594,8 @@ const handleProductChange = async (value) => { // --- Custom field event handlers --- const handleCustomFieldInput = (field, value) => { - if (resolveFieldReadOnly(field)) return - const fieldValueId = resolveFieldId(field) + if (field.readOnly) return + const fieldValueId = field.customFieldValueId if (!fieldValueId) return const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId) if (fieldValue) fieldValue.value = value @@ -553,7 +603,7 @@ const handleCustomFieldInput = (field, value) => { const handleCustomFieldBlur = async (field) => { await updateCustomField(field) - const cfId = field?.customFieldId || field?.customField?.id || null + const cfId = field?.customFieldId || null if (cfId || field?.customFieldValueId) { emit('custom-field-update', { fieldId: cfId, diff --git a/frontend/app/components/common/CustomFieldDisplay.vue b/frontend/app/components/common/CustomFieldDisplay.vue index 5dcab65..ba6166f 100644 --- a/frontend/app/components/common/CustomFieldDisplay.vue +++ b/frontend/app/components/common/CustomFieldDisplay.vue @@ -9,15 +9,15 @@
@@ -26,32 +26,32 @@