Skip to content

Rules and formulas ​

A FormRule connects fields: while its test is definitely true for the current values, its effects apply; otherwise the form stays as designed. Rules belong to the form (FormDefinition.rules), not to any field, and run in a pure engine, so a server can evaluate them exactly as FormView does.

text
FormRule({
  required String id,          // unique in the form
  required Expr<bool> when,    // an expression of vyuh_expression
  required Iterable<FormEffect> effects,
  String? title,               // "CAPA for major findings"; without one the designer summarises the rule
  bool enabled = true,         // a rule switched off is kept but never evaluated
})

What a rule tests is an expression: when is an Expr<bool> of vyuh_expression, the same language every Blueprint kit tests with. There is no tree of conditions and no condition kind. What a rule does is an effect, and each effect is applied, saved and checked by an effect kind in the form's kit; the built-ins are registered for you, and apps add their own (see Rules as expressions).

The test ​

The tests are written in the Dart DSL off the fields' handles, or as text, or built from vyuh_expression nodes directly. All three are the same Expr<bool>:

dart
final when = severity.isAnyOf(['major', 'critical']) & ~notes.isEmpty;
final text = expression(r'$qty * $unit_price > 1000', scope: scopeOf([qty, unitPrice]));
final same = ex.And([...]); // built from nodes

&, | and ~ join tests (chains flatten); all([...]), any([...]) and not(x) do the same by name. The text syntax reads a field as $key.

  • Typed fields. Each field has one expression type (expressionTypeOf): text and single choices are text; number, money, quantity and rating are an exact number; a duration is a Duration; a date is a day (by its own year, month and day); a time is a time of day; a date & time is an instant; a toggle is true or false; multiple choices and attachments are a list of texts; anything else (ranges, signatures, custom fields) is text whose presence is all a rule asks. A value of another type than its field (a number in a text field) is refused: the engine reads it as absent, and FormExpressions.refusalsOf(values) lists the refusals for the host to show.
  • Three-valued, no guards. An empty value makes a comparison unknown, not false, and an unknown test does not pass, also under not. A rule applies only when its test is definitely true. So "show Reason while Severity is not Minor" does not show an empty Severity: say severity.isEmpty | severity.notEquals('minor'). An untouched toggle reads as Off, never empty, and a text of only spaces is empty for the text operators.
  • Zones. The day of a date & time and a time of day are read in the zone of the evaluation, UTC by default: FormRuleEngine.evaluate(..., zone:) and FormValidationEngine.validate(..., zone:). A form on a device passes DeviceZone() (FormKitForm does); a server passes the tenant's ExprZone, so it judges the form as the device did. A fixed day (moment.on(someDate)) is the day in that zone, not the digits of the date given.
  • The clock. now() in an expression reads one instant per pass: pass now: to the engines, or the engine takes the clock once when it starts and every rule and validator of that pass sees it.
  • Typed operators. The DSL offers only what suits a field's type, so a test the engine would refuse doesn't compile. The operators come from the field's value type (operatorsOf(field)), and every type also has is empty and is not empty.
Value typeFieldsOperatorsOperand
textText, text formulasequals, does not equal, contains, starts with, ends with, matchestext; a pattern in the portable subset for matches
numberNumber, money and quantity amounts, rating, number formulas= ≠ < ≤ > ≥, betweena number; two for between
durationDuration= ≠ < ≤ > ≥, betweena Duration
date and timeDate, date & time, time, date formulasbefore, after, on, betweena DateTime
toggleToggle, boolean formulasis on, is offnone
single choice or referenceChoice, Referenceis, is not, is any of, is none ofa choice value; a list for any of and none of
multiple choice or referenceChoice and Reference with multipleincludes, does not include, has at least, has at mosta choice value; a whole number for the counts
attachmentAttachmenthas at least, has at mosta whole number of files

CompareOperator and operatorsOf only label these (the phrase, the shape of the operand, which fields offer which); nothing evaluates or saves them, which is why a rule editor builds expressions from them. Each one is a plain expression: "Severity is any of major, critical" is not is_blank($severity) and one_of($severity, ["major", "critical"]), and "Due is on the day of D" is date_of($due) = date_of(D) in the zone. The package README lists every operator's expression.

  • Text compares exactly as written, with case and spaces; matches runs its pattern as written. To ignore case, use the text syntax: lower($name) = "paid".
  • A date field compares calendar days, as the validators do; a date & time compares instants, and on compares its day; a time field compares the time of day. A date or a time compared with a date & time field reads the other side through date_of or time_of.
  • An empty value makes every comparison unknown, the negative ones too (does not equal, ≠, is not, is none of, does not include). To include the empty, say so: qty.isEmpty | (qty < 5). "Has at most" counts none as 0.
  • A formula as text is a rule test as well: $qty * $unit_price > 1000. It no longer needs every field it names, a / 0 is unknown rather than false, and a field it reads is typed as above (a Duration, a day, a time of day).

Facts ​

A question only the app can answer, such as "equipment is in calibration", is a fact: a FormFact registered in a FormKitExtension's facts, with a port name, a version, typed parameters, the field keys it reads, and a synchronous resolver over a snapshot of the values.

dart
// import 'package:vyuh_expression/vyuh_expression.dart' as ex;
final inCalibration = FormFact(
  name: 'qa.in_calibration',
  version: '1',
  title: 'Equipment is in calibration',
  description: 'The equipment named by a field is in calibration.',
  parameters: [ex.ExprParameterSpec('field', const [ex.ValueType.text])],
  reads: (arguments) => {arguments.single as String},
  resolve: (arguments, scope) => scope.valueOf('${arguments.single}_calibrated') == true,
);
final kit = FormKit([FormKitExtension.standard, FormKitExtension(id: 'qa', facts: [inCalibration])]);

final when = inCalibration.ask<bool>(['press']) & ~notes.isEmpty;

The engine resolves each distinct (name, arguments) once per scope in a pass (the form's, or one repeating group entry's) and shares the answer between the rules of that scope. If a rule or a formula writes a field the fact reads (reads), the fact is asked again by the next rule that needs it, so it never sees a stale value. A fact nobody registered, whose arguments or version the port refuses, whose resolver throws, or whose answer is not of its type, is unknown, so the gate fails closed, also under not. A fact has no text form: a rule editor offers facts as pickers, and a text expression cannot name one.

Effects ​

Each effect is active while its rule holds; otherwise the default applies.

Effect (sealed FormEffect)TargetWhile the rule holdsOtherwise
Show(nodeId)any nodeshownhidden
Hide(nodeId)any nodehiddenshown
Enable(nodeId)a field or containerenableddisabled
Disable(nodeId)a field or containerdisabledenabled
Require(fieldKey)a fieldrequiredas declared
SetValue(fieldKey, value)a fieldtakes value, shown read-onlykeeps what was entered
ApplyValidation(fieldKey, validationId)a declared validationrunsdoesn't run
CustomEffect<E>(typeId, config:)as the app's kind saysas the app's kind says
  • Show, Hide, Enable and Disable target node ids, so a group, row, grid or piece of content can be the target. Hiding or disabling a container applies to everything in it. A hidden node takes no room at fill time, and a container left with nothing shown is hidden too; see hidden elements take no room.
  • A node several rules show is shown while any of them holds, whether they run for the form or for an entry; a node a holding rule hides is hidden whatever else shows it. Enable and Disable work the same way.
  • SetValue's value is sealed: Fixed(value) or Formula(expression). A formula's result becomes the field's own kind of value: a whole number for a whole-number field, money in the field's currency, a duration from seconds; a rating outside its stars sets no rating. A choice takes only a fixed value, one of its choices: a formula could give any text, so a formula for a choice is a design issue. Moving a Set value onto another field starts its value again: a value fits the field it was chosen for.
  • ApplyValidation names a validation by its id. Every validation has one: the designer gives each one it adds a new id (pattern, pattern-2), a validation declared in code without one takes its type id, and the codec writes and requires it.

The deviation example shows the CAPA group and requires its owner for major or critical findings, and computes the total:

dart
final rules = [
  FormRule(
    id: 'capa',
    title: 'CAPA for major findings',
    when: severity.isAnyOf(['major', 'critical']),
    effects: const [Show('capa'), Require('capa_owner')],
  ),
  FormRule(
    id: 'total',
    when: qty.isNotEmpty & unitPrice.isNotEmpty,
    effects: const [SetValue('total', Formula(r'$qty * $unit_price'))],
  ),
];

How rules are evaluated ​

FormRuleEngine.evaluate(definition, values, kit: kit) is pure and returns a RuleOutcome:

MemberMeaning
isVisible(nodeId, {entry}), isFieldVisible(key, {entry})Whether the node, or the field's slot, shows: neither it nor a container of it is hidden.
isEnabled(nodeId, {entry}), isFieldEnabled(key, {entry})The same for enabled.
isRequired(key, {entry}), isRequiredByRule(key, {entry})Required as declared or by a rule; by a rule alone.
isComputed(key, {entry}), computedValue(key, {entry})Whether a holding rule sets the field's value, and the value.
activeValidationIds(key, {entry})The ids of the field's validations that run.
valuesThe form's values as computed: every formula field's result and every value a holding rule sets, at form level and in each entry.
submittedThe values to submit: values, less every hidden field and a hidden repeating group's entries.
  1. One pass. Formula fields and rules run in one dependency-ordered pass, the RulePlan: a formula field or a rule that sets a value runs before every step that reads it. So a formula sees a value a rule set (a rule sets Quantity, and Total = $qty * $unit_price follows), and a rule's expression sees a formula field's result under its key, typed by the field's output type. Otherwise formula fields come first and rules keep their declared order. Rules that set values in a cycle, through other rules or through formula fields, don't run: every rule on any such cycle, however long; a rule reading the value it sets itself isn't a cycle (it reads the value as it was), so "when Discount is empty → set Discount to 0" is a default.
  2. Repeating groups. A rule whose expression reads fields inside a repeating group runs once per entry, reading the entry's values first and then the form's, the same scoping as validation. What it changes inside the group is decided per entry (ask with entry: (groupId: 'lots', index: 0)); what it changes outside the group holds when it holds for any entry. A form-level rule that changes something inside a group changes it in every entry.
  3. Validation. FormValidationEngine.validate follows the outcome: a hidden field isn't checked at all, a field a rule requires is checked as required, an applied validation runs only while its rule holds, and computed values are the ones checked.
  4. Formula fields compute in the same pass; there is no separate formula engine.
dart
final values = <String, Object?>{'severity': 'major'};
final outcome = FormRuleEngine.evaluate(deviationIntake, values);

assert(outcome.isVisible('capa'));
assert(outcome.isRequired('capa_owner'));

final issues = FormValidationEngine.validate(deviationIntake, values, outcome: outcome);

A FormKitForm does this as the form is filled, and FormView shows it: nodes hide and disable (a disabled field is muted), required marks follow, computed values show read-only (fully legible, as CdxAccess.readOnly), and applied validations follow. form.submitted gives what to submit, without the fields rules hide. Neither needs a host callback. Give the form the app's kit when it uses app kinds.

Design issues ​

kit.rules.designIssuesOf(definition) lists what can't be used as it stands, as FormRuleDesignIssue{ruleId, place, message}, where place is the WholeRule, a ConditionPlace(path) or an EffectPlace(index). A ConditionPlace addresses a node of the test by indices through the operands of an and or or and index 0 through a not. For one expression, FormExpressions.of(definition, kit.rules).issuesOf(expression) gives the same issues with their paths:

  • a reference to a field, element or validation the form doesn't have;
  • an empty and or or ("Add at least one condition."), and a rule without effects;
  • a field the form doesn't have, an empty field name, and a field read as another type than its own ("Toggle is a true/false value, but this reads it as a number.");
  • a call the function registry refuses (an unknown function, the wrong arguments), and a node of an extension kind nothing here evaluates (an error: it is kept, but it is never known, so the rule never holds until it is removed);
  • a fact nobody registered (a warning: it is kept and never known), a fact whose arguments, types or version the port refuses, and a fact reading a field the form lacks;
  • what a form has no value for: related rows and aggregates, policies, parameters and the acting user;
  • rules that set values in a cycle, naming the rules and formula fields on it (they don't run), and a rule reading fields of two repeating groups;
  • a formula that doesn't compile to the type expected;
  • a per-entry rule setting a field outside its entry;
  • a custom effect whose kind isn't registered (it is kept as it was, but does nothing).

The designer shows them on the rows at fault and adds them to the controller's designIssues with the validators' (see Form status). An effect whose kind isn't registered, and a fact nobody registered, is a warning; the rest are errors. Only an empty or duplicate rule id stops a definition from being constructed.

Editing rules ​

The pure edits in vyuh_form_types keep rules consistent with the form:

  • addRule, replaceRule, removeRule, moveRule, duplicateRule, and expressionAt / replaceExpressionAt / nestedExpressionsOf for a node of the test at a path.
  • Deleting a field or node a rule reads or changes is refused with "Used by 2 rules" (usedByRules); the rule has to change first. So is ungrouping or unwrapping a container a rule targets, and removing a validation a rule applies.
  • Renaming a field key or a node id rewrites the rules that use it, in the same step: the fields an expression names (a field read as outer from an aggregate included) and the arguments of its facts (through FormFact.renamed).

In the designer, rules live on the Form's Rules tab; see The editor.

Formulas and scopes ​

A FormulaFormField declares only expression and outputType. Its inputs are found by compiling the expression, where $key refers to a field, against the fields in its scope. There is no dependency list to keep in sync.

  • Scopes. formulaScopesOf(layout) gives form-level fields the scope null and fields inside a repeating group that group's id. A formula reads only fields in its own scope, so line_cost in the example computes once per entry from that entry's qty and unit_cost.
  • Readable types. Formulas read numbers, money and quantity amounts, durations, ratings, dates and times, toggles as booleans, other formulas, and text: a text field, a single choice (its value) and a single reference (its id). An empty text reads as "". Multiple choices and references, ranges, attachments, signatures and custom fields are not readable.
  • Text. Quoted text compares with == and !=: $status == "open". The text functions are len, contains, starts_with, upper, lower, trim and is_blank. A formula field's result is still a number, date, time or date and time; a Set value formula into a text field returns text.
  • Inputs. formulaInputsFor(definition, formulaKey) lists every field as that formula sees it, in form order: a FormFormulaInput with the key, label, the type it reads as, the title of its group, and, when it can't be read, why (unavailable). Without formulaKey the fields are as a rule's formula sees them. readableTypes gives the ValueType of every readable field, and inputs.scope the typed, labelled scope parseFormula reads.
  • Compile errors. A formula that does not parse or type-check (parseFormula; diagnostics are expr.*) is a design error: the constructor throws a FormatException naming the field, the problem and a remedy. formulaDependenciesOf drives cycle detection.
  • Results. FormRuleEngine.evaluate(definition, values).values holds every formula result as a plain value (Rational, bool, String, LocalDate, LocalTime or a UTC DateTime), or null where it cannot be told. A formula is evaluated three-valued: an empty field empties the result only where the formula reads it, so if($flag, $a, $b) with $flag true and $b empty gives $a. FormView shows results but never writes them back through onChanged.
dart
final inputs = formulaInputsFor(deviationIntake, 'line_cost');
inputs.readableTypes;
// {material: ValueType.text, qty: ValueType.rational, unit_cost: ValueType.rational}
inputs.firstWhere((i) => i.key == 'severity').unavailable;
// It’s outside the repeating group “Affected materials”; a formula there reads
// only the fields of its own entry.

Current limits ​

  • Tests and effects never call providers; dependent options come with providers. Only a fact reaches the app, synchronously, over the values.
  • The designer edits groups one level deep; the model nests any depth, and deeper groups show as one row.
  • A computed value is shown and submitted, but not written back through onChanged.