Button
The button interaction event defines the runtime contract between form buttons and Studio scripts.
Use it for preset buttons, custom buttons, before-click scripts, custom action scripts, and after-click scripts configured from Button Design.
Applicable Scenarios
Use a button script when the runtime action needs to:
- validate form input before the action continues
- confirm whether the user should continue
- update parent or child draft values before save or submit
- add or remove child rows from a custom button
- show runtime messages, notifications, or modals
Execution Phases
| Phase | When it runs | Can stop the action |
|---|---|---|
beforeClick | Before the button action starts | Yes |
action | As the main custom button action | Yes |
afterClick | After the button action succeeds | No |
For preset buttons, action is the built-in product action. For custom buttons, action runs the configured Studio script.
Script Function
type FormButtonRuntimePayload = {
schema: 'soln.runtime-context/v1';
source: {
type: 'form_button';
phase: 'beforeClick' | 'action' | 'afterClick';
};
draft: { data: Record<string, unknown> } | null;
};
type StudioRuntimeContext = Record<string, unknown>;
export default async function main(
payload: FormButtonRuntimePayload,
_context: StudioRuntimeContext,
) {
return {
ok: true,
effects: [],
};
}
Use _context when the script does not need the Studio runtime context. The underscore avoids unused-parameter noise in strict TypeScript editors.
| Parameter | Type | Required | Description |
|---|---|---|---|
payload | FormButtonRuntimePayload | Yes | Runtime payload sent by the button executor |
context | object | Yes | Studio runtime context provided by the Studio executor |
Payload
type FormButtonRuntimePayload = {
schema: 'soln.runtime-context/v1';
source: {
type: 'form_button';
buttonId: string;
buttonType: 'preset' | 'custom';
placement: string;
operation: string;
phase: 'beforeClick' | 'action' | 'afterClick';
};
form: {
id: string;
apiName: string;
name: string | null;
} | null;
record: RuntimeRecord | null;
draft: { data: Record<string, unknown> } | null;
selection: {
records: RuntimeRecord[];
childRows: Array<{ id: string | null; 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;
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_button' | Identifies the button execution source |
source.phase | 'beforeClick' | 'action' | 'afterClick' | Current execution phase |
form.id | string | Form schema ID when a form is available |
form.apiName | string | Form API name when a form is available |
source.buttonId | string | Button key. Preset buttons use values such as form.submit |
source.buttonType | 'preset' | 'custom' | Whether the button is built in or custom |
source.placement | string | Runtime placement such as FORM_HEADER, LIST_ROW, or LIST_BATCH |
source.operation | string | Operation name such as submit, save, saveDraft, or custom |
draft.data | Record<string, unknown> | Current draft values keyed by field API name |
record | RuntimeRecord | null | Existing record identity. Usually null on create pages; record.data is not preloaded for button bindings |
result | unknown | null | Completed action result. Mainly useful in afterClick |
runtime.locale | string | Browser locale such as en-US or zh-CN |
runtime.timezone | string | Browser timezone such as Asia/Shanghai |
Read current form values from payload.draft.data. 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 | Child-form identity when a button is clicked from a child table |
child.relationFieldApiName | string | null | Relation field that connects the child form |
child.row | object | null | Current child row for row buttons |
selection.childRows | Array<object> | Selected child rows for batch buttons |
Child-table drafts are also available in payload.draft.data.__childFormDrafts[childFormApiName] as an array of row objects.
| Placement | Extra payload |
|---|---|
FORM_LIST_HEADER | child.form |
FORM_LIST_BATCH | child.form and selection.childRows |
FORM_LIST_ROW | child.form and child.row |
Response
Unknown response properties are ignored.
type FormButtonRuntimeResponse = {
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 phase can continue. Missing values are treated as true |
preventDefault | boolean | Explicitly stops the current button action 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 |
If ok is false, preventDefault is true, or a confirm modal with awaitAction: true is cancelled, the runtime stops the current button action.
Effects
Effects are runtime UI instructions. They do not change form design configuration.
| 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 | Overrides child-table row state |
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
{
type: 'fieldState',
field: 'closeReason',
hidden: false,
required: true,
readonly: false,
disabled: false,
}
{
type: 'fieldValue',
field: 'status',
value: 'submitted',
}
{
type: 'fieldError',
field: 'amount',
message: 'Amount must be greater than zero.',
}
| Field | Type | Applies to | Description |
|---|---|---|---|
type | string | all | fieldState, fieldOptionFilter, fieldValue, fieldError, or fieldErrorClear |
field | string | all | Parent field API name |
allowedValues | string[] | fieldOptionFilter | Stable option values that remain selectable |
clearInvalidValue | boolean | fieldOptionFilter | Clears values that are no longer allowed |
reset | boolean | fieldOptionFilter | Restores the original configured options |
hidden | boolean | fieldState | Hides or shows the field |
required | boolean | fieldState | Changes whether the field is required |
readonly | boolean | fieldState | Changes whether the field is read-only |
disabled | boolean | fieldState | Changes whether the field is disabled |
value | unknown | fieldValue | New field value |
message | string | fieldError | Error text shown on the field |
Child Field Effects
Use table for the child form API name. You can also use childTable, formApiName, or childFormApiName.
Use where to target rows by child-field values. If where is omitted, the effect applies to every current row in that child table.
{
type: 'childFieldState',
table: 'orderItems',
field: 'discount',
where: {
logic: 'and',
children: [
{ field: 'productType', operator: 'eq', value: 'service' },
],
},
readonly: true,
required: false,
}
{
type: 'childFieldValue',
table: 'orderItems',
field: 'currency',
where: {
logic: 'and',
children: [
{ field: 'region', operator: 'eq', value: 'US' },
],
},
value: 'USD',
}
{
type: 'childFieldError',
table: 'orderItems',
field: 'quantity',
where: {
logic: 'and',
children: [
{ field: 'quantity', operator: 'lte', value: 0 },
],
},
message: 'Quantity must be greater than zero.',
}
{
type: 'childFieldErrorClear',
table: 'orderItems',
field: 'quantity',
where: {
logic: 'and',
children: [
{ field: 'quantity', operator: 'gt', value: 0 },
],
},
}
| Field | Type | Applies to | Description |
|---|---|---|---|
type | string | all | childFieldState, childFieldValue, childFieldError, or childFieldErrorClear |
table | string | all | Child form API name |
field | string | all | Child field API name |
where | ApprovalFlowConditionGroup | all | Row filter evaluated against each child row |
hidden | boolean | childFieldState | Hides or shows the child field. Hidden child fields clear their draft value |
required | boolean | childFieldState | Changes whether the child field is required |
readonly | boolean | childFieldState | Changes whether the child field is read-only |
disabled | boolean | childFieldState | Changes whether the child field is disabled |
value | unknown | childFieldValue | New child field value |
message | string | childFieldError | Child field error text |
Child Row Effects
{
type: 'childRowAdd',
table: 'orderItems',
rows: [
{
productName: 'Standard service',
quantity: 1,
currency: 'USD',
},
{
productName: 'Implementation',
quantity: 1,
currency: 'USD',
},
],
}
{
type: 'childRowRemove',
table: 'orderItems',
where: {
logic: 'and',
children: [
{ field: 'status', operator: 'eq', value: 'cancelled' },
],
},
}
| Field | Type | Applies to | Description |
|---|---|---|---|
type | string | all | childRowState, childRowAdd, or childRowRemove |
table | string | all | Child form API name |
selected | boolean | childRowState | Marks matching rows as selected |
backgroundColor | string | childRowState | Runtime background color for matching rows |
hoverBackgroundColor | string | childRowState | Runtime hover background color for matching rows |
data | Record<string, unknown> | childRowAdd | Single row to append |
rows | Array<Record<string, unknown>> | childRowAdd | Multiple rows to append. When rows is present, data is ignored |
where | ApprovalFlowConditionGroup | childRowState, childRowRemove | Matching rows are updated or removed from the current draft |
Feedback Effects
{
type: 'message',
level: 'warning',
text: 'Please review the highlighted fields.',
}
{
type: 'notification',
level: 'info',
title: 'Validation completed',
description: 'The record passed the external validation.',
placement: 'topRight',
duration: 4,
closable: true,
width: 480,
maxHeight: 320,
}
{
type: 'modal',
level: 'confirm',
title: 'Continue submission?',
content: 'This amount is high. Continue submitting this record?',
okText: 'Continue',
cancelText: 'Cancel submit',
awaitAction: true,
}
| Field | Type | Applies to | Description |
|---|---|---|---|
type | string | all | message, notification, or modal |
level | string | all | success, info, warning, error; modal also supports confirm |
text | string | message | Simple message text |
title | string | notification, modal | Title text |
description | string | notification | Notification body text |
content | string | modal | Modal body text |
okText | string | modal | OK button text |
cancelText | string | modal | Cancel button text for confirm modals |
awaitAction | boolean | modal | When true on a confirm modal, waits for the user and cancels the action if they cancel |
placement | string | notification | Toast position |
duration | number | notification | Duration in seconds |
closable | boolean | notification | Whether a close control is shown |
centered | boolean | modal | Whether the modal is vertically centered in the viewport |
width | number | string | notification, modal | Width |
height | number | string | notification, modal | Fixed body height |
maxHeight | number | string | notification, modal | Maximum body height |
Top-level notification and modal shortcuts accept the same parameters except type.
Refresh
{
type: 'refresh',
target: 'record',
}
| Field | Type | Description |
|---|---|---|
type | 'refresh' | Refresh request |
target | 'record' | 'list' | 'children' | 'all' | Requested refresh target. Support depends on the current surface |
Examples
Validate Before Submit
type FormButtonRuntimePayload = {
draft?: {
data?: Record<string, unknown>;
};
};
export default async function main(payload: FormButtonRuntimePayload) {
const amount = Number(payload.draft?.data?.amount || 0);
if (amount <= 0) {
return {
ok: false,
message: 'Amount must be greater than zero.',
effects: [
{
type: 'fieldError',
field: 'amount',
message: 'Amount must be greater than zero.',
},
],
};
}
return { ok: true };
}
Confirm Before Continuing
type FormButtonRuntimePayload = {
draft?: {
data?: Record<string, unknown>;
};
};
export default async function main(payload: FormButtonRuntimePayload) {
const amount = Number(payload.draft?.data?.amount || 0);
if (amount > 10000) {
return {
ok: true,
modal: {
level: 'confirm',
title: 'Continue submission?',
content: 'This amount is high. Continue submitting this record?',
okText: 'Continue',
cancelText: 'Cancel submit',
awaitAction: true,
},
};
}
return { ok: true };
}
Change Field UI
type FormButtonRuntimePayload = {
draft?: {
data?: Record<string, unknown>;
};
};
export default async function main(payload: FormButtonRuntimePayload) {
const status = payload.draft?.data?.status;
const closed = status === 'closed';
return {
ok: true,
effects: [
{
type: 'fieldState',
field: 'closeReason',
hidden: !closed,
required: closed,
readonly: false,
},
],
};
}
Add Child Rows From A Custom Button
type FormButtonRuntimePayload = {
child: { form: { apiName: string } } | null;
};
export default async function main(payload: FormButtonRuntimePayload) {
const table = payload.child?.form.apiName || 'orderItems';
return {
ok: true,
effects: [
{
type: 'childRowAdd',
table,
rows: [
{ productName: 'Standard service', quantity: 1 },
{ productName: 'Implementation', quantity: 1 },
],
},
{
type: 'message',
level: 'success',
text: 'Rows added.',
},
],
};
}
Remove Matching Child Rows From A Custom Button
type FormButtonRuntimePayload = {
child: {
form: { apiName: string };
row: { data: Record<string, unknown> };
} | null;
};
export default async function main(payload: FormButtonRuntimePayload) {
const table = payload.child?.form.apiName;
const sku = String(payload.child?.row.data.sku || '').trim();
if (!table || !sku) {
return { ok: false, message: 'No child row key found.' };
}
return {
ok: true,
effects: [
{
type: 'childRowRemove',
table,
where: {
logic: 'and',
children: [
{ field: 'sku', operator: 'eq', value: sku },
],
},
},
{
type: 'message',
level: 'success',
text: 'Row removed.',
},
],
};
}
Patch Several Draft Values Before Submit
Use patch.draft during beforeClick to merge several parent fields before the built-in save or submit action runs. The patch only changes the current page draft; the following button action must succeed before those values become persistent.
type FormButtonRuntimePayload = {
source: { type: 'form_button'; phase: string; operation: string };
draft: { data: Record<string, unknown> } | null;
};
export default async function main(payload: FormButtonRuntimePayload) {
if (payload.source.phase !== 'beforeClick') {
return { ok: true };
}
return {
ok: true,
patch: {
draft: {
submittedAt: new Date().toISOString(),
submissionSource: 'runtime_button',
},
},
};
}
The current implementation merges both patch.draft and patch.record into the current form state. Neither one bypasses the button action to write directly to the tenant database. Prefer the semantically explicit patch.draft in button scripts.
Set And Clear A Parent Field Error
Return fieldError when validation fails. Explicitly return fieldErrorClear after the value becomes valid to clear a previously displayed error.
export default async function main(payload: FormButtonRuntimePayload) {
const amount = Number(payload.draft?.data?.amount || 0);
return amount > 0
? {
ok: true,
effects: [{ type: 'fieldErrorClear', field: 'amount' }],
}
: {
ok: false,
effects: [
{
type: 'fieldError',
field: 'amount',
message: 'Amount must be greater than zero.',
},
],
};
}
Use top-level fieldErrors when several parent fields only need errors set:
return {
ok: false,
fieldErrors: {
amount: 'Enter a valid amount.',
ownerId: 'Select an owner.',
},
};
Set And Clear Child Field Errors
This before-click script validates all current child drafts and clears the corresponding errors after the rows become valid.
export default async function main(payload: FormButtonRuntimePayload) {
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 hasInvalidQuantity = rows.some(
(row) => Number(row.quantity || 0) <= 0,
);
return {
ok: !hasInvalidQuantity,
effects: [
hasInvalidQuantity
? {
type: 'childFieldError',
table: 'orderItems',
field: 'quantity',
where: {
logic: 'and',
children: [{ field: 'quantity', operator: 'lte', value: 0 }],
},
message: 'Quantity must be greater than zero.',
}
: {
type: 'childFieldErrorClear',
table: 'orderItems',
field: 'quantity',
},
],
};
}
Omitting where from childFieldErrorClear clears the target field error from every current row in that child table.
Filter And Restore Choice Options
fieldOptionFilter filters stable option values already configured in field design. It does not accept display labels.
export default async function main(payload: FormButtonRuntimePayload) {
const domestic = payload.draft?.data?.region === 'domestic';
return {
ok: true,
effects: [
domestic
? {
type: 'fieldOptionFilter',
field: 'shippingMethod',
allowedValues: ['standard', 'express'],
clearInvalidValue: true,
}
: {
type: 'fieldOptionFilter',
field: 'shippingMethod',
reset: true,
},
],
};
}
Read Selected Records From A List Batch Button
A LIST_BATCH button reads selected records from selection.records. Do not infer record IDs from the URL or page state.
type FormButtonRuntimePayload = {
source: { type: 'form_button'; phase: string; placement: string };
selection: {
records: Array<{ id: string; data: Record<string, unknown> }>;
};
};
export default async function main(payload: FormButtonRuntimePayload) {
const records = payload.selection.records;
if (payload.source.placement !== 'LIST_BATCH' || records.length === 0) {
return {
ok: false,
message: 'Select at least one record.',
};
}
return {
ok: true,
notification: {
level: 'info',
title: 'Batch operation ready',
description: `${records.length} records will be processed.`,
},
};
}
This example only demonstrates the selection context. To update records, call a Studio Runtime domain method such as context.form.batchUpdate with the real selected record IDs.
Read Selected Rows From A Child Batch Button
A FORM_LIST_BATCH button uses child.form to identify the child table and selection.childRows for the draft rows selected by the user.
export default async function main(payload: FormButtonRuntimePayload) {
const table = payload.child?.form.apiName;
const selectedRows = payload.selection.childRows;
if (!table || selectedRows.length === 0) {
return { ok: false, message: 'Select at least one child row.' };
}
return {
ok: true,
message: `${selectedRows.length} ${table} rows selected.`,
};
}
Use The Action Result In AfterClick
afterClick runs only after the button action succeeds. The completed action result is available as payload.result. Use it for result feedback or post-success domain side effects; it cannot prevent an action that already completed.
type FormButtonRuntimePayload = {
source: { type: 'form_button'; phase: string };
result: Record<string, unknown> | null;
};
export default async function main(payload: FormButtonRuntimePayload) {
if (payload.source.phase !== 'afterClick') {
return { ok: true };
}
const recordId = String(payload.result?.recordId || '').trim();
return {
ok: true,
notification: {
level: 'success',
title: 'Action completed',
description: recordId
? `Record ${recordId} was saved.`
: 'The record was saved.',
duration: 4,
},
};
}
Do not depend on an undeclared result property. Check for the actual result fields and types returned by the corresponding button action.
Read A Persisted Record
On an edit page, payload.draft.data is the current unsaved draft and payload.record only provides record identity. Query the tenant-side record through Studio Runtime when the script needs already persisted values.
type StudioRuntimeContext = {
form: {
get(input: { recordId: string }): Promise<{
data?: Record<string, unknown>;
}>;
};
};
export default async function main(
payload: FormButtonRuntimePayload,
context: StudioRuntimeContext,
) {
if (!payload.record?.id) {
return { ok: false, message: 'This page has no saved record yet.' };
}
const persisted = await context.form.get({ recordId: payload.record.id });
const savedStatus = String(persisted.data?.status || '');
return savedStatus === 'locked'
? {
ok: false,
modal: {
level: 'warning',
title: 'Record locked',
content: 'The saved record is locked, so this action cannot continue.',
},
}
: { ok: true };
}
Organize One Script By Execution Phase
When one script file is reused across phases, branch explicitly on source.phase. Use beforeClick for validation, action for the custom main action, and afterClick only for success feedback or side effects.
export default async function main(payload: FormButtonRuntimePayload) {
if (payload.source.phase === 'beforeClick') {
return payload.draft?.data?.ownerId
? { ok: true }
: {
ok: false,
fieldErrors: { ownerId: 'Select an owner.' },
};
}
if (payload.source.phase === 'action') {
return {
ok: true,
message: 'The custom action ran.',
};
}
return {
ok: true,
notification: {
level: 'success',
title: 'Button flow completed',
},
};
}
Refresh Support
The button response contract accepts this effect:
return {
ok: true,
effects: [{ type: 'refresh', target: 'record' }],
};
The current form-button runtime validates and normalizes record, list, children, and all, but does not execute a refresh handler yet. Do not rely on this effect to reload page data. Return fieldValue, child-table effects, or patch.draft to update the current draft; use Studio Runtime form domain methods to write to the tenant database.
Runtime Rules
beforeClickis the right place for validation, confirmation, and action interceptionbeforeClickconfirm modals withawaitAction: truestop the action when the user cancelsactionis the main logic for a custom button. Preset buttons use built-in product logic for their main actionafterClickis best for follow-up notifications or domain side effects after success; do not currently rely on therefresheffect- Effects override runtime state only. They do not rewrite field design settings
- Hidden fields are not rendered on create and edit pages
- Required field validation follows the current runtime field state
Related Declarative Runtime Rules
Form runtime pages can also apply declarative rules from form custom configuration.
{
"formRuntimeRules": [
{
"when": {
"logic": "and",
"children": [
{
"field": "status",
"operator": "eq",
"value": "closed"
}
]
},
"effects": [
{
"type": "fieldState",
"field": "closeReason",
"hidden": false,
"required": true
}
],
"otherwiseEffects": [
{
"type": "fieldState",
"field": "closeReason",
"hidden": true,
"required": false
}
]
}
]
}