Page
The page interaction event defines the runtime contract between create or edit form pages and Studio scripts configured in Layout Design.
Use it when a layout needs page-level behavior such as dynamic required fields, read-only fields, child-table validation, default values, selected-row highlighting, or user prompts.
Configure A Page Script
- Open Work > Forms
- Open the target form
- Go to Layout Design
- Select the layout used by the create or edit page
- Open the Page Events tab
- Select a Studio script and, when needed, a specific script file
- Enable the triggers that should run the script
- Save the layout
The configuration is stored in form custom configuration under formPageScripts[layoutId] and formChildPageScripts[layoutId]. It does not require a database schema change.
Triggers
Main Page Triggers
| Trigger | Runtime phase | Description |
|---|---|---|
| Run on load | load | Runs once after the create or edit page opens with the selected layout |
| Run on field change | change | Runs after parent form values change. It can watch all fields or selected parent fields |
Child Table Triggers
| Trigger | Runtime phase | Description |
|---|---|---|
| Child row added | childRowAdd | Runs after a child-table row is added |
| Child row removed | childRowRemove | Runs after a child-table row is removed |
| Child field changed | childFieldChange | Runs after a child-table field changes. It can watch all child fields or selected childFormApiName.fieldApiName pairs |
Field-change triggers are debounced briefly by the runtime so scripts do not run on every intermediate keystroke.
Script Function
type FormPageRuntimePayload = {
schema: 'soln.runtime-context/v1';
source: {
type: 'form_page';
phase: 'load' | 'change' | 'childRowAdd' | 'childFieldChange' | 'childRowRemove';
};
draft: {
data: Record<string, unknown>;
};
};
type StudioRuntimeContext = Record<string, unknown>;
export default async function main(
payload: FormPageRuntimePayload,
_context: StudioRuntimeContext,
) {
return {
ok: true,
effects: [],
};
}
Payload
type FormPageRuntimePayload = {
schema: 'soln.runtime-context/v1';
source: {
type: 'form_page';
page: 'form' | 'view' | 'list';
layoutId: string | null;
phase: 'load' | 'change' | 'childRowAdd' | 'childFieldChange' | 'childRowRemove';
changedFields: string[];
changedField: string | null;
previousData: Record<string, unknown> | null;
previousValue: unknown | null;
value: unknown | null;
};
form: {
id: string | null;
apiName: string | null;
};
draft: {
data: Record<string, unknown>;
};
child: {
form: { id: string; apiName: string; name: string | null };
relationFieldApiName: string | null;
row: { id: string | null; data: Record<string, unknown> } | null;
} | null;
record: RuntimeRecord | null;
selection: { records: RuntimeRecord[]; childRows: RuntimeChildRow[] };
input: Record<string, unknown>;
result: unknown | null;
runtime: {
locale: string;
timezone: string;
executionId: string | null;
traceId: string | null;
};
};
Base Context
| Field | Type | Description |
|---|---|---|
schema | 'soln.runtime-context/v1' | Shared runtime context version |
source.type | 'form_page' | Identifies the form-page execution source |
source.phase | 'load' | 'change' | 'childRowAdd' | 'childFieldChange' | 'childRowRemove' | Current runtime phase |
form.id | string | Form schema ID when a form is available |
form.apiName | string | Form API name when a form is available |
source.layoutId | string | null | Current layout ID |
draft.data | Record<string, unknown> | Current draft values keyed by field API name |
record | { id: string } | null | Existing record identity. Usually null on create pages; record data is not preloaded for page bindings |
source.page | 'form' | 'view' | 'list' | Current page surface |
runtime.locale | string | Browser locale |
runtime.timezone | string | Browser timezone |
Parent field values are available in payload.draft.data[fieldApiName]. When a script specifically needs persisted values, first require payload.record?.id, then call context.form.get({ recordId: payload.record.id }).
Child Table Context
| Field | Type | Description |
|---|---|---|
child.form | object | null | Current child form for child-table triggers |
child.row.data | Record<string, unknown> | Current row draft data |
source.previousData | Record<string, unknown> | null | Previous row draft data |
source.changedField | string | null | Field API name that changed |
source.changedFields | string[] | List of changed fields |
source.previousValue | unknown | null | Previous field value |
source.value | unknown | null | Current field value |
Child-table drafts are also available in payload.draft.data.__childFormDrafts[childFormApiName] as an array of row objects.
Response
Unknown response properties are ignored.
type FormPageRuntimeResponse = {
ok?: boolean;
preventDefault?: boolean;
message?: string;
fieldErrors?: Record<string, string>;
patch?: {
draft?: Record<string, unknown>;
record?: Record<string, unknown>;
};
effects?: FormRuntimeEffect[];
notification?: RuntimeNotificationShortcut;
modal?: RuntimeModalShortcut;
};
| Field | Type | Description |
|---|---|---|
ok | boolean | Whether the current script flow can continue |
preventDefault | boolean | Explicitly stops the current script flow when true |
message | string | Shows a simple runtime message |
fieldErrors | Record<string, string> | Sets field validation errors by field API name |
patch.draft | Record<string, unknown> | Merges values into the current draft state |
patch.record | Record<string, unknown> | Merges values into the current record state |
effects | FormRuntimeEffect[] | Ordered runtime effects |
notification | object | string | Shortcut for one top-level notification effect |
modal | object | string | Shortcut for one top-level modal effect |
The response shape is shared with the button interaction event. See Button for the full message, notification, modal, refresh, fieldErrors, and patch behavior.
Effects
Page scripts can return all shared runtime effects, plus row-selection effects for child tables.
| Effect | Purpose |
|---|---|
fieldState | Overrides parent field hidden, required, readonly, or disabled state |
fieldOptionFilter | Filters a parent choice field's configured options by stable option values |
fieldValue | Sets a parent field value |
fieldError | Displays a parent field validation error |
fieldErrorClear | Clears a parent field validation error |
childFieldState | Overrides child-table field hidden, required, readonly, or disabled state |
childRowState | Temporarily marks child-table rows as selected |
childFieldValue | Sets a child-table field value |
childFieldError | Displays a child-table field validation error |
childFieldErrorClear | Clears child-table field validation errors |
childRowAdd | Appends one or more draft rows to a child table |
childRowRemove | Removes one or more draft rows from a child table |
message | Shows a simple message |
notification | Shows a richer toast |
modal | Shows a modal dialog |
refresh | Requests a runtime refresh |
Parent Field Effects
return {
ok: true,
effects: [
{
type: 'fieldState',
field: 'closeReason',
hidden: false,
required: true,
readonly: false,
},
],
};
Parent field effect fields follow the same shape as the parent field effects in Button.
Use fieldOptionFilter for small static choice-field cascades on select, multi_select, radio, and checkbox_group fields. The effect filters the target field's existing configured options by allowedValues. It does not accept labels; labels, tag colors, and translations continue to come from the field configuration.
return {
ok: true,
effects: [
{
type: 'fieldOptionFilter',
field: 'closeReason',
allowedValues: ['resolved', 'duplicate'],
clearInvalidValue: true,
},
],
};
| Field | Type | Description |
|---|---|---|
field | string | Target parent field API name |
allowedValues | string[] | Stable option values that remain selectable |
clearInvalidValue | boolean | When true, clears a single value or removes multi values that are no longer allowed |
reset | boolean | When true, removes the runtime filter and restores the field's original configured options |
To restore the full option list:
return {
ok: true,
effects: [
{
type: 'fieldOptionFilter',
field: 'closeReason',
reset: true,
},
],
};
Child Field Effects
Use table for the child form API name. You can also use childTable, formApiName, or childFormApiName; the runtime normalizes them to the same target.
Use where to target rows by child-field values. If where is omitted, the effect applies to every current row in that child table.
return {
ok: true,
effects: [
{
type: 'childFieldState',
table: 'orderItems',
field: 'discount',
where: {
logic: 'and',
children: [
{ field: 'productType', operator: 'eq', value: 'service' },
],
},
readonly: true,
required: false,
},
],
};
return {
ok: true,
effects: [
{
type: 'childFieldValue',
table: 'orderItems',
field: 'currency',
where: {
logic: 'and',
children: [
{ field: 'region', operator: 'eq', value: 'US' },
],
},
value: 'USD',
},
],
};
return {
ok: true,
effects: [
{
type: 'childFieldError',
table: 'orderItems',
field: 'quantity',
where: {
logic: 'and',
children: [
{ field: 'quantity', operator: 'lte', value: 0 },
],
},
message: 'Quantity must be greater than zero.',
},
],
};
Child field effect fields follow the same shape as the child field effects in Button.
Child Row State
Use where to target rows by child-field values. If where is omitted, the state applies to every current row in that child table.
Rows with selected: true use the Ant Design Table rowSelectedBg token configured by the runtime page.
return {
ok: true,
effects: [
{
type: 'childRowState',
table: 'orderItems',
where: {
logic: 'and',
children: [
{ field: 'status', operator: 'eq', value: 'delayed' },
],
},
selected: true,
},
],
};
| Field | Type | Required | Description |
|---|---|---|---|
type | 'childRowState' | Yes | Identifies a child-row state override |
table | string | Yes | Child form API name |
where | ApprovalFlowConditionGroup | No | Row filter. Omit it to target all current rows |
selected | boolean | No | Whether the matching rows should be visually selected |
Child Row Add And Remove
return {
ok: true,
effects: [
{
type: 'childRowAdd',
table: 'orderItems',
rows: [
{
productName: 'Standard service',
quantity: 1,
currency: 'USD',
},
{
productName: 'Implementation',
quantity: 1,
currency: 'USD',
},
],
},
],
};
return {
ok: true,
effects: [
{
type: 'childRowRemove',
table: 'orderItems',
where: {
logic: 'and',
children: [
{ field: 'status', operator: 'eq', value: 'cancelled' },
],
},
},
],
};
Child row effect fields follow the same shape as the child row effects in Button.
Examples
Require A Reason When Status Is Closed
type FormPageRuntimePayload = {
draft?: {
data?: Record<string, unknown>;
};
};
export default async function main(payload: FormPageRuntimePayload) {
const closed = payload.draft?.data?.status === 'closed';
return {
ok: true,
effects: [
{
type: 'fieldState',
field: 'closeReason',
hidden: !closed,
required: closed,
},
],
};
}
Set A Child Row Default Value
type FormPageRuntimePayload = {
source: {
type: 'form_page';
phase: string;
};
child: {
form: { apiName: string };
row: { data: Record<string, unknown> };
} | null;
};
export default async function main(payload: FormPageRuntimePayload) {
const lineNo = payload.child?.row.data.lineNo;
if (
payload.source.phase !== 'childRowAdd' ||
payload.child?.form.apiName !== 'orderItems' ||
!lineNo
) {
return { ok: true };
}
return {
ok: true,
effects: [
{
type: 'childFieldValue',
table: 'orderItems',
field: 'quantity',
where: {
logic: 'and',
children: [
{ field: 'lineNo', operator: 'eq', value: lineNo },
],
},
value: 1,
},
],
};
}
Add Child Rows From A Page Script
type FormPageRuntimePayload = {
source: { type: 'form_page'; phase: string };
draft?: {
data?: Record<string, unknown>;
};
};
export default async function main(payload: FormPageRuntimePayload) {
if (payload.source.phase !== 'load') {
return { ok: true };
}
const shouldSeedItems = payload.draft?.data?.template === 'standard';
return {
ok: true,
effects: shouldSeedItems
? [
{
type: 'childRowAdd',
table: 'orderItems',
rows: [
{ productName: 'Standard service', quantity: 1 },
{ productName: 'Implementation', quantity: 1 },
],
},
]
: [],
};
}
Validate A Child Field Change
type FormPageRuntimePayload = {
source: {
type: 'form_page';
phase: string;
changedField: string | null;
value: unknown | null;
};
child: {
form: { apiName: string };
row: { data: Record<string, unknown> };
} | null;
};
export default async function main(payload: FormPageRuntimePayload) {
const quantity = Number(payload.source.value || 0);
const isQuantityChange =
payload.source.phase === 'childFieldChange' &&
payload.child?.form.apiName === 'orderItems' &&
payload.source.changedField === 'quantity';
if (!isQuantityChange || quantity > 0) {
return { ok: true };
}
return {
ok: false,
effects: [
{
type: 'childFieldError',
table: 'orderItems',
field: 'quantity',
where: {
logic: 'and',
children: [
{ field: 'sku', operator: 'eq', value: payload.child?.row.data.sku },
],
},
message: 'Quantity must be greater than zero.',
},
],
};
}
Highlight Child Rows That Need Attention
export default async function main() {
return {
ok: true,
effects: [
{
type: 'childRowState',
table: 'orderItems',
where: {
logic: 'and',
children: [
{ field: 'status', operator: 'eq', value: 'delayed' },
],
},
selected: true,
},
],
};
}
Show A Message After The Page Loads
Use message for short feedback that does not need a title or additional details. This example only displays the message for the Run on load trigger, so a field change does not show it again.
type FormPageRuntimePayload = {
source: { type: 'form_page'; phase: string };
};
export default async function main(payload: FormPageRuntimePayload) {
if (payload.source.phase !== 'load') {
return { ok: true };
}
return {
ok: true,
effects: [
{
type: 'message',
level: 'info',
text: 'Review the customer details before saving.',
},
],
};
}
Use success, info, warning, or error for level. A top-level message is a shorter equivalent when the default presentation is sufficient:
return {
ok: true,
message: 'Draft values loaded.',
};
Show A Detailed Notification After A Field Change
Use notification when the feedback needs a title, description, placement, or a longer display time. Configure the page event to watch the customerType field for this example.
type FormPageRuntimePayload = {
source: {
type: 'form_page';
phase: string;
changedFields: string[];
};
draft?: { data?: Record<string, unknown> };
};
export default async function main(payload: FormPageRuntimePayload) {
const isEnterpriseCustomer =
payload.source.phase === 'change' &&
payload.source.changedFields.includes('customerType') &&
payload.draft?.data?.customerType === 'enterprise';
if (!isEnterpriseCustomer) {
return { ok: true };
}
return {
ok: true,
effects: [
{
type: 'notification',
level: 'warning',
title: 'Enterprise review required',
description: 'Complete the risk assessment before saving this record.',
placement: 'topRight',
duration: 6,
closable: true,
},
],
};
}
The top-level shortcut is useful when only one notification is needed:
return {
ok: true,
notification: {
level: 'success',
title: 'Defaults applied',
description: 'Currency and payment terms were populated from the customer.',
placement: 'topRight',
duration: 4,
},
};
Explain A Blocking Validation Error In A Modal
Use modal for information that the user must acknowledge. This example combines a field error with a modal when an amount exceeds the page limit.
type FormPageRuntimePayload = {
source: {
type: 'form_page';
phase: string;
changedFields: string[];
};
draft?: { data?: Record<string, unknown> };
};
export default async function main(payload: FormPageRuntimePayload) {
const amount = Number(payload.draft?.data?.amount || 0);
const amountChanged =
payload.source.phase === 'change' &&
payload.source.changedFields.includes('amount');
if (!amountChanged || amount <= 100000) {
return { ok: true };
}
return {
ok: false,
fieldErrors: {
amount: 'The amount exceeds the page limit.',
},
modal: {
level: 'warning',
title: 'Amount requires approval',
content: 'Amounts above 100,000 must be submitted through the approval process.',
okText: 'Got it',
centered: true,
width: 520,
},
};
}
ok: false stops the remaining page-script flow. It does not undo the field change that triggered the page event. Use fieldErrors or a fieldValue effect when the draft also needs to be marked invalid or changed.
For a confirm modal, set level: 'confirm' and awaitAction: true. If the user cancels, the page-script result is marked as prevented. Page events still do not roll back the triggering field change.
return {
ok: true,
effects: [
{
type: 'modal',
level: 'confirm',
title: 'Review required',
content: 'Confirm that you reviewed the customer risk information.',
okText: 'Reviewed',
cancelText: 'Go back',
awaitAction: true,
},
],
};
Do not place draft mutations after a confirm modal and expect cancellation to make the effect array transactional. Use a confirm modal as standalone feedback in a page event; use a button beforeClick script when confirmation must gate a user action.
Combine Feedback With Draft Changes
Use effects when one response must perform multiple ordered actions. The top-level message, notification, and modal fields are shortcuts for a single feedback effect; do not use them when execution order matters.
export default async function main() {
return {
ok: true,
effects: [
{
type: 'fieldValue',
field: 'status',
value: 'draft',
},
{
type: 'message',
level: 'success',
text: 'The initial status was set to Draft.',
},
],
};
}
Set And Clear A Parent Field Error
Return fieldError while a value is invalid and explicitly return fieldErrorClear after it becomes valid. A previously displayed error is not cleared automatically. Configure this page event to watch startDate and endDate.
type FormPageRuntimePayload = {
source: { type: 'form_page'; phase: string; changedFields: string[] };
draft?: { data?: Record<string, unknown> };
};
export default async function main(payload: FormPageRuntimePayload) {
if (payload.source.phase !== 'change') {
return { ok: true };
}
const startDate = String(payload.draft?.data?.startDate || '');
const endDate = String(payload.draft?.data?.endDate || '');
const invalid = Boolean(startDate && endDate && endDate < startDate);
return {
ok: !invalid,
effects: [
invalid
? {
type: 'fieldError',
field: 'endDate',
message: 'End date cannot be earlier than start date.',
}
: {
type: 'fieldErrorClear',
field: 'endDate',
},
],
};
}
Clear A Child Field Error
childFieldErrorClear uses table, field, and an optional where condition to clear errors on matching rows. This script only handles the SKU row that changed.
export default async function main(payload: FormPageRuntimePayload) {
const row = payload.child?.row?.data;
if (
payload.source.phase !== 'childFieldChange' ||
payload.child?.form.apiName !== 'orderItems' ||
payload.source.changedField !== 'quantity' ||
!row?.sku
) {
return { ok: true };
}
const valid = Number(payload.source.value || 0) > 0;
const target = {
table: 'orderItems',
field: 'quantity',
where: {
logic: 'and',
children: [{ field: 'sku', operator: 'eq', value: row.sku }],
},
};
return {
ok: valid,
effects: [
valid
? { type: 'childFieldErrorClear', ...target }
: {
type: 'childFieldError',
...target,
message: 'Quantity must be greater than zero.',
},
],
};
}
Merge Several Draft Values With Patch
Use patch.draft to merge several parent fields at once. It is a shallow merge: it does not save the record or change field design.
export default async function main(payload: FormPageRuntimePayload) {
if (
payload.source.phase !== 'change' ||
!payload.source.changedFields.includes('customerType') ||
payload.draft?.data?.customerType !== 'enterprise'
) {
return { ok: true };
}
return {
ok: true,
patch: {
draft: {
riskLevel: 'high',
requiresApproval: true,
},
},
};
}
The current form-page implementation merges both patch.draft and patch.record into the current form state. Neither one writes directly to the tenant database. Prefer the semantically explicit patch.draft in page scripts; values become persistent only after a form save or submit action.
Compare The Previous And Current Field Values
On a parent field change, use source.previousValue and source.value to detect the direction of the change instead of repeatedly responding only to the current draft.
export default async function main(payload: FormPageRuntimePayload) {
const becameHighPriority =
payload.source.phase === 'change' &&
payload.source.changedFields.includes('priority') &&
payload.source.previousValue !== 'high' &&
payload.source.value === 'high';
return becameHighPriority
? {
ok: true,
notification: {
level: 'warning',
title: 'Priority increased',
description: 'Add a due date and an owner.',
},
}
: { ok: true };
}
For a child field change, source.previousData contains the entire row before the change and child.row.data contains the current row. Use these objects when several fields in one row must be compared.
Recalculate A Parent Total After A Child Row Is Removed
The childRowRemove page event runs after a child row is removed. Read the current child drafts from draft.data.__childFormDrafts[childFormApiName].
export default async function main(payload: FormPageRuntimePayload) {
if (
payload.source.phase !== 'childRowRemove' ||
payload.child?.form.apiName !== 'orderItems'
) {
return { ok: true };
}
const drafts = payload.draft?.data?.__childFormDrafts;
const orderItems =
drafts && typeof drafts === 'object'
? (drafts as Record<string, unknown>).orderItems
: null;
const rows =
Array.isArray(orderItems)
? (orderItems as Array<Record<string, unknown>>)
: [];
const totalAmount = rows.reduce(
(sum, row) =>
sum + Number(row.quantity || 0) * Number(row.unitPrice || 0),
0,
);
return {
ok: true,
effects: [
{ type: 'fieldValue', field: 'totalAmount', value: totalAmount },
],
};
}
Read A Persisted Record On An Edit Page
payload.draft.data contains the current page draft. When the script specifically needs values already saved in the database, require payload.record.id and query the tenant-side record through Studio Runtime context.form.get.
type StudioRuntimeContext = {
form: {
get(input: { recordId: string }): Promise<{
data?: Record<string, unknown>;
}>;
};
};
export default async function main(
payload: FormPageRuntimePayload,
context: StudioRuntimeContext,
) {
if (payload.source.phase !== 'load' || !payload.record?.id) {
return { ok: true };
}
const persisted = await context.form.get({ recordId: payload.record.id });
const ownerChanged =
persisted.data?.ownerId !== payload.draft?.data?.ownerId;
return ownerChanged
? {
ok: true,
message: 'The current owner differs from the saved record.',
}
: { ok: true };
}
Do not infer the current record ID from URL parameters, and do not assume payload.record.data is preloaded.
ok And preventDefault
In the current page-script result, both ok: false and preventDefault: true produce a prevented result. They stop the subsequent page-script flow, but they do not undo a field change or child-row mutation performed by the user and do not roll back effects that were already applied.
return {
ok: false,
preventDefault: true,
effects: [
{
type: 'fieldError',
field: 'amount',
message: 'Correct the amount before continuing.',
},
],
};
Normally return only one of the two flags. Prefer ok: false to express validation failure; use preventDefault: true when the intent is explicitly to stop subsequent flow.
Effects Supported By Each Page Surface
| Effect | form create/edit | view | list |
|---|---|---|---|
message, notification, modal | Supported | Supported | Supported |
refresh | Accepted but not executed | Accepted but not executed | Accepted but not executed |
fieldState | All states supported | Only hidden: true is retained | Only hidden: true is retained |
childFieldState | All states supported | Only hidden: true is retained | Only hidden: true is retained |
childRowState | Supported | Supported | Supported |
| Field values, field errors, option filters, and child-row mutations | Supported | Not executed | Not executed |
The runtime filters unsupported effects before applying them. Do not rely on filtered draft-mutation effects in view-page or list-page scripts.
Refresh Support
The response contract reserves the following refresh request:
return {
ok: true,
effects: [
{
type: 'refresh',
target: 'record',
},
],
};
The current form-page runtime validates and normalizes record, list, children, and all, but it does not execute a refresh handler for page scripts yet. Do not rely on this effect to reload record, list, or child-table data. Return explicit fieldValue, childFieldValue, childRowAdd, childRowRemove, or patch.draft changes when the page must update immediately.
Runtime Rules
- Effects override runtime state only. They do not rewrite field design or layout design settings
- Page script field states are applied on create and edit pages for the current layout
- Child field states apply to editable child-table rows rendered by the current page
- Hidden child fields have their current draft value cleared by the runtime
preventDefaultandok: falsestop the current script flow, but page scripts do not replace the user action that caused the event