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.
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>:
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 aDuration; 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, andFormExpressions.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: sayseverity.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:)andFormValidationEngine.validate(..., zone:). A form on a device passesDeviceZone()(FormKitFormdoes); a server passes the tenant'sExprZone, 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: passnow: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 type | Fields | Operators | Operand |
|---|---|---|---|
| text | Text, text formulas | equals, does not equal, contains, starts with, ends with, matches | text; a pattern in the portable subset for matches |
| number | Number, money and quantity amounts, rating, number formulas | = ≠ < ≤ > ≥, between | a number; two for between |
| duration | Duration | = ≠ < ≤ > ≥, between | a Duration |
| date and time | Date, date & time, time, date formulas | before, after, on, between | a DateTime |
| toggle | Toggle, boolean formulas | is on, is off | none |
| single choice or reference | Choice, Reference | is, is not, is any of, is none of | a choice value; a list for any of and none of |
| multiple choice or reference | Choice and Reference with multiple | includes, does not include, has at least, has at most | a choice value; a whole number for the counts |
| attachment | Attachment | has at least, has at most | a 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_ofortime_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 / 0is unknown rather than false, and a field it reads is typed as above (aDuration, 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.
// 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) | Target | While the rule holds | Otherwise |
|---|---|---|---|
Show(nodeId) | any node | shown | hidden |
Hide(nodeId) | any node | hidden | shown |
Enable(nodeId) | a field or container | enabled | disabled |
Disable(nodeId) | a field or container | disabled | enabled |
Require(fieldKey) | a field | required | as declared |
SetValue(fieldKey, value) | a field | takes value, shown read-only | keeps what was entered |
ApplyValidation(fieldKey, validationId) | a declared validation | runs | doesn't run |
CustomEffect<E>(typeId, config:) | as the app's kind says | as 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)orFormula(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.ApplyValidationnames a validation by itsid. 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:
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:
| Member | Meaning |
|---|---|
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. |
values | The form's values as computed: every formula field's result and every value a holding rule sets, at form level and in each entry. |
submitted | The values to submit: values, less every hidden field and a hidden repeating group's entries. |
- 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_pricefollows), 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. - 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. - Validation.
FormValidationEngine.validatefollows 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. - Formula fields compute in the same pass; there is no separate formula engine.
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
andoror("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, andexpressionAt/replaceExpressionAt/nestedExpressionsOffor 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
outerfrom an aggregate included) and the arguments of its facts (throughFormFact.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 scopenulland fields inside a repeating group that group's id. A formula reads only fields in its own scope, soline_costin the example computes once per entry from that entry'sqtyandunit_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 arelen,contains,starts_with,upper,lower,trimandis_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: aFormFormulaInputwith the key, label, the type it reads as, the title of its group, and, when it can't be read, why (unavailable). WithoutformulaKeythe fields are as a rule's formula sees them.readableTypesgives theValueTypeof every readable field, andinputs.scopethe typed, labelled scopeparseFormulareads. - Compile errors. A formula that does not parse or type-check (
parseFormula; diagnostics areexpr.*) is a design error: the constructor throws aFormatExceptionnaming the field, the problem and a remedy.formulaDependenciesOfdrives cycle detection. - Results.
FormRuleEngine.evaluate(definition, values).valuesholds every formula result as a plain value (Rational,bool,String,LocalDate,LocalTimeor a UTCDateTime), ornullwhere it cannot be told. A formula is evaluated three-valued: an empty field empties the result only where the formula reads it, soif($flag, $a, $b)with$flagtrue and$bempty gives$a.FormViewshows results but never writes them back throughonChanged.
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.