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) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-11 13:33:06 +02:00
parent 5e8e7947f0
commit 73c06169f3
2 changed files with 943 additions and 32 deletions

View File

@@ -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
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
final class CustomFieldNamesController
{
public function __construct(private readonly Connection $connection)
{
}
#[Route(
path: '/api/custom-fields/names',
name: 'api_custom_fields_names',
methods: ['GET']
)]
#[IsGranted('ROLE_VIEWER')]
public function __invoke(): JsonResponse
{
$sql = <<<'SQL'
SELECT DISTINCT name
FROM custom_fields
WHERE name IS NOT NULL AND name <> ''
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
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
final class CustomFieldNamesControllerTest extends AbstractApiTestCase
{
public function testReturns401WhenUnauthenticated(): void
{
$client = $this->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 `<script setup>`, après la `computed displayedOptions` (ligne ~173), ajouter :
```js
const creatableSuggestion = computed(() => {
if (!props.creatable) return null
const term = searchTerm.value.trim()
if (!term) return null
// Affiche "Créer ..." uniquement si aucune option exacte ne matche (case-insensitive)
const exists = baseOptions.value.some(option => {
const label = resolveLabel(option).toLowerCase()
return label === term.toLowerCase()
})
return exists ? null : term
})
```
- [ ] **Step 5: Afficher la ligne "Créer ..." dans le template**
Localiser le bloc dropdown (lignes ~40-81). Juste **après** la `<ul>` qui contient les options (donc juste avant la fermeture du `<div v-if="openDropdown">`), ajouter :
```vue
<button
v-if="creatableSuggestion"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
@click="confirmCreatable"
>
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
Créer « {{ creatableSuggestion }} »
</button>
```
Ajouter l'import en haut :
```js
import IconLucidePlus from '~icons/lucide/plus'
```
- [ ] **Step 6: Ajouter la fonction `confirmCreatable`**
Après `clearSelection` (ligne ~295), ajouter :
```js
function confirmCreatable () {
if (creatableSuggestion.value) {
emit('update:modelValue', creatableSuggestion.value)
}
openDropdown.value = false
}
```
- [ ] **Step 7: Ajuster la sync `searchTerm` ↔ `modelValue` en mode creatable**
Localiser le `watch` sur `modelValue` (lignes ~194-202). Remplacer par :
```js
watch(
() => props.modelValue,
() => {
if (props.creatable) {
if (searchTerm.value !== props.modelValue) {
searchTerm.value = String(props.modelValue ?? '')
}
return
}
if (!openDropdown.value) {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
},
{ immediate: true }
)
```
> En mode creatable, `modelValue` et `searchTerm` reflètent la même chose (le texte tapé) — on évite juste la boucle infinie en testant l'égalité.
- [ ] **Step 8: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
Attendu : 0 errors.
- [ ] **Step 9: Lancer ESLint**
```bash
cd frontend && npm run lint:fix
```
- [ ] **Step 10: Test de non-régression manuel**
Vérifier qu'un usage existant de `SearchSelect` (par exemple sur la page `frontend/app/pages/index.vue` ou similaire — chercher `<SearchSelect`) fonctionne toujours **sans** le prop `creatable` : le comportement strict doit être identique à avant.
```bash
cd frontend && grep -rln "SearchSelect" app/ | head -5
```
Ouvrir un de ces écrans en dev et vérifier que :
- La sélection d'une option marche
- Le blur sans sélection reset au label précédent (= mode strict inchangé)
- [ ] **Step 11: Commit**
```bash
git add frontend/app/components/common/SearchSelect.vue
git commit -m "feat(search-select) : ajoute prop creatable pour autoriser la saisie libre
En mode creatable=true, le composant emit le texte tape en temps reel
et ne reset plus au blur. Une ligne 'Creer XYZ' apparait quand le texte
ne matche aucune option. Mode strict (defaut) inchange."
```
---
## Task 4: Frontend — Composable `useCustomFieldNameSuggestions`
**Files:**
- Create: `frontend/app/composables/useCustomFieldNameSuggestions.ts`
- [ ] **Step 1: Vérifier le pattern `useApi` existant**
```bash
cat frontend/app/composables/useApi.ts | head -30
```
Noter la signature exacte (`<T>(path, opts?) => Promise<T>` ou autre) pour l'adapter.
- [ ] **Step 2: Créer le composable**
Créer `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): Promise<string[]> {
if (cache.value && !force) return cache.value
if (loading.value) return cache.value ?? []
loading.value = true
try {
const result = await api<string[]>('/api/custom-fields/names')
cache.value = Array.isArray(result) ? result : []
return cache.value
} catch (err) {
console.error('[useCustomFieldNameSuggestions] failed to load', err)
cache.value = cache.value ?? []
return cache.value
} finally {
loading.value = false
}
}
function invalidate(): void {
cache.value = null
}
return {
suggestions: cache,
loading,
load,
invalidate,
}
}
```
> Note : `cache` et `loading` sont déclarés **au niveau du module** (hors de la fonction) → cache partagé entre toutes les instances.
- [ ] **Step 3: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
Attendu : 0 errors. Si le `ReturnType<typeof useApi>` pose souci (selon comment `useApi` est typé), remplacer par un type explicite plus simple :
```ts
interface Deps {
api: <T>(path: string, opts?: RequestInit) => Promise<T>
}
```
- [ ] **Step 4: Commit**
```bash
git add frontend/app/composables/useCustomFieldNameSuggestions.ts
git commit -m "feat(custom-fields) : ajoute composable useCustomFieldNameSuggestions
Cache module-level partage entre toutes les instances. Lazy load au
premier appel a load(). invalidate() permet de forcer un refresh apres
creation/modification d'un champ perso."
```
---
## Task 5: Frontend — Composant wrapper `CustomFieldNameInput`
**Files:**
- Create: `frontend/app/components/common/CustomFieldNameInput.vue`
- [ ] **Step 1: Créer le composant**
Créer `frontend/app/components/common/CustomFieldNameInput.vue` :
```vue
<template>
<SearchSelect
:model-value="modelValue"
:options="options"
:placeholder="placeholder"
option-value="name"
option-label="name"
creatable
:size="size"
@update:model-value="onUpdate"
@focus="ensureLoaded"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SearchSelect from './SearchSelect.vue'
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
const props = withDefaults(defineProps<{
modelValue: string
placeholder?: string
size?: 'xs' | 'sm' | 'md' | 'lg'
}>(), {
placeholder: 'Nom du champ',
size: 'xs',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
function ensureLoaded(): void {
void load()
}
function onUpdate(value: string | number): void {
emit('update:modelValue', String(value ?? ''))
}
</script>
```
> `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
<input v-model='field.name'> par <CustomFieldNameInput v-model='field.name'>
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
<input
v-model="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
>
```
Remplacer par :
```vue
<CustomFieldNameInput
v-model="field.name"
placeholder="Nom du champ"
size="sm"
/>
```
- [ ] **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
<input
:value="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
/>
```
⚠️ **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
<CustomFieldNameInput
:model-value="field.name"
placeholder="Nom du champ"
size="sm"
@update:model-value="(value) => handleDefinitionUpdate(field, 'name', value)"
/>
```
> 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
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
```
Remplacer par :
```vue
<CustomFieldNameInput
v-model="field.name"
placeholder="Nom du champ"
size="xs"
/>
```
- [ ] **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
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
/>
```
Remplacer par :
```vue
<CustomFieldNameInput
v-model="field.name"
placeholder="Nom du champ"
size="xs"
/>
```
- [ ] **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 `<script setup>` de `ModelTypeForm.vue` :
```ts
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
```
Puis, après la sauvegarde réussie du ModelType (typiquement après le `await api(...)` qui POST/PATCH `/api/model_types/...`) :
```ts
invalidateCustomFieldNames()
```
- [ ] **Step 5: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 6: Test manuel**
Scénario :
1. Ouvrir une machine, ajouter un champ perso « Test invalidation 2026 » et save.
2. Ouvrir une autre machine ou un ModelType.
3. Tenter d'ajouter un champ perso → taper « Test invalid » → vérifier que « Test invalidation 2026 » apparaît dans les suggestions.
- [ ] **Step 7: Commit**
```bash
git add frontend/app/composables/useMachineCustomFieldDefs.ts frontend/app/components/model-types/ModelTypeForm.vue
git commit -m "feat(custom-fields) : invalide le cache de suggestions apres save
Apres chaque save reussi de champs perso (machine ou ModelType), on
invalide le cache useCustomFieldNameSuggestions pour que les noms
nouvellement crees apparaissent dans les futures autocomplete."
```
---
## Task 11: Validation finale
**Files:** aucun changement, juste vérification end-to-end.
- [ ] **Step 1: Lancer le typecheck complet**
```bash
cd frontend && npx nuxi typecheck
```
Attendu : 0 errors.
- [ ] **Step 2: Lancer le linter complet**
```bash
cd frontend && npm run lint:fix
```
Attendu : 0 errors (ou seulement des fixes auto).
- [ ] **Step 3: Test end-to-end manuel**
Démarrer l'environnement local (`make start` si pas déjà fait), puis :
1. **Machine** : créer une machine, ajouter 2 champs perso « Numéro de série » et « Tension ». Save.
2. **ModelType** : créer un ModelType de catégorie composant, ajouter une pièce dans le skeleton, ajouter à cette pièce un champ perso. Vérifier que « Numéro de série » et « Tension » apparaissent dans les suggestions.
3. **Structure** : créer un ModelType de catégorie machine, naviguer dans la structure, ajouter un champ perso à un composant. Vérifier les suggestions.
4. **Création libre** : taper un nom inédit, voir la ligne « Créer ... », cliquer ou faire blur → garder le texte.
5. **Sélection** : cliquer sur une suggestion → input se remplit avec le nom exact.
- [ ] **Step 4: Vérifier le commit log**
```bash
git log --oneline -15
```
Confirmer qu'on a bien 1 commit par task, avec des messages cohérents.
- [ ] **Step 5: Push (optionnel, à confirmer avec l'utilisateur)**
```bash
git push
```
⚠️ Ne PAS push sans demande explicite de l'utilisateur.
---
## Récapitulatif des fichiers
### Créés
- `src/Controller/CustomFieldNamesController.php`
- `tests/Api/Controller/CustomFieldNamesControllerTest.php`
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
- `frontend/app/components/common/CustomFieldNameInput.vue`
### Modifiés
- `frontend/app/components/common/SearchSelect.vue`
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
- `frontend/app/components/machine/MachineCustomFieldsCard.vue`
- `frontend/app/components/PieceModelStructureEditor.vue`
- `frontend/app/components/StructureNodeEditor.vue`
- `frontend/app/composables/useMachineCustomFieldDefs.ts`
- `frontend/app/components/model-types/ModelTypeForm.vue`

View File

@@ -6,9 +6,10 @@
## 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`)
- 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.
@@ -33,20 +34,21 @@ 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 ▼
SELECT DISTINCT name CustomFieldNameInput.vue (wrapper)
FROM custom_fields │
│ utilise
SearchSelect.vue (creatable=true)
│ utilisé par
┌───────────────┼───────────────┐
│ │ │
MachineCustomFieldDefEditor│ StructureNodeEditor
PieceModelStructureEditor
┌───────────────┼─────────────────────
│ │
MachineCustomFieldDef- StructureNodeEditor PieceModelStructure-
Editor (composants) Editor (pièces)
MachineCustomFieldsCard
(édition inline d'une machine)
```
## Backend
@@ -70,29 +72,12 @@ Union SQL : CustomFieldNameInput.vue (wrapper)
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
SELECT DISTINCT name FROM custom_fields
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`.
> 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