quasar-zod-forms
v0.6.2
Published
Zod-schema-driven Quasar forms with Firestore codecs, JSON form definitions, and a form builder
Maintainers
Readme
quasar-zod-forms
Zod-schema-driven Quasar forms with Firestore codecs. One zod (v4) schema describes the data, the UI, and the storage format:
- decode (read): Firestore doc → form model (
Timestamp→Date,DocumentReference→"collection/id"path) - encode (validate + save): form model → Firestore payload — the same direction the backend validates
import { z } from "zod";
import { ZodForm, firestoreTimestamp, firestoreRef, fromFirestore, emptyValues } from "quasar-zod-forms";
const schema = z.object({
title: z.string().min(3).meta({ label: "Title", section: { title: "Event" } }),
org: firestoreRef({ label: "Org", options: [{ label: "QCEC", value: "orgs/qcec" }] }),
startsAt: firestoreTimestamp({ label: "Starts at" }),
published: z.boolean().default(false).meta({ label: "Published" }),
});
// load
const model = reactive(fromFirestore(schema, (await getDoc(ref)).data()));
// render — @submit receives the ENCODED (storage-shaped) payload
// <ZodForm :schema="schema" :model-value="model" @submit="p => setDoc(ref, p)" />Point the codecs at the real SDK once per app (defaults are plain-object mocks, which is what the demo uses):
import { Timestamp, doc } from "firebase/firestore";
setFirestoreAdapter({ timestampFromDate: Timestamp.fromDate, docRef: (p) => doc(db, p) });The same schema file works in Cloud Functions: schema.parse(doc.data()) validates the
stored shape; UI metadata is ignored there.
Field metadata (.meta({...}))
label, hint, placeholder, options, props (spread onto the Quasar component),
section: { title, caption } (renders a header above the field — content lives in the
schema without polluting the data shape), hidden, showIf, optionNotes (same
semantics as their JSON-definition counterparts below), and widget to override the
auto-mapping:
| zod type | default widget | overrides |
|---|---|---|
| z.string() | q-input | textarea, color |
| z.number() | q-input type=number | slider, rating |
| z.boolean() | q-checkbox | toggle |
| z.enum() | q-select | radio, btn-toggle |
| z.array(enum) | q-select multiple | |
| z.array(object) | repeater (add/remove rows) | |
| z.object() | nested group | |
| z.date() / firestoreTimestamp() | date / datetime picker | |
Text-entry and select widgets (q-input/q-select/q-file/date pickers) render with
Quasar's filled design by default — override per field with
props: { filled: false, outlined: true } (or any other design prop).
Validation runs in the encode direction (z.safeEncode) on submit, then live; errors are
flattened by path and shown per field, including inside nested objects and array rows.
JSON form definitions & builder
Forms can also be defined as serializable JSON (storable in Firestore) and hydrated into a zod schema — same renderer, same validation, same codecs:
import { hydrateForm, FormBuilder } from "quasar-zod-forms";
const schema = hydrateForm({
fields: [
{ key: "about", type: "section", label: "About", hint: "Display-only header" },
{ key: "name", type: "text", label: "Name", min: 2 },
{ key: "team", type: "select", options: ["Events", "Comms"] },
{ key: "startDate", type: "timestamp", label: "Start date" },
{ key: "attendees", type: "repeater", min: 1, fields: [{ key: "name", type: "text" }] },
],
});
// <ZodForm :schema="schema" ... /> as usualField type is one of FIELD_TYPES (text, textarea, email, url, number, slider, rating,
toggle, checkbox, select, multiselect, combobox, radio, btn-toggle, color, date, datetime,
timestamp, ref, upload, section, html, group, repeater), plus label/hint/placeholder/required/default/
options/min/max/fields. An upload field renders a file picker (accept +
maxSizeMB client-side limits); on pick, the file goes through the app-provided
upload adapter and the field's stored value becomes the returned metadata object
({ path, name, size, contentType, url? }):
import { setUploadAdapter } from "quasar-zod-forms";
setUploadAdapter({
upload: async (file, field) => {
const r = ref(storage, `links/${token}/${field.key}/${file.name}`);
await uploadBytes(r, file);
return { path: r.fullPath, name: file.name, size: file.size,
contentType: file.type, url: await getDownloadURL(r) };
},
});A section field is display-only (header + caption from
label/hint) and holds no value. An html field is also display-only: its html string is
injected into the form via v-html (author-trusted content — never render user-supplied
markup). In hand-written schemas use z.unknown().optional().meta({ widget: "html", html: "<p>…</p>" }). A combobox is a text input with filterable suggestions
from options — freeform text is allowed (built for things like a 200-entry church list).
Definitions are validated with jsonFormSchema before hydration (they're untrusted once
stored in a DB), and duplicate keys are rejected.
Per-field extras:
showIf: { key, equals? }— render (and require, unlessrequired: false) only while the sibling fieldkeyequalsequals(defaulttrue); when the sibling's value is an array (multiselect), while it containsequals— e.g.showIf: { key: "diets", equals: "Other" }. Values typed behind a gate that later closes are kept in the model but pruned before validation and submit.optionNotes: { "Option": "prompt" }(multiselect) — selecting a listed option reveals a required comment box; the value shape becomes{ selected: [], notes: {} }.notesKey: "dietNotes"(withoptionNotes) — store the notes in that SIBLING key (an{ option: note }record) instead of wrapping the value: the field's own value stays a plainstring[]. Built for flat storage shapes like Firestore docs withdiets: []+dietNotes: {}. The sibling key is added to the schema automatically (hidden) unless the definition declares it.hidden: true— never rendered; the value stays in the model and survives encode (e.g. admin-only columns).locked: true— the builder UI shows the field disabled with a lock icon: it can be reordered but not edited or deleted. Use it to seed definitions with fields the backend depends on.props: { filled: true, rounded: true, ... }— spread onto the Quasar component, same asmeta.propson hand-written schemas.
Hosts that render their own submit button (via the #actions slot) can trigger the
same validation ZodForm's submit runs: call formRef.validate() on the component
(paints inline errors and arms live re-validation), or use the exported
validateModel(schema, model) outside the component — both prune values behind
closed showIf gates before encoding.
<FormBuilder :fields="def.fields" /> is the dynamic builder UI — users add, edit,
reorder, and nest fields; the array it mutates IS the JSON definition. See the
"Form builder" demo tab for the full builder → JSON → live-preview loop.
Demo
npm install && npm run devThree tabs: kitchen sink (all widgets), Firestore binding (mock store, watch the raw doc
round-trip), nested & arrays. npm test runs the codec round-trip self-checks.
