Agent Skillswebiny/webiny-js › webiny-form-model

webiny-form-model

GitHub

Webiny表单模型技能,用于通过构建器API定义字段、布局、验证及动态规则。支持条件可见性、计算字段和动态区域,帮助开发者快速声明式构建复杂表单结构。

skills/user-skills/admin/form-model/SKILL.md webiny/webiny-js

Trigger Scenarios

需要定义表单字段及其类型 构建表单布局与渲染器 配置表单验证逻辑 实现条件显示或禁用规则

Install

npx skills add webiny/webiny-js --skill webiny-form-model -g -y
More Options

Non-standard path

npx skills add https://github.com/webiny/webiny-js/tree/next/skills/user-skills/admin/form-model -g -y

Use without installing

npx skills use webiny/webiny-js@webiny-form-model

指定 Agent (Claude Code)

npx skills add webiny/webiny-js --skill webiny-form-model -a claude-code -g -y

安装 repo 全部 skill

npx skills add webiny/webiny-js --all -g -y

预览 repo 内 skill

npx skills add webiny/webiny-js --list

SKILL.md

Frontmatter
{
    "name": "webiny-form-model",
    "description": "Building forms with the FormModel system — field types, renderers, layout, validation, conditional rules, computed fields, and dynamic zones. Use this skill when the developer needs to define form fields with the builder API, choose renderers, build layouts with tabs\/rows\/separators, add validation (Zod or imperative), use conditional visibility\/disable rules, create computed fields, or work with object fields and templates (dynamic zones)."
}

Form Model

TL;DR

The Form Model is Webiny's declarative form system. Define fields with a fluent builder API (fields.text(), fields.datetime(), etc.), arrange them with a layout builder (layout.row(), layout.tabs(), etc.), and validate with Zod schemas or imperative rules. Fields support conditional visibility, computed values, reactive context from other fields (.context()), and deeply nested object/list structures with templates (dynamic zones).

Field Types

All fields are created via the fields registry callback. Each builder method returns a chainable builder.

Text

fields.text();

Default renderer: textInput. Value: string | null.

fields.text().label("Title").placeholder("Enter title").required("Title is required")
fields.text().renderer("textarea", { rows: 4 })
fields.text().list().renderer("tags").defaultValue([])
fields.text().list().renderer("textInputs", { addItemLabel: "Add text" })
fields.text().list().renderer("textareas", { addItemLabel: "Add description" })
fields.text().renderer("codeEditor", { language: "html", height: 300 })
fields.text().options([
    { label: "Option A", value: "a" },
    { label: "Option B", value: "b" }
])  // auto-switches to "select" renderer
fields.text().options([...]).renderer("radioButtons")
fields.text().list().options([...]).renderer("checkboxes")

// Dynamic options — callback receives { field, form }, re-evaluated reactively
fields.text().options(({ form }) => {
    const type = form.field("general.type").getValue();
    return getOptionsForType(type);
})

Number

fields.number();

Default renderer: numberInput. Value: number | null. Auto-normalizes to number.

fields.number().label("Count").placeholder("0").required();
fields.number().list().renderer("numberInputs", { addItemLabel: "Add number" });
fields.number().options([
  { label: "Tier 1", value: 100 },
  { label: "Tier 2", value: 200 }
]);

Boolean

fields.boolean();

Default renderer: switch. Value: boolean | null.

fields.boolean().label("Featured").defaultValue(false);

DateTime

fields.datetime();

Default renderer: dateTimeInput. Pick a variant method to set the subtype:

Variant Value Format Example
.dateOnly() "2026-05-01" Birthdays, due dates
.timeOnly() "14:30:00" Opening hours
.withTimezone() "2026-05-01T14:30:00+02:00" Events tied to a locale
.withoutTimezone() "2026-05-01T14:30:00.000Z" Timestamps, logs
.monthOnly() "2026-05" Billing cycles
.weekOnly({ startsOn: 1 }) "2026-W18" Sprint planning
.yearOnly({ range: [2020, 2035] }) 2026 (number) Fiscal years
.dateRange() { from: "...", to: "..." } Vacation requests
.multipleDates() ["2026-05-01", "2026-05-03"] Blackout dates
.multipleMonths() ["2026-01", "2026-03"] Seasonal availability
.multipleYears({ range: [2020, 2035] }) [2024, 2025, 2026] Multi-year budgets

Additional chainable methods:

.presets([
    { label: "Today", value: () => new Date() },
    { label: "In a week", value: () => addDays(new Date(), 7) }
])
.displayFormat("dd/MM/yyyy")  // date-fns format tokens
.list()  // switches renderer to "dateTimeInputs"

File

fields.file();

Default renderer: filePicker. Value: FileValue | null (object with id, name, size, mimeType, src, width, height).

fields.file().label("Image");

File URL

fields.fileUrl();

Default renderer: fileUrlPicker. Value: string | null (URL only).

fields.fileUrl().label("Image URL");

Lexical

fields.lexical();

Default renderer: lexical. Value: RichTextValueWithHtml | null ({ state: string; html: string }).

fields.lexical().label("Content").required("Content is required");

Password

fields.password();

Default renderer: passwordInput. Value: string | null.

fields.password().label("Password").required("Password is required");

Permissions

fields.permissions();

Default renderer: permissions. Value: Record<string, unknown>[]. Has a built-in Zod schema requiring at least one permission entry.

fields.permissions().label("Permissions");

Roles Multi-Select

fields.rolesMultiSelect();

Default renderer: rolesMultiSelect. Value: unknown[].

fields.rolesMultiSelect().label("Roles");

Object

fields.object();

Default renderer: objectAccordionSingle. For nested structures, lists, and dynamic zones.

// Simple nested object
fields.object().label("Address").fields(f => ({
    street: f.text().label("Street"),
    city: f.text().label("City"),
    zip: f.text().label("ZIP")
}))

// List of objects
fields.object().list().label("Authors").fields(f => ({
    name: f.text().label("Name").required(),
    email: f.text().label("Email")
}))

// Dynamic zone (single template selection)
fields.object().label("Content Block")
    .template("hero", t => {
        t.label("Hero Banner")
            .icon({ type: "icon", name: "fas/image" })
            .fields(f => ({
                heading: f.text().label("Heading").required(),
                image: f.file().label("Image")
            }));
    })
    .template("text", t => {
        t.label("Rich Text").fields(f => ({
            body: f.text().label("Body").renderer("textarea")
        }));
    })

// Dynamic zone list (multiple items, each picks a template)
fields.object().list().label("Page Sections")
    .renderer("dynamicZone", { container: false })
    .template("hero", t => { ... })
    .template("cta", t => { ... })

// Key-value list
fields.object().list().label("Meta Tags")
    .renderer("keyValueTags", { addItemLabel: "Add tag" })
    .fields(f => ({
        name: f.text().placeholder("Name"),
        content: f.text().placeholder("Content")
    }))

Template visibility can be conditional:

.template("premium", t => {
    t.label("Premium Widget")
        .visible(form => form.field("plan").getValue() === "enterprise")
        .fields(f => ({ ... }));
})

Common Builder Methods

These are available on all field types:

Method Description
.label(text) Field label
.description(text) Description text below the field
.help(text) Help text
.note(text) Supplementary note
.placeholder(text) Input placeholder
.defaultValue(value) Default value (can be a function for dynamic defaults)
.required(message?) Mark as required
.requiredWhen(fn, message?) Conditionally required based on other field values
.schema(zodSchema) Zod validation schema
.renderer(name, settings?) Override the default renderer
.options([...]) Add value options (auto-switches text/number to select)
.list() Convert to array field
.hidden() Hide the field (value still in form data)
.hiddenWhen(fn) Conditionally hide based on form state
.disabled(value?) Disable the field
.disabledWhen(fn) Conditionally disable based on form state
.rules([...]) Conditional visibility/disable rules
.computed(fn) Always-computed value from other fields
.computedUntilDirty(fn) Computed until user edits the field
.beforeChange(fn) Transform value before change
.afterChange(fn) Side effects after value changes
.afterSetValue(fn) Side effects after programmatic value set
.onBlur(fn) Blur event callback
.cloneValue(fn) Custom clone logic for list item duplication
.context(fn) Inject reactive context from other fields into the VM
.tags([...]) Tag the field for programmatic lookup

Renderers

Complete Renderer Reference

Renderer Field Type Settings Description
textInput text Single-line text input (default for text)
textarea text { rows?: number } Multi-line text area
textInputs text (list) { addItemLabel?: string } List of text inputs
textareas text (list) { addItemLabel?: string } List of textareas
tags text (list) Comma-separated tag input
codeEditor text { language?: string; height?: number } Code editor with syntax highlighting
select text, number Select dropdown (auto-selected when .options() is used)
multiSelect text (list), number (list) { showSelectionCount?: boolean } Multi-select dropdown (requires .options() + .list())
dropdown text, number Deprecated alias for select
radioButtons text, number Radio button group (requires .options())
checkboxes text (list), number (list) Checkbox group (requires .options() + .list())
numberInput number Number input (default for number)
numberInputs number (list) { addItemLabel?: string } List of number inputs
switch boolean Toggle switch (default for boolean)
dateTimeInput datetime { type, displayFormat?, yearRange?, weekStartsOn?, presets? } Date/time picker (default for datetime)
dateTimeInputs datetime (list) { type, displayFormat?, weekStartsOn?, addItemLabel? } List of date/time pickers
lexical lexical Lexical rich text editor (default for lexical)
filePicker file File picker with full metadata (default for file)
fileUrlPicker fileUrl File picker returning URL only (default for fileUrl)
objectAccordionSingle object { open?: boolean } Single object in accordion (default for object)
objectAccordionMultiple object (list) { open?, container?, itemTitle?, addItemLabel? } List of objects in accordions (auto for .list())
dynamicZone object (templates) { container?: boolean } Template picker zone (auto for .template())
passthrough object Renders child fields inline without wrapper
keyValueTags object (list) { addItemLabel?: string } Key-value tag pairs
hidden any Hidden field (no UI rendered, but field stays visible in VM)
passwordInput password Password input (default for password)
permissions permissions Permissions editor (default for permissions)
rolesMultiSelect rolesMultiSelect Roles multi-select (default for rolesMultiSelect)

Automatic Renderer Switching

  • Calling .options() on text/number fields switches to select
  • Calling .list() on datetime switches to dateTimeInputs
  • Calling .list() on object switches to objectAccordionMultiple
  • Calling .template() on object switches to dynamicZone

Layout

Layout controls how fields are arranged in the UI. Defined via the layout callback.

Basic Layout

layout: layout => [
  layout.row("title"), // single field row
  layout.row("firstName", "lastName"), // two fields side by side
  layout.separator() // visual divider
];

Tabs

layout: layout => [
  layout
    .tabs("myTabs")
    .tab("general", tab => {
      tab
        .label("General")
        .icon({ type: "icon", name: "fas/cog" })
        .description("Basic settings")
        .layout(l => [l.row("title"), l.row("description")]);
    })
    .tab("advanced", tab => {
      tab.label("Advanced").layout(l => [l.row("config")]);
    })
];

Vertical tabs (used by page settings):

layout.tabs("settings-tabs").renderer("tabsVertical");

Tabs with renderer settings:

layout.tabs("field-settings").renderer("tabsHorizontal", {
  spacing: "lg",
  size: "md",
  separator: true
});

Tab-level conditional visibility:

.tab("premium", tab => {
    tab.label("Premium")
        .rules([{
            type: "condition",
            target: "plan",
            operator: "neq",
            value: "enterprise",
            action: "hide"
        }])
        .layout(l => [...]);
})

Object Layout

For object fields, define inner layout per template or for a flat object:

// Flat object
layout.object("address", l => [l.row("street"), l.row("city", "zip")]);

// Per-template layout (for dynamic zones)
layout.object("sections", {
  hero: inner => [inner.row("heading", "subheading"), inner.row("image")],
  cta: inner => [inner.row("label", "url")]
});

Positioning

When modifying an existing layout (e.g., in a modifier), use .after() or .before() to position relative to existing fields:

layout.row("newField").after("existingField");
layout.row("anotherField").before("existingField");

Validation

Field-Level (Zod)

import { z } from "zod";

fields.text().label("Email").schema(z.string().email("Must be a valid email"));

fields
  .text()
  .label("URL")
  .schema(z.string().refine(val => !val || URL_REGEX.test(val), "Invalid URL format"));

Conditional Required

fields
  .text()
  .label("Seats")
  .requiredWhen(
    ({ form }) => form.field("plan").getValue() === "pro",
    "Pro plan requires a seat count"
  );

Form-Level Rules

// Zod cross-field validation
form.addRule(
  z
    .object({
      password: z.string().nullable(),
      confirm: z.string().nullable()
    })
    .refine(d => d.password === d.confirm || (!d.password && !d.confirm), {
      message: "Passwords must match",
      path: ["confirm"]
    })
);

// Imperative validation
form.addRule(form => {
  const slug = String(form.field("slug").getValue() ?? "");
  if (slug.length > 0 && slug.length < 3) {
    return [{ path: "slug", message: "Slug must be at least 3 characters" }];
  }
  return [];
});

Conditional Rules (Visibility / Disable)

Rules control field visibility and disabled state based on other field values:

fields
  .text()
  .label("Feature Name")
  .rules([
    {
      type: "condition",
      target: "enableFeature", // field to watch
      operator: "isFalsy", // condition
      value: null, // comparison value (null for unary operators)
      action: "hide" // "hide" or "disable"
    }
  ]);

Multiple rules can be chained (all are evaluated):

fields
  .text()
  .label("Advanced Config")
  .rules([
    {
      type: "condition",
      target: "enableFeature",
      operator: "isFalsy",
      value: null,
      action: "hide"
    },
    {
      type: "condition",
      target: "featureMode",
      operator: "neq",
      value: "advanced",
      action: "disable"
    }
  ]);

Available Operators

Operator Description
"eq" Equal to value
"neq" Not equal to value
"isEmpty" Null, undefined, empty string, or empty array
"isNotEmpty" Has a non-empty value
"isTruthy" Boolean coercion is true
"isFalsy" Boolean coercion is false
"matches" Exact string match

Callback Parameters ({ field, form })

All field callbacks (computed, computedUntilDirty, hiddenWhen, disabledWhen, requiredWhen, options, context) receive a single { field, form } object:

  • form — the root IFormModel for absolute field access (e.g., form.field("title"))
  • field — a navigator scoped to the current field. Call .parent() to get the containing object, then .field(name) to access fields at that level. Chain .parent() for higher levels.

Value-first callbacks (beforeChange, afterChange, afterSetValue, onBlur) receive (value, { field, form }).

// Relative: access a sibling within the same object
fields
  .object()
  .renderer("passthrough")
  .fields(f => ({
    label: f.text().defaultValue("Hello"),
    slug: f.text().computedUntilDirty(({ field }) =>
      String(field.parent().field("label").getValue() || "")
        .toLowerCase()
        .replace(/\s+/g, "-")
    )
  }));

// Absolute: access a root-level field
fields.text().computedUntilDirty(({ form }) =>
  String(form.field("title").getValue() ?? "")
    .trim()
    .toLowerCase()
    .replace(/\s+/g, "-")
);

// Multi-level traversal: parent().parent() goes up two levels
inner.file().context(({ field }) => ({
  title: field.parent().parent().field("title").getValue()
}));

Conditional Visibility / Disable (callback form)

For dynamic visibility and disabled state that depends on other field values:

// Hide a field based on a sibling value (inside an object)
fields
  .text()
  .label("Details")
  .hiddenWhen(({ field }) => field.parent().field("mode").getValue() !== "advanced");

// Disable based on a root-level field
fields
  .text()
  .label("Name")
  .disabledWhen(({ form }) => Boolean(form.field("locked").getValue()));

Both hiddenWhen and disabledWhen accept (params: IFieldCallbackParams) => boolean. Multiple calls chain — any returning true triggers the effect.

Computed Fields

// Always computed — recalculated when dependencies change
fields
  .text()
  .label("Full Name")
  .computed(({ form }) => `${form.field("first").getValue()} ${form.field("last").getValue()}`);

// Computed until the user edits the field manually
fields
  .text()
  .label("Slug")
  .computedUntilDirty(({ form }) => {
    const name = String(form.field("title").getValue() ?? "");
    return name.trim().toLowerCase().replace(/\s+/g, "-");
  });

Cross-Field Interaction

Use .afterChange() to react to value changes and modify other fields:

fields
  .text()
  .label("Visibility")
  .options([
    { label: "Public", value: "public" },
    { label: "Password Protected", value: "password" }
  ])
  .afterChange((value, { form }) => {
    const path = form.field("general.path").as("text").getValue() ?? "";
    if (value === "password") {
      form.field("general.path").setValue(path + "/protected");
    } else {
      form.field("general.path").setValue(path.replace("/protected", ""));
    }
  });

Field Context

Use .context() to push data from other fields into a field's VM. The renderer reads it via field.context — no hooks, no reaching up to the parent form. The callback is MobX-reactive: only the specific fields accessed inside it trigger re-renders.

The callback receives { field, form } — the same IFieldCallbackParams used by all other callbacks (see Callback Parameters).

Sibling access (fields at the same level)

fields
  .file()
  .label("Media")
  .context(({ field }) => ({
    title: field.parent().field("title").getValue(),
    description: field.parent().field("description").getValue()
  }));

Nested field accessing root-level fields

// Inside an object: settings > media needs root-level "title"
fields
  .object()
  .label("Settings")
  .fields(f => ({
    media: f.file().context(({ form }) => ({
      title: form.field("title").getValue()
    }))
  }));

Deep nesting — traversing multiple levels up

// settings > nested > media needs settings-level "label"
fields
  .object()
  .label("Settings")
  .fields(f => ({
    label: f.text().defaultValue("Settings Label"),
    nested: f.object().fields(inner => ({
      media: inner.file().context(({ field }) => ({
        // parent() = nested, parent().parent() = settings
        label: field.parent().parent().field("label").getValue()
      }))
    }))
  }));

Using both field navigator and form

fields
  .file()
  .label("Media")
  .context(({ field, form }) => ({
    // Relative: sibling via parent
    label: field.parent().field("label").getValue(),
    // Absolute: root-level field
    slug: form.field("slug").getValue()
  }));

Reading context in a renderer

const MediaPickerRenderer = createFieldRenderer<"mediaPicker">(({ field }) => {
    const { title, description } = field.context as { title: string; description: string };
    return <MediaPicker field={field} title={title} description={description} />;
});

Fields without .context() have field.context defaulting to {}.

Extending Object Fields After Creation

Object fields can be extended with additional children (modifier pattern):

// Original definition
profile: fields
  .object()
  .label("Profile")
  .fields(f => ({
    title: f.text().label("Title")
  }));

// Later: add more fields
form
  .field("profile")
  .as("object")
  .fields(f => ({
    company: f.text().label("Company"),
    bio: f.text().label("Short bio")
  }));

Runtime Template Management

Templates on object fields can be added/removed at runtime:

const sections = form.field("sections").as("object");

sections.templates.remove("text");

sections.templates.add("runtimeBanner", t => {
  t.label("Runtime Banner").fields(f => ({
    headline: f.text().label("Headline").required(),
    note: f.text().label("Note")
  }));
});

Form API

Submit with Skip Validation

// Normal submit — validates first, returns false if invalid
const data = await form.submit();

// Skip validation — returns data immediately
const data = await form.submit({ skipValidation: true });

FormVM

The IFormVM exposes reactive state for the UI:

form.vm.layout; // LayoutNodeVM[] — resolved layout nodes
form.vm.errors; // IFormError[] — current validation errors
form.vm.hasErrors; // boolean — shorthand for errors.length > 0
form.vm.isDirty; // boolean — any field changed from initial value
form.vm.isValid; // boolean | null — null until first validation
form.vm.submitCount; // number — increments on each submit attempt
form.vm.focusField(path); // scroll to and focus a field
form.vm.getData(); // current form data snapshot
form.vm.setData(); // replace all form data

FormErrors Component

import { FormErrors } from "webiny/admin/form";

<FormErrors form={presenter.vm.form} className="my-4" />

Renders an alert with all validation errors. Accepts an optional className prop.

Related Skills

  • webiny-page-settings-extensions — Adding new settings groups or modifying existing ones in the Website Builder page settings drawer

Version History

  • 80eb1c5 Current 2026-08-20 10:06

Same Skill Collection

.claude/skills/grill-me/SKILL.md
.claude/skills/prd-to-plan/SKILL.md
.claude/skills/preflight/SKILL.md
.claude/skills/tester/SKILL.md
.claude/skills/write-a-prd/SKILL.md
skills/repo-skills/add-feature-flag/SKILL.md
skills/user-skills/admin/admin-architect/SKILL.md
skills/user-skills/admin/admin-permissions/SKILL.md
skills/user-skills/admin/new-entry-wizard/SKILL.md
skills/user-skills/admin/website-builder/page-settings/SKILL.md
skills/user-skills/admin/website-builder/wb-preview-url-modifier/SKILL.md
skills/user-skills/api-bundle-size-limit/SKILL.md
skills/user-skills/api/api-architect/SKILL.md
skills/user-skills/api/cms-bulk-actions/SKILL.md
skills/user-skills/api/custom-field-type/SKILL.md
skills/user-skills/api/event-handler-pattern/SKILL.md
skills/user-skills/api/graphql-api/SKILL.md
skills/user-skills/api/http-route/SKILL.md
skills/user-skills/api/permissions/SKILL.md
skills/user-skills/api/use-case-pattern/SKILL.md
skills/user-skills/api/v5-to-v6-migration/SKILL.md
skills/user-skills/api/websocket-notifications/SKILL.md
skills/user-skills/cli-extensions/SKILL.md
skills/user-skills/configure-auth0/SKILL.md
skills/user-skills/configure-entraid/SKILL.md
skills/user-skills/configure-okta/SKILL.md
skills/user-skills/dependency-injection/SKILL.md
skills/user-skills/full-stack-architect/SKILL.md
skills/user-skills/generated/api/aco/SKILL.md
skills/user-skills/generated/api/cms/SKILL.md
skills/user-skills/generated/api/file-manager/SKILL.md
skills/user-skills/generated/api/scheduler/SKILL.md
skills/user-skills/generated/api/security/SKILL.md
skills/user-skills/generated/api/system/SKILL.md
skills/user-skills/generated/api/tenancy/SKILL.md
skills/user-skills/generated/api/tenant-manager/SKILL.md
skills/user-skills/generated/api/website-builder/SKILL.md
skills/user-skills/generated/infra/SKILL.md
skills/user-skills/infrastructure-extensions/SKILL.md
skills/user-skills/local-development/SKILL.md
skills/user-skills/mailer-smtp/SKILL.md
skills/user-skills/project-structure/SKILL.md
.claude/skills/webiny-skill-creator/SKILL.md
skills/user-skills/admin/ui-extensions/SKILL.md
skills/user-skills/api/ai-powerups-content/SKILL.md
skills/user-skills/cognito-federation/SKILL.md
skills/user-skills/content-models/SKILL.md
skills/user-skills/generated/admin/aco/SKILL.md
skills/user-skills/generated/admin/ai-powerups/SKILL.md

Metadata

Files
0
Version
80eb1c5
Hash
e472efa4
Indexed
2026-08-20 10:06

inicio - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-22 02:24
浙ICP备14020137号-1 $mapa de visitantes$