docs : add custom fields simplification implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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)
|
||||
|
||||
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<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'
|
||||
}
|
||||
|
||||
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<CustomFieldInput[]>(() => {
|
||||
const defs = toValue(options.definitions)
|
||||
const vals = toValue(options.values)
|
||||
return mergeDefinitionsWithValues(defs, vals)
|
||||
})
|
||||
|
||||
// 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))
|
||||
|
||||
// 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
|
||||
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<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
|
||||
}
|
||||
|
||||
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"
|
||||
```
|
||||
Reference in New Issue
Block a user