# 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)
