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-private/cms/interfaces/cms-base";
// or
import { Form, FormComponents } from "@antelopejs-private/cms/interfaces/cms-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-private/cms/interfaces/cms/page";
import { Form } from "@antelopejs-private/cms/interfaces/cms-base";
import { DefaultDataTypes } from "@antelopejs-private/cms/interfaces/cms-base/data-types";
import { DefaultLayout } from "@antelopejs-private/cms/interfaces/cms-base/layouts";
import { HttpMethod } from "@antelopejs-private/cms/interfaces/cms-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. Required. |
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. |
FormPropsSerialized is FormProps with fields replaced by FormFieldOrGroupSerialized[] and one added property, schema, holding the JSON Schema produced from the compiled Zod object.
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-private/cms/interfaces/cms-base/form";
import { DefaultDataTypes } from "@antelopejs-private/cms/interfaces/cms-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-private/cms/interfaces/cms-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-private/cms/interfaces/cms-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-private/cms/interfaces/cms-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 |
InputTree(options) | TreeOptions |
InputAddress(options?) | AddressOptions |
PermissionsTree(options) | PermissionsTreeOptions |
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? |
TextareaOptions | placeholder?, rows?, maxLength? |
InputNumberOptions | min?, max?, step?, placeholder? |
InputTimeOptions | min?, max?, placeholder? |
InputPhoneOptions | placeholder?, requiredPrefix? |
InputPasswordOptions | placeholder?, minLength?, confirmPassword?, confirmPlaceholder? |
SliderOptions | min?, max?, step? |
CalendarOptions | range?, multiple?, minDate?, maxDate? |
DatePickerRangeOptions | minDate?, maxDate? |
TreeOptions | items?: TreeNode[], fetchUrl?, placeholder?, multiple? |
PermissionsTreeNode | id: string, label: string, icon?, children?: PermissionsTreeNode[] |
PermissionsTreeOptions | permissions?: PermissionsTreeNode[], fetchUrl? |
RelationOptions | searchUrl: string, placeholder?, multiple?, deselectable?, keyMapping?: { label?, value?, avatar?, disabled? }, addForm?: ComponentInfoSerialized<FormPropsSerialized>, addPermissionId? |
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? |
TreeOptions.items uses the TreeNode shape documented in Tree.
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 | "CmsComponent.Form.Submit" |
FormEvents.SUBMIT_SUCCESS | "CmsComponent.Form.SubmitSuccess" |
FormEvents.SUBMIT_ERROR | "CmsComponent.Form.SubmitError" |
FormEvents.FIELD_CHANGE | "CmsComponent.Form.FieldChange" |
FormEvents.RESET | "CmsComponent.Form.Reset" |
FormFunctions.SET_FIELD_DISABLED | "CmsComponent.Form.SetFieldDisabled" |
FormFunctions.SET_FIELD_HIDDEN | "CmsComponent.Form.SetFieldHidden" |
FormFunctions.SET_FIELD_REQUIRED | "CmsComponent.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-private/cms/interfaces/cms-base/form";
import { DefaultDataTypes } from "@antelopejs-private/cms/interfaces/cms-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 },
});
.watch() records the watch on the component itself; .watchOn(childId, …) records it on a child declared with .child(). Both come from ComponentBuilder in the cms interface.
Next Steps
- Tables - Turn a data controller into a table view with columns, row actions and guards.