Forms
Overview
Form builds a form component from a list of fields. Each field names a data type, and the data type supplies both the input component the frontend renders and the Zod schema the value is validated against. The builder does three things when you call it: it serializes every field, it compiles the field list into a single Zod object schema, and it attaches the JSON Schema form of that schema to the component options — so the description shipped to the frontend already carries its own validation contract.
Everything on this page is exported from the form module, reachable either from the package root or from its own subpath:
import { Form } from "@antelopejs/interface-dms/base";
Or import directly from the form subpath:
import { Form, FormComponents } from "@antelopejs/interface-dms/base/form";
Form returns a FormBuilder, which is a ComponentBuilder<FormPropsSerialized> with one extra readonly property, fields, carrying the original unserialized FormFieldOrGroup[]. That property is what lets formSchema derive a schema straight from a builder.
Declare a Form
Assign the builder to a static field of a page class. The form fetches its initial values from fetchUrl and posts back to submitUrl.
import {
PageController,
pagesCategory,
RegisterPage,
} from "@antelopejs/interface-dms/page";
import { Form } from "@antelopejs/interface-dms/base";
import { DefaultDataTypes } from "@antelopejs/interface-dms/base/data-types";
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
import { HttpMethod } from "@antelopejs/interface-dms/base/types";
const notesForm = Form({
title: "Notes",
fields: [
{
id: "message",
label: "Message",
description: "Stored in the playground `notes` table.",
type: new DefaultDataTypes.StringType({
placeholder: "Hello from the playground",
maxLength: 500,
}),
required: true,
},
],
fetchUrl: "/playground/notes",
submitUrl: "/playground/notes",
submitUrlMethod: HttpMethod.post,
});
@RegisterPage()
export class NotesPage extends PageController(
"notes",
{ displayName: "Notes", icon: "i-ph-note", category: pagesCategory },
DefaultLayout(),
) {
static content = notesForm;
}
FormProps
FormProps extends BaseComponentProps.
| Option | Type | Description |
|---|---|---|
fields | FormFieldOrGroup[] | The fields and field groups of the form. Beside sections, they come first, outside any section. |
sections | FormSection[] | The fields split into titled cards, { id, label, description?, icon?, navGroup?, fields } (navGroup: the heading its entry sits under in the navigation, side list or chips, shared by consecutive sections naming the same one), on one page and saved together. |
sectionNav | FormSectionNav | "side" (a list beside the sections following the scroll, chips on a narrow form), "jump" (chips above them) or "none". Defaults to "side" from three sections, "none" below. |
title | string | Form title. Also becomes the component's display name; defaults to "Form" when absent. |
description | string | Form description. |
fetchUrl | string | URL the form loads its initial values from. |
fetchUrlMethod | HttpMethod | Method for fetchUrl. |
submitUrl | string | URL the form submits to. |
submitUrlMethod | HttpMethod | Method for submitUrl. |
submitLabel | string | Label of the submit button, as an i18n key or a literal. Defaults to the generic "Save changes" label. |
successMessage | string | Message shown after a successful submit. |
errorMessage | string | Message shown after a failed submit. |
fieldsOrientation | "horizontal" | "vertical" | Layout direction of the field list. |
redirectOnSuccess | string | Path to navigate to after a successful submit. Accepts {{params.X}} and {{query.X}} tokens like submitUrl, plus {{response.X}} which resolves against the JSON body of the submit response. |
submitDefaults | Record<string, unknown> | Values merged into the submit payload even when the field is absent from the form. String values accept {{params.X}} and {{query.X}} tokens resolved against the form's route at submit time; entries whose tokens do not resolve are dropped. |
saveMode | FormSaveMode | How the form offers to save. "bar" (default): a sticky bar shown while there are unsaved changes, naming them, with Discard and Save; on a page it floats over the form, taking no space while hidden. "footer": buttons in the form's footer. "none": no buttons. "instant": each change saves on its own, the changed field alone sent to submitUrl (so the endpoint must accept a partial body), a pick at once and a text once typing pauses; a refused save is put back to the last confirmed value, with a Retry; an "action" form ignores it. In a drawer or modal, "bar" and "footer" both use the container's footer. |
kind | FormKind | What the form edits. "record" (default): keeps its values once saved, and offers Cancel while clean only when it has somewhere to go back to (the drawer or modal it sits in, or backTo on a page). "action": a send, invite or run form; its submitLabel always shows in its footer, ready, so a pre-filled form sends its defaults as they are (a form with no field to fill in included); Reset joins it once a value changes (in a drawer or a modal, Cancel before that), and it empties after a successful submit (each field back to its defaultValue, or what fetchUrl loaded). |
backTo | string | Where Cancel leads a "record" form on a page; without it the form offers no Cancel there. TableView sets it on its form pages, to the list. |
labelKey | string | Field of the loaded record that names it: on a page, the breadcrumb ends with its value once the form has loaded. TableView sets it on its edit and details pages from its own labelKey. Also in the page editor's Form schema, as an advanced option. |
requiresRecord | boolean | The form is about the record fetchUrl loads: while a route value that URL names is missing (a page reached without its ?id=), it shows "No entry selected" instead of its fields, and cannot be saved. ResourceForm sets it in edit and view. |
A form on a page that sets neither saveMode nor kind saves with the save bar, as a record: declare kind: "action" on a form that sends something new each time. FORM_SAVE_MODES and FORM_KINDS list the accepted values.
An "action" form resets after a successful submit, a "record" keeps its values; labelKey names the field labelling the record.
FormPropsSerialized is FormProps with fields replaced by FormFieldOrGroupSerialized[] — every entry of the form, its own then each section's (formEntries) — sections by FormSectionSerialized[] (each section with the fieldIds of the entries it holds), and one added property, schema, holding the JSON Schema produced from the compiled Zod object. A required boolean field whose input is a checkbox validates as z.literal(true) (isAcceptanceField).
Fields
A FormField describes one input.
| Option | Type | Description |
|---|---|---|
id | string | Field key in the payload. Required. |
type | DataType | Data type instance. Supplies the input component and the validation schema. Required. |
label | string | Field label. |
description | string | Help text. |
inputComponent | ComponentInfoSerialized | Overrides the input component the data type would provide. |
disabled | boolean | Render the input non-editable. |
required | boolean | Whether a value must be supplied. Drives the schema, see below. |
defaultValue | DefaultValue | Initial value. |
localized | boolean | The value is a per-locale record rather than a single value. |
FormFieldSerialized replaces type with the data type's registered id string and inputComponent with a mandatory component: ComponentInfoSerialized — the resolved input component, taken from inputComponent when set and from type.inputComponent() otherwise.
Group Fields
A FieldGroup nests fields under a common heading. It is an alternative member of the fields array, not a wrapper around it.
import type { FieldGroup } from "@antelopejs/interface-dms/base/form";
import { DefaultDataTypes } from "@antelopejs/interface-dms/base/data-types";
const contact: FieldGroup = {
id: "contact",
label: "Contact",
orientation: "horizontal",
order: 1,
fields: [
{ id: "email", label: "Email", type: new DefaultDataTypes.EmailType() },
{ id: "phone", label: "Phone", type: new DefaultDataTypes.PhoneType() },
],
};
| Option | Type | Description |
|---|---|---|
id | string | Group identifier. Required. |
fields | FormField[] | Fields in the group. Required. |
label | string | Group heading. |
description | string | Group description. |
orientation | "horizontal" | "vertical" | Layout direction inside the group. |
order | number | Ordering hint among the top-level entries. |
FieldGroupSerialized is the same shape with fields: FormFieldSerialized[].
FormFieldOrGroup is FormField | FieldGroup, and FormFieldOrGroupSerialized is FormFieldSerialized | FieldGroupSerialized. Two guards discriminate them, both by checking for an array-valued fields property:
import {
isFieldGroup,
isFieldGroupSerialized,
} from "@antelopejs/interface-dms/base/form";
for (const item of formProps.fields) {
if (isFieldGroup(item)) {
// item is FieldGroup
}
}
Validate a Submission
The schema the form ships to the frontend is also available on the backend, so the route that receives the submission validates against exactly the same rules.
import { Controller, HTTPResult, JSONBody, Post } from "@antelopejs/interface-api";
import { formSchema } from "@antelopejs/interface-dms/base";
const notesFormSchema = formSchema(notesForm);
export class NotesController extends Controller("/playground/notes") {
@Post("")
async create(@JSONBody() body: unknown) {
const parsed = notesFormSchema.safeParse(body);
if (!parsed.success) {
return new HTTPResult(400, { error: "Invalid form payload" });
}
// parsed.data is validated
}
}
formSchema
function formSchema(
source: FormSchemaSource,
): z.ZodObject<Record<string, z.ZodTypeAny>>;
FormSchemaSource is FormFieldOrGroup[] | FormProps | FormBuilder. formSchema accepts any of the three: an array is compiled directly, a FormBuilder and a FormProps object are compiled from their fields. Anything else throws Error("formSchema: unsupported source").
buildFormSchema
function buildFormSchema(
fields: FormFieldOrGroup[],
): z.ZodObject<Record<string, z.ZodTypeAny>>;
buildFormSchema is the field-list form of the same operation and is what Form itself calls. It walks the list, flattens groups so a grouped field lands in the object under its own id, and adapts every field's base schema. The resulting object is flat: grouping affects layout, not the payload shape.
adaptFieldValidationSchema
function adaptFieldValidationSchema(
baseSchema: z.ZodTypeAny,
options: { localized?: boolean; required?: boolean },
): z.ZodTypeAny;
adaptFieldValidationSchema turns the schema a data type returns from getValidation() into the schema for one particular field:
- With
localized: truethe schema is wrapped inz.record(z.string(), schema), so the value becomes a map of locale to value. - When
requiredis falsy the schema becomes.nullable().optional(). - When
requiredis true and the schema is aZodStringor aZodArray, a.min(1)is added so an empty string or empty array does not satisfy the requirement.
It is exported because the same adaptation is applied outside forms — the Column decorator uses it to register a field validator on a data controller.
serializeFormFields
function serializeFormFields(
fields: FormFieldOrGroup[],
): FormFieldOrGroupSerialized[];
serializeFormFields produces the serialized field list: for each field it resolves the input component (inputComponent if given, otherwise type.inputComponent()) and replaces the data type instance with its registered id, falling back to the string "unknown" when the type is not registered. Groups keep their metadata and have their fields serialized in place.
Input Components
FormComponents is a namespace of factory functions, each returning a ComponentInfoSerialized for one input widget. Data types call them to produce their default input; you call them directly when a field needs a widget other than its type's default, via FormField.inputComponent or ColumnOptions.inputComponent.
import { FormComponents } from "@antelopejs/interface-dms/base/form";
const priority = FormComponents.InputSelect({
items: [
{ label: "Low", value: "low" },
{ label: "High", value: "high", icon: "i-ph-warning" },
],
placeholder: "Pick a priority",
});
| Function | Options interface |
|---|---|
InputCheckbox() | — |
InputText(options?) | InputTextOptions |
InputTextarea(options?) | TextareaOptions |
InputEmail(options?) | InputEmailOptions |
InputColor(options?) | InputColorOptions |
InputPassword(options?) | InputPasswordOptions |
InputNumber(options?) | InputNumberOptions |
InputPercentage(options?) | InputNumberOptions |
InputTime(options?) | InputTimeOptions |
InputPhone(options?) | InputPhoneOptions |
InputSlider(options?) | SliderOptions |
InputSwitch() | — |
InputRadioGroup(options) | RadioGroupOptions |
InputSelect(options) | SelectOptions |
InputRelation(options) | RelationOptions |
InputCascader(options) | CascaderRelationOptions |
InputAddress(options?) | AddressOptions |
Calendar(options?) | CalendarOptions |
DatePicker(options?) | CalendarOptions |
DatePickerRange(options?) | DatePickerRangeOptions |
RichText(options?) | RichTextOptions |
File(options?) | FileOptions |
Image(options?) | ImageOptions |
The option interfaces live in the same namespace. The ones with more than a placeholder:
| Interface | Fields |
|---|---|
SelectOption | label: string, value: string | number, disabled?, icon?, iconColor?, textColor? |
SelectOptions | items: SelectOption[], placeholder?, multiple?, deselectable? |
RadioGroupOptions | items: SelectOption[], orientation?: AxeOrientation |
InputTextOptions | placeholder?, maxLength?, minLength?, showCount? |
TextareaOptions | placeholder?, rows?, maxLength?, showCount? |
InputNumberOptions | min?, max?, step?, placeholder?, suffix?, clamp? (InputNumber only: false keeps a value outside min / max as typed and reports it under the field instead of clamping it on blur) |
InputTimeOptions | min?, max?, placeholder? |
InputPhoneOptions | placeholder?, requiredPrefix? |
InputPasswordOptions | placeholder?, minLength?, confirmPassword?, confirmPlaceholder? |
SliderOptions | min?, max?, step? |
CalendarOptions | range?, multiple?, minDate?, maxDate? |
DatePickerRangeOptions | minDate?, maxDate? |
RelationOptions | searchUrl: string, placeholder?, multiple?, deselectable?, keyMapping?: { label?, value?, avatar?, disabled? }, addForm?: ComponentInfoSerialized<FormPropsSerialized>, addPermissionIds?: string[], addFormTexts?: { displayName?, description? } |
CascaderRelationKeyMapping | label: string, value: string, parent: string, disabled? |
CascaderRelationOptions | searchUrl: string, keyMapping: CascaderRelationKeyMapping, placeholder?, multiple?, deselectable?, maxDepth?, leafOnly?, fallback? |
AddressOptions | placeholder? (per address component), autocomplete?: DefaultDataTypes.AddressAutocomplete |
FileOptions | multiple?, constraints?: { maxSize?, allowedMimetypes? }, path?, storage? |
ImageOptions | FileOptions plus max? and resize?: ImageResizeOptions |
ImageResizeOptions | maxWidth: number, maxHeight: number, fit?: "cover" | "contain" |
InputEmailOptions, InputColorOptions, RichTextOptions | placeholder? |
ImageResizeOptions downscales client-side before upload. contain (the default) scales down while preserving the source ratio; cover crops the image centred to the maxWidth/maxHeight ratio first, then scales down. An image already within bounds is uploaded untouched. This is a UX-level bound, not a server-enforced guarantee — keep constraints.maxSize for the latter.
Events and Functions
FormEvents names the events a form emits, FormFunctions the functions it exposes to watch actions.
| Namespace member | Value |
|---|---|
FormEvents.SUBMIT | "DmsComponent.Form.Submit" |
FormEvents.SUBMIT_SUCCESS | "DmsComponent.Form.SubmitSuccess" |
FormEvents.SUBMIT_ERROR | "DmsComponent.Form.SubmitError" |
FormEvents.FIELD_CHANGE | "DmsComponent.Form.FieldChange" |
FormEvents.RESET | "DmsComponent.Form.Reset" |
FormFunctions.SET_FIELD_DISABLED | "DmsComponent.Form.SetFieldDisabled" |
FormFunctions.SET_FIELD_HIDDEN | "DmsComponent.Form.SetFieldHidden" |
FormFunctions.SET_FIELD_REQUIRED | "DmsComponent.Form.SetFieldRequired" |
Both functions register their parameter shape in WatchFunctionParamMap, so .watch() type-checks the params object:
| Function | Params |
|---|---|
SET_FIELD_DISABLED | { targetField: string; setDisabled: boolean } |
SET_FIELD_HIDDEN | { targetField: string; setHidden: boolean } |
SET_FIELD_REQUIRED | { targetField: string; setRequired: boolean } |
import { Form, FormEvents, FormFunctions } from "@antelopejs/interface-dms/base/form";
import { DefaultDataTypes } from "@antelopejs/interface-dms/base/data-types";
const form = Form({
fields: [
{ id: "isCompany", label: "Company", type: new DefaultDataTypes.BooleanType() },
{ id: "vatNumber", label: "VAT number", type: new DefaultDataTypes.StringType() },
],
submitUrl: "/api/customers/new",
}).watch(FormEvents.FIELD_CHANGE, FormFunctions.SET_FIELD_HIDDEN, {
params: { targetField: "vatNumber", setHidden: true },
});
FIELD_CHANGE carries { fieldId, value } and fires once per field with the values the form starts from (its defaults, or the record it loaded), then on each change: the watch above, which names no condition, hides vatNumber as soon as the form opens. Add a value condition to onParam for a watch that applies only when the field holds that value, whether it opened with it or the user picked it.
.watch() records the watch on the component itself; .watchOn(childId, …) records it on a child declared with .child(). Both come from ComponentBuilder in @antelopejs/interface-dms/component.
Next Steps
- Tables - Turn a data controller into a table view with columns, row actions and guards.
Shared Types
The `types` subpath holds the vocabulary every other part of the `base` domain is built from: the base props interface every component's props extend, the colour and size scales, the action targets a button or a row…
Tables
TableView over a data controller: columns, routes, row actions, displays, filters and the forms it opens.