Skip to main content

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​

  1. Open Work > Forms
  2. Open the target form
  3. Go to Layout Design
  4. Select the layout used by the create or edit page
  5. Open the Page Events tab
  6. Select a Studio script and, when needed, a specific script file
  7. Enable the triggers that should run the script
  8. 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​

TriggerRuntime phaseDescription
Run on loadloadRuns once after the create or edit page opens with the selected layout
Run on field changechangeRuns after parent form values change. It can watch all fields or selected parent fields

Child Table Triggers​

TriggerRuntime phaseDescription
Child row addedchildRowAddRuns after a child-table row is added
Child row removedchildRowRemoveRuns after a child-table row is removed
Child field changedchildFieldChangeRuns 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​

FieldTypeDescription
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.idstringForm schema ID when a form is available
form.apiNamestringForm API name when a form is available
source.layoutIdstring | nullCurrent layout ID
draft.dataRecord<string, unknown>Current draft values keyed by field API name
record{ id: string } | nullExisting record identity. Usually null on create pages; record data is not preloaded for page bindings
source.page'form' | 'view' | 'list'Current page surface
runtime.localestringBrowser locale
runtime.timezonestringBrowser 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​

FieldTypeDescription
child.formobject | nullCurrent child form for child-table triggers
child.row.dataRecord<string, unknown>Current row draft data
source.previousDataRecord<string, unknown> | nullPrevious row draft data
source.changedFieldstring | nullField API name that changed
source.changedFieldsstring[]List of changed fields
source.previousValueunknown | nullPrevious field value
source.valueunknown | nullCurrent 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;
};
FieldTypeDescription
okbooleanWhether the current script flow can continue
preventDefaultbooleanExplicitly stops the current script flow when true
messagestringShows a simple runtime message
fieldErrorsRecord<string, string>Sets field validation errors by field API name
patch.draftRecord<string, unknown>Merges values into the current draft state
patch.recordRecord<string, unknown>Merges values into the current record state
effectsFormRuntimeEffect[]Ordered runtime effects
notificationobject | stringShortcut for one top-level notification effect
modalobject | stringShortcut 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.

EffectPurpose
fieldStateOverrides parent field hidden, required, readonly, or disabled state
fieldOptionFilterFilters a parent choice field's configured options by stable option values
fieldValueSets a parent field value
fieldErrorDisplays a parent field validation error
fieldErrorClearClears a parent field validation error
childFieldStateOverrides child-table field hidden, required, readonly, or disabled state
childRowStateTemporarily marks child-table rows as selected
childFieldValueSets a child-table field value
childFieldErrorDisplays a child-table field validation error
childFieldErrorClearClears child-table field validation errors
childRowAddAppends one or more draft rows to a child table
childRowRemoveRemoves one or more draft rows from a child table
messageShows a simple message
notificationShows a richer toast
modalShows a modal dialog
refreshRequests 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,
},
],
};
FieldTypeDescription
fieldstringTarget parent field API name
allowedValuesstring[]Stable option values that remain selectable
clearInvalidValuebooleanWhen true, clears a single value or removes multi values that are no longer allowed
resetbooleanWhen 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,
},
],
};
FieldTypeRequiredDescription
type'childRowState'YesIdentifies a child-row state override
tablestringYesChild form API name
whereApprovalFlowConditionGroupNoRow filter. Omit it to target all current rows
selectedbooleanNoWhether 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​

Effectform create/editviewlist
message, notification, modalSupportedSupportedSupported
refreshAccepted but not executedAccepted but not executedAccepted but not executed
fieldStateAll states supportedOnly hidden: true is retainedOnly hidden: true is retained
childFieldStateAll states supportedOnly hidden: true is retainedOnly hidden: true is retained
childRowStateSupportedSupportedSupported
Field values, field errors, option filters, and child-row mutationsSupportedNot executedNot 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
  • preventDefault and ok: false stop the current script flow, but page scripts do not replace the user action that caused the event