[data-reveal]{opacity:1!important;transform:none!important}
CMS Interfaces

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.

OptionTypeDescription
fieldsFormFieldOrGroup[]The fields and field groups of the form. Required.
titlestringForm title. Also becomes the component's display name; defaults to "Form" when absent.
descriptionstringForm description.
fetchUrlstringURL the form loads its initial values from.
fetchUrlMethodHttpMethodMethod for fetchUrl.
submitUrlstringURL the form submits to.
submitUrlMethodHttpMethodMethod for submitUrl.
submitLabelstringLabel of the submit button, as an i18n key or a literal. Defaults to the generic "Save changes" label.
successMessagestringMessage shown after a successful submit.
errorMessagestringMessage shown after a failed submit.
fieldsOrientation"horizontal" | "vertical"Layout direction of the field list.
redirectOnSuccessstringPath 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.
submitDefaultsRecord<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.

OptionTypeDescription
idstringField key in the payload. Required.
typeDataTypeData type instance. Supplies the input component and the validation schema. Required.
labelstringField label.
descriptionstringHelp text.
inputComponentComponentInfoSerializedOverrides the input component the data type would provide.
disabledbooleanRender the input non-editable.
requiredbooleanWhether a value must be supplied. Drives the schema, see below.
defaultValueDefaultValueInitial value.
localizedbooleanThe 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() },
  ],
};
OptionTypeDescription
idstringGroup identifier. Required.
fieldsFormField[]Fields in the group. Required.
labelstringGroup heading.
descriptionstringGroup description.
orientation"horizontal" | "vertical"Layout direction inside the group.
ordernumberOrdering 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: true the schema is wrapped in z.record(z.string(), schema), so the value becomes a map of locale to value.
  • When required is falsy the schema becomes .nullable().optional().
  • When required is true and the schema is a ZodString or a ZodArray, 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",
});
FunctionOptions 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:

InterfaceFields
SelectOptionlabel: string, value: string | number, disabled?, icon?, iconColor?, textColor?
SelectOptionsitems: SelectOption[], placeholder?, multiple?, deselectable?
RadioGroupOptionsitems: SelectOption[], orientation?: AxeOrientation
InputTextOptionsplaceholder?, maxLength?, minLength?
TextareaOptionsplaceholder?, rows?, maxLength?
InputNumberOptionsmin?, max?, step?, placeholder?
InputTimeOptionsmin?, max?, placeholder?
InputPhoneOptionsplaceholder?, requiredPrefix?
InputPasswordOptionsplaceholder?, minLength?, confirmPassword?, confirmPlaceholder?
SliderOptionsmin?, max?, step?
CalendarOptionsrange?, multiple?, minDate?, maxDate?
DatePickerRangeOptionsminDate?, maxDate?
TreeOptionsitems?: TreeNode[], fetchUrl?, placeholder?, multiple?
PermissionsTreeNodeid: string, label: string, icon?, children?: PermissionsTreeNode[]
PermissionsTreeOptionspermissions?: PermissionsTreeNode[], fetchUrl?
RelationOptionssearchUrl: string, placeholder?, multiple?, deselectable?, keyMapping?: { label?, value?, avatar?, disabled? }, addForm?: ComponentInfoSerialized<FormPropsSerialized>, addPermissionId?
CascaderRelationKeyMappinglabel: string, value: string, parent: string, disabled?
CascaderRelationOptionssearchUrl: string, keyMapping: CascaderRelationKeyMapping, placeholder?, multiple?, deselectable?, maxDepth?, leafOnly?, fallback?
AddressOptionsplaceholder? (per address component), autocomplete?: DefaultDataTypes.AddressAutocomplete
FileOptionsmultiple?, constraints?: { maxSize?, allowedMimetypes? }, path?, storage?
ImageOptionsFileOptions plus max? and resize?: ImageResizeOptions
ImageResizeOptionsmaxWidth: number, maxHeight: number, fit?: "cover" | "contain"
InputEmailOptions, InputColorOptions, RichTextOptionsplaceholder?

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 memberValue
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:

FunctionParams
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.