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>
984 lines
36 KiB
Markdown
984 lines
36 KiB
Markdown
# 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
|
|
readOnly?: boolean
|
|
/** options joined by newline — used by category editor textareas (v-model) */
|
|
optionsText?: 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<string, CustomFieldValue>()
|
|
const valueByName = new Map<string, CustomFieldValue>()
|
|
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<string>()
|
|
const matchedNames = new Set<string>()
|
|
|
|
// 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)
|
|
|
|
const optionsText = def.options.length ? def.options.join('\n') : undefined
|
|
|
|
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,
|
|
optionsText,
|
|
}
|
|
}
|
|
|
|
// 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 ?? '',
|
|
optionsText,
|
|
}
|
|
})
|
|
|
|
// 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
|
|
|
|
const orphanOptionsText = v.customField.options.length ? v.customField.options.join('\n') : undefined
|
|
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,
|
|
optionsText: orphanOptionsText,
|
|
})
|
|
}
|
|
|
|
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 (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
|
|
}
|
|
|
|
/** 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.
|
|
*
|
|
* 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 { ref, watch, 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<any[]>
|
|
/** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
|
|
values: MaybeRef<any[]>
|
|
/** Entity type for API upsert calls */
|
|
entityType: CustomFieldEntityType
|
|
/** Entity ID for API upsert calls */
|
|
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) {
|
|
const { entityType, context } = options
|
|
const {
|
|
updateCustomFieldValue: updateApi,
|
|
upsertCustomFieldValue,
|
|
} = useCustomFields()
|
|
const { showSuccess, showError } = useToast()
|
|
|
|
// 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)
|
|
_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)
|
|
})
|
|
|
|
// 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)
|
|
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 — 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) {
|
|
// 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
|
|
}
|
|
|
|
showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
|
|
return false
|
|
}
|
|
|
|
// Save all fields that have values
|
|
const saveAll = async (): Promise<string[]> => {
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
return failed
|
|
}
|
|
|
|
return {
|
|
/** All merged fields filtered by context */
|
|
fields,
|
|
/** All merged fields (unfiltered) */
|
|
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,
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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 shared components + standalone composables (atomic batch)
|
|
|
|
**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:
|
|
```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 typed `CustomFieldInput`, direct property access (`.name`, `.type`, `.options`, `.value`, `.readOnly`) replaces the `resolveFieldXxx()` wrapper functions. Read the full file and adapt the template accordingly.
|
|
|
|
- [ ] **Step 3: Migrate `useComponentEdit.ts`**
|
|
|
|
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
|
|
|
|
- [ ] **Step 4: Migrate `useComponentCreate.ts`**
|
|
|
|
Same pattern. Definitions come from `selectedType.value?.structure?.customFields`, values are empty `[]` for creation.
|
|
|
|
- [ ] **Step 5: Migrate `usePieceEdit.ts`**
|
|
|
|
Same pattern. Definitions from piece type structure, values from `piece.customFieldValues`.
|
|
|
|
- [ ] **Step 6: Run lint + typecheck**
|
|
|
|
```bash
|
|
cd frontend && npm run lint:fix && npx nuxi typecheck
|
|
```
|
|
|
|
- [ ] **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 8: Commit**
|
|
|
|
```bash
|
|
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 5: 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 6: 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` — pure custom field functions**
|
|
|
|
Replace the following pure-CF functions (~168 lines) with the new module:
|
|
|
|
| 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 |
|
|
|
|
- [ ] **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**
|
|
|
|
```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 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`
|
|
- 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 { useCustomFields } from '~/composables/useCustomFields'
|
|
import { fieldKey, formatValueForDisplay, hasDisplayableValue, 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)
|
|
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 `props.piece.customFields`.
|
|
|
|
- [ ] **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 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:**
|
|
- 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"
|
|
```
|