Créer un composant et un panneau personnalisés
QForm Builder peut être étendu à deux niveaux indépendants :
- un élément de catalogue, qui ajoute un outil dans le drawer de gauche ;
- un type FormKit personnalisé, qui définit le composant Vue/Quasar réellement rendu ;
- 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.
// 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-]+$/',
},
}),
]<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
<!-- 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, attributon*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 :
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
// 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.
// 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
// 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.
<!-- 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 :
<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 :
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
$formkitet 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-htmlsans sanitizer HTML dédié.
5. Tests recommandés
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:checkPour stabiliser les captures Playwright :
- utilisez un schéma fixe et des valeurs anonymisées ;
- videz
localStorageavant 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.