Schema
Describe an entity once as a FieldDefinition[]. The form, the Zod schema and the table columns are all derived from it, so they cannot drift apart.
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import SchemaForm from '@core/schema/SchemaForm.vue'
import { ref } from 'vue'
const schema: FieldDefinition[] = [
{ key: 'name', type: 'text', label: 'Full name', required: true, grid: 6 },
{ key: 'email', type: 'email', label: 'Email', required: true, grid: 6 },
{
key: 'seats',
type: 'number',
label: 'Seats',
required: true,
grid: 6,
validations: { min: 1, max: 50, message: 'Pick between 1 and 50 seats' },
},
{
key: 'plan',
type: 'select',
label: 'Plan',
required: true,
grid: 6,
options: [
{ label: 'Starter', value: 'starter' },
{ label: 'Growth', value: 'growth' },
{ label: 'Scale', value: 'scale' },
],
},
{ key: 'notes', type: 'textarea', label: 'Notes', placeholder: 'Anything we should know' },
]
const submitted = ref<Record<string, unknown> | null>(null)
</script>
<template>
<div class="flex flex-col gap-4">
<SchemaForm :schema="schema" @submit="submitted = $event" />
<pre v-if="submitted" class="rounded-lg border border-border p-3 text-xs">{{ submitted }}</pre>
</div>
</template>Submit it empty and every required field reports itself. Put 99 in Seats and the constraint speaks with the field's own message.
The field list
import type { FieldDefinition } from '@core/schema/types'
export const productFormSchema: FieldDefinition[] = [
{ key: 'name', type: 'text', label: 'products.fields.name', required: true, grid: 6 },
{
key: 'sku',
type: 'text',
label: 'products.fields.sku',
required: true,
grid: 6,
validations: { pattern: '^[A-Z]{3}-\d{4}$', message: 'products.errors.sku' },
},
{
key: 'price',
type: 'number',
label: 'products.fields.price',
required: true,
validations: { min: 0, max: 1000000 },
},
{
key: 'category',
type: 'select',
label: 'products.fields.category',
options: [
{ label: 'Audio', value: 'audio' },
{ label: 'Computing', value: 'computing' },
],
},
{ key: 'createdAt', type: 'date', label: 'products.fields.createdAt', hideInForm: true },
]label is a translation key. uiText resolves it where it renders and returns an unknown key verbatim, so a plain English label works too.
Field types
Fourteen, each mapped to a control and a Zod rule.
| Type | Control | Notes |
|---|---|---|
text | Input | |
email | Input | Validated as an email address. |
password | Input | Masked, with a show and hide toggle. Never a table column. |
number | Input | Numeric keyboard, digits only on key and paste. |
textarea | Textarea | Always full width. |
select | Select | Needs options. |
combobox | SaCombobox | Type to filter a long list. Needs options. |
multiselect | SaMultiSelect | Several values. min and max count selections. |
date | Calendar in a popover | A YYYY-MM-DD string. |
datetime | SaDateTimePicker | Date and time. |
checkbox | Checkbox | |
switch | Switch | |
file | FileUpload | Configured by upload. Never a table column. |
editor | SaEditor | Rich text. Loaded on demand, so Tiptap stays out of the first chunk. Never a table column. |
Every key
| Key | Type | Effect |
|---|---|---|
key | string | The property on the model. |
type | FieldType | One of the fourteen above. |
label | string | Translation key or literal. |
placeholder | string | Translation key or literal. |
description | string | Helper text under the control. |
required | boolean | Also rejects an empty string, and an empty list on a file field. |
disabled | boolean | |
defaultValue | unknown | The form's initial value. Not a Zod default, so a missing key stays distinguishable from a default on a PATCH. |
options | FieldOption[] | { label, value, disabled? }, for select, combobox and multiselect. |
upload | object | file only: endpoint, maxSize, acceptedTypes, multiple, maxFiles. |
validations | FieldValidations | See below. |
visibleIf | object or function | { field, value } compares, or (data) => boolean decides. |
grid | 1–12 | Column span on the twelve-column form grid. |
group | string | Section or tab name. Drives the grouped, wizard and tabs layouts. |
sortable | boolean | Table column flag. |
filterable | boolean | Table column flag. |
searchable | boolean | Table column flag. |
hideInTable | boolean | Keep it out of the columns. |
hideInForm | boolean | Keep it out of the form, and out of validation. |
render | function | Custom cell output. |
Validations
| Key | Applies to | Effect |
|---|---|---|
min / max | numbers, dates, multiselect | Bounds, or the number of selections. |
minLength / maxLength | text | Length bounds. |
pattern | text | A regular expression source string, not a RegExp, so a schema stays serializable. |
message | all | Replaces the message for this field's constraints. |
message covers constraints only. An empty required field and a wrong type keep the engine's own wording, whatever the field says about its bounds — so "The email field is required" stays consistent across the app while "Pick between 1 and 50 seats" is yours.
The default messages live under validation.* in the locale files and carry the field's label:
{
"required": "The {field} field is required",
"min": "The {field} must be {min} or more",
"minLength": "The {field} must be at least {min} characters"
}Placeholders
A field with no placeholder of its own gets one built from its label, from src/core/schema/fieldPlaceholders.ts:
| Control | English placeholder |
|---|---|
text, email, password, number, textarea | "Enter {label}" |
select, combobox, multiselect, date | "Select {label}" |
The search box of a combobox, a multiselect, or a select with six options or more | "Search {label}…" |
The wording lives under field.* in the locale files, so every language has its own.
In English the label sits inside a sentence, so it is written in lower case: a field labeled "Full name" reads "Enter full name", a "Plan" select reads "Select plan", and a searchable one shows "Search plan…" in its search box. The validation messages follow the same rule: "The full name field is required". A word with more than one capital keeps them, so SKU and "API key" stay as they are. Other languages keep the label exactly as written, because German, for one, capitalizes every noun.
A field's own placeholder always wins over the generated one.
What derives from it
useSchema takes the field list and returns five things:
import { useSchema } from '@core/schema/useSchema'
const { fieldList, formFields, tableColumns, zodSchema, fieldGroups } = useSchema(() => schema)fieldList | The list as given. |
formFields | Everything without hideInForm. |
tableColumns | Everything without hideInTable. |
zodSchema | A Zod object built from formFields only. |
fieldGroups | formFields bucketed by group, in first-seen order. |
Validating only formFields is deliberate: a field hidden from the form cannot block a submit. tableColumns keeps hideInForm fields, because a column such as createdAt needs no input.
It takes a ref, a getter or a plain array, and everything it returns is computed, so a schema that changes with the language regenerates its messages without a reload.
To table columns
import { schemaToColumns } from '@core/schema/schemaToColumns'
const columns = computed(() => schemaToColumns(productFormSchema))include keeps named keys in a given order, exclude drops more on top of hideInTable, and locale overrides the tag used to format date cells. editor, file and password are never columns unless include asks for them. Dates are formatted at render time, not at call time, so they follow the language switcher.
To a form
<script setup lang="ts">
import SchemaForm from '@core/schema/SchemaForm.vue'
import { productFormSchema } from '../types/product.schemas'
</script>
<template>
<SchemaForm :schema="productFormSchema" @submit="save" />
</template>The form, its layouts and the server-error path are their own page: Forms.
Conditional fields
{ key: 'vatNumber', type: 'text', label: 'Tax number', visibleIf: { field: 'business', value: true } }
{ key: 'shipping', type: 'text', label: 'Shipping note', visibleIf: data => data.country !== 'local' }A hidden field is out of the render and out of validation while it is hidden, so an invisible rule cannot block a submit.
Where to go next
- Forms —
SchemaForm, its layouts, drafts and server errors. - DataTable — what the generated columns feed.
- Add a module — the CLI writes this file for you.