From 73c06169f3ef0b10ace404001404c29488c5d876 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 11 May 2026 13:33:06 +0200 Subject: [PATCH] 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) --- ...26-05-11-custom-field-name-autocomplete.md | 926 ++++++++++++++++++ ...1-custom-field-name-autocomplete-design.md | 49 +- 2 files changed, 943 insertions(+), 32 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-11-custom-field-name-autocomplete.md diff --git a/docs/superpowers/plans/2026-05-11-custom-field-name-autocomplete.md b/docs/superpowers/plans/2026-05-11-custom-field-name-autocomplete.md new file mode 100644 index 0000000..3e64e66 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-custom-field-name-autocomplete.md @@ -0,0 +1,926 @@ +# Custom Field Name Autocomplete — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ajouter une autocomplétion sur les noms de champs personnalisés dans tous les éditeurs (machine + ModelType) pour permettre la réutilisation des noms existants tout en gardant la possibilité d'en créer de nouveaux. + +**Architecture:** +- **Backend** : un endpoint utilitaire `GET /api/custom-fields/names` qui retourne la liste plate des noms distincts de la table `custom_fields`. +- **Frontend** : extension de `SearchSelect.vue` avec un prop `creatable`, composable `useCustomFieldNameSuggestions` avec cache module-level, composant wrapper `CustomFieldNameInput.vue`, migration de 4 éditeurs. + +**Tech Stack:** Symfony 8 + API Platform + Doctrine DBAL, Nuxt 4 + Vue 3 Composition API + TypeScript, DaisyUI. + +**Référence spec:** `docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md` + +--- + +## Task 1: Backend — Controller `CustomFieldNamesController` + +**Files:** +- Create: `src/Controller/CustomFieldNamesController.php` + +- [ ] **Step 1: Créer le controller** + +Créer `src/Controller/CustomFieldNamesController.php` avec le contenu : + +```php + '' + ORDER BY name ASC + SQL; + + $names = $this->connection->fetchFirstColumn($sql); + + return new JsonResponse($names); + } +} +``` + +- [ ] **Step 2: Vérifier que la route est bien exposée** + +Exécuter : +```bash +docker exec -u www-data php-inventory-apache php bin/console debug:router | grep custom-fields/names +``` + +Attendu : une ligne contenant `GET /api/custom-fields/names` et `api_custom_fields_names`. + +- [ ] **Step 3: Tester manuellement le endpoint** + +```bash +curl -s -b "$(curl -s -c - -X POST http://localhost:8081/api/session/profile \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin"}' | grep PHPSESSID)" \ + http://localhost:8081/api/custom-fields/names | head -c 500 +``` + +> Si la session est galère à monter en curl, on peut tester via le navigateur après login (DevTools → fetch). + +Attendu : un tableau JSON `["Numéro de série", "Tension", ...]` (ou `[]` si la base de dev est vide). + +- [ ] **Step 4: Lancer php-cs-fixer** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Controller/CustomFieldNamesController.php +git commit -m "feat(custom-fields) : ajoute endpoint GET /api/custom-fields/names + +Retourne la liste plate des noms de champs perso distincts (table +custom_fields), pour alimenter une autocompletion cote frontend." +``` + +> ⚠️ Le pre-commit hook va lancer PHPUnit. Si des tests existants échouent (peu probable car on n'a touché à rien d'existant), résoudre le souci avant de continuer. + +--- + +## Task 2: Backend — Test PHPUnit du endpoint + +**Files:** +- Create: `tests/Api/Controller/CustomFieldNamesControllerTest.php` + +- [ ] **Step 1: Repérer un test existant à copier-coller pour le style** + +Lire `tests/Api/Controller/HealthCheckController*Test.php` ou un controller simple existant pour récupérer le pattern (auth helpers, `ApiTestCase`). Adapter selon ce qu'on trouve. + +```bash +ls tests/Api/Controller/ | head +``` + +- [ ] **Step 2: Créer le test** + +Créer `tests/Api/Controller/CustomFieldNamesControllerTest.php` : + +```php +createUnauthenticatedClient(); + $client->request('GET', '/api/custom-fields/names'); + + self::assertResponseStatusCodeSame(401); + } + + public function testReturnsEmptyArrayWhenNoCustomFields(): void + { + $client = $this->createViewerClient(); + $client->request('GET', '/api/custom-fields/names'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertIsArray($data); + } + + public function testReturnsDistinctSortedNames(): void + { + // Crée 3 machines avec des CustomField : "Tension", "Numéro de série", "Tension" (doublon) + $machine1 = $this->createMachine(); + $this->createCustomField(['name' => 'Tension', 'machine' => $machine1]); + $this->createCustomField(['name' => 'Numéro de série', 'machine' => $machine1]); + + $machine2 = $this->createMachine(); + $this->createCustomField(['name' => 'Tension', 'machine' => $machine2]); // doublon + + $client = $this->createViewerClient(); + $client->request('GET', '/api/custom-fields/names'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + + self::assertContains('Tension', $data); + self::assertContains('Numéro de série', $data); + // Pas de doublon + self::assertSame(count(array_unique($data)), count($data)); + // Tri alpha + $sorted = $data; + sort($sorted, SORT_STRING); + self::assertSame($sorted, $data); + } +} +``` + +> Si la factory `createCustomField` n'a pas la signature attendue (1er argument = array), regarder `tests/AbstractApiTestCase.php` pour adapter aux helpers réels du projet. + +- [ ] **Step 3: Vérifier que les helpers utilisés existent** + +```bash +grep -n "createCustomField\|createMachine\|createViewerClient\|createUnauthenticatedClient" tests/AbstractApiTestCase.php | head +``` + +Si l'un des helpers manque ou a une autre signature, **adapter le test** plutôt que d'ajouter de nouveaux helpers. + +- [ ] **Step 4: Lancer le test ciblé** + +```bash +make test FILES=tests/Api/Controller/CustomFieldNamesControllerTest.php +``` + +Attendu : 3 tests OK. + +- [ ] **Step 5: Commit** + +```bash +git add tests/Api/Controller/CustomFieldNamesControllerTest.php +git commit -m "test(custom-fields) : ajoute test PHPUnit pour endpoint /api/custom-fields/names" +``` + +--- + +## Task 3: Frontend — Étendre `SearchSelect.vue` avec le prop `creatable` + +**Files:** +- Modify: `frontend/app/components/common/SearchSelect.vue` + +- [ ] **Step 1: Ajouter le prop `creatable` au composant** + +Dans le bloc `defineProps` (lignes ~91-141), ajouter après le prop `serverSearch` : + +```js +creatable: { + type: Boolean, + default: false +} +``` + +- [ ] **Step 2: Modifier `handleInput` pour emit en mode creatable** + +Remplacer la fonction `handleInput` (lignes ~284-289) par : + +```js +function handleInput () { + if (!openDropdown.value) { + openDropdown.value = true + } + if (props.creatable) { + emit('update:modelValue', searchTerm.value) + } + emit('search', searchTerm.value) +} +``` + +- [ ] **Step 3: Modifier `closeDropdown` pour ne pas reset en mode creatable** + +Remplacer la fonction `closeDropdown` (lignes ~297-304) par : + +```js +function closeDropdown () { + openDropdown.value = false + if (props.creatable) { + return // garde le texte tapé tel quel + } + if (searchTerm.value.trim() === '' && selectedOption.value) { + emit('update:modelValue', '') + } else if (selectedOption.value) { + searchTerm.value = resolveLabel(selectedOption.value) + } +} +``` + +- [ ] **Step 4: Ajouter une computed `creatableSuggestion`** + +Dans le bloc ` +``` + +> `SearchSelect` n'expose pas nativement un événement `@focus`. Vérifier dans la Task 3 si on l'a ajouté ou si on doit charger autrement. + +- [ ] **Step 2: Exposer `@focus` depuis `SearchSelect.vue`** + +Retourner sur `SearchSelect.vue` et vérifier si `@focus` est propagé. Si non, modifier le handler `handleFocus` (ligne ~267-272) pour également émettre : + +```js +const emit = defineEmits(['update:modelValue', 'search', 'focus']) + +function handleFocus () { + openDropdown.value = true + if (searchTerm.value === '' && selectedOption.value) { + searchTerm.value = resolveLabel(selectedOption.value) + } + emit('focus') +} +``` + +Ajouter `'focus'` à la liste des emits si pas déjà présent. + +- [ ] **Step 3: Vérifier le typecheck** + +```bash +cd frontend && npx nuxi typecheck +``` + +- [ ] **Step 4: Vérifier l'auto-import Nuxt** + +Le composant étant dans `components/common/`, Nuxt devrait l'auto-importer. Vérifier après build : + +```bash +cd frontend && npm run dev +``` + +Sans erreur de référence `CustomFieldNameInput is not defined` quand on l'utilisera dans les tâches suivantes. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/app/components/common/CustomFieldNameInput.vue frontend/app/components/common/SearchSelect.vue +git commit -m "feat(custom-fields) : ajoute CustomFieldNameInput wrapper + +Encapsule SearchSelect en mode creatable, branche useCustomFieldName- +Suggestions, charge la liste au focus. Permet de remplacer un simple + par +dans les editeurs de champs perso." +``` + +--- + +## Task 6: Frontend — Migrer `MachineCustomFieldDefEditor.vue` + +**Files:** +- Modify: `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` + +- [ ] **Step 1: Remplacer l'input du nom** + +Dans `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`, localiser les lignes 36-41 : + +```vue + +``` + +Remplacer par : + +```vue + +``` + +- [ ] **Step 2: Vérifier le typecheck** + +```bash +cd frontend && npx nuxi typecheck +``` + +- [ ] **Step 3: Test manuel rapide** + +Ouvrir une machine en édition, ajouter un champ perso, vérifier que l'input : +1. Affiche un dropdown au focus avec les noms existants +2. Filtre quand on tape +3. Sélection d'une suggestion → input rempli +4. Texte libre + blur → garde le texte tapé + +- [ ] **Step 4: Commit** + +```bash +git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue +git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldDefEditor" +``` + +--- + +## Task 7: Frontend — Migrer `MachineCustomFieldsCard.vue` + +**Files:** +- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue` + +- [ ] **Step 1: Remplacer l'input du nom** + +Dans `frontend/app/components/machine/MachineCustomFieldsCard.vue`, localiser les lignes ~53-59 : + +```vue + +``` + +⚠️ **Attention** : cet input utilise `:value` + `@blur` (pas `v-model`) parce qu'il déclenche une mise à jour seulement au blur (avec un appel API). + +Remplacer par : + +```vue + +``` + +> Le `@update:model-value` se déclenchera à chaque changement (donc à chaque caractère tapé en mode creatable). Si ce comportement génère trop d'appels API, on peut wrapper avec un `debounce` côté `handleDefinitionUpdate`. Pour l'instant, on garde simple. + +- [ ] **Step 2: Vérifier le comportement de `handleDefinitionUpdate`** + +Vérifier que cette fonction est idempotente (rejouer un même nom = pas d'effet). Cherche la fonction dans le composant et confirme qu'elle compare l'ancienne/nouvelle valeur avant d'appeler l'API. + +```bash +grep -n "handleDefinitionUpdate" frontend/app/components/machine/MachineCustomFieldsCard.vue +``` + +Si elle ne dédoublonne pas, l'ajout d'un test rapide `if (field.name === value) return` peut éviter des PATCH inutiles. + +- [ ] **Step 3: Vérifier le typecheck** + +```bash +cd frontend && npx nuxi typecheck +``` + +- [ ] **Step 4: Test manuel** + +Sur la page d'une machine, modifier inline un nom de champ perso → vérifier que ça déclenche un PATCH unique (DevTools Network). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/app/components/machine/MachineCustomFieldsCard.vue +git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldsCard" +``` + +--- + +## Task 8: Frontend — Migrer `PieceModelStructureEditor.vue` + +**Files:** +- Modify: `frontend/app/components/PieceModelStructureEditor.vue` + +- [ ] **Step 1: Remplacer l'input du nom** + +Dans `frontend/app/components/PieceModelStructureEditor.vue`, localiser les lignes 97-102 : + +```vue + +``` + +Remplacer par : + +```vue + +``` + +- [ ] **Step 2: Vérifier le typecheck** + +```bash +cd frontend && npx nuxi typecheck +``` + +- [ ] **Step 3: Test manuel** + +Ouvrir un ModelType (catégorie composant ou skeleton), ajouter une pièce dans le skeleton, lui ajouter un champ perso → vérifier dropdown. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/app/components/PieceModelStructureEditor.vue +git commit -m "feat(custom-fields) : autocomplete sur le nom dans PieceModelStructureEditor" +``` + +--- + +## Task 9: Frontend — Migrer `StructureNodeEditor.vue` + +**Files:** +- Modify: `frontend/app/components/StructureNodeEditor.vue` + +- [ ] **Step 1: Remplacer l'input du nom** + +Dans `frontend/app/components/StructureNodeEditor.vue`, localiser les lignes 106-111 : + +```vue + +``` + +Remplacer par : + +```vue + +``` + +- [ ] **Step 2: Vérifier le typecheck** + +```bash +cd frontend && npx nuxi typecheck +``` + +- [ ] **Step 3: Test manuel** + +Ouvrir un ModelType de catégorie machine, naviguer dans la structure (composants/sous-composants), ajouter un champ perso à un node → vérifier dropdown. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/app/components/StructureNodeEditor.vue +git commit -m "feat(custom-fields) : autocomplete sur le nom dans StructureNodeEditor" +``` + +--- + +## Task 10: Frontend — Invalidation du cache après save + +**Files:** +- Modify: `frontend/app/composables/useMachineCustomFieldDefs.ts` +- Modify: `frontend/app/components/model-types/ModelTypeForm.vue` + +- [ ] **Step 1: Repérer le save dans `useMachineCustomFieldDefs.ts`** + +```bash +grep -n "POST\|PATCH\|api(" frontend/app/composables/useMachineCustomFieldDefs.ts | head -20 +``` + +Localiser la(es) fonction(s) qui sauvegarde les champs perso (probablement `saveDefinitions`, `addCustomFields`, etc.). + +- [ ] **Step 2: Ajouter l'invalidation après save** + +Dans `useMachineCustomFieldDefs.ts`, en haut du fichier, après les imports existants : + +```ts +import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions' +``` + +Dans le corps du composable, ajouter (à placer près des autres `use*` calls) : + +```ts +const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() }) +``` + +Puis, après chaque save réussi (à la fin du `try` du POST/PATCH des definitions), appeler : + +```ts +invalidateCustomFieldNames() +``` + +> Identifier précisément les points de save dans le fichier — probablement 1 ou 2 endroits maximum. + +- [ ] **Step 3: Repérer le save dans `ModelTypeForm.vue`** + +```bash +grep -n "POST\|PATCH\|api(\|emit('saved')\|emit('save'" frontend/app/components/model-types/ModelTypeForm.vue | head -20 +``` + +- [ ] **Step 4: Ajouter l'invalidation après save** + +Dans le `