# 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 `` 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(null)
const loading = ref(false)
interface Deps {
api: ReturnType
}
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('/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
```
**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 ``** par :
```vue
```
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)