Forms & Validation
The standard way to build a form in the App - useModelState, field components and validation rules.
Every form in the App uses one pattern from Vexa: a useModelState holding the form's values, Vexa field components bound to it, and validation rules declared on each field. The template around the form runs the rules and calls a server action to save.
A form
The fields live in a client component:
"use client";
import { maxLength, required, Select, TextField, useModelState, type SelectOption } from "@ernokieran/vexa";
export function AddTariffFields({ energyTypes }: { energyTypes: readonly SelectOption[] }) {
const tariff = useModelState({ name: "", energyType_UID: undefined as string | undefined });
return (
<>
<TextField model={tariff} field="name" label="vexa.common.name" rules={[required(), maxLength(100)]} />
<Select model={tariff} field="energyType_UID" label="tariffs.energyType" options={energyTypes} rules={[required()]} />
</>
);
}The page wraps them in a template and gives it a server action to save:
<TemplatePopup title="tariffs.add" closeHref={tariffsUrl()} saveLabel="tariffs.add" onSave={addTariffAction}>
<AddTariffFields energyTypes={energyTypes} />
</TemplatePopup>useModelState(initialValues)holds the values. Its keys are the field names, sofieldis checked by the compiler.- Field components (
TextField,NumberField,Select,DateRangeField,ListFieldand the rest) takemodel,field,labelandrules. Every input in a form is one of these, even when it has no rules. - The template runs every rule on Save and only calls
onSavewhen all of them pass.TemplatePopup,TemplateEditorand the other form templates work the same way. - Typing in a field clears that field's error.
Rules
Rules come from @ernokieran/vexa. A rule receives the field's value, its label and the form's values, and returns an error message or undefined. The first failing rule for a field is the one shown.
| Rule | Checks |
|---|---|
required() | The value isn't empty. Marks the field with *. |
requiredIf(condition) | The value isn't empty while condition holds. Marks the field with * only while it does. |
minLength(n), maxLength(n) | Text length. |
min(n) | A number is at least n. |
greaterThan(field, label), lessThan(field, label) | A value compared with another field. greaterThanIf and lessThanIf only apply while a condition holds. |
after(field, label) | A date is after another field's date. |
matches(field, label) | The value equals another field, such as a password confirmation. |
email(), time() | The value is an email address or a time. |
onlyWhen(condition, rule) | Runs a rule only while a condition holds. |
Conditions are built with field, isFilled, isEqualTo, isGreaterThan, isLessThan, not, all and any. For a check of your own, use createRule, the same way the tariff rate schedule checks its windows cover the whole day.
Server-defined fields
Integration configuration fields come from the API with their own validation rules. configurationFieldRules turns those into Vexa rules, so they're never written by hand. Render them with ConfigurationFields from shell/ConfigurationFields.
Errors from the API
The save action returns a FormSaveResult when something the rules couldn't check goes wrong, such as a duplicate name. Put the message on the field it belongs to:
if (result.status === "name-taken") {
return { errors: { name: "tariffs.nameTaken" } };
}Use message only for problems that don't belong to a single field.
Things to avoid
- Don't validate inside the save action if the check can be a rule. Rules run before Save and show on the right field.
- Don't build inputs by hand in a form. Use the field components so every value goes through the model state.