Files
Inventory/docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md
Matthieu 73c06169f3 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>
2026-05-11 13:33:06 +02:00

12 KiB
Raw Blame History

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).

["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 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 :

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 de useApi).

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 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 :

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)