diff --git a/docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md b/docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md new file mode 100644 index 0000000..f78fbcf --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md @@ -0,0 +1,288 @@ +# 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 (entité `CustomField` avec FK `typeComposantId` / `typePieceId` / `typeProductId`) +- Au niveau des skeleton requirements d'un ModelType (JSON `customFields` dans `SkeletonPieceRequirement`, `SkeletonSubcomponentRequirement`, `SkeletonProductRequirement`) + +À 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...", ...] │ + ▼ ▼ +Union SQL : CustomFieldNameInput.vue (wrapper) + custom_fields │ + + skeleton_piece_requirements │ utilise + + skeleton_subcomponent_requirements│ + + skeleton_product_requirements ▼ + SearchSelect.vue (creatable=true) + ▲ + │ utilisé par + │ + ┌───────────────┼───────────────┐ + │ │ │ + MachineCustomFieldDefEditor│ StructureNodeEditor + │ + PieceModelStructureEditor +``` + +## 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 ( + SELECT name FROM custom_fields + UNION + SELECT jsonb_array_elements(customfields)->>'name' AS name + FROM skeleton_piece_requirements + WHERE customfields IS NOT NULL + UNION + SELECT jsonb_array_elements(customfields)->>'name' AS name + FROM skeleton_subcomponent_requirements + WHERE customfields IS NOT NULL + UNION + SELECT jsonb_array_elements(customfields)->>'name' AS name + FROM skeleton_product_requirements + WHERE customfields IS NOT NULL +) AS all_names +WHERE name IS NOT NULL AND name <> '' +ORDER BY name ASC +``` + +**À vérifier au moment de l'implémentation** : +- Le nom exact de la colonne JSON `customfields` en lowercase dans PostgreSQL (Doctrine = camelCase, PG = lowercase). +- Le nom exact des tables `skeleton_*_requirements` (à confirmer dans les migrations). +- Le type de la colonne (JSON vs JSONB) — `jsonb_array_elements` ne fonctionne que sur JSONB ; pour JSON utiliser `json_array_elements`. + +### 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)