Skip to main content

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​

PhaseWhen it runsCan stop the action
beforeClickBefore the button action startsYes
actionAs the main custom button actionYes
afterClickAfter the button action succeedsNo

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.

ParameterTypeRequiredDescription
payloadFormButtonRuntimePayloadYesRuntime payload sent by the button executor
contextobjectYesStudio 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​

FieldTypeDescription
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.idstringForm schema ID when a form is available
form.apiNamestringForm API name when a form is available
source.buttonIdstringButton key. Preset buttons use values such as form.submit
source.buttonType'preset' | 'custom'Whether the button is built in or custom
source.placementstringRuntime placement such as FORM_HEADER, LIST_ROW, or LIST_BATCH
source.operationstringOperation name such as submit, save, saveDraft, or custom
draft.dataRecord<string, unknown>Current draft values keyed by field API name
recordRuntimeRecord | nullExisting record identity. Usually null on create pages; record.data is not preloaded for button bindings
resultunknown | nullCompleted action result. Mainly useful in afterClick
runtime.localestringBrowser locale such as en-US or zh-CN
runtime.timezonestringBrowser 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​

FieldTypeDescription
child.formobjectChild-form identity when a button is clicked from a child table
child.relationFieldApiNamestring | nullRelation field that connects the child form
child.rowobject | nullCurrent child row for row buttons
selection.childRowsArray<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.

PlacementExtra payload
FORM_LIST_HEADERchild.form
FORM_LIST_BATCHchild.form and selection.childRows
FORM_LIST_ROWchild.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;
};
FieldTypeDescription
okbooleanWhether the phase can continue. Missing values are treated as true
preventDefaultbooleanExplicitly stops the current button action 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

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.

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
childRowStateOverrides child-table row state
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​

{
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.',
}
FieldTypeApplies toDescription
typestringallfieldState, fieldOptionFilter, fieldValue, fieldError, or fieldErrorClear
fieldstringallParent field API name
allowedValuesstring[]fieldOptionFilterStable option values that remain selectable
clearInvalidValuebooleanfieldOptionFilterClears values that are no longer allowed
resetbooleanfieldOptionFilterRestores the original configured options
hiddenbooleanfieldStateHides or shows the field
requiredbooleanfieldStateChanges whether the field is required
readonlybooleanfieldStateChanges whether the field is read-only
disabledbooleanfieldStateChanges whether the field is disabled
valueunknownfieldValueNew field value
messagestringfieldErrorError 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 },
],
},
}
FieldTypeApplies toDescription
typestringallchildFieldState, childFieldValue, childFieldError, or childFieldErrorClear
tablestringallChild form API name
fieldstringallChild field API name
whereApprovalFlowConditionGroupallRow filter evaluated against each child row
hiddenbooleanchildFieldStateHides or shows the child field. Hidden child fields clear their draft value
requiredbooleanchildFieldStateChanges whether the child field is required
readonlybooleanchildFieldStateChanges whether the child field is read-only
disabledbooleanchildFieldStateChanges whether the child field is disabled
valueunknownchildFieldValueNew child field value
messagestringchildFieldErrorChild 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' },
],
},
}
FieldTypeApplies toDescription
typestringallchildRowState, childRowAdd, or childRowRemove
tablestringallChild form API name
selectedbooleanchildRowStateMarks matching rows as selected
backgroundColorstringchildRowStateRuntime background color for matching rows
hoverBackgroundColorstringchildRowStateRuntime hover background color for matching rows
dataRecord<string, unknown>childRowAddSingle row to append
rowsArray<Record<string, unknown>>childRowAddMultiple rows to append. When rows is present, data is ignored
whereApprovalFlowConditionGroupchildRowState, childRowRemoveMatching 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,
}
FieldTypeApplies toDescription
typestringallmessage, notification, or modal
levelstringallsuccess, info, warning, error; modal also supports confirm
textstringmessageSimple message text
titlestringnotification, modalTitle text
descriptionstringnotificationNotification body text
contentstringmodalModal body text
okTextstringmodalOK button text
cancelTextstringmodalCancel button text for confirm modals
awaitActionbooleanmodalWhen true on a confirm modal, waits for the user and cancels the action if they cancel
placementstringnotificationToast position
durationnumbernotificationDuration in seconds
closablebooleannotificationWhether a close control is shown
centeredbooleanmodalWhether the modal is vertically centered in the viewport
widthnumber | stringnotification, modalWidth
heightnumber | stringnotification, modalFixed body height
maxHeightnumber | stringnotification, modalMaximum body height

Top-level notification and modal shortcuts accept the same parameters except type.

Refresh​

{
type: 'refresh',
target: 'record',
}
FieldTypeDescription
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​

  • beforeClick is the right place for validation, confirmation, and action interception
  • beforeClick confirm modals with awaitAction: true stop the action when the user cancels
  • action is the main logic for a custom button. Preset buttons use built-in product logic for their main action
  • afterClick is best for follow-up notifications or domain side effects after success; do not currently rely on the refresh effect
  • 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

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
}
]
}
]
}