Skip to content

Form Designer — Conditional Sections: Pre/Post Dependencies

Feature: FEAT-234
Applies to: parrot-formdesigner >= (version containing FEAT-234)

This document is the authoritative reference for the conditional-logic system in Form Designer. It covers:

  • Pre-dependencies (depends_on) — a field controlling its own visibility/required state based on values of earlier fields.
  • Operations (DependencyOperation) — computed/derived values.
  • Post-dependencies (post_depends) — a field triggering effects on other fields.
  • Authoring via EditToolkit and get_dependency_rule_snippets.
  • Optional server-side resolution via RuleEvaluator.

1. Pre-dependencies (FormField.depends_on)

A pre-dependency (DependencyRule) decides whether the owning field is visible, required, or disabled based on the current values of fields that appear earlier in the form.

from parrot_formdesigner.core import (
    DependencyRule, FieldCondition, ConditionOperator, DependencyOperation,
)

# Simple show/hide rule
rule = DependencyRule(
    conditions=[
        FieldCondition(field_id="has_dependents", operator=ConditionOperator.EQ, value="yes")
    ],
    logic="and",
    effect="show",   # "show" | "hide" | "require" | "disable"
)

1.1 Logic gates

logic Semantics
"and" All conditions must be true (default).
"or" At least one condition must be true.
"xor" Exactly one condition must be true.
"not" Negates the AND-combination of conditions (v1 default per spec §8).

NOT semantics: "not" evaluates not all(conditions). It is not a prefix NOT on a single condition — it negates the whole group.

# XOR: field only shown when exactly one category is selected
DependencyRule(
    conditions=[
        FieldCondition(field_id="cat_a", operator=ConditionOperator.EQ, value="selected"),
        FieldCondition(field_id="cat_b", operator=ConditionOperator.EQ, value="selected"),
    ],
    logic="xor",
    effect="show",
)

1.2 Effects

effect What happens when the rule fires
"show" Field becomes visible.
"hide" Field is hidden.
"require" Field becomes required.
"disable" Field is hidden (v1: surfaced as not-visible).

1.3 Supported ConditionOperator values

eq · neq · gt · lt · gte · lte · in · not_in · is_empty · is_not_empty

Numeric operators (gt/lt/gte/lte) require the referenced field to hold a coercible-to-float value; a non-numeric value evaluates to False (safe no-op).


2. Operations (DependencyOperation)

An operation computes a derived value and writes it to a target field. Operations can appear in two places:

  1. Inline in a DependencyRule — applied when the rule fires.
  2. In a PostDependency — for effect="set" or effect="calc".
from parrot_formdesigner.core import DependencyOperation

op = DependencyOperation(
    op="multiply",            # see table below
    operands=["price", "qty"],  # field_ids
    target="total",           # field_id to write the result to
    options=None,             # optional extra config (e.g. sep, template, unit)
)

2.1 Operation kinds

op Description Notable options
"copy" Copy first operand value to target.
"add" Sum of all operand values.
"subtract" First operand minus remainder.
"multiply" Product of all operand values.
"divide" First operand divided by remainder. Division by zero → no-op.
"percent" base * pct / 100 (operands[0] = base, operands[1] = percent).
"concat" String concatenation of all operands. sep (separator string)
"format" Positional string template. template ("{0} {1}")
"date_diff" Days (or weeks) between two ISO date operands. unit ("days" / "weeks")
"lookup" Server-side table lookup (see open questions below).
"aggregate" Numeric aggregate over flat operand list. fn ("sum" / "avg" / "min" / "max" / "count")

2.2 Inline operation example (arithmetic)

from parrot_formdesigner.core import DependencyRule, FieldCondition, ConditionOperator, DependencyOperation

DependencyRule(
    conditions=[],     # empty = unconditional
    logic="and",
    effect="show",
    operations=[
        DependencyOperation(
            op="add",
            operands=["base_salary", "bonus"],
            target="total_compensation",
        )
    ],
)

3. Post-dependencies (FormField.post_depends)

A post-dependency (PostDependency) makes the owning field trigger effects on other (typically later) fields. This is the "forward" direction — f1 controlling f2, f3, etc.

from parrot_formdesigner.core import PostDependency, DependencyOperation

# When 'country' changes, reload 'city' options and clear 'zip'
PostDependency(
    target="city",
    effect="reload_options",  # see table below
)

PostDependency(
    target="zip",
    effect="cascade_clear",
)

3.1 Post-dependency effects

effect Description
"show" Make the target field visible.
"hide" Hide the target field.
"require" Make the target field required.
"disable" Hide the target field (v1).
"set" Write a value to the target field via an operation. operation is required.
"calc" Compute and write a value; always fires (no condition check). operation is required.
"reload_options" Signal that the target field's option list should be reloaded.
"cascade_clear" Clear the target field's answer when this field changes.

3.2 Conditions on post-dependencies

Post-dependencies MAY include their own conditions list (same format as pre-dependencies). When conditions is omitted, the rule fires whenever the owning field has a non-empty answer.

3.3 Example: arithmetic calc

PostDependency(
    target="total",
    effect="calc",
    operation=DependencyOperation(
        op="multiply",
        operands=["price", "qty"],
        target="total",
    ),
)

3.4 Constraints

  • post_depends is only on FormField (not FormSection / FormSubsection) in v1.
  • A PostDependency with effect="set" or effect="calc" must supply an operation — the Pydantic model validator raises ValueError otherwise.

4. End-to-end example

The following form has:

  1. An xor pre-dependency (amount_type exactly one path active shows fixed_amount or percentage_amount).
  2. An arithmetic operation computing tax = price × 0.21.
  3. A set post-dependency cascading a computed subtotal.
{
  "form_id": "invoice_line",
  "title": "Invoice Line",
  "sections": [
    {
      "section_id": "main",
      "fields": [
        { "field_id": "price",     "field_type": "number",  "label": "Unit Price" },
        { "field_id": "qty",       "field_type": "integer", "label": "Quantity" },
        {
          "field_id": "subtotal",
          "field_type": "number",
          "label": "Subtotal",
          "depends_on": {
            "conditions": [],
            "logic": "and",
            "effect": "show",
            "operations": [
              {
                "op": "multiply",
                "operands": ["price", "qty"],
                "target": "subtotal"
              }
            ]
          }
        },
        { "field_id": "amount_type", "field_type": "select", "label": "Amount Type",
          "options": [{"value": "fixed", "label": "Fixed"}, {"value": "pct", "label": "Percentage"}] },
        {
          "field_id": "fixed_amount",
          "field_type": "number",
          "label": "Fixed Amount",
          "depends_on": {
            "conditions": [
              { "field_id": "amount_type", "operator": "eq", "value": "fixed" }
            ],
            "logic": "and",
            "effect": "show"
          }
        },
        {
          "field_id": "percentage_amount",
          "field_type": "number",
          "label": "Percentage (%)",
          "depends_on": {
            "conditions": [
              { "field_id": "amount_type", "operator": "eq", "value": "pct" }
            ],
            "logic": "and",
            "effect": "show"
          }
        },
        {
          "field_id": "tax",
          "field_type": "number",
          "label": "VAT (21%)",
          "post_depends": []
        },
        {
          "field_id": "discount",
          "field_type": "number",
          "label": "Discount"
        }
      ]
    }
  ]
}

Python construction equivalent:

from parrot_formdesigner.core import (
    DependencyOperation, DependencyRule, FieldCondition, ConditionOperator,
    FieldType, FormField, FormSchema, FormSection, PostDependency,
)

price = FormField(field_id="price", field_type=FieldType.NUMBER, label="Unit Price")

subtotal = FormField(
    field_id="subtotal",
    field_type=FieldType.NUMBER,
    label="Subtotal",
    depends_on=DependencyRule(
        conditions=[],
        logic="and",
        effect="show",
        operations=[
            DependencyOperation(op="multiply", operands=["price", "qty"], target="subtotal")
        ],
    ),
)

qty = FormField(
    field_id="qty",
    field_type=FieldType.INTEGER,
    label="Quantity",
    post_depends=[
        PostDependency(
            target="subtotal",
            effect="calc",
            operation=DependencyOperation(op="multiply", operands=["price", "qty"], target="subtotal"),
        )
    ],
)

form = FormSchema(
    form_id="invoice_line",
    title="Invoice Line",
    sections=[FormSection(section_id="main", fields=[price, qty, subtotal])],
)

5. Authoring via EditToolkit

The EditToolkit exposes five async methods for dependency CRUD:

from parrot_formdesigner.tools import EditToolkit

toolkit = EditToolkit(form)

# Add a pre-dependency
await toolkit.add_dependency("subtotal", {
    "conditions": [{"field_id": "price", "operator": "neq", "value": None}],
    "logic": "and",
    "effect": "show",
})

# Update existing dependency (merge-patch)
await toolkit.update_dependency("subtotal", {"logic": "or"})

# Remove dependency
await toolkit.remove_dependency("subtotal")

# Add a post-dependency
await toolkit.add_post_dependency("qty", {
    "target": "subtotal",
    "effect": "calc",
    "operation": {"op": "multiply", "operands": ["price", "qty"], "target": "subtotal"},
})

# Remove a post-dependency by target
await toolkit.remove_post_dependency("qty", "subtotal")

All methods validate the rule via FormValidator (rule-integrity pass) before applying. Invalid rules return an error dict; the form is not mutated.

5.1 Rule capability metadata

The control registry exposes capability hints to guide authoring:

from parrot_formdesigner.controls import get_controls

for ctrl in get_controls():
    print(ctrl.type, ctrl.supported_operators, ctrl.supported_operations)

FieldControlMetadata now carries:

Field Type Description
supported_operators list[str] Meaningful ConditionOperator values for this control.
supported_effects list[str] Applicable dependency effects.
supported_operations list[str] DependencyOperation.op values that make semantic sense.

Empty list means "all values applicable" (extension controls default to []).

5.2 Rule snippets

Quick-start skeletons for building rules:

from parrot_formdesigner.tools import get_dependency_rule_snippets

snippets = get_dependency_rule_snippets()
# snippets["depends_on"]   — DependencyRule skeleton dict
# snippets["post_depends"] — list of PostDependency skeleton dicts

6. Server-side evaluation via RuleEvaluator

The RuleEvaluator is an optional authoritative Python evaluator. Renderers (HTML5, Adaptive Card, JSON Schema) MAY interpret the rules client-side; they may also ignore them if the client UI handles visibility independently.

import asyncio
from parrot_formdesigner.services import RuleEvaluator

evaluator = RuleEvaluator()
resolution = asyncio.run(evaluator.resolve(form, answers={"qty": 3, "price": 10}))

print(resolution.visible)   # dict[str, bool]  — True = visible
print(resolution.required)  # dict[str, bool]
print(resolution.computed)  # dict[str, Any]   — computed/operation results
print(resolution.cleared)   # list[str]        — cascade-cleared field ids

RuleResolution fields:

Field Type Default
visible dict[str, bool] All True
required dict[str, bool] From FormField.required
computed dict[str, Any] {}
cleared list[str] []

7. YAML serialization

Pre/post-dependencies are fully supported in YAML forms:

form_id: invoice_line
title: Invoice Line
sections:
  - section_id: main
    fields:
      - field_id: price
        field_type: number
        label: Unit Price
      - field_id: qty
        field_type: integer
        label: Quantity
        post_depends:
          - target: subtotal
            effect: calc
            operation:
              op: multiply
              operands: [price, qty]
              target: subtotal
      - field_id: subtotal
        field_type: number
        label: Subtotal
        depends_on:
          conditions:
            - field_id: price
              operator: neq
              value: null
          logic: and
          effect: show
          operations:
            - op: multiply
              operands: [price, qty]
              target: subtotal

8. JSON Schema rendering (x- extensions)

JsonSchemaRenderer emits depends_on and post_depends as vendor extensions on each property:

{
  "properties": {
    "subtotal": {
      "type": "number",
      "x-depends-on": { "conditions": [...], "logic": "and", "effect": "show" },
      "x-post-depends": [{ "target": "...", "effect": "..." }]
    }
  }
}

JsonSchemaExtractor reconstructs DependencyRule / PostDependency models from these extensions on import (round-trip lossless).

Note: Renderers that do not understand these extensions will silently ignore them. The rendered schema remains a valid JSON Schema.


9. Open questions / Future work

The following behaviours are marked as TODO(FEAT-234 open question) in the code and should be revisited in a future spec revision:

  • reload_options timing: The server-side evaluator sets a sentinel value ("__reload__") in computed[target] to signal that option reload is needed. The timing and mechanism for actually reloading options (e.g., async callback) is not yet specified.
  • ARRAY-operand aggregation scope: The aggregate operation currently aggregates over a flat list of operand values. Aggregating over repeated ARRAY row values (e.g., sum all values of a specific sub-field across rows) is a future extension.
  • Nested boolean condition trees: v1 uses a flat list of conditions under a single logic gate. Nested boolean trees (AND of ORs, etc.) are a Non-Goal in v1 (spec §1 Non-Goals) and may be addressed in a later feature.
  • lookup operation server-side: The lookup operation produces None server-side (conservative no-op); the lookup table format and resolution mechanism are not yet defined.

10. Validation

Rule integrity is validated by FormValidator.validate() (or the standalone FormValidator.validate_rules() / check_schema()):

  • Unknown references: conditions, operation operands, and post-dep targets must reference existing field_id values.
  • Ordering violations: depends_on conditions must reference earlier fields; post_depends targets must reference later fields.
  • Type compatibility: numeric operators (gt/lt/gte/lte) must only be used on numeric field types.
  • Cycle detection: the validator detects and rejects circular dependency graphs.
from parrot_formdesigner.services import FormValidator

validator = FormValidator()
errors = validator.validate_rules(form)  # list[str] — empty = clean

11. Legacy parrot.forms re-exports

All new public symbols are re-exported from the parrot.forms backward-compat shim:

from parrot.forms import (
    DependencyOperation,
    PostDependency,
    RuleEvaluator,
    RuleResolution,
    get_dependency_rule_snippets,
)