Skip to main content

Form Validation

Validation can happen both on the frontend (required-field checks) and on the backend. Backend validation lets you apply business rules that the frontend cannot — cross-field checks, database lookups, and workflow state guards.

Validation outcomes are communicated back through the /runevent response — a failed validation highlights the offending fields in red (via SetFormValidationData) or shows a message (via ShowMessage), while a passing one proceeds to persist data and returns the appropriate follow-up commands.


How backend validation works

  1. The user triggers a save action.
  2. The SDK fires an onSave (or onClick on a save button) event to your backend.
  3. Your handler reads field values from the incoming request via record.
  4. If validation fails, highlight the offending fields in red via SetFormValidationData (the SDK will block further saves until they are filled) or show a message via ShowMessagedo not close the form.
  5. If validation passes, persist the record and close (or redirect).

Built-in required-field validation

formValidator is a protected field available in every handler. It automatically validates all fields marked as Required in the Form Builder, but only for fields that are currently visible — hidden fields are never flagged.

Use it inside Form_onBeforeSave() so it runs on every save attempt:

public override async Task Form_onBeforeSave()
{
Dictionary<string, string> requiredFields = await formValidator.Validate(record, screen);
cmd.SetFormValidationData(requiredFields);

if (requiredFields.Count > 0)
throw new ActionTerminationException();
}
  • formValidator.Validate(record, screen) — returns a Dictionary<string, string> of fieldName → fieldLabel for every required, visible field that is empty.
  • cmd.SetFormValidationData(requiredFields) — sends the failing fields back to the SDK, which highlights them in red and blocks any further save attempts until they are filled in. This also clears any other queued commands so nothing else runs.
  • throw new ActionTerminationException() — aborts the save. The response is returned immediately with the validation data.

You can combine the built-in check with custom rules — run the required-field check first, then add your own business logic after:

public override async Task Form_onBeforeSave()
{
// Required-field check — respects Form Builder settings and current widget visibility
Dictionary<string, string> requiredFields = await formValidator.Validate(record, screen);
cmd.SetFormValidationData(requiredFields);

if (requiredFields.Count > 0)
throw new ActionTerminationException();

// Custom business rule — check for duplicate email
string? email = record.GetField("email")?.ToString();
bool exists = await _db.Employees
.AnyAsync(e => e.Email == email && e.Id.ToString() != record.GetRecordGuid());

if (exists)
{
ShowMessage("error", $"An employee with email '{email}' already exists.");
throw new ActionTerminationException();
}
}

Manual required-field check

public async Task Form_onClick(string widgetName)
{
if (widgetName != "savebtn") return;

var errors = new List<string>();

if (string.IsNullOrWhiteSpace(record.GetField("firstName")?.ToString()))
errors.Add("First name is required.");

if (string.IsNullOrWhiteSpace(record.GetField("email")?.ToString()))
errors.Add("Email is required.");

if (errors.Count > 0)
{
ShowMessage("warning", string.Join(" | ", errors));
return;
}

await SaveEmployee(record.GetData(), Context.Guid);
ShowMessage("success", "Employee saved.");
CloseForm();
}

Cross-field validation

var startDate = DateTime.Parse(record.GetField("startDate")?.ToString()!);
var endDate = DateTime.Parse(record.GetField("endDate")?.ToString()!);

if (endDate <= startDate)
{
ShowMessage("warning", "End date must be after start date.");
return;
}

Database-backed validation

// Check for duplicate email
string email = record.GetField("email")?.ToString()!;
bool exists = await _db.Employees
.AnyAsync(e => e.Email == email && e.Id.ToString() != Context.Guid);

if (exists)
{
ShowMessage("error",
$"An employee with email '{email}' already exists.");
return;
}

Workflow state validation

// Only allow editing if the record is in Draft state
var record = await _db.Orders.FindAsync(Guid.Parse(Context.Guid));

if (record?.Status != "Draft")
{
ShowMessage("error",
"Only orders in Draft status can be edited.");
return;
}

Highlighting invalid fields

cmd.SetFormValidationData() highlights fields in red and blocks further save attempts until they are filled in. Pass a dictionary of fieldName → fieldLabel for the fields that failed.

var invalid = new Dictionary<string, string>
{
{ "email", "Email" },
{ "startDate", "Start Date" }
};

cmd.SetFormValidationData(invalid);
throw new ActionTerminationException();

For required-field checks driven by the Form Builder, use formValidator.Validate() (see Built-in required-field validation) — it builds this dictionary automatically.


Throwing UserWarningException

Alternatively, throw UserWarningException anywhere in your handler to surface a warning without writing explicit command code:

throw new UserWarningException("Approval limit exceeded. Maximum allowed: $10,000.");

The controller catches this and automatically converts it to a ShowMessage(warning, ...) command.