Files
Inventory/docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md
Matthieu 5e8e7947f0 docs(custom-fields) : design pour autocomplete des noms de champs perso
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>
2026-05-11 12:29:07 +02:00

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

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

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)