Spec validee : endpoint backend qui agrege les noms existants (table custom_fields + JSON dans skeleton requirements), composant CustomFieldNameInput qui wrap SearchSelect en mode creatable, cache module-level partage entre toutes les instances, invalidation apres save. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
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é
CustomFieldavec FKmachineId) - Au niveau d'un ModelType (entité
CustomFieldavec FKtypeComposantId/typePieceId/typeProductId) - Au niveau des skeleton requirements d'un ModelType (JSON
customFieldsdansSkeletonPieceRequirement,SkeletonSubcomponentRequirement,SkeletonProductRequirement)
À 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...", ...] │
▼ ▼
Union SQL : CustomFieldNameInput.vue (wrapper)
custom_fields │
+ skeleton_piece_requirements │ utilise
+ skeleton_subcomponent_requirements│
+ skeleton_product_requirements ▼
SearchSelect.vue (creatable=true)
▲
│ utilisé par
│
┌───────────────┼───────────────┐
│ │ │
MachineCustomFieldDefEditor│ StructureNodeEditor
│
PieceModelStructureEditor
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).
["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 :
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
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
customfieldsen 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_elementsne fonctionne que sur JSONB ; pour JSON utiliserjson_array_elements.
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 :
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
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 deuseApi).
3. Composant wrapper CustomFieldNameInput.vue
Fichier : frontend/app/components/common/CustomFieldNameInput.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 :
<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 danscomponents/common/, il est auto-importé par Nuxt — pas besoin d'importdans 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 :
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
- User clique « Ajouter un champ »
- Un input vide apparaît
- User clique dedans → dropdown s'ouvre avec tous les noms existants triés alpha
- User tape « num » → dropdown filtre sur
["Numéro de lot", "Numéro de série"] - User clique sur « Numéro de série » → l'input se remplit exactement avec « Numéro de série »
- 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
- User voit un champ existant nommé « Numéro de série »
- User clique dans l'input → dropdown s'ouvre, suggestions filtrées sur « Numéro de série »
- User efface et tape « Tension » → dropdown filtre, il peut sélectionner ou retaper librement
- 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.phpfrontend/app/composables/useCustomFieldNameSuggestions.tsfrontend/app/components/common/CustomFieldNameInput.vue
Fichiers modifiés
frontend/app/components/common/SearchSelect.vue(ajout propcreatable)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(ajoutinvalidate()après save)frontend/app/components/model-types/ModelTypeForm.vue(ajoutinvalidate()après save)