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
|
## Contexte et problème
|
||||||
|
|
||||||
Aujourd'hui dans Inventory, on définit des "champs personnalisés" (custom fields) à plusieurs endroits :
|
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'une **machine** (entité `CustomField` avec FK `machineId`)
|
||||||
- Au niveau d'un ModelType (entité `CustomField` avec FK `typeComposantId` / `typePieceId` / `typeProductId`)
|
- Au niveau d'un **ModelType**, dans 3 contextes (composant / pièce / produit) : entité `CustomField` avec respectivement `typeComposantId`, `typePieceId`, `typeProductId`.
|
||||||
- Au niveau des skeleton requirements d'un ModelType (JSON `customFields` dans `SkeletonPieceRequirement`, `SkeletonSubcomponentRequirement`, `SkeletonProductRequirement`)
|
|
||||||
|
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.
|
À 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)
|
│ │ (cache module-level)
|
||||||
│ returns: ["Numéro...", ...] │
|
│ returns: ["Numéro...", ...] │
|
||||||
▼ ▼
|
▼ ▼
|
||||||
Union SQL : CustomFieldNameInput.vue (wrapper)
|
SELECT DISTINCT name CustomFieldNameInput.vue (wrapper)
|
||||||
custom_fields │
|
FROM custom_fields │
|
||||||
+ skeleton_piece_requirements │ utilise
|
│ utilise
|
||||||
+ skeleton_subcomponent_requirements│
|
▼
|
||||||
+ skeleton_product_requirements ▼
|
|
||||||
SearchSelect.vue (creatable=true)
|
SearchSelect.vue (creatable=true)
|
||||||
▲
|
▲
|
||||||
│ utilisé par
|
│ utilisé par
|
||||||
│
|
│
|
||||||
┌───────────────┼───────────────┐
|
┌───────────────┼─────────────────────┐
|
||||||
│ │ │
|
│ │ │
|
||||||
MachineCustomFieldDefEditor│ StructureNodeEditor
|
MachineCustomFieldDef- StructureNodeEditor PieceModelStructure-
|
||||||
|
Editor (composants) Editor (pièces)
|
||||||
│
|
│
|
||||||
PieceModelStructureEditor
|
MachineCustomFieldsCard
|
||||||
|
(édition inline d'une machine)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
@@ -70,29 +72,12 @@ Union SQL : CustomFieldNameInput.vue (wrapper)
|
|||||||
Le controller exécute une seule requête SQL brute via `Doctrine\DBAL\Connection` :
|
Le controller exécute une seule requête SQL brute via `Doctrine\DBAL\Connection` :
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT DISTINCT name FROM (
|
SELECT DISTINCT name FROM custom_fields
|
||||||
SELECT name FROM custom_fields
|
|
||||||
UNION
|
|
||||||
SELECT jsonb_array_elements(customfields)->>'name' AS name
|
|
||||||
FROM skeleton_piece_requirements
|
|
||||||
WHERE customfields IS NOT NULL
|
|
||||||
UNION
|
|
||||||
SELECT jsonb_array_elements(customfields)->>'name' AS name
|
|
||||||
FROM skeleton_subcomponent_requirements
|
|
||||||
WHERE customfields IS NOT NULL
|
|
||||||
UNION
|
|
||||||
SELECT jsonb_array_elements(customfields)->>'name' AS name
|
|
||||||
FROM skeleton_product_requirements
|
|
||||||
WHERE customfields IS NOT NULL
|
|
||||||
) AS all_names
|
|
||||||
WHERE name IS NOT NULL AND name <> ''
|
WHERE name IS NOT NULL AND name <> ''
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
```
|
```
|
||||||
|
|
||||||
**À vérifier au moment de l'implémentation** :
|
> 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.
|
||||||
- Le nom exact de la colonne JSON `customfields` en lowercase dans PostgreSQL (Doctrine = camelCase, PG = lowercase).
|
|
||||||
- Le nom exact des tables `skeleton_*_requirements` (à confirmer dans les migrations).
|
|
||||||
- Le type de la colonne (JSON vs JSONB) — `jsonb_array_elements` ne fonctionne que sur JSONB ; pour JSON utiliser `json_array_elements`.
|
|
||||||
|
|
||||||
### Pas de cache HTTP
|
### Pas de cache HTTP
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user