Form Events
The /runevent endpoint is the heart of your Buildocs integration. It receives every user interaction — button clicks, field changes, table loads — and returns updated state for the form to render.
How it works
- The user interacts with a widget (clicks a button, changes a field, etc.).
- The SDK serializes the current form state and POSTs it to your
/runeventendpoint — includingwidgetName,widgetEvent,formData,guid,pluginCode, and more. - Your controller receives the request and passes it to
EventHandlerHelper, which resolves the correct handler class for the givenpluginCodeandformCode. EventHandlerHelperalways callsForm_onInitfirst, then constructs the target method name fromwidgetEventandwidgetName(e.g.savebtn+onClick→savebtn_onClick) and invokes it on the handler via reflection.- Your handler method runs — reading from
Context, setting field values withrecord.SetField(), calling helpers likeShowMessage()orOpenModal(). EventHandlerHelpercollects the result and builds the response object.- The SDK receives the response and applies it — updating field values, dropdown options, widget visibility, and executing any frontend commands.
Method naming convention
The Workforce Management example on GitHub uses an EventHandlerHelper class inside the runEvent controller action. When a request arrives, EventHandlerHelper constructs the expected method name from the incoming widgetEvent and widgetName values, then uses reflection to find and invoke that method on the handler class. This is what makes the naming convention below load-bearing — the helper derives the method name automatically, so your handler methods must be named exactly right for the dispatch to work.
Handler methods follow a strict naming convention that EventHandlerHelper uses to locate them via reflection. There are two patterns:
| Pattern | Example | When called |
|---|---|---|
Form_{event} | Form_onClick | Every time that event fires, regardless of which widget triggered it |
{widgetName}_{event} | savebtn_onClick | Only when the named widget triggers that event |
Both variants can coexist. For onClick, the call order is: Form_onClick → {widgetName}_onClick. For onTableLoadData, the widget-specific method is tried first; Form_onTableLoadData is only called if the widget-specific method returns no data.
Form_onInit is called on every request before any event method — including /runevent. Use it for shared initialization (loading lookup data, checking permissions, etc.). The isInitialLoad parameter is true only during /loadform.
Event method reference
widgetEvent | Handler methods invoked | Parameters |
|---|---|---|
| (every request) | Form_onInit | (bool isInitialLoad) |
onClick | Form_onClick, {widgetName}_onClick, | |
onChange | {widgetName}_onChange | (string? value) or (string[] values) |
onRefresh | {widgetName}_onRefresh, Form_onRefresh | (string widgetName, string? value) |
onSave | Form_onBeforeSave, Form_onSave, Form_onAfterSave | none |
onCancel | Form_onCancel | none |
onDelete | Form_onBeforeDelete, Form_onDelete, Form_onAfterDelete | none |
onPrint | Form_onPrint | none |
onTableLoadData | {widgetName}_onTableLoadData → fallback Form_onTableLoadData | none (returns data) |
onTableEditLoadData | {widgetName}_onTableEditLoadData → fallback Form_onTableEditLoadData | none (returns data) |
onTableRunActionEvent | Form_onTableRunActionEvent, {widgetName}_onTableRunActionEvent | (string action, object rowData) |
onTableEditRunActionEvent | Form_onTableEditRunActionEvent, {widgetName}_onTableEditRunActionEvent | (string action, string data) |
onTableRunBatchActionEvent | Form_onTableRunBatchActionEvent, {widgetName}_onTableRunBatchActionEvent | (string action, object[] data) |
onTableCreateRecordEvent | {widgetName}_onTableCreateRecordEvent, Form_onTableCreateRecordEvent | none |
onRowCheckbox | Form_onRowCheckbox | (object value) |
onGetCustomResponse | {widgetName}_{methodName} | (object value) |
Writing a handler class
There is no enforced class structure — as long as your method names match the convention above, you can organise the handler however suits your project.
public class EmployeeFormHandler : FormHandlerBase
{
// Runs on every request — load shared data here
public override async Task Form_onInit(bool isInitialLoad)
{
await base.Form_onInit(isInitialLoad);
if (isInitialLoad && !record.IsNewByRequest())
{
var employee = await _db.Employees.FindAsync(record.GetRecordGuid());
record.SetField("firstName", employee?.FirstName);
record.SetField("lastName", employee?.LastName);
record.SetField("department", employee?.DepartmentId?.ToString());
}
}
// Runs for every onClick on this form
public async Task Form_onClick(string widgetName)
{
if (widgetName == "savebtn")
await SaveEmployee();
}
// Runs only when savebtn is clicked — use either Form_onClick or this, not both
public async Task savebtn_onClick(string widgetName)
{
await SaveEmployee();
ShowMessage("success", "Employee saved.");
CloseForm();
}
// onChange is widget-specific — there is no Form_onChange
public async Task department_onChange(string? value)
{
var roles = await _db.Roles
.Where(r => r.DepartmentId.ToString() == value)
.ToDictionaryAsync(r => r.Id.ToString(), r => r.Name);
SetFieldAllowedValues("role", roles);
}
// Table load — return the row data
public async Task<IEnumerable<object>> employeetbl_onTableLoadData()
{
return await _db.Employees
.Select(e => new
{
_id = e.Id.ToString(),
_sk = e.Id.ToString(),
_code = "EMPLTBL",
e.FirstName,
e.LastName,
e.Status
})
.ToListAsync();
}
// Table row action
public async Task Form_onTableRunActionEvent(string action, object rowData)
{
if (action == "delete")
{
var row = ((JObject)rowData).ToObject<EmployeeRow>();
await _db.Employees.Where(e => e.Id.ToString() == row!._id).DeleteAsync();
}
}
// Save lifecycle — onBeforeSave runs first; throw to abort
public async Task Form_onBeforeSave()
{
if (string.IsNullOrEmpty(GetData("firstName")))
throw new UserWarningException("First name is required.");
}
public async Task Form_onSave()
{
await SaveEmployee();
}
public async Task Form_onAfterSave() { }
// Delete lifecycle
public async Task Form_onBeforeDelete() { }
public async Task Form_onDelete() => await DeleteEmployee(Context.Guid);
public async Task Form_onAfterDelete() { }
}
The request context
Inside your handler, the current request is available through base-class properties:
| Property | Description |
|---|---|
record | DataRecord with current field values — record.GetField(name) to read, record.SetField(name, value) to write |
Context.Guid | Record identifier ("new" for new records) |
Context.FormCode | The form being processed |
Context.PluginCode | The project plugin code |
Context.WidgetName | The widget that triggered the event |
Context.WidgetValue | The new value (for onChange events) |
Next step
Use Commands to control the frontend from your handler.