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