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>
274 lines
12 KiB
Markdown
274 lines
12 KiB
Markdown
# Custom Field Name Autocomplete — Design
|
||
|
||
**Date** : 2026-05-11
|
||
**Statut** : Design validé, prêt pour planification
|
||
|
||
## 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**, 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.
|
||
|
||
**Objectif** : proposer une autocomplétion sur le nom du champ qui suggère les noms déjà existants dans la base, tout en autorisant la création libre d'un nouveau nom.
|
||
|
||
## Décisions clés
|
||
|
||
| Question | Choix retenu |
|
||
|----------|--------------|
|
||
| Scope des suggestions | **Cross-entité** (machine + composant + pièce + produit confondus) — objectif d'uniformisation globale |
|
||
| Comportement utilisateur | **Création libre** : si l'utilisateur tape un nom sans cliquer sur une suggestion, on garde son texte tel quel |
|
||
| Suggestion du type | Non : la suggestion porte uniquement sur le nom |
|
||
| Compteur d'usage | Non : on reste simple, juste les noms triés alpha |
|
||
| Pattern UI | **Étendre `SearchSelect.vue`** existant avec un prop `creatable` plutôt que datalist natif ou nouveau composant — cohérence visuelle avec le reste de l'app |
|
||
|
||
## Architecture
|
||
|
||
```
|
||
Backend Frontend
|
||
───────── ─────────
|
||
GET /api/custom-fields/names ◄── useCustomFieldNameSuggestions()
|
||
│ │ (cache module-level)
|
||
│ returns: ["Numéro...", ...] │
|
||
▼ ▼
|
||
SELECT DISTINCT name CustomFieldNameInput.vue (wrapper)
|
||
FROM custom_fields │
|
||
│ utilise
|
||
▼
|
||
SearchSelect.vue (creatable=true)
|
||
▲
|
||
│ utilisé par
|
||
│
|
||
┌───────────────┼─────────────────────┐
|
||
│ │ │
|
||
MachineCustomFieldDef- StructureNodeEditor PieceModelStructure-
|
||
Editor (composants) Editor (pièces)
|
||
│
|
||
MachineCustomFieldsCard
|
||
(édition inline d'une machine)
|
||
```
|
||
|
||
## Backend
|
||
|
||
### Nouveau endpoint : `GET /api/custom-fields/names`
|
||
|
||
**Fichier** : `src/Controller/CustomFieldNamesController.php`
|
||
|
||
**Sécurité** : `ROLE_VIEWER` (cohérent avec les autres GET sur `CustomField`).
|
||
|
||
**Format de réponse** : tableau JSON plat de strings, trié alphabétique, dédupliqué (case-insensitive sur l'union).
|
||
|
||
```json
|
||
["Numéro de série", "Puissance", "Tension nominale"]
|
||
```
|
||
|
||
> Pas de wrapper `hydra:` — ce n'est pas une resource API Platform mais un endpoint utilitaire.
|
||
|
||
### Implémentation SQL
|
||
|
||
Le controller exécute une seule requête SQL brute via `Doctrine\DBAL\Connection` :
|
||
|
||
```sql
|
||
SELECT DISTINCT name FROM custom_fields
|
||
WHERE name IS NOT NULL AND name <> ''
|
||
ORDER BY name ASC
|
||
```
|
||
|
||
> 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
|
||
|
||
La liste change quand un utilisateur crée un nouveau champ perso. Le cache se fait côté frontend (cf. composable). Pas de header `Cache-Control` particulier.
|
||
|
||
## Frontend
|
||
|
||
### 1. Extension de `SearchSelect.vue`
|
||
|
||
**Nouveau prop** :
|
||
|
||
```js
|
||
creatable: {
|
||
type: Boolean,
|
||
default: false // strict par défaut → zéro régression sur les 10+ usages actuels
|
||
}
|
||
```
|
||
|
||
**Changements de comportement quand `creatable=true`** :
|
||
|
||
| Aspect | Mode strict (défaut) | Mode `creatable` |
|
||
|--------|---------------------|------------------|
|
||
| `modelValue` | ID de l'option | **Texte libre** (le nom est la valeur) |
|
||
| `handleInput` | emit `'search'` uniquement | emit aussi `'update:modelValue'` en temps réel |
|
||
| `closeDropdown` (blur) | reset au label de l'option sélectionnée | **garde** le texte tapé |
|
||
| Dropdown | liste filtrée | liste filtrée + une ligne **« Créer XYZ »** en bas si le texte tapé ne matche aucune option (icône `+`, texte plus discret) |
|
||
| Clavier | ↑/↓/Enter sélectionne une option | ↑/↓ navigue, Enter valide soit l'option soit le « Créer XYZ » |
|
||
|
||
**Garanti** : mode strict 100% inchangé → les 10+ usages actuels de `SearchSelect` ne sont pas affectés.
|
||
|
||
### 2. Composable `useCustomFieldNameSuggestions`
|
||
|
||
**Fichier** : `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) {
|
||
if (cache.value && !force) return cache.value
|
||
if (loading.value) return cache.value ?? []
|
||
loading.value = true
|
||
try {
|
||
cache.value = await api<string[]>('/api/custom-fields/names')
|
||
return cache.value
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function invalidate() {
|
||
cache.value = null
|
||
}
|
||
|
||
return {
|
||
suggestions: cache,
|
||
loading,
|
||
load,
|
||
invalidate,
|
||
}
|
||
}
|
||
```
|
||
|
||
**Choix de design** :
|
||
- **Cache module-level** (déclaré hors de la fonction) → partagé entre toutes les instances du composable, donc une seule requête HTTP pour toute l'app.
|
||
- **Lazy load** au 1er focus → pas de surcoût au démarrage.
|
||
- **Invalidation manuelle** via `invalidate()` → appelée après chaque save de champ perso pour rafraîchir.
|
||
- **Pattern `Deps`** → cohérent avec la convention du projet (`interface Deps`, injection de `useApi`).
|
||
|
||
### 3. Composant wrapper `CustomFieldNameInput.vue`
|
||
|
||
**Fichier** : `frontend/app/components/common/CustomFieldNameInput.vue`
|
||
|
||
```vue
|
||
<template>
|
||
<SearchSelect
|
||
v-model="modelValue"
|
||
:options="options"
|
||
:placeholder="placeholder"
|
||
option-value="name"
|
||
option-label="name"
|
||
creatable
|
||
size="xs"
|
||
@focus="ensureLoaded"
|
||
/>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import SearchSelect from './SearchSelect.vue'
|
||
import { computed } from 'vue'
|
||
|
||
const props = defineProps<{
|
||
modelValue: string
|
||
placeholder?: string
|
||
}>()
|
||
defineEmits<{ 'update:modelValue': [value: string] }>()
|
||
|
||
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
|
||
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
|
||
const ensureLoaded = () => load()
|
||
</script>
|
||
```
|
||
|
||
**Pourquoi un wrapper** : encapsule le branchement (load, map, props `creatable`/`option-value`) → impossible de l'oublier dans un consommateur, et tous les paramètres restent uniformes par construction.
|
||
|
||
### 4. Migration des éditeurs
|
||
|
||
Dans chacun des fichiers ci-dessous, **remplacer le `<input v-model="field.name">`** par :
|
||
|
||
```vue
|
||
<CustomFieldNameInput v-model="field.name" placeholder="Nom du champ" />
|
||
```
|
||
|
||
Fichiers concernés :
|
||
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (ligne ~36-41)
|
||
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (ligne ~57)
|
||
- `frontend/app/components/PieceModelStructureEditor.vue` (ligne ~97-102)
|
||
- `frontend/app/components/StructureNodeEditor.vue` (ligne ~106-111)
|
||
|
||
> Note : `CustomFieldNameInput` étant dans `components/common/`, il est auto-importé par Nuxt — pas besoin d'`import` dans les consommateurs.
|
||
|
||
### 5. Invalidation du cache
|
||
|
||
Après chaque save réussi de champs perso, appeler `invalidate()` pour que la prochaine ouverture du dropdown récupère les nouveaux noms.
|
||
|
||
| Endroit | Quand |
|
||
|---------|-------|
|
||
| `useMachineCustomFieldDefs` (composable existant) | Après PATCH/POST réussi des custom fields machine |
|
||
| `ModelTypeForm.vue` (save ModelType + skeleton requirements) | Après sauvegarde du ModelType |
|
||
|
||
Pattern :
|
||
```ts
|
||
const { invalidate } = useCustomFieldNameSuggestions({ api: useApi() })
|
||
|
||
async function save() {
|
||
await api(...) // sauvegarde existante
|
||
invalidate() // ← nouveau
|
||
}
|
||
```
|
||
|
||
> On n'a pas besoin d'invalider lors d'une simple modification d'un nom existant (au pire la liste a une suggestion en trop, ce n'est pas un bug). On invalide à chaque save pour rester simple.
|
||
|
||
## Comportement utilisateur
|
||
|
||
### Cas 1 — Création d'un nouveau champ
|
||
1. User clique « Ajouter un champ »
|
||
2. Un input vide apparaît
|
||
3. User clique dedans → dropdown s'ouvre avec tous les noms existants triés alpha
|
||
4. User tape « num » → dropdown filtre sur `["Numéro de lot", "Numéro de série"]`
|
||
5. User clique sur « Numéro de série » → l'input se remplit exactement avec « Numéro de série »
|
||
6. **OU** user tape « num XYZ » et clique ailleurs → l'input garde « num XYZ », une ligne « Créer 'num XYZ' » lui suggère explicitement la création
|
||
|
||
### Cas 2 — Modification d'un nom existant
|
||
1. User voit un champ existant nommé « Numéro de série »
|
||
2. User clique dans l'input → dropdown s'ouvre, suggestions filtrées sur « Numéro de série »
|
||
3. User efface et tape « Tension » → dropdown filtre, il peut sélectionner ou retaper librement
|
||
4. Pas de fusion automatique des données — chaque champ reste indépendant
|
||
|
||
### Cas 3 — Plusieurs inputs visibles en même temps
|
||
- Toutes les instances partagent le même cache (module-level) → une seule requête HTTP pour la session
|
||
- Si user crée un champ « Nouveau nom » dans l'input A et passe à l'input B sans rafraîchir, « Nouveau nom » apparaîtra dans les suggestions de B dès que `invalidate()` a été appelé au save
|
||
|
||
## Hors-scope
|
||
|
||
- **Renommage en cascade** : si on change un nom partout (ex: « Num serie » → « Numéro de série » pour les unifier), pas de migration automatique des champs existants. C'est un travail manuel, ou un futur outil dédié.
|
||
- **Compteur d'usage** : peut être ajouté plus tard sans changer l'API (format de réponse extensible).
|
||
- **Suggestion du type** : on ne propose pas un type par défaut quand l'utilisateur sélectionne une suggestion. À évaluer si besoin émerge.
|
||
- **Tests** : pas de tests Vue dans le projet actuellement → validation manuelle. Côté backend, un test PHPUnit du controller est recommandé (cf. plan d'implémentation).
|
||
|
||
## Fichiers impactés (résumé)
|
||
|
||
### Nouveaux fichiers
|
||
- `src/Controller/CustomFieldNamesController.php`
|
||
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
|
||
- `frontend/app/components/common/CustomFieldNameInput.vue`
|
||
|
||
### Fichiers modifiés
|
||
- `frontend/app/components/common/SearchSelect.vue` (ajout prop `creatable`)
|
||
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (remplacer input)
|
||
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (remplacer input)
|
||
- `frontend/app/components/PieceModelStructureEditor.vue` (remplacer input)
|
||
- `frontend/app/components/StructureNodeEditor.vue` (remplacer input)
|
||
- `frontend/app/composables/useMachineCustomFieldDefs.ts` (ajout `invalidate()` après save)
|
||
- `frontend/app/components/model-types/ModelTypeForm.vue` (ajout `invalidate()` après save)
|