Files
Inventory/docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md
Matthieu 73c06169f3 docs(custom-fields) : corrige la source de verite (table custom_fields unique)
L'investigation initiale supposait des customFields JSON dans les
skeleton_*_requirements ; en realite SkeletonStructureService traduit
les customFields du payload ModelType en entites CustomField stockees
dans la table custom_fields. Le SQL est donc un simple SELECT DISTINCT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:33:06 +02:00

274 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Custom Field Name Autocomplete — Design
**Date** : 2026-05-11
**Statut** : Design validé, prêt pour planification
## Contexte et problème
Aujourd'hui dans Inventory, on définit des "champs personnalisés" (custom fields) à plusieurs endroits :
- Au niveau d'une **machine** (entité `CustomField` avec FK `machineId`)
- Au niveau d'un **ModelType**, dans 3 contextes (composant / pièce / produit) : entité `CustomField` avec respectivement `typeComposantId`, `typePieceId`, `typeProductId`.
Côté frontend, l'éditeur de structure d'un ModelType expose des `customFields` array sur chaque node, mais lors du save le backend (`SkeletonStructureService::updateCustomFields`) traduit ça en entités `CustomField` persistées dans la table unique `custom_fields`. La table `custom_fields` est donc **l'unique source de vérité** pour tous les noms de champs perso de l'application.
À chaque création/modification, l'utilisateur saisit librement un **nom** dans un `<input>` texte. Conséquence : les mêmes concepts métier finissent écrits différemment (« Numéro de série », « N° série », « Num serie »), ce qui empêche toute uniformisation et complique les rapports/recherches.
**Objectif** : proposer une autocomplétion sur le nom du champ qui suggère les noms déjà existants dans la base, tout en autorisant la création libre d'un nouveau nom.
## Décisions clés
| Question | Choix retenu |
|----------|--------------|
| Scope des suggestions | **Cross-entité** (machine + composant + pièce + produit confondus) — objectif d'uniformisation globale |
| Comportement utilisateur | **Création libre** : si l'utilisateur tape un nom sans cliquer sur une suggestion, on garde son texte tel quel |
| Suggestion du type | Non : la suggestion porte uniquement sur le nom |
| Compteur d'usage | Non : on reste simple, juste les noms triés alpha |
| Pattern UI | **Étendre `SearchSelect.vue`** existant avec un prop `creatable` plutôt que datalist natif ou nouveau composant — cohérence visuelle avec le reste de l'app |
## Architecture
```
Backend Frontend
───────── ─────────
GET /api/custom-fields/names ◄── useCustomFieldNameSuggestions()
│ │ (cache module-level)
│ returns: ["Numéro...", ...] │
▼ ▼
SELECT DISTINCT name CustomFieldNameInput.vue (wrapper)
FROM custom_fields │
│ utilise
SearchSelect.vue (creatable=true)
│ utilisé par
┌───────────────┼─────────────────────┐
│ │ │
MachineCustomFieldDef- StructureNodeEditor PieceModelStructure-
Editor (composants) Editor (pièces)
MachineCustomFieldsCard
(édition inline d'une machine)
```
## Backend
### Nouveau endpoint : `GET /api/custom-fields/names`
**Fichier** : `src/Controller/CustomFieldNamesController.php`
**Sécurité** : `ROLE_VIEWER` (cohérent avec les autres GET sur `CustomField`).
**Format de réponse** : tableau JSON plat de strings, trié alphabétique, dédupliqué (case-insensitive sur l'union).
```json
["Numéro de série", "Puissance", "Tension nominale"]
```
> Pas de wrapper `hydra:` — ce n'est pas une resource API Platform mais un endpoint utilitaire.
### Implémentation SQL
Le controller exécute une seule requête SQL brute via `Doctrine\DBAL\Connection` :
```sql
SELECT DISTINCT name FROM custom_fields
WHERE name IS NOT NULL AND name <> ''
ORDER BY name ASC
```
> Toutes les sources de noms (machines, ModelType×composant/pièce/produit) convergent dans la même table `custom_fields` via les FKs `machineId`/`typeComposantId`/`typePieceId`/`typeProductId`. Pas de jointure ni de parsing JSON nécessaire — un simple `SELECT DISTINCT` suffit.
### Pas de cache HTTP
La liste change quand un utilisateur crée un nouveau champ perso. Le cache se fait côté frontend (cf. composable). Pas de header `Cache-Control` particulier.
## Frontend
### 1. Extension de `SearchSelect.vue`
**Nouveau prop** :
```js
creatable: {
type: Boolean,
default: false // strict par défaut → zéro régression sur les 10+ usages actuels
}
```
**Changements de comportement quand `creatable=true`** :
| Aspect | Mode strict (défaut) | Mode `creatable` |
|--------|---------------------|------------------|
| `modelValue` | ID de l'option | **Texte libre** (le nom est la valeur) |
| `handleInput` | emit `'search'` uniquement | emit aussi `'update:modelValue'` en temps réel |
| `closeDropdown` (blur) | reset au label de l'option sélectionnée | **garde** le texte tapé |
| Dropdown | liste filtrée | liste filtrée + une ligne **« Créer XYZ »** en bas si le texte tapé ne matche aucune option (icône `+`, texte plus discret) |
| Clavier | ↑/↓/Enter sélectionne une option | ↑/↓ navigue, Enter valide soit l'option soit le « Créer XYZ » |
**Garanti** : mode strict 100% inchangé → les 10+ usages actuels de `SearchSelect` ne sont pas affectés.
### 2. Composable `useCustomFieldNameSuggestions`
**Fichier** : `frontend/app/composables/useCustomFieldNameSuggestions.ts`
```ts
import { ref } from 'vue'
const cache = ref<string[] | null>(null)
const loading = ref(false)
interface Deps {
api: ReturnType<typeof useApi>
}
export function useCustomFieldNameSuggestions(deps: Deps) {
const { api } = deps
async function load(force = false) {
if (cache.value && !force) return cache.value
if (loading.value) return cache.value ?? []
loading.value = true
try {
cache.value = await api<string[]>('/api/custom-fields/names')
return cache.value
} finally {
loading.value = false
}
}
function invalidate() {
cache.value = null
}
return {
suggestions: cache,
loading,
load,
invalidate,
}
}
```
**Choix de design** :
- **Cache module-level** (déclaré hors de la fonction) → partagé entre toutes les instances du composable, donc une seule requête HTTP pour toute l'app.
- **Lazy load** au 1er focus → pas de surcoût au démarrage.
- **Invalidation manuelle** via `invalidate()` → appelée après chaque save de champ perso pour rafraîchir.
- **Pattern `Deps`** → cohérent avec la convention du projet (`interface Deps`, injection de `useApi`).
### 3. Composant wrapper `CustomFieldNameInput.vue`
**Fichier** : `frontend/app/components/common/CustomFieldNameInput.vue`
```vue
<template>
<SearchSelect
v-model="modelValue"
:options="options"
:placeholder="placeholder"
option-value="name"
option-label="name"
creatable
size="xs"
@focus="ensureLoaded"
/>
</template>
<script setup lang="ts">
import SearchSelect from './SearchSelect.vue'
import { computed } from 'vue'
const props = defineProps<{
modelValue: string
placeholder?: string
}>()
defineEmits<{ 'update:modelValue': [value: string] }>()
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
const ensureLoaded = () => load()
</script>
```
**Pourquoi un wrapper** : encapsule le branchement (load, map, props `creatable`/`option-value`) → impossible de l'oublier dans un consommateur, et tous les paramètres restent uniformes par construction.
### 4. Migration des éditeurs
Dans chacun des fichiers ci-dessous, **remplacer le `<input v-model="field.name">`** par :
```vue
<CustomFieldNameInput v-model="field.name" placeholder="Nom du champ" />
```
Fichiers concernés :
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (ligne ~36-41)
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (ligne ~57)
- `frontend/app/components/PieceModelStructureEditor.vue` (ligne ~97-102)
- `frontend/app/components/StructureNodeEditor.vue` (ligne ~106-111)
> Note : `CustomFieldNameInput` étant dans `components/common/`, il est auto-importé par Nuxt — pas besoin d'`import` dans les consommateurs.
### 5. Invalidation du cache
Après chaque save réussi de champs perso, appeler `invalidate()` pour que la prochaine ouverture du dropdown récupère les nouveaux noms.
| Endroit | Quand |
|---------|-------|
| `useMachineCustomFieldDefs` (composable existant) | Après PATCH/POST réussi des custom fields machine |
| `ModelTypeForm.vue` (save ModelType + skeleton requirements) | Après sauvegarde du ModelType |
Pattern :
```ts
const { invalidate } = useCustomFieldNameSuggestions({ api: useApi() })
async function save() {
await api(...) // sauvegarde existante
invalidate() // ← nouveau
}
```
> On n'a pas besoin d'invalider lors d'une simple modification d'un nom existant (au pire la liste a une suggestion en trop, ce n'est pas un bug). On invalide à chaque save pour rester simple.
## Comportement utilisateur
### Cas 1 — Création d'un nouveau champ
1. User clique « Ajouter un champ »
2. Un input vide apparaît
3. User clique dedans → dropdown s'ouvre avec tous les noms existants triés alpha
4. User tape « num » → dropdown filtre sur `["Numéro de lot", "Numéro de série"]`
5. User clique sur « Numéro de série » → l'input se remplit exactement avec « Numéro de série »
6. **OU** user tape « num XYZ » et clique ailleurs → l'input garde « num XYZ », une ligne « Créer 'num XYZ' » lui suggère explicitement la création
### Cas 2 — Modification d'un nom existant
1. User voit un champ existant nommé « Numéro de série »
2. User clique dans l'input → dropdown s'ouvre, suggestions filtrées sur « Numéro de série »
3. User efface et tape « Tension » → dropdown filtre, il peut sélectionner ou retaper librement
4. Pas de fusion automatique des données — chaque champ reste indépendant
### Cas 3 — Plusieurs inputs visibles en même temps
- Toutes les instances partagent le même cache (module-level) → une seule requête HTTP pour la session
- Si user crée un champ « Nouveau nom » dans l'input A et passe à l'input B sans rafraîchir, « Nouveau nom » apparaîtra dans les suggestions de B dès que `invalidate()` a été appelé au save
## Hors-scope
- **Renommage en cascade** : si on change un nom partout (ex: « Num serie » → « Numéro de série » pour les unifier), pas de migration automatique des champs existants. C'est un travail manuel, ou un futur outil dédié.
- **Compteur d'usage** : peut être ajouté plus tard sans changer l'API (format de réponse extensible).
- **Suggestion du type** : on ne propose pas un type par défaut quand l'utilisateur sélectionne une suggestion. À évaluer si besoin émerge.
- **Tests** : pas de tests Vue dans le projet actuellement → validation manuelle. Côté backend, un test PHPUnit du controller est recommandé (cf. plan d'implémentation).
## Fichiers impactés (résumé)
### Nouveaux fichiers
- `src/Controller/CustomFieldNamesController.php`
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
- `frontend/app/components/common/CustomFieldNameInput.vue`
### Fichiers modifiés
- `frontend/app/components/common/SearchSelect.vue` (ajout prop `creatable`)
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (remplacer input)
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (remplacer input)
- `frontend/app/components/PieceModelStructureEditor.vue` (remplacer input)
- `frontend/app/components/StructureNodeEditor.vue` (remplacer input)
- `frontend/app/composables/useMachineCustomFieldDefs.ts` (ajout `invalidate()` après save)
- `frontend/app/components/model-types/ModelTypeForm.vue` (ajout `invalidate()` après save)