Skip to content

Créer un composant et un panneau personnalisés

QForm Builder peut être étendu à deux niveaux indépendants :

  1. un élément de catalogue, qui ajoute un outil dans le drawer de gauche ;
  2. un type FormKit personnalisé, qui définit le composant Vue/Quasar réellement rendu ;
  3. un panneau de configuration personnalisé, qui remplace le panneau du drawer droit.

Un élément de catalogue peut réutiliser un type déjà fourni (q-input, q-select, etc.). Un nouveau composant métier doit en plus être enregistré dans FormKit.

1. Cas simple : réutiliser un composant existant

Le helper public defineQFormElement() ajoute une entrée typée au catalogue sans modifier le layer.

ts
// app/forms/catalog.ts
import {
  createCommunityCatalog,
  defineQFormElement,
} from '@vevedh/qform-builder-layer/catalog'
import type { QFormBuilderCatalog } from '@vevedh/qform-builder-layer/types'

export const businessCatalog: QFormBuilderCatalog = [
  ...createCommunityCatalog(),
  defineQFormElement({
    key: 'employee-code',
    category: 'fields',
    icon: 'badge',
    title: 'Matricule agent',
    description: 'Identifiant interne en lettres majuscules.',
    schema: {
      $formkit: 'q-input',
      name: 'employee_code',
      label: 'Matricule agent',
      inputType: 'text',
      maxlength: 20,
      validation: 'required|matches:/^[A-Z0-9-]+$/',
    },
  }),
]
vue
<script setup lang="ts">
import { businessCatalog } from '~/forms/catalog'
</script>

<template>
  <FormBuilder
    builder-id="business-form"
    :catalog="businessCatalog"
    catalog-mode="replace"
  />
</template>

Cette méthode est suffisante lorsque le rendu Quasar existe déjà.

2. Créer un vrai type FormKit/Quasar

L'exemple suivant crée un champ q-business-code. Il utilise QInput, normalise sa valeur et ne transmet jamais aveuglément les attributs du schéma au DOM.

2.1 Composant runtime

vue
<!-- app/components/forms/BusinessCodeInput.vue -->
<script setup lang="ts">
import type { FormKitFrameworkContext } from '@formkit/core'

interface BusinessCodeAttrs {
  prefix?: string
  maxlength?: number | string
  placeholder?: string
  description?: string
  disable?: boolean
  readonly?: boolean
}

const props = defineProps<{
  context: FormKitFrameworkContext & { attrs: BusinessCodeAttrs }
}>()

const prefix = computed(() => {
  const value = String(props.context.attrs.prefix || '').trim().toUpperCase()
  return value.replace(/[^A-Z0-9-]/g, '').slice(0, 10)
})

const maximumLength = computed(() => {
  const value = Number(props.context.attrs.maxlength)
  return Number.isInteger(value) && value >= 1 && value <= 64 ? value : 20
})

const model = computed(() => String(props.context.value || ''))
const hasError = computed(() => props.context.state?.valid === false)

function updateValue(value: string | number | null) {
  const normalized = String(value || '')
    .toUpperCase()
    .replace(/[^A-Z0-9-]/g, '')
    .slice(0, maximumLength.value)

  props.context.node.input(normalized)
}
</script>

<template>
  <q-input
    :model-value="model"
    :label="context.label"
    :hint="context.help || context.attrs.description"
    :placeholder="context.attrs.placeholder"
    :prefix="prefix || undefined"
    :maxlength="maximumLength"
    :disable="context.attrs.disable === true"
    :readonly="context.attrs.readonly === true"
    :error="hasError"
    filled
    clearable
    @update:model-value="updateValue"
  />
</template>

Points importants :

  • la valeur est normalisée avant node.input() ;
  • seules les props explicitement autorisées sont transmises à Quasar ;
  • aucun v-html, attribut on* ou objet arbitraire n'est accepté ;
  • le composant reste compatible avec le runtime FormKit et le FormViewer.

2.1.1 Typer un composant Quasar contrôlé

Certaines interfaces Quasar rendent modelValue obligatoire. Dans un adapter FormKit, le modèle est pourtant fourni séparément depuis context.value. Le type des attributs sérialisables doit donc rendre les props Quasar optionnelles et exclure modelValue :

ts
import type { QSelectProps } from 'quasar'

type BusinessSelectAttrs = Record<string, unknown>
  & Partial<Omit<QSelectProps, 'modelValue'>>
  & {
    description?: string
  }

Cette forme évite de fabriquer un faux modelValue dans context.attrs, reste compatible avec une normalisation par dictionnaire et préserve le typage des props connues. La valeur contrôlée doit continuer à être passée avec :model-value puis validée avant node.input().

2.2 Plugin FormKit de l'application hôte

ts
// formkit.config.ts
import type { FormKitNode } from '@formkit/core'
import { en, fr } from '@formkit/i18n'
import { defineFormKitConfig } from '@formkit/vue'
import { qformBuilderQuasarPlugin } from '@vevedh/qform-builder-layer/formkit.config'
import BusinessCodeInput from './app/components/forms/BusinessCodeInput.vue'

function businessInputsPlugin() {}

businessInputsPlugin.library = (node: FormKitNode) => {
  if (node.props.type !== 'q-business-code') return

  node.define({
    type: 'input',
    props: ['columns', 'prefix', 'maxlength'],
    component: BusinessCodeInput,
  })
}

export default defineFormKitConfig({
  plugins: [
    qformBuilderQuasarPlugin,
    businessInputsPlugin,
  ],
  locales: { fr, en },
  locale: 'fr',
})

Lorsque l'application hôte remplace le fichier de configuration FormKit du layer, elle doit réenregistrer qformBuilderQuasarPlugin. Sans cela, les types Community comme q-input ou q-columns deviennent inconnus.

ts
// nuxt.config.ts
import { fileURLToPath } from 'node:url'

const formkitConfigFile = fileURLToPath(
  new URL('./formkit.config.ts', import.meta.url),
)

export default defineNuxtConfig({
  extends: ['@vevedh/qform-builder-layer'],
  formkit: {
    configFile: formkitConfigFile,
    defaultConfig: true,
    autoImport: false,
  },
})

2.3 Élément de catalogue du composant

ts
// app/forms/catalog.ts
import { defineQFormElement } from '@vevedh/qform-builder-layer/catalog'
import type { QFormBuilderCatalog } from '@vevedh/qform-builder-layer/types'

export const businessCatalog: QFormBuilderCatalog = [
  defineQFormElement({
    key: 'business-code',
    category: 'fields',
    icon: 'badge',
    title: 'Code métier',
    description: 'Code normalisé et préfixé.',
    schema: {
      $formkit: 'q-business-code',
      name: 'business_code',
      label: 'Code métier',
      prefix: 'ORG-',
      maxlength: 20,
      validation: 'required|length:3,20',
    },
  }),
]

Le schéma reste déclaratif et sérialisable. Les composants Vue et fonctions ne doivent jamais être stockés dans le JSON exporté.

3. Créer le panneau de configuration associé

Le panneau reçoit le champ sélectionné et une fonction updateField(). Cette fonction doit être utilisée afin de conserver l'historique, le schéma contrôlé et l'autosave synchronisés.

vue
<!-- app/components/forms/BusinessCodePanel.vue -->
<script setup lang="ts">
import type { QFormBuilderConfigPanelProps } from '@vevedh/qform-builder-layer/types'

const props = defineProps<QFormBuilderConfigPanelProps>()

const label = computed({
  get: () => String(props.field.label || ''),
  set: value => props.updateField({ label: value.slice(0, 120) }),
})

const prefix = computed({
  get: () => String(props.field.prefix || ''),
  set: (value) => {
    const normalized = value
      .toUpperCase()
      .replace(/[^A-Z0-9-]/g, '')
      .slice(0, 10)
    props.updateField({ prefix: normalized || undefined })
  },
})

const maxlength = computed({
  get: () => Number(props.field.maxlength || 20),
  set: (value) => {
    const normalized = Math.min(64, Math.max(1, Math.round(Number(value) || 20)))
    props.updateField({ maxlength: normalized })
  },
})
</script>

<template>
  <q-list separator>
    <q-item-label header>Code métier</q-item-label>

    <q-item>
      <q-item-section class="q-gutter-md">
        <q-input v-model="label" filled dense label="Libellé" />
        <q-input v-model="prefix" filled dense label="Préfixe" maxlength="10" />
        <q-input
          v-model.number="maxlength"
          filled
          dense
          type="number"
          min="1"
          max="64"
          label="Longueur maximale"
        />
      </q-item-section>
    </q-item>

    <q-item>
      <q-item-section side>
        <q-btn flat no-caps label="Fermer" @click="props.close" />
      </q-item-section>
    </q-item>
  </q-list>
</template>

Enregistrement du panneau :

vue
<script setup lang="ts">
import { markRaw } from 'vue'
import type {
  QFormBuilderCatalog,
  QFormBuilderConfigPanelRegistry,
} from '@vevedh/qform-builder-layer/types'
import BusinessCodePanel from '~/components/forms/BusinessCodePanel.vue'
import { businessCatalog } from '~/forms/catalog'

const catalog: QFormBuilderCatalog = businessCatalog

const configPanels: QFormBuilderConfigPanelRegistry = [
  {
    key: 'business-code-panel',
    component: markRaw(BusinessCodePanel),
    match: 'q-business-code',
    priority: 100,
  },
]
</script>

<template>
  <FormBuilder
    builder-id="business-form"
    :catalog="catalog"
    catalog-mode="append"
    :config-panels="configPanels"
  />
</template>

Le matcher peut également être un prédicat :

ts
match: field => field.$formkit === 'q-input' && field.qformKind === 'employee-id'

4. Import/export et sécurité

L'import JSON de QForm Builder interdit volontairement les composants dynamiques comme $cmp, les fonctions, les gestionnaires on*, innerHTML, outerHTML, srcdoc et les clés de pollution de prototype.

Pour un composant personnalisé :

  • enregistrez le type dans le code de confiance de l'application ;
  • exportez uniquement son identifiant $formkit et ses props sérialisables ;
  • appliquez une allowlist de props dans l'adapter ;
  • validez une seconde fois le schéma côté serveur avant persistance ;
  • n'affichez jamais une chaîne de schéma avec v-html sans sanitizer HTML dédié.

5. Tests recommandés

bash
bun run typecheck
bun run formkit:check
bun run catalog:check
bun run e2e:visual:update   # après une modification visuelle intentionnelle
bun run e2e:visual          # comparaison avec les références validées
bun run runtime:build:check

Pour stabiliser les captures Playwright :

  • utilisez un schéma fixe et des valeurs anonymisées ;
  • videz localStorage avant chaque scénario ;
  • désactivez les animations ;
  • ciblez des attributs data-* stables plutôt que des classes internes Quasar.

Voir aussi Panneaux de configuration personnalisés et Catalogue Community.

QForm Builder — layer Nuxt 4, Quasar et FormKit pour formulaires dynamiques.