Rule Types¶
P21 supports three distinct rule types, each designed for a different trigger pattern and interaction model. Choosing the right type is critical — using a Validator where an OnEvent is appropriate (or vice versa) leads to poor user experience or incorrect behavior.
Validator Rules¶
Validator rules fire when a field value changes or when the user attempts to save a record. They run synchronously — the user's action is blocked until the rule returns. This makes them the appropriate type for any logic that must either approve or reject a field change before processing continues.
Characteristics¶
- Fire on: field
TabOut, field change commit, or Save button press - Run synchronously on the UI thread — user waits
- Can return
RuleResult.Error()to block the save and display an error - Can modify other fields in the same window
- Can display custom dialogs via
ResponseAttributes - Configured in P21: Setup → Business Rules → Rule Assignments → Window → Field → Validator
Configuration Options¶
| Setting | Description |
|---|---|
| Window | The P21 window this rule applies to (e.g., Order Entry) |
| Tab | The tab within the window (e.g., Line Items) |
| Field | The field whose change triggers this rule |
| Multi-Row | If checked, Data.Set.Tables is populated with all rows; if unchecked, only the current row's fields are in scope via Data.Fields |
Single-Row Validator Example¶
A single-row validator has access to Data.Fields for the current row's values:
using System;
using System.Globalization;
using P21.Extensions.BusinessRule;
namespace YourCompany.P21.Rules;
/// <summary>
/// Validates that fields contain a properly formatted date.
/// Configure as: any window, date-type fields, Validator, Single-Row.
/// </summary>
public class ValidDatetimeRule : Rule
{
// Accepted date formats — order matters; most specific first
private static readonly string[] AcceptedFormats =
{
"MM/dd/yyyy",
"M/d/yyyy",
"MM/d/yyyy",
"M/dd/yyyy"
};
public override string GetName() => "ValidDatetime";
public override string GetDescription() =>
"Validates that fields contain a properly formatted date (MM/dd/yyyy).";
public override RuleResult Execute()
{
foreach (var field in Data.Fields)
{
var value = field.Value?.ToString();
// Empty/null is acceptable — required-field enforcement is separate
if (string.IsNullOrWhiteSpace(value))
continue;
if (!DateTime.TryParseExact(
value,
AcceptedFormats,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out _))
{
return RuleResult.Error(
$"Invalid date in '{field.Name}': '{value}'. " +
$"Expected format: MM/dd/yyyy.");
}
}
return RuleResult.Success;
}
}
Multi-Row Validator Example¶
A multi-row validator has access to Data.Set.Tables, which contains all rows of the named datawindow. This is appropriate when your validation logic depends on aggregate values or must examine every row:
using System.Data;
using System.Linq;
using P21.Extensions.BusinessRule;
namespace YourCompany.P21.Rules;
/// <summary>
/// Multi-row validator: blocks order save if total exceeds customer credit limit.
/// Configure as: Window=Order Entry, Tab=Line Items, Field=unit_quantity,
/// Type=Validator, Multi-Row=Yes.
/// </summary>
public class OrderTotalCreditCheck : Rule
{
public override string GetName() => "OrderTotalCreditCheck";
public override string GetDescription() =>
"Prevents saving an order whose total exceeds the customer's available credit.";
public override RuleResult Execute()
{
var lines = Data.Set.Tables["oe_line"];
if (lines == null) return RuleResult.Success;
decimal orderTotal = lines.AsEnumerable()
.Where(r => r.RowState != DataRowState.Deleted)
.Sum(r => r.Field<decimal>("extended_price"));
var creditLimit = Data.Fields["credit_limit"]?.Value<decimal>() ?? decimal.MaxValue;
var creditUsed = Data.Fields["credit_used"]?.Value<decimal>() ?? 0m;
decimal available = creditLimit - creditUsed;
if (orderTotal > available)
{
return RuleResult.Error(
$"Order total ({orderTotal:C}) exceeds available credit ({available:C}). " +
"Contact your credit manager.");
}
return RuleResult.Success;
}
}
OnEvent Rules¶
OnEvent rules fire in response to system events that P21 raises at specific points in its processing pipeline. They run asynchronously — the triggering action has already completed by the time the rule executes. This means OnEvent rules cannot prevent or roll back the event that triggered them, but they also do not add latency to the user's workflow.
Characteristics¶
- Fire on: system-defined events (see event list below)
- Run asynchronously on a background thread — user does not wait
- Cannot prevent the triggering action (it already committed)
- Can access the event's data via
Data.FieldsandData.Set.Tables - Cannot display interactive dialogs (no user context to respond)
- Configured in P21: Setup → Business Rules → Rule Assignments → Event
Common Events¶
| Event Name | Description |
|---|---|
OrderUpdated | Fires after an order is saved (add or update) |
InvoiceCreated | Fires after an invoice is generated |
FormDatastreamCreated | Fires after a form datastream is built (before printing) |
POCreated | Fires after a purchase order is saved |
ReceiptPosted | Fires after a receipt is posted |
PaymentPosted | Fires after an AR or AP payment is posted |
MessageBoxOpening | Fires when P21 is about to display a message dialog |
CustomerUpdated | Fires after a customer record is saved |
InventoryUpdated | Fires after an inventory movement is posted |
Order Updated Example¶
using P21.Extensions.BusinessRule;
namespace YourCompany.P21.Rules;
/// <summary>
/// OnEvent rule: logs order activity and triggers downstream notification.
/// Configure as: Event=OrderUpdated.
/// </summary>
public class OrderUpdatedEventRule : Rule
{
public override string GetName() => "OrderUpdatedEvent";
public override string GetDescription() =>
"Fires when an order is saved. Logs the event and triggers downstream sync.";
public override RuleResult Execute()
{
// The event data provides the key value and action type
var orderNo = Data.Fields["key_value"]?.ToString();
var action = Data.Fields["action"]?.ToString(); // "ADD" or "UPDATE"
if (string.IsNullOrEmpty(orderNo))
return RuleResult.Success;
Log.AddAndPersist($"[OrderUpdatedEvent] Order {orderNo} was {action}.");
// Example: call an external webhook asynchronously
// NotifyExternalSystem(orderNo, action);
return RuleResult.Success;
}
}
Suppress a P21 Message Box¶
The MessageBoxOpening event fires just before P21 displays any message dialog. A rule registered on this event can inspect the message and suppress it (auto-click the default button) to prevent interruption of automated workflows:
using P21.Extensions.BusinessRule;
namespace YourCompany.P21.Rules;
/// <summary>
/// OnEvent rule: suppresses a specific P21 message box by number and text.
/// Configure as: Event=MessageBoxOpening.
///
/// IMPORTANT: Always check BOTH message number AND text. Checking only the number
/// risks suppressing unrelated messages that share a number in different contexts.
/// </summary>
public class SuppressExpediteDateMessage : Rule
{
// Replace with the actual message number found in P21 message log
private const int TargetMessageNo = 12345;
private const string TargetMessageText = "Expedite date is in the past";
public override string GetName() => "SuppressExpediteDateMessage";
public override string GetDescription() =>
"Suppresses the 'expedite date in the past' warning message automatically.";
public override RuleResult Execute()
{
var messageNo = Data.Fields["message_no"]?.Value<int>() ?? 0;
var messageText = Data.Fields["message_text"]?.ToString() ?? string.Empty;
if (messageNo == TargetMessageNo &&
messageText.Contains(TargetMessageText, StringComparison.OrdinalIgnoreCase))
{
// Suppress the dialog entirely
Data.Fields["suppress_message"].Value = true;
// Auto-select button index 1 (typically OK or Yes)
Data.Fields["default_button"].Value = 1;
}
return RuleResult.Success;
}
}
Be Specific When Suppressing Messages
Always check both the message number and the message text before suppressing. Suppressing by number alone can accidentally silence unrelated P21 warnings or errors, masking real problems from users.
On Demand Rules¶
On Demand rules are triggered by a user-defined button placed on a P21 window. A P21 administrator creates a custom toolbar button or form button and associates it with an On Demand rule. When the user clicks the button, the rule executes synchronously.
Characteristics¶
- Fire on: user clicking a custom button in the P21 UI
- Run synchronously — user waits for the rule to complete
- Cannot prevent a save directly (the save hasn't been triggered)
- Can display custom dialogs via
ResponseAttributesand collect user input - Can read and modify the current window's data, then let the user continue editing or save
- Configured in P21: Setup → Business Rules → On Demand Rule Assignments
Add Discount Example¶
using System.Data;
using System.Linq;
using P21.Extensions.BusinessRule;
namespace YourCompany.P21.Rules;
/// <summary>
/// On Demand rule: calculates a 10% flat discount and adds or updates a discount
/// line on the current order.
/// Configure as: On Demand button on Order Entry, Line Items tab.
/// </summary>
public class AddDiscountRule : Rule
{
private const decimal DiscountPercent = 0.10m;
private const string DiscountItemId = "DISCOUNT";
public override string GetName() => "AddDiscount";
public override string GetDescription() =>
"Applies a 10% flat discount line to the current order.";
public override RuleResult Execute()
{
var lines = Data.Set.Tables["oe_line"];
if (lines == null)
return RuleResult.Error("Order line data is not available.");
// Sum the extended price of all non-discount, non-deleted lines
decimal orderTotal = lines.AsEnumerable()
.Where(r => r.RowState != DataRowState.Deleted
&& r.Field<string>("item_id") != DiscountItemId)
.Sum(r => r.Field<decimal>("extended_price"));
decimal discountAmount = orderTotal * DiscountPercent;
// Prevent field-change events from cascading while we manipulate rows
Data.SetCascade(false);
try
{
var existingDiscountRow = lines.AsEnumerable()
.FirstOrDefault(r => r.Field<string>("item_id") == DiscountItemId
&& r.RowState != DataRowState.Deleted);
if (existingDiscountRow != null)
{
// Update existing discount line
existingDiscountRow.SetField("unit_price", -discountAmount);
existingDiscountRow.SetField("extended_price", -discountAmount);
}
else
{
// Add a new discount line
Data.AddNewRow("oe_line");
var newRow = lines.Rows[lines.Rows.Count - 1];
newRow.SetField("item_id", DiscountItemId);
newRow.SetField("item_desc", "Order Discount (10%)");
newRow.SetField("unit_quantity", 1m);
newRow.SetField("unit_price", -discountAmount);
newRow.SetField("extended_price", -discountAmount);
}
}
finally
{
Data.SetCascade(true);
}
Data.SetFocus("unit_quantity");
return RuleResult.Success;
}
}
Multi-Row vs. Single-Row Mode¶
The distinction between multi-row and single-row mode is specific to Validator rules (On Demand rules always have full DataSet access).
Single-Row Mode¶
- Scope:
Data.Fieldsis populated with the field values of the currently active row only. - Use when: Validation logic depends only on the row being edited (e.g., checking that a date field is valid, that a required field is not empty).
- Performance: Lower overhead — P21 only passes the current row to the rule.
// Single-row: access via Data.Fields
var itemId = Data.Fields["item_id"]?.ToString();
var qty = Data.Fields["unit_quantity"]?.Value<decimal>() ?? 0m;
Multi-Row Mode¶
- Scope:
Data.Set.Tables["{tableName}"]is populated with all rows of the target datawindow. - Use when: Validation logic must look at the aggregate (e.g., order total, line count) or compare values across rows.
- Performance: Higher overhead — P21 serializes the entire datawindow into the DataSet.
// Multi-row: access via Data.Set.Tables
var lines = Data.Set.Tables["oe_line"];
decimal total = lines.AsEnumerable()
.Sum(r => r.Field<decimal>("extended_price"));
Summary Comparison¶
| Aspect | Single-Row | Multi-Row |
|---|---|---|
| Access method | Data.Fields["field"] | Data.Set.Tables["table"] |
| Rows available | Current row only | All rows in the datawindow |
| Performance overhead | Low | Higher |
| Use case | Per-row field validation | Aggregate validation, cross-row logic |
| P21 config | Multi-Row = No | Multi-Row = Yes |
Default to Single-Row
Only enable multi-row mode when your rule genuinely needs data from multiple rows. Multi-row rules are more expensive and can cause sluggish UI response on orders with many lines.