Skip to content

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.Fields and Data.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 ResponseAttributes and 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.Fields is 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.