Form Builder para Quasar

🇺🇸 English | 🇧🇷 Versão em português

npm version license Vue Quasar TypeScript Node

Extensión Quasar para construir formularios declarativos en apps Vue 3 + Quasar.

Tabla de contenidos

En 30 segundos

Flujo base recomendado:

  1. Definir tipo de payload.
  2. Construir config con useFormBuilder().defineForm(...).
  3. Renderizar FormBuilder con esa config.
  4. Manejar submit/reset por eventos.

La extensión instala boots, directivas y scripts en el proyecto host para que el uso sea inmediato.

Qué es y qué incluye

Form Builder arma formularios declarativos sobre Quasar: un modelo Pinia por formName, campos tipados y validación reutilizable.

Requisitos y compatibilidad

Item Valor
Node >=20.0.0
Quasar ^2.6.0
Vue ^3.4.18
Pinia ^2.0.11 || ^3.0.0
moment ^2.30.1
DataTable @benjaminor-dev/datatable ^0.1.0 — peer requerido
Formato de salida ES modules

El boot de Pinia del host debe correr antes del boot de store de esta extensión.

Instalación en app Quasar

Instala DataTable antes que Form Builder (peer requerido — FormList, InputFileMultiple y el check de compatibilidad lo exigen):

quasar ext add @benjaminor-dev/datatable
quasar ext add @benjaminor-dev/form-builder

Extensión recomendada: vista previa de archivos

Con InputFile / InputFileMultiple, conviene instalar dialog-file-preview (opcional). showPreview viene en true por defecto: con la extensión, ver/descargar abre el modal; sin ella, el campo sigue como QFile estándar.

quasar ext add @benjaminor-dev/dialog-file-preview

Orden de boots (stack completo): ver Extensiones opcionales.

Remover extensión:

quasar ext remove @benjaminor-dev/form-builder

Qué agrega la extensión al host

Recurso Ruta
Boot store ~@benjaminor-dev/quasar-app-extension-form-builder/boot/store
Boot directivas ~@benjaminor-dev/quasar-app-extension-form-builder/boot/directives
CSS Form Builder ~@benjaminor-dev/quasar-app-extension-form-builder/main.css
CSS DataTable ~@benjaminor-dev/quasar-app-extension-datatable/main.css (peer; registrado también por esta extensión)
Scaffold bor:help, bor:make-form, bor:make-formlist, bor:make-field
Scripts host (scripts/bor/) bor-help.mjs, bor-make-form.mjs, bor-make-formlist.mjs, bor-make-field.mjs — prefijo bor- del ecosistema @benjaminor-dev
Defaults (host) src/boot/bor/bor-form-builder-defaults.ts — creado en install

Install (quasar ext add / invoke): crea src/boot/bor/bor-form-builder-defaults.ts (o .js) y lo registra en quasar.configboot: [] automáticamente. No hace falta editarlo a mano en una instalación normal. Los boots de paquete (boot/store, boot/directives) y CSS los inyecta la extensión en cada dev/build (no aparecen como entradas cortas en quasar.config).

Remove (quasar ext remove): elimina scaffolds generados, el defaults boot, su entrada en quasar.config y los scripts npm bor:* de esta extensión (solo si siguen apuntando a los archivos del install).

Si borras el defaults boot, la app sigue con built-in (filled, stacked, lazy, md).

Orden de boots (esta extensión): Pinia → DataTable (CSS) → boot store FB → bor/bor-form-builder-defaults → resto.

Defaults globales (boot)

Evita repetir fieldDesign, labelPosition, validationMode y gap en cada defineForm. Prioridad: defineForm / props de <FormBuilder> → boot (configureFormBuilderDefaults) → built-in (filled, stacked, lazy, md).

Tras quasar ext add, revisa src/boot/bor/bor-form-builder-defaults.ts — plantilla completa con defaultFieldProps, comentarios // recomendado, etc.

// src/boot/bor/bor-form-builder-defaults.ts (extracto)
import { configureFormBuilderDefaults } from "@benjaminor-dev/quasar-app-extension-form-builder";

configureFormBuilderDefaults({
  fieldDesign: "filled",
  labelPosition: "stacked",
  validationMode: "lazy",
  gap: "md",
  // defaultFieldProps: { InputSelect: { clearable: true, fieldUi: { color: "primary" } } }
  // defaultFormListUi: { addBtnColor: { light: "white", dark: "primary" } }
});

configureFormBuilder es alias de configureFormBuilderDefaults.

Opción boot Descripción
fieldDesign Diseño global de campos (outlined, filled, …)
labelPosition inner | stacked | top
validationMode lazy (recomendado) | eager
gap Espaciado vertical en <FormBuilder>
defaultFieldProps Props estáticas por fields[].type (BootFieldPropsFor<"InputX"> en /types); incluye fieldUi (tokens visuales del input)
defaultFormListUi Tokens visuales globales de FormList (toolbar, diálogos); override en props.listUi del field
Boot defineForm Efectivo
filled filled
filled fieldDesign: "outlined" outlined solo en ese form

Comandos scaffold en host

La extensión registra scripts npm en el host para formularios y formularios con lista (FormList). Cada runner en scripts/bor/bor-make-*.mjs delega en la API npm /scaffold (runBorHelp, runBorMakeForm, …). Ejecuta npm run bor:help para ver todos los scaffolds instalados en el proyecto, opciones y guía de uso.

Ayuda (bor:help)

Lista los generadores instalados, la sintaxis con -- y las opciones comunes (--force):

npm run bor:help

Formularios (bor:make-form)

Después de -- va el nombre o ruta del formulario a generar.

npm run bor:make-form -- <nombre-o-ruta-del-formulario>

El argumento se normaliza a PascalCase y el archivo generado siempre termina en Form.vue.

Ejemplo — crear un formulario de login (LoginForm.vue):

npm run bor:make-form -- login

Resultado:

Ejemplo con subcarpeta — crear users/RegisterForm.vue y sobrescribir si ya existe:

npm run bor:make-form -- users/register --force

Resultado:

Reglas de naming:

Formularios con lista (bor:make-formlist)

Genera un borrador Vue con FormBuilder, un campo FormList embebido y botones de envío (SlotField) en src/components/FormBuilder/misma convención de ruta y nombre que bor:make-form; solo cambia la plantilla:

npm run bor:make-formlist -- <nombre-o-ruta-del-formulario>

Ejemplo:

npm run bor:make-formlist -- temp
npm run bor:make-formlist -- users/TeamRegister

Field presets (bor:make-field)

Genera un archivo reutilizable en src/components/FormBuilder/Fields/ con defineFieldPreset — para selects, servidores u otros campos que repites en muchos formularios.

npm run bor:make-field -- <NombrePreset> [FieldType]
Entrada Genera
CountrySelect InputSelect Fields/CountrySelect.field.ts → export countrySelectField
catalog/CitySelect InputSelectServer Fields/catalog/CitySelect.field.ts
CountrySelect (sin tipo) Menú interactivo en terminal para elegir el type
selectserver (como tipo) Acepta el nombre sin distinguir mayúsculas (InputSelectServer)
--force / -f Sobrescribe si el archivo ya existe

Ejemplo — preset de país (select local):

npm run bor:make-field -- CountrySelect InputSelect

Edita el archivo generado (servicio, options, iconos) y úsalo en cualquier defineForm — ver Field presets.

Plantilla override en el host: scripts/bor/stubs/bor-make-field.stub.

Uso rápido

Ejemplo mínimo del escenario alta de cliente corporativo (identidad + envío). El ejemplo integrado más abajo muestra el formulario de referencia casi completo.

<script setup lang="ts">
import FormBuilder, {
  useFormBuilder,
} from "@benjaminor-dev/quasar-app-extension-form-builder";

type CorporateIdentityForm = {
  legalName: string | null;
  tradeName: string | null;
  taxId: string | null;
  brandColor: string | null;
  corporateEmail: string | null;
  acceptContract: boolean;
};

const { defineForm, inputRules } = useFormBuilder();

const config = defineForm<CorporateIdentityForm>({
  formName: "corporateIdentityForm",
  fieldDesign: "filled",
  labelPosition: "stacked",
  fields: [
    {
      type: "InputText",
      model: "legalName",
      label: "Razón social",
      requiredOn: () => true,
      rules: [
        inputRules.legalTextExtendedRule(),
      ],
      props: {
        placeholder: "Ej. Acme Servicios S.A. de C.V.",
      },
    },
    {
      type: "InputText",
      model: "tradeName",
      label: "Nombre comercial",
      readonlyOn: (form) => !form?.legalName,
      props: {
        placeholder: "Opcional — cómo se conoce la marca",
      },
    },
    {
      type: "InputText",
      model: "taxId",
      label: "RFC / identificador fiscal",
      requiredOn: (form) => !!form?.legalName,
      rules: [
        inputRules.rfc(),
      ],
      props: {
        placeholder: "Ej. ACME850101ABC",
        maxLength: 13,
      },
    },
    {
      type: "InputColor",
      model: "brandColor",
      label: "Color corporativo",
      props: {
        defaultColor: "#1976D2",
        placeholder: "#RRGGBB",
      },
    },
    {
      type: "InputEmail",
      model: "corporateEmail",
      label: "Correo corporativo",
      requiredOn: () => true,
      props: {
        placeholder: "contacto@empresa.com",
      },
    },
    {
      type: "ToggleButton",
      model: "acceptContract",
      label: "Acepto contrato marco y tratamiento de datos",
      requiredOn: () => true,
    },
    {
      type: "SlotField",
      props: {
        slotName: "actions",
      },
    },
  ],
});

function onSubmit(payload: CorporateIdentityForm) {
  console.log("submit", payload);
}
</script>

<template>
  <FormBuilder :config="config" @submit="onSubmit">
    <template #slot-field-actions>
      <div class="flex justify-end q-gutter-sm">
        <q-btn type="reset" flat label="Limpiar" />
        <q-btn type="submit" color="primary" label="Enviar solicitud" />
      </div>
    </template>
  </FormBuilder>
</template>

Idioma (i18n)

Integrado en, es y pt. Por defecto los textos de inputs, FormList y archivos siguen el idioma de Quasar ($q.lang.isoName); si Quasar no tiene idioma, el fallback es en.

Resolución de locale: boot locale → prefijo de $q.lang.isoName (es*es, pt*pt) → en.

Opción Cuándo usarla
framework.lang en quasar.config.ts Recomendado — alinea Form Builder con el resto de Quasar
locale: 'es' en boot Fijar idioma (en | es | pt); ignora cambios de $q.lang
messages: { formListAdd: '…' } en boot Cambiar un texto sin fijar locale

Ejemplo en quasar.config.ts (español para toda la app):

// quasar.config.ts
framework: {
  lang: "es",
},

Plantilla boot (tras install):

configureFormBuilderDefaults({
  // locale: "es", // fija solo el idioma de Form Builder
  // messages: { formListAdd: "Agregar" },
});

Claves i18n: acciones comunes (clear, save, cancel, preview), aria de inputs (showHidePassword, openDatePicker, clearField), toolbar del editor, chrome de FormList (formListAdd, formListValidationTitle, formListMinimized, …) y rechazos de archivo (fileRejectionMaxSize, fileCapacityOverLimit con plantillas {max}, {name}, {reason}). Catálogo completo en FormBuilderMessages (/types).

Prioridad de mensajes: boot messages fusiona sobre el catálogo built-in del locale activo.

API en runtime: useFormBuilderI18n() devuelve messages reactivos según $q.lang.isoName. Para acceso imperativo (tests, código fuera de Vue), usa resolveFormBuilderMessages(isoName?) — misma resolución boot → $q.langen.

import {
  configureFormBuilderDefaults,
  resolveFormBuilderMessages,
  useFormBuilderI18n,
} from "@benjaminor-dev/quasar-app-extension-form-builder";

configureFormBuilderDefaults({ locale: "es" });
const msgs = resolveFormBuilderMessages(); // locale de boot

// Dentro de un componente:
const { messages } = useFormBuilderI18n();
// messages.value.formListAdd, messages.value.clear, …

TypeScript en tu IDE

No hace falta memorizar cada prop: el tipado y el autocompletado te muestran el contrato mientras escribes.

Si una prop no aparece en autocompletado, probablemente no existe en la API pública o el type del campo no la admite.

Guía de configuración de formularios

Anatomía de un campo

Cada entrada de fields[] describe un control del formulario:

Propiedad Descripción
type Tipo de campo (InputText, InputSelect, …)
model Clave en el modelo tipado del formulario
label FormAwareValue<string>
requiredOn / hideOn / disabledOn / readonlyOn / unmountOn Comportamiento condicional (form?) => boolean
rules Reglas Quasar adicionales — ver inputRules
props Configuración del componente (placeholder, hint, helpTooltip, prependIcon, FormAwareValue, …)
onChange, onMounted, … Efectos con acceso opcional al formulario
// dentro de defineForm({ fields: [ ... ] })
{
  type: "InputEmail",
  model: "corporateEmail",
  label: "Correo corporativo",
  requiredOn: () => true,
  rules: [
    inputRules.email(),
  ],
  props: {
    placeholder: "nombre@empresa.com",
    clearable: true,
  },
  onChange: (newValue, _oldValue, form) => {
    console.log(newValue, form?.legalName);
  },
},

requiredOn ya inyecta la regla required; no la dupliques en rules salvo que necesites otra validación independiente.

Nivel raíz vs props: en el objeto del campo van type, model, label, rules, callbacks condicionales (*On) y lifecycles (onChange, …). La presentación del widget (placeholder, hint, helpTooltip, prependIcon) va siempre dentro de props del tipo de input correspondiente. label (nivel raíz o en props), placeholder, hint, helpTooltip y prependIcon aceptan valor fijo o callback según el modelo (ver tabla).

Props de presentación del widget (props)

Prop Tipo Descripción
placeholder FormAwareValue<string> Texto de ayuda dentro del control (no aplica a grupos, toggle, archivos, editor, SlotField)
hint FormAwareValue<string | undefined> Texto bajo el campo (Quasar hint)
helpTooltip FormAwareValue<string> Ayuda contextual: icono ? en prepend en la mayoría de inputs; excepción InputCheckbox (icono ? junto al label, sin prepend marginal). En InputFile / InputFileMultiple con dragDrop vacío se muestra como leyenda bajo dropHint
prependIcon FormAwareValue<string> Icono Material en prepend; callback (form) => …. En FormList: FormListPresentationProp<string | null> (= FormAware con FormListAwareContext) — (data) => … con data.parentForm / data.items. No aplica a SlotField ni InputCheckbox

Tooltip nativo del label: en pantallas estrechas el texto del label puede truncarse. Al pasar el cursor sobre el campo o sobre el label visible, el navegador muestra el label completo (atributo HTML title, resuelto con FormAware igual que label). No confundir con helpTooltip (popover del ?).

// dentro de fields: [ ... ]
{
  type: "InputText",
  model: "legalName",
  label: "Razón social",
  requiredOn: () => true,
  props: {
    placeholder: "Ej. Acme Servicios S.A. de C.V.",
    hint: "Nombre registrado ante el SAT.",
    helpTooltip: "Debe coincidir con el acta constitutiva.",
  },
},

Callbacks condicionales

Callback Efecto
requiredOn Obligatoriedad condicional (inyecta required)
hideOn Oculta con v-show; el valor en el store se conserva
unmountOn Si devuelve false, desmonta el campo del DOM
disabledOn Desactiva interacción
readonlyOn Solo lectura

Firma: (form?: FormBuilderSubmitPayload<T>) => boolean. Usa form?.campo o () => valor cuando no necesites el modelo.

Excepciones por tipo de campo:

Tipo requiredOn disabledOn / readonlyOn
Inputs estándar Validación Quasar automática Bloqueo automático en el componente
ToggleButton Obligatorio = valor true (marcado) Bloqueo del toggle
SlotField separador (sin model) No usar No usar
SlotField custom (con model) Pasas required al slot; enlazas :rules si quieres validación al submit Pasas readonly y disable al control del slot

Ordena fields[] de arriba hacia abajo: los callbacks condicionales deben depender solo de campos anteriores (ver ejemplo integrado).

Valores iniciales (defaultValues)

Propiedad opcional. Si la omites, cada campo con model recibe el vacío según su type:

Vacío por defecto Tipos de campo
null Texto, email, número, fecha, select simple, archivo único, etc.
[] InputCheckboxGroup, InputSelectMultiple, InputSelectServerMultiple, InputFileMultiple, FormList
false ToggleButton, InputCheckbox

Puedes pasar un objeto parcial con solo los overrides que necesites; las claves omitidas usan la tabla anterior.

También puedes pasar una función (sync o async) que devuelve un objeto parcial. FormBuilder la ejecuta en onMounted — útil para precarga o edición desde API. Al resolver, actualiza el store y la base del reset (resetForm restaura lo precargado, no el estado vacío intermedio).

defaultValues: async () => {
  const record = await fetchCorporateRecord(id);
  return {
    legalName: record.name,
    corporateEmail: record.email,
    billingCountry: record.country,
  };
},

El formulario arranca con vacíos por tipo; tras la precarga puede verse un instante con esos vacíos antes de hidratar los valores. Puedes llamar defineForm en el componente padre y pasar el config resultante a FormBuilder; la precarga se ejecuta igual al montar el formulario.

Props y callbacks según el formulario (FormAware)

Además de los callbacks condicionales de campo (requiredOn, hideOn, …), puedes leer el modelo actual en props de configuración y en callbacks de efecto. Tres capas complementarias:

Capa Mecanismo Cuándo usarla
Condicional de campo requiredOn, hideOn, disabledOn, readonlyOn, unmountOn Mostrar, bloquear u obligar el campo
Prop de configuración FormAwareValue<T> = T | (form?) => T Límites, textos, flags que dependen del modelo (sin efectos secundarios)
Callback de efecto Último argumento opcional form? Reaccionar al cambio o a una acción explícita

FormAwareValue — valor fijo o (form?) => T. Sin efectos secundarios; ideal para límites, textos y flags. Cada campo documenta sus props en el catálogo.

Además de límites y flags por tipo, label (nivel raíz), placeholder, hint, helpTooltip y prependIcon (en props) aceptan FormAwareValue para textos e iconos que cambian con el modelo.

En FormList, las mismas props de presentación en props usan FormListPresentationProp<T> (homólogo FormAware con contexto parentForm / items / draftItem): callback (data) => … en lugar de (form) => ….

// dentro de fields: [ ... ]
{
    type: "InputFile",
    model: "contractPdf",
    label: "Contrato",
    props: {
      maxSize: (form) =>
        form?.plan === "enterprise" ? 10 * 1024 * 1024 : 5 * 1024 * 1024,
    },
  },
{
    type: "InputIntlPhone",
    model: "contactIntlPhone",
    label: (form) =>
      form?.billingCountry === "US"
        ? "US contact phone"
        : "Teléfono internacional",
    props: {
      hint: (form) =>
        form?.billingCountry && form.billingCountry !== "MX"
          ? "Obligatorio fuera de México."
          : undefined,
    },
  },

Callbacks con form — el snapshot del modelo se pasa como último argumento opcional:

Callback Firma
onChange (cualquier campo) (newValue, oldValue, form?) => void
onBeforeMount / onMounted (fieldProps, form?) => void
onSearch (InputSearch) (value, form?) => void
optionLabel (grupos y selects) (option, form?) => string

El pipeline de opciones ya exponía form en options(form), filterOptions(options, form) y callOptionsOn(form). Con optionLabel(option, form?) los textos visibles se recalculan cuando cambia el modelo sin volver a cargar opciones.

optionLabel: (option, form) => {
    const discount = Number(form?.negotiatedDiscount ?? 0);
    return discount > 0
      ? `${option.label} (desc. ${discount}%)`
      : `${option.label} (${option.meta?.tier ?? "n/a"})`;
  },

Tipos exportados en /types: FormAwareValue, FormSnapshot, FormListAwareValue, FormListPresentationProp, OptionLabelFn.

Patrones avanzados: condicional + servidor + SlotField

Formulario de referencia que combina hideOn encadenado, catálogo remoto y secciones con SlotField. El flujo es solo hacia adelante: cada bloque depende de valores ya capturados. El fragmento siguiente es ilustrativo; combínalo con el catálogo y el autocompletado del IDE.

function planOptionsVisible(form?: { priorityScore?: number | null }) {
  return Number(form?.priorityScore ?? 0) >= 5;
}

const config = defineForm<CorporateForm>({
  formName: "corporateOnboardingForm",
  fields: [
    {
      type: "InputNumber",
      model: "priorityScore",
      label: "Prioridad (1–10)",
      props: { min: 1, max: 10 },
    },
    { type: "SlotField", props: { slotName: "section-location" } },
    {
      type: "InputSelect",
      model: "billingCountry",
      label: "País de facturación",
      hideOn: (form) => !planOptionsVisible(form),
      props: {
        callOptionsOn: (form) => form?.priorityScore,
        options: (form) => (planOptionsVisible(form) ? COUNTRY_OPTIONS : []),
      },
    },
    {
      type: "InputIntlPhone",
      model: "contactIntlPhone",
      label: "Teléfono internacional",
      hideOn: (form) => !form?.billingCountry || form.billingCountry === "MX",
      requiredOn: (form) =>
        form?.billingCountry != null && form.billingCountry !== "MX",
    },
    {
      type: "InputSelectServer",
      model: "headquartersCity",
      label: "Ciudad sede (catálogo remoto)",
      hideOn: (form) => !form?.billingCountry,
      props: {
        callOptionsOn: (form) => form?.billingCountry,
        optionsService: fetchMockCities,
        usePagination: true,
        searchOnServer: true,
        perPage: 50,
      },
    },
    { type: "SlotField", props: { slotName: "actions" } },
  ],
});

En la plantilla, nombra los slots #slot-field-{slotName} (p. ej. #slot-field-section-location, #slot-field-actions).

Flujo condicional resumido (mismo formulario de referencia):

Disparador Efecto (campos posteriores)
Aceptar contrato Cláusulas (InputEditor), contrato PDF (InputFile) y anexos (InputFileMultiple) visibles; PDF y cláusulas requeridos
Prioridad ≥ 5 Descuento, plan, país, canales
Plan Enterprise Presupuesto anual obligatorio
País ≠ México Teléfono internacional visible y requerido
País elegido Ciudad sede (catálogo remoto)

Field presets (campos reutilizables)

Cuando el mismo campo se repite en muchos formularios (mismo servicio, mismas opciones, mismo icono…), extrae la config a un field preset en src/components/FormBuilder/Fields/.

Las dos formas de fields (compatibles)

Forma Cuándo usarla
fields: [ ... ] Formularios sin presets o campos totalmente inline.
fields: ({ preset }) => [ ... ] Con field presets: mezcla inline + preset(...) sin repetir el genérico del formulario.

No hace falta migrar formularios existentes: el arreglo clásico sigue siendo válido. El callback solo aplica al defineForm raíz; FormListitemForm.fields sigue siendo arreglo.

1. Crear el preset

npm run bor:make-field -- CountrySelect InputSelect
// src/components/FormBuilder/Fields/CountrySelect.field.ts
import { defineFieldPreset } from "@benjaminor-dev/quasar-app-extension-form-builder";

export const countrySelectField = defineFieldPreset("InputSelect", {
  label: "País",
  props: {
    prependIcon: "public",
    placeholder: "Seleccione país",
    options: [
      { label: "México", value: "MX" },
      { label: "Colombia", value: "CO" },
    ],
  },
});

2. Usarlo en el formulario

import { countrySelectField } from "src/components/FormBuilder/Fields/CountrySelect.field";

defineForm<RegisterForm>({
  formName: "register",
  fields: ({ preset }) => [
    { type: "InputText", model: "legalName", label: "Razón social" },
    preset(countrySelectField, { model: "country" }),
    preset(countrySelectField, {
      model: "billingCountry",
      label: "País de facturación",
    }),
  ],
});

API de presets

Export Uso
defineFieldPreset(type, base) Define un preset exportable (archivo en Fields/)
preset(fieldPreset, overrides) Dentro de fields: ({ preset }) => …model obligatorio; props parcial y FormAware

Los overrides respetan el mismo tipado que un campo inline (hideOn, label: (form) => …, props.placeholder, etc.).

API pública

useFormBuilder() retorna:

Miembro Uso
defineForm<T>(config) Construye la configuración del formulario (config tipado como DefineFormInput<T> en /types)
defineFieldPreset Field presets reutilizables — ver Field presets
inputRules Reglas de validación reutilizables
selectFormStore<T>(formName) Vista tipada de la sesión Pinia (values, errors, reset, …)
utils Helpers para condicionales y búsqueda tolerante

defineForm(config)

Propiedad Tipo Descripción
formName string Identificador único del formulario
fields FormField<T>[] | ({ preset }) => FormField<T>[] Listado de campos (arreglo clásico o callback con preset() para field presets)
defaultValues Partial<T> | () => Partial<T> | Promise<Partial<T>> Opcional. Overrides por campo; las claves omitidas usan el vacío del tipo ([], false, null). Si es función, se ejecuta en onMounted (precarga / edición)
fieldDesign standard | outlined | filled | standout | borderless Diseño global (default filled). No aplica a SlotField ni ToggleButton
labelPosition inner | stacked | top Posición del label (default stacked)
validationMode lazy | eager Cuándo valida Quasar (default lazy: blur o submit)

Prop de <FormBuilder> (no en defineForm):

Propiedad Tipo Descripción
gap false | xs | sm | md | lg | xl Gutter vertical entre campos (default md)

Presentación global (fieldDesign, labelPosition, validationMode, gap, defaultFieldProps): § Defaults globales (boot).

Formularios grandes y hijos con el modelo

Un solo FormBuilder por pantalla (un <q-form>). Reparte fields en módulos .ts o en Fields/*.field.ts (presets). Para leer o mutar el modelo en composables, cabeceras o hijos profundos, usa selectFormStore:

const { defineForm, selectFormStore } = useFormBuilder();
const formName = "LoginForm";

const config = defineForm<LoginForm>({
  formName,
  fields: [...identityFields, ...billingFields],
  defaultValues: { email: null },
});

// Mismo estado en cualquier componente o composable
const loginFormStore = selectFormStore<LoginForm>(formName);

loginFormStore.values.value.email = "a@b.com";
loginFormStore.setValues({ email: "a@b.com" });
if (loginFormStore.hasErrors.value) loginFormStore.clearErrors();
loginFormStore.reset();
Necesidad API recomendada
Modelo reactivo (padre, hijo, composable) selectFormStore<T>(formName).values
Errores por campo selectFormStore<T>(formName).errors / hasErrors
Asignar valores setValues({ ... })
Restaurar defaults reset()
Varios forms en la misma página Un formName distinto por formulario

FormBuilder (componente)

Prop Tipo Descripción
config retorno de defineForm Configuración del formulario
gap false | xs | sm | md | lg | xl Espaciado vertical entre campos: aplica q-gutter-{size} en el <q-form> (default md). Con :gap="false" no añade gutter integrado — usa tu propio espaciado en class o style

Por defecto los campos quedan separados con q-gutter-md. Para otro tamaño Quasar, cambia la prop; para Tailwind (gap-4), una clase SCSS o style, desactiva el gutter integrado y define el gap en el host:

<!-- Default: q-gutter-md -->
<FormBuilder :config="config" @submit="onSubmit" />

<!-- Otro tamaño Quasar -->
<FormBuilder :config="config" gap="sm" @submit="onSubmit" />

<!-- Espaciado propio (Tailwind, SCSS, style, …) -->
<FormBuilder
  :config="config"
  :gap="false"
  class="flex flex-col gap-4"
  @submit="onSubmit"
/>

<FormBuilder
  :config="config"
  :gap="false"
  :style="{ gap: '1.25rem' }"
  @submit="onSubmit"
/>

Eventos:

Evento Payload Descripción
@submit form Valores actuales tras validación de Quasar y de cada FormList visible del config
@reset form, resetForm Al pulsar un control type="reset". resetForm() restaura defaultValues y limpia errores. Si no escuchas @reset, el reset del store se aplica automáticamente

Ejemplo con reset personalizado:

<script setup lang="ts">
import type { FormBuilderResetAction } from "@benjaminor-dev/quasar-app-extension-form-builder/types";

function onReset(
  _form: CorporateIdentityForm,
  resetForm: FormBuilderResetAction,
) {
  if (confirm("¿Descartar cambios?")) {
    resetForm();
  }
  // Si no llamas resetForm(), el formulario conserva los valores actuales
}
</script>

<template>
  <FormBuilder :config="config" @submit="onSubmit" @reset="onReset" />
</template>

inputRules

Reglas para el arreglo rules de cada campo. La obligatoriedad va en requiredOn, no aquí — inputRules no expone required. En InputDate, las reglas de fecha reciben el valor interno YYYY-MM-DD; en InputNumber, InputDecimal y InputCurrency el modelo es string | null.

Regla Valida Parámetros Ejemplo
email Correo electrónico message? inputRules.email()
url URL http/https message? inputRules.url()
ip Dirección IPv4 o IPv6 (version opcional: 'ipv4', 'ipv6', 'both') message?, version? inputRules.ip() · inputRules.ip("IP inválida", "ipv4")
mac Dirección MAC de 6 octetos hex (separator opcional) message?, separator? inputRules.mac() · inputRules.mac("MAC inválida", "dash")
uuid UUID (versiones 1–8) message? inputRules.uuid()
password Mín. 8 caracteres, mayúscula, minúscula y símbolo message? inputRules.password()
regex Patrón personalizado pattern, message? inputRules.regex(/^[A-Z]{3}$/)
rfc RFC mexicano message? inputRules.rfc()
curp CURP mexicana message? inputRules.curp()
postalCode Código postal de 5 dígitos message? inputRules.postalCode()
onlyLetters Solo letras message? inputRules.onlyLetters()
onlyLettersAndSpaces Letras y espacios message? inputRules.onlyLettersAndSpaces()
onlyNumbers Solo dígitos message? inputRules.onlyNumbers()
onlyNumbersAndLetters Alfanumérico sin espacios message? inputRules.onlyNumbersAndLetters()
onlyNumbersLettersAndSpaces Alfanumérico con espacios message? inputRules.onlyNumbersLettersAndSpaces()
alphaDash Letras, dígitos, - y _ message? inputRules.alphaDash()
legalTextRule Texto en una línea (sin emojis) message? inputRules.legalTextRule()
legalTextExtendedRule Texto multilínea permitido message? inputRules.legalTextExtendedRule()
startsWith Prefijo obligatorio prefix, message? inputRules.startsWith("MX-")
endsWith Sufijo obligatorio suffix, message? inputRules.endsWith(".pdf")
date Fecha YYYY-MM-DD message? inputRules.date()
dateBeforeNow Fecha anterior a hoy message? inputRules.dateBeforeNow()
dateLTEnow Fecha ≤ hoy message? inputRules.dateLTEnow()
dateAfterNow Fecha posterior a hoy message? inputRules.dateAfterNow()
dateGTEnow Fecha ≥ hoy message? inputRules.dateGTEnow()
dateMin Fecha ≥ límite minDate, message? inputRules.dateMin("2025-01-01")
dateMax Fecha ≤ límite maxDate, message? inputRules.dateMax("2025-12-31")
minLength Longitud mínima min, message? inputRules.minLength(3)
maxLength Longitud máxima max, message? inputRules.maxLength(120)
length Longitud exacta length, message? inputRules.length(10)
between Valor, longitud o cantidad en rango min, max, message? inputRules.between(1, 100)
minNumberValue Número ≥ tope min, message? inputRules.minNumberValue(1)
maxNumberValue Número ≤ tope max, message? inputRules.maxNumberValue(100)
integer Entero (sin decimales) message? inputRules.integer()
decimal Decimales en rango (si hay punto) minDecimals, maxDecimals?, message? inputRules.decimal(2, 4)
inValue Valor en lista permitida values[], message? inputRules.inValue(["draft", "published"])
notInValues Valor fuera de lista prohibida values[], message? inputRules.notInValues(["admin"])
noRepeatedNumbers Sin un solo dígito repetido message? inputRules.noRepeatedNumbers()
file Instancia File (o arreglo de File) message? inputRules.file()
image Archivo(s) con MIME image/* message? inputRules.image()
{
    type: "InputText",
    model: "legalName",
    label: "Razón social",
    requiredOn: () => true,
    rules: [inputRules.legalTextExtendedRule(), inputRules.minLength(3)],
  },

InputFile aplica file internamente; no hace falta repetirla salvo validación manual fuera de ese campo.

selectFormStore

Acceso tipado al estado Pinia del formulario (también disponible como función independiente desde el entrypoint principal).

Fuera del template, usa selectFormStore<T>(formName) (values, errors, setValues, reset, …) — ver Formularios grandes y hijos con el modelo y Store y mapa conceptual de estado/errores.

utils

Utilidades generales para lógica condicional en el host, normalización de búsqueda y limpieza numérica. Disponibles con const { utils } = useFormBuilder().

Función Descripción
isEmpty / isNotEmpty Comprueba vacío (null, undefined, "", arrays u objetos anidados)
cleanText Normaliza texto para búsqueda tolerante (minúsculas, sin acentos ni espacios/guiones)
cleanNumber Elimina ceros a la izquierda mientras el usuario escribe un número
cleanLeadingZeros / cleanTrailingZeros Variantes de limpieza numérica en strings

cleanText alinea la búsqueda remota con la de InputSelect / InputSelectServer y con el selector de países en InputIntlPhone. isEmpty / isNotEmpty evitan reimplementar vacío en requiredOn, hideOn u options. Ver ejemplo integrado (fetchMockCities).

Catálogo de campos soportados

Referencia rápida de fields[].type. Props, reglas y ejemplos al día: autocompletado en el IDE (type + props con Ctrl/Cmd+Space) y tipos en /types — ver TypeScript en tu IDE.

type /inputs Diseño* model típico Notas
InputText string | null Texto general
InputTextarea string | null Multilínea
InputEmail string | null Regla email integrada
InputUrl string | null Regla URL integrada
InputSearch string | null Icono búsqueda + debounce
InputPassword string | null Toggle visibilidad
InputNumber string | null Solo dígitos en texto
InputNumericCode string | null Código numérico (ceros a la izquierda)
InputOtp string | null Celdas OTP; onComplete
InputDecimal string | null Decimal formateado
InputCurrency string | null Moneda + símbolo
InputPercent number Steppers ±; 0–100 default
InputSlider number Solo arrastre (QSlider)
InputSliderRange { min, max } | null Rango con dos thumbs
InputCheckbox boolean Sin fieldDesign; borderless
InputCheckboxGroup (string | number)[] Opciones locales; multiselección
InputRadioGroup string | number | null Opciones locales; una selección
InputSelect string | number | null Dropdown + búsqueda local
InputSelectMultiple (string | number)[] Chips; multiselección
InputSelectServer string | number | null Catálogo remoto; optionsService
InputSelectServerMultiple (string | number)[] Remoto + chips
InputDate string | null YYYY-MM-DD; picker; clearable (default true)
InputDateRange rango fechas minDate / maxDate / maxRange; clearable
InputTime string | null HH:mm:ss; 12h/24h; clearable
InputTel string | null Solo dígitos; formato país
InputIntlPhone string | null E.164 o structured; selector país
InputIpAddress string | null IPv4/IPv6; inputRules.ip()
InputMacAddress string | null Máscara MAC; inputRules.mac(); clearable
InputColor string | null Hex/RGB; picker modal; clearable
InputEditor string | null HTML sanitizado; toolbar; clearable
InputFile File | null Un archivo; drag & drop; preview opcional
InputFileMultiple File[] Varios archivos; tabla inline
FormList T[] Lista CRUD en tabla + diálogo; maximize centra la columna del ítem (~900px)
ToggleButton boolean Booleano estilo toggle
SlotField según slot UI custom vía #slot-field-{name}

* Diseño = soporta fieldDesign / labelPosition del formulario. = presentación propia (borderless u otro).

Tipos por campo: FieldTypes["InputSelect"], BootFieldPropsFor<"InputSelect">, etc. en @benjaminor-dev/quasar-app-extension-form-builder/types.

Ejemplos representativos

Texto + select local

// dentro de fields: [ ... ]
{ type: "InputText", model: "legalName", label: "Razón social", requiredOn: () => true },
{
  type: "InputSelect",
  model: "plan",
  label: "Plan",
  props: {
    options: [
      { label: "Básico", value: "basic" },
      { label: "Pro", value: "pro" },
    ],
    clearable: true,
  },
},

Fecha con límites dinámicos

{
  type: "InputDate",
  model: "startDate",
  label: "Inicio",
  props: {
    minDate: () => moment().format("YYYY-MM-DD"),
    maxDate: (form) => form?.endDate ?? undefined,
    clearable: true,
  },
},

Limpiar valor (clearable) — en pickers (InputDate, InputDateRange, InputTime, InputColor), InputMacAddress e InputEditor: prop FormAwareValue<boolean> (default true). Desactívala por campo (clearable: false) o en boot (defaultFieldProps.InputDate: { clearable: false }). InputSelect* usa el :clearable nativo de Quasar.

Archivo con preview (extensión dialog-file-preview opcional en el host)

{
  type: "InputFile",
  model: "contract",
  label: "Contrato",
  props: {
    accept: ".pdf",
    maxSize: 10 * 1024 * 1024,
    dragDrop: true,
    showPreview: true,
  },
},

Select remoto (catálogo grande / API)

{
  type: "InputSelectServer",
  model: "cityId",
  label: "Ciudad",
  props: {
    optionsService: async ({ query, pagination, form }) => {
      const res = await api.searchCities({ q: query, country: form?.country, ...pagination });
      return { items: res.items, pagination: res.meta };
    },
    searchOnServer: true,
    usePagination: true,
    perPage: 50,
  },
},

FormList embebido

{
  type: "FormList",
  model: "teamMembers",
  label: "Integrantes",
  requiredOn: () => true,
  props: {
    listName: "members",
    itemLabel: "Miembro",
    itemForm: {
      fields: [
        { type: "InputText", model: "fullName", label: "Nombre", requiredOn: () => true },
      ],
    },
    columns: [{ name: "fullName", label: "Nombre", field: "fullName" }],
  },
},

Slot de columna en el FormBuilder padre: #form-list-members-column-fullName. Más detalle en ejemplo integrado y tipos FormListConfig, InputFormListProps en /types.

Directivas registradas

Convención runtime: form-builder-{directiveName}. En templates Vue se usan como v-form-builder-*.

Store y mapa conceptual de estado/errores

Acceso: useFormBuilder().selectFormStore(formName) o selectFormStore(formName) importado del paquete.

selectFormStore<T>(formName)

Vista tipada de la sesión Pinia. El mismo formName en otro .vue o composable apunta al mismo estado.

Miembro Descripción
formName Identificador (defineForm.formName)
values Ref<T> — modelo anidado reactivo
flat Ref — modelo aplanado (claves con punto)
errors Errores por campo (reactivo)
hasErrors true si hay algún mensaje
getValues() / getFlat() Snapshots no reactivos
setValues(partial) Asigna por claves planas
hydrateDefaults(partial) Precarga y actualiza base del reset
setErrors / clearErrors Errores de validación
reset() Restaura defaults y limpia errores

Tipo exportado: FormSessionHandle<T> en /types.

Flujo conceptual:

  1. defineForm crea (si no existe) la estructura del formulario en el store con vacíos por tipo o con el objeto parcial de defaultValues.
  2. Si defaultValues es función, FormBuilder la ejecuta en onMounted, hidrata forms y actualiza formsDefaults (base del reset).
  3. Cada input escribe en forms[formName][campo].
  4. Errores de campo viven en formsErrors[formName][campo].
  5. resetForm restaura formsDefaults y limpia errores.

SlotField y uso de refs

En slots de SlotField con model, model y error son refs y se enlazan con .value.

Nombre Qué es
type: "SlotField" Tipo de campo en config.fields[]
SlotFieldProps Props del field (slotName, lifecycles) — /types
SlotFieldBind Payload del slot scoped (attrs) — /types

Patrón canónico en el host:

  1. Importar SlotFieldBind desde @benjaminor-dev/quasar-app-extension-form-builder/types.
  2. En el template: #slot-field-{slotName}="attrs: SlotFieldBind".
  3. En el control hijo: v-model="attrs.model.value" (y attrs.error.value si aplica).

Los separadores de sección (slotName: "section-…", sin model) no usan attrs.model; solo markup estático.

Para campos custom con model, FormBuilder resuelve requiredOn / disabledOn / readonlyOn y expone required, readonly y disable en el slot; debes cablearlos en tu control y, si usas requiredOn, añadir :rules en el template para que q-form valide al enviar.

<script setup lang="ts">
import FormBuilder, {
  useFormBuilder,
} from "@benjaminor-dev/quasar-app-extension-form-builder";
import type { SlotFieldBind } from "@benjaminor-dev/quasar-app-extension-form-builder/types";

type DemoForm = {
  customText: string | null;
};

const { defineForm } = useFormBuilder();

const config = defineForm<DemoForm>({
  formName: "demoForm",
  fields: [
    {
      type: "SlotField",
      model: "customText",
      props: {
        slotName: "custom-text",
      },
    },
  ],
});
</script>

<template>
  <FormBuilder :config="config">
    <template #slot-field-custom-text="attrs: SlotFieldBind">
      <q-input
        v-model="attrs.model.value"
        :error="!!attrs.error.value"
        :error-message="attrs.error.value || ''"
        :label="attrs.label"
        :hint="attrs.hint"
        :readonly="attrs.readonly"
        :disable="attrs.disable"
        :rules="attrs.required ? [(v) => !!v || 'Campo requerido'] : undefined"
      >
        <template v-if="attrs.required" #append>
          <span class="text-negative">*</span>
        </template>
      </q-input>
    </template>
  </FormBuilder>
</template>

Ejemplo con lista embebida (patrón recomendado — campo nativo FormList):

// dentro de fields: [ ... ]
{
  type: "FormList",
  model: "teamMembers",
  label: "Integrantes",
  requiredOn: () => true,
  props: {
    listName: "members",
    itemLabel: "Miembro",
    workflow: "free",
    itemForm: {
      fields: [
        {
          type: "InputText",
          model: "fullName",
          label: "Nombre",
          requiredOn: () => true,
        },
      ],
    },
    columns: [
      { name: "fullName", label: "Nombre", field: "fullName" },
    ],
  },
},

El scaffold bor:make-formlist genera un FormBuilder con campo FormList embebido (patrón canónico).

Extensiones opcionales relacionadas

Extensión Propósito Relación con Form Builder
@benjaminor-dev/datatable Requerida. Tabla homologada (DataTable) usada por FormList, InputFileMultiple y Table Builder Peer npm — instalar con quasar ext add @benjaminor-dev/datatable
@benjaminor-dev/table-builder Tablas declarativas con filtros Form Builder, paginación servidor/cliente y cards en móvil Peer requerido de Table Builder; usa Form Builder y DataTable
@benjaminor-dev/dialog-file-preview Diálogo modal para previsualizar File, Blob o URL (imagen con zoom, PDF con scroll continuo en documentos ligeros o paginado en archivos pesados, video, audio, texto) vía useDialogFilePreview(); loader mientras prepara y carga el visor InputFile e InputFileMultiple la detectan automáticamente (showPreview default true): append o filas de la tabla inline con ver/descargar + limpiar; también usable manualmente con useDialogFilePreview(); no es dependencia de este paquete

Instalación en el host (extensiones adicionales):

quasar ext add @benjaminor-dev/datatable
quasar ext add @benjaminor-dev/dialog-file-preview

Orden de boots (stack completo):

1. Pinia (host)
2. DataTable — CSS
3. Form Builder — boot/store
4. bor/bor-form-builder-defaults (host)
5. Table Builder — boot/store
6. bor/bor-table-builder-defaults (host)
7. Dialog File Preview — boot/dialog
8. bor/bor-dialog-file-preview-defaults (host)
9. Dialog Loader — boot/dialog
10. bor/bor-dialog-loader-defaults (host)
11. Dialog Message — boot/dialog
12. bor/bor-dialog-message-defaults (host)

Regla: registra el defaults boot antes del primer uso (defineForm, defineTable, show()). En quasar.config verás entradas como bor/bor-form-builder-defaults; los boots del paquete los inyecta la extensión al compilar — no aparecen como entradas cortas en tu config.

Customizar plantilla scaffold

Puedes reemplazar la plantilla que genera bor:make-form creando en tu app host:

scripts/bor/stubs/bor-make-form.stub

Para formularios con lista (bor:make-formlist):

scripts/bor/stubs/bor-make-formlist.stub

Para field presets (bor:make-field):

scripts/bor/stubs/bor-make-field.stub

Ese archivo tiene prioridad sobre la plantilla incluida en la extensión.

Entrypoints públicos

Entrypoint Propósito
@benjaminor-dev/quasar-app-extension-form-builder FormBuilder, useFormBuilder, selectFormStore, defineFieldPreset, configureFormBuilderDefaults, configureFormBuilder (alias), boots e inputs reexportados
@benjaminor-dev/quasar-app-extension-datatable DataTable, TablePaginationBar, composables y helpers de paginación (peer requerido de Form Builder)
@benjaminor-dev/quasar-app-extension-form-builder/boot/store Boot de integración Pinia para formularios
@benjaminor-dev/quasar-app-extension-form-builder/boot/directives Boot de registro de directivas
@benjaminor-dev/quasar-app-extension-form-builder/inputs Componentes de input publicados
@benjaminor-dev/quasar-app-extension-form-builder/scaffold runBorHelp, runBorMakeForm, runBorMakeFormlist, runBorMakeField (bor:help, bor:make-form, bor:make-formlist, bor:make-field)
@benjaminor-dev/quasar-app-extension-form-builder/types Tipos de config, campos, boot (DefineFormInput, DefineFieldPresetFn, DefineFormFieldsContext, FormBuilderDefaults, BootFieldPropsFor, FieldUiConfigFor, FormListConfig, FormAwareValue, …) — autocompletado en IDE
@benjaminor-dev/quasar-app-extension-form-builder/main.css Estilos de la librería

No hay entrypoint /form-list ni paquete npm aparte para listas: FormList es un tipo de campo nativo de este paquete.

Troubleshooting

El callback condicional marca error de tipos

Si declaras requiredOn: (form: MyForm) => ..., TypeScript puede fallar por contravarianza. La firma real acepta form opcional.

Usa:

requiredOn: (form) =>
  form?.phone === "123";

o:

requiredOn: (
  form?: FormBuilderSubmitPayload<MyForm>,
) => form?.phone === "123";

Duplicar validación required en rules

requiredOn ya inyecta la obligatoriedad internamente. No añadas reglas manuales de «campo requerido» en rules; resérvalas para validaciones adicionales (email, rfc, tamaño, etc.).

El defaults boot no aparece en quasar.config

En condiciones normales el install lo registra solo. Si falta (proyecto antiguo o edición manual):

npx quasar ext invoke @benjaminor-dev/form-builder

El comando npm no aparece en host

Ejecuta en el host:

npx quasar ext invoke @benjaminor-dev/form-builder

Pinia no inicializada

Asegura que el boot de Pinia corra antes del boot store de la extensión.

Falla el check de @benjaminor-dev/quasar-app-extension-datatable

Form Builder exige el peer DataTable. Instálalo antes que esta extensión:

quasar ext add @benjaminor-dev/datatable

Sin él, FormList / InputFileMultiple no resuelven el componente y faltan estilos de tabla.

InputDate/InputDateRange/InputTime no respetan límites

Verifica formato de FormAwareValue:

gap="false" no desactiva el gutter de Quasar

Para quitar q-gutter-* usa :gap="false" (boolean con v-bind). Si escribes gap="false" sin :, Vue envía el string "false"; desde 0.9.7 el runtime lo trata como desactivado, pero en plantillas nuevas prefiere siempre :gap="false".

validationMode eager molesta en campos con máscara

validationMode: 'eager' hace que Quasar evalúe reglas en cada pulsación. En campos con formato visual (fechas, teléfonos, moneda) suele ser mejor el default lazy, que valida al salir del campo o al enviar el formulario.

InputFile / InputFileMultiple no muestran archivos precargados

InputFile solo acepta File | null; InputFileMultiple solo acepta File[] (vacío = []). Si pasas un Blob suelto, una URL, base64 o un objeto { url, name }, el valor se normaliza a vacío. Crea File en el cliente:

const blob = await fetch(url).then(
  (r) => r.blob(),
);
form.signedContractPdf = new File(
  [blob],
  "documento.pdf",
  {
    type:
      blob.type || "application/pdf",
  },
);

En formularios de alta pura, deja el campo en null hasta que el usuario seleccione el archivo.

No aparece el botón ver/descargar en InputFile / InputFileMultiple

Select/radio/checkbox group no reconoce el valor precargado

Los campos con opciones comparan modelValue con options[].value de forma estricta (===). Si el API devuelve cityId: 1 pero las opciones usan value: "1", no coincidirán. Alinea tipos en defineForm, en defaultValues y en el value de cada opción. Con valores numéricos, 0 es selección válida para required en selección única.

El archivo tarda en aparecer tras elegirlo en el diálogo del sistema

Es normal con archivos pesados: InputFile e InputFileMultiple muestran un indicador de carga mientras el navegador expone el File al modelo. El diálogo de @benjaminor-dev/dialog-file-preview también muestra loader al abrir y mientras el visor carga el contenido (PDF, imagen grande, etc.).

defaultValues como función no precarga el formulario

La lista no aparece en el submit del formulario padre

El diálogo de filas inválidas solo aparece una vez


MIT © Benjamín Olvera R.