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:
@@ -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`
|
||||
@@ -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
|
||||
MachineCustomFieldDef- StructureNodeEditor PieceModelStructure-
|
||||
Editor (composants) Editor (pièces)
|
||||
│
|
||||
PieceModelStructureEditor
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user