Skip to main content

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

  1. The user interacts with a widget (clicks a button, changes a field, etc.).
  2. The SDK serializes the current form state and POSTs it to your /runevent endpoint — including widgetName, widgetEvent, formData, guid, pluginCode, and more.
  3. Your controller receives the request and passes it to EventHandlerHelper, which resolves the correct handler class for the given pluginCode and formCode.
  4. EventHandlerHelper always calls Form_onInit first, then constructs the target method name from widgetEvent and widgetName (e.g. savebtn + onClicksavebtn_onClick) and invokes it on the handler via reflection.
  5. Your handler method runs — reading from Context, setting field values with record.SetField(), calling helpers like ShowMessage() or OpenModal().
  6. EventHandlerHelper collects the result and builds the response object.
  7. The SDK receives the response and applies it — updating field values, dropdown options, widget visibility, and executing any frontend commands.

Method naming convention

How the GitHub example wires this up

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:

PatternExampleWhen called
Form_{event}Form_onClickEvery time that event fires, regardless of which widget triggered it
{widgetName}_{event}savebtn_onClickOnly 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.

info

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

widgetEventHandler methods invokedParameters
(every request)Form_onInit(bool isInitialLoad)
onClickForm_onClick, {widgetName}_onClick,
onChange{widgetName}_onChange(string? value) or (string[] values)
onRefresh{widgetName}_onRefresh, Form_onRefresh(string widgetName, string? value)
onSaveForm_onBeforeSave, Form_onSave, Form_onAfterSavenone
onCancelForm_onCancelnone
onDeleteForm_onBeforeDelete, Form_onDelete, Form_onAfterDeletenone
onPrintForm_onPrintnone
onTableLoadData{widgetName}_onTableLoadData → fallback Form_onTableLoadDatanone (returns data)
onTableEditLoadData{widgetName}_onTableEditLoadData → fallback Form_onTableEditLoadDatanone (returns data)
onTableRunActionEventForm_onTableRunActionEvent, {widgetName}_onTableRunActionEvent(string action, object rowData)
onTableEditRunActionEventForm_onTableEditRunActionEvent, {widgetName}_onTableEditRunActionEvent(string action, string data)
onTableRunBatchActionEventForm_onTableRunBatchActionEvent, {widgetName}_onTableRunBatchActionEvent(string action, object[] data)
onTableCreateRecordEvent{widgetName}_onTableCreateRecordEvent, Form_onTableCreateRecordEventnone
onRowCheckboxForm_onRowCheckbox(object value)
onGetCustomResponse{widgetName}_{methodName}(object value)

Writing a handler class

tip

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:

PropertyDescription
recordDataRecord with current field values — record.GetField(name) to read, record.SetField(name, value) to write
Context.GuidRecord identifier ("new" for new records)
Context.FormCodeThe form being processed
Context.PluginCodeThe project plugin code
Context.WidgetNameThe widget that triggered the event
Context.WidgetValueThe new value (for onChange events)

Next step

Use Commands to control the frontend from your handler.