diff --git a/CLAUDE.md b/CLAUDE.md index 66ca7b8..dea8c7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,6 +199,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER - **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)` - **Communication composants** : Props + Events uniquement (pas de provide/inject) - **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session +- **⚠️ Préfixe `/api`** : `useApi()` **prepend déjà** `apiBaseUrl` (= `/api` par défaut, cf. `nuxt.config.ts`). Les appels doivent donc utiliser des chemins **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` et **PAS** `api.get('/api/custom-fields/names')` (sinon 404 sur `/api/api/...`). - **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH - **Auth** : `useProfileSession` + middleware global `profile.global.ts` - **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client diff --git a/config/services.yaml b/config/services.yaml index d811e2d..1b1b920 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -69,3 +69,8 @@ when@test: autowire: true autoconfigure: true public: true + + App\Service\SkeletonStructureService: + autowire: true + autoconfigure: true + public: true 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 ` +``` + +**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) diff --git a/frontend/app/components/PieceModelStructureEditor.vue b/frontend/app/components/PieceModelStructureEditor.vue index 6d08a92..5509c73 100644 --- a/frontend/app/components/PieceModelStructureEditor.vue +++ b/frontend/app/components/PieceModelStructureEditor.vue @@ -94,12 +94,11 @@
- + size="xs" + /> + size="sm" + />