MD.OFFICE
FAL

Dynaforms — Dependent Fields Feature Documentation


1. Overview

The Dependent Fields system enables conditional field behavior in Dynaforms. A field (the "target") can change its visibility and required status at runtime based on another field's value (the "controller").

What it enables:

  • Conditional visibility: Show/hide fields based on another field's value
  • Conditional requirement: Make fields required or optional dynamically
  • Value clearing on hide: Optionally reset a hidden field's value to prevent stale data submission
  • Multi-rule evaluation: Multiple controllers can affect the same target with deterministic precedence
  • Typed authoring: Controller-type-aware value inputs prevent runtime type mismatches

Architecture Model:

  • Target-owned rules: Dependencies are stored in dependsOn.rules[] on the target field itself
  • Runtime overlay: Effects mutate runtime state (visibility stream, validators) without permanently changing the canonical schema
  • Inverted index evaluation: Only affected targets are re-evaluated when a controller changes

2. Data Contract

Schema Structure

Dependencies are persisted on the target field:

interface FormElementConfig {
  // ... existing field properties
  dependsOn?: FieldDependency;
}

interface FieldDependency {
  rules: DependencyRule[];
}

interface DependencyRule {
  ruleId: string;
  controllerKey: string;           // jsonKey of the source field
  condition: DependencyCondition;
  effects: DependencyEffect[];
}

interface DependencyCondition {
  operator: DependencyOperator;
  value?: unknown;                 // Typed: boolean for checkbox, number for number, string for text
}

type DependencyOperator =
  | 'eq' | 'neq' | 'contains'
  | 'lt' | 'gt' | 'lte' | 'gte'
  | 'empty' | 'notEmpty';

type DependencyEffect =
  | { type: 'setVisibility'; visible: boolean; clearOnHide?: boolean }
  | { type: 'setRequired'; required: boolean };

Example Persisted Rule

{
  "dependsOn": {
    "rules": [
      {
        "ruleId": "dep-abc123",
        "controllerKey": "country",
        "condition": { "operator": "eq", "value": "US" },
        "effects": [
          { "type": "setVisibility", "visible": true, "clearOnHide": true },
          { "type": "setRequired", "required": true }
        ]
      }
    ]
  }
}

Primary Type File

  • src/ifile-teapot-web-dynaforms/components/form-renderer/engine/form-element-config.types.ts

3. Supported Field Types

Target fields (can have dependency rules):

textbox, textarea, checkbox, number, toggleswitch, datepicker, file, dropdown, radio

Controller fields (can be referenced as source):

Same set as targets — any field in DEPENDENCY_SUPPORTED_FIELD_TYPES.

Effect compatibility:

All supported types get both setVisibility and setRequired. Per-type overrides are possible via DEPENDENCY_EFFECT_COMPAT map but currently uniform.

Configuration constants:

  • src/ifile-teapot-web-dynaforms/components/dynaform-builder/right-panel-static/right-panel-static.config.ts

Controller limitations (current MVP)

  • datepicker as controller is not handled properly yet:
    • value authoring is free-text (no date picker input in dependency condition value)
    • no date-specific operator semantics (only generic text/empty operators path)
    • no dedicated date parsing/range comparison behavior in dependency evaluator
  • file as controller is partially supported only for presence checks in practice:
    • empty / notEmpty are meaningful
    • text-like operators (contains) are not meaningful for file-list/object values
    • equality checks are brittle for object/array payloads

4. Builder Architecture

4.1 Entry Point — Right Panel Summary Card

The right panel's main tab shows a lightweight dependency status card:

  • "No dependency rules configured" or "N rule(s) configured"
  • Action button: "Create" or "Edit"
  • No method calls in template — uses precomputed dependencyUi state object

Key methods:

  • openDependencyConfigurator() → switches to dedicated tab
  • closeDependencyConfigurator() → returns to main tab
  • onDependencyConfigSave(rules) → writes selectedItem.dependsOn = {rules} or deletes dependsOn when empty
  • canConfigureDependencies(item) → checks DEPENDENCY_SUPPORTED_FIELD_TYPES
  • hasDependencyRules(item) → checks dependsOn.rules.length > 0
  • refreshDependencyUiState() → recomputes all card state (count, summary, action label)
  • updateDependencyControllerOptions() → builds controller picker options via collectFieldsRecursivelyWithPath

4.2 Dependency Configurator Component (826 LOC)

Standalone component handling full authoring lifecycle:

Draft Management:

  • Local draftRules array (structuredClone from persisted rules)
  • __isPersisted flag tracks unsaved state per rule
  • Blocks adding new rule when an unsaved rule exists

Rule Operations:

  • Add / Remove / Move Up / Move Down
  • Each rule: select controller → select operator → set value → configure effects
  • Multiple effects per rule

Effect Configuration:

  • Supported types filtered by target field via DEPENDENCY_EFFECT_COMPAT
  • setVisibility: Show/Hide toggle + clearOnHide switch
  • setRequired: Require/Optional toggle
  • Precomputed UI state (__uiOperation, __uiSummary, __uiOutcomeText) — no method calls in template

Controller-Aware Value Input:

  • boolean controllers (checkbox, toggleswitch) → True/False select, persisted as boolean
  • number controllers → numeric input, persisted as number
  • static-options controllers (dropdown, multiselect) → options select from controller's values
  • free-input controllers (text, others) → free text input

Operator Restrictions by Controller Type:

  • Boolean: eq, neq, empty, notEmpty
  • Number: eq, neq, lt, gt, lte, gte, empty, notEmpty
  • Text/Options: eq, neq, contains, empty, notEmpty

Save-Time Validation:

  • Effect type compatibility check against target field type
  • Controller/operator/value completeness check
  • Typed value normalization (normalizeConditionValueForPersist)

4.3 Key Files

FilePurpose
right-panel-static.component.tsHost: card UI, save handler, controller options, rename propagation
dependency-configurator.component.tsFull authoring lifecycle
dependency-configurator.component.htmlConfigurator template
dependency-configurator.component.jest.spec.ts10 unit tests
right-panel-static.config.tsConstants: operators, effects, field types, compatibility

5. Renderer Architecture

5.1 Build-Time Wiring

Sequence during FormEngineService.buildForm():

  1. Build form controls recursively from schema
  2. Build flat control index: buildControlByJsonKeyIndex(rootGroup)Map<string, AbstractControl>
  3. Tear down previous dependency engine state
  4. Wire up dependency engine: depEngine.wireUp(elements, rootGroup, controlByJsonKey)

5.2 Dependency Engine Service

Central service managing runtime dependency graph. Lifecycle:

wireUp() → tearDown() → compile() → buildControlCache() → subscribe() → initialEval()

Internal Indexes (built in compile()):

IndexTypePurpose
controllerIndexMap<controllerKey, Array<{targetConfig, rule}>>Controller → affected targets
targetIndexMap<targetKey, {targetConfig, rules}>Target → all its rules
controllerToTargetKeysMap<controllerKey, Set<targetKey>>Fast lookup for re-evaluation
controlMapMap<jsonKey, AbstractControl>Cached control lookups (O(1) via flat map)
baseRequiredMapMap<targetKey, boolean>Authored baseline required state for fallback
visibilityMapMap<targetKey, BehaviorSubject<boolean>>Visibility streams for template consumption
evaluationInProgressSet<controllerKey>Reentrancy guard for clearOnHide cycles

Runtime Loop:

  1. Subscribe to each controller's valueChanges
  2. On change → evaluateAndApply(controllerKey)
  3. Look up affected targets via controllerToTargetKeys
  4. For each target → evaluateTarget(targetKey)
  5. Apply matched effects or fallback

5.3 Condition Evaluator (Pure Utility)

evaluateCondition(condition, controllerValue) — stateless, side-effect-free.

Behaviors:

  • eq/neq: Strict typed equality (no cross-type coercion — '5' !== 5)
  • contains: Case-insensitive substring match, string-only
  • lt/gt/lte/gte: Numeric comparison, returns false for non-numeric operands
  • empty/notEmpty: Handles null, undefined, '', empty arrays

5.4 Effect Application Semantics

Precedence: Last matched rule per effect type wins.

Evaluation per target:

  1. Iterate all rules for this target
  2. Check condition against controller's current value
  3. Collect matched effects by type (last match wins per type)
  4. Apply matched effects
  5. Apply fallback for any effect types with no matching rule

setVisibility effect:

  • Pushes boolean to visibilityMap BehaviorSubject
  • If visible=false and clearOnHide=true:
    • Resets control value with emitEvent: true (so formula validation refreshes)
    • Downstream dependencies re-evaluate via valueChanges subscription
    • Reentrancy guard prevents cyclic A→B→A loops
  • Fallback: visible=true

setRequired effect:

  • Adds/removes Validators.required (or requiredTrue for boolean controls)
  • Fallback: restores baseRequiredMap baseline

5.5 Template Consumption

Visibility is consumed via [hidden] binding (not *ngIf — keeps controls in DOM):

<div [hidden]="(depEngine.getVisibility$(el.jsonKey) | async) === false">

Used in 8 template locations across:

  • form-renderer-root.component.html
  • form-renderer-core.component.html
  • div-field.component.html
  • section-field.component.html
  • container-field.component.html
  • tab-field.component.html (2 locations)
  • button-group-field.component.html

5.6 Key Files

FilePurpose
dependency-engine.service.tsRuntime engine: graph, subscriptions, evaluation
dependency-evaluator.util.tsPure condition evaluation
form-engine.service.tsBuild-time wiring, flat control index
form-element-config.types.tsType definitions
dependency-engine.service.jest.spec.ts6 runtime engine tests
dependency-evaluator.util.jest.spec.ts7 evaluator tests
form-engine.jest.spec.tsIntegration seam tests

6. Safeguards

6.1 Rename Propagation

When a field's jsonKey is renamed in the builder:

  • updateAllDependencies(oldKey, newKey) recursively traverses all canvas elements
  • Updates any dependsOn.rules[].controllerKey that references the old key
  • Prevents stale/orphan controller references after rename

6.2 Delete Blocking

Fields currently used as dependency controllers cannot be deleted:

  • Check scans all elements for dependsOn.rules[].controllerKey references
  • Toast notification blocks the delete operation

6.3 Cyclic clearOnHide Protection

The evaluationInProgress Set prevents infinite recursion:

  • Before evaluating downstream deps after a clearOnHide reset, the controller key is added to the Set
  • If re-entered for the same controller, evaluation is skipped
  • Cleaned up after the evaluation completes

Handles: A→B→A, A→B→C→A, and deeper chains.

6.4 Hidden Fields in Formula/Validation (MVP Policy)

  • Hidden fields remain in form data semantics
  • If hidden with clearOnHide, value is reset, and dependent formula/validation re-evaluates immediately
  • Formula outcomes must be authored with null/empty-aware logic when referenced fields can be hidden+cleared
  • Automatic exclusion of hidden fields from formula semantics is deferred (not in current MVP)

7. Design Decisions

DecisionRationale
Target-owned rules (dependsOn on target)Keeps ownership explicit; target knows its own constraints
Runtime overlay (no schema mutation)Effects are transient; schema stays canonical for re-serialization
[hidden] over *ngIfPreserves FormControl in DOM for downstream formulas and state restoration
Last-match precedence per effect typeDeterministic; no ambiguous priority conflicts between rules
Inverted index for evaluationO(affected_targets) per change, not O(all_fields)
Flat control map via buildControlByJsonKeyIndexO(1) lookups in buildControlCache instead of O(depth) DFS per key
emitEvent: true on clearOnHide resetEnsures formula validation subscriptions refresh; reentrancy guard prevents cycles
Operator restrictions by controller typePrevents nonsensical conditions (e.g., lt on boolean, contains on number)
Typed value persistenceBoolean controllers persist true/false, number controllers persist numbers — no string coercion at runtime

8. Testing Coverage

Unit Tests

SuiteFileTestsCoverage
Evaluator operatorsdependency-evaluator.util.jest.spec.ts7All operators, strict typing, empty/array, unsupported ops
Engine runtimedependency-engine.service.jest.spec.ts6Multi-controller merge, last-match precedence, fallback, clearOnHide downstream, clearOnHide emitEvent, cyclic loops
Configurator builderdependency-configurator.component.jest.spec.ts10Draft lifecycle, boolean/number typed values, operator restrictions, unsaved guards, reordering, effect operations
Engine wiringform-engine.jest.spec.ts3+Teardown ordering, wireUp args (flat map), integration seam (persisted JSON → runtime behavior)

Integration Tests

TestFileWhat It Validates
applies required and visibility effects from persisted dependsOn config at runtimeform-engine.jest.spec.tsFull round-trip: builder-authored JSON → FormEngineService.buildForm → DependencyEngineService evaluation → runtime visibility + required state

Verification Results

All dependency-related tests pass as of 2026-04-30.


9. Known Gaps — Status Tracker

✅ Fixed / Completed

#GapResolutionDate
1Cycle/reentrancy protection for clearOnHide chainsevaluationInProgress Set in DependencyEngineServiceShipped
2Dedicated runtime engine tests6 tests in dependency-engine.service.jest.spec.tsShipped
3Delete blocking for controller fieldsToast guard blocks deleting fields used as controllerKeyShipped
4Builder compatibility mismatch (multiselect etc.)DEPENDENCY_SUPPORTED_FIELD_TYPES and DEPENDENCY_EFFECT_COMPAT alignedShipped
5Integration seam tests (builder → renderer)form-engine.jest.spec.ts — persisted JSON → runtime behaviorShipped
6Renderer-root teardown lifecycledestroy$ cleanup verified in form-renderer-root.component.tsShipped
7P0: clearOnHide + emitEvent:false skipped formula validationreset() now uses emitEvent: true. Test: 'emits cleared target valueChanges on clearOnHide...'Apr 2026
8P1#2: findControl() DFS bottleneck in buildControlCachebuildControlByJsonKeyIndex() builds flat map, O(1) per key. Test: wireUp receives expect.any(Map)Apr 2026
9clearOnHide + formula/validation downstream propagationDocumented in flow doc §13-14. Hidden-cleared fields trigger immediate formula re-evalApr 2026

⚠️ Open — Performance (P1)

#GapImpactPriority
P1#3applyFallback() recomputes static booleans (hasVisibilityEffect/hasRequiredEffect) every evaluationMinor perf — .some() scans are O(rules) but could be O(1) if precomputed in compile()Low-Medium
P1#4initialEval() evaluates same target N times if it has N controllersRedundant work at form load; iterate targetIndex.keys() directly insteadLow-Medium
P1#5Double tree walk on field selection (controller options + formula suggestions)Two collectFieldsRecursivelyWithPath calls could be mergedLow

⚠️ Open — Code Health (P2)

#GapImpactPriority
P2#6providedIn: 'root' singleton scope on DependencyEngineServiceTwo simultaneous forms would clobber each other. Fine for current single-form usageLow
P2#73 duplicate tree-walk implementations (walkElements, traverse, collectFieldsRecursivelyWithPath)Code duplication risk; could extract shared utilityLow
P2#8Pervasive any in dependency-configurator.component.tsRefactoring risk in 826-line component; types exist in form-element-config.types.ts but unusedLow
P2#9Unused DependencyEffect variants in type union (setValueFromMap, reloadOptionsFromApi, setFieldProps)Forward-compatible stubs; should be annotated // @futureLow
P2#10No delete-time orphan cleanup for dependency rulesRename propagation exists but delete only blocks; orphan rules persist if guard is bypassedLow

⚠️ Open — Validation & Testing (P2-P3)

#GapImpactPriority
P2#11Schema validator doesn't validate dependsOn contract (operators, effect payloads, orphan refs)Invalid dependency JSON silently fails at runtimeMedium
P3#12Template method call pattern for getVisibility$() in 8 HTML filesPotential change-detection churn; could use precomputed map or pipeLow
P3#13No test for buildControlCache with nested isParent:true formsCould silently break visibility in deeply nested formsLow
P3#14No test for updateAllDependencies() rename propagationBuilder-side traversal untestedLow
P3#15No test for contains operator with null condValueEdge case — evaluator L26Low
P3#16No negative test for unsupported effect types being silently droppedCould confuse authorsLow

10. End-to-End Sequence


11. Quick Reference — Key Files

Builder

FileLOCRole
right-panel-static.component.ts3567Host: summary card, save handler, rename propagation, controller options
dependency-configurator.component.ts826Full authoring lifecycle: drafts, validation, typed value normalization
right-panel-static.config.ts244Constants: operators, effects, field types, compatibility map

Renderer

FileLOCRole
dependency-engine.service.ts434Runtime engine: graph, subscriptions, evaluation, clearOnHide
dependency-evaluator.util.ts96Pure condition evaluation (stateless)
form-engine.service.ts1082Build-time wiring, flat control index
form-element-config.types.ts562Type definitions

Tests

FileTestsCoverage
dependency-evaluator.util.jest.spec.ts7All operators
dependency-engine.service.jest.spec.ts6Runtime engine
dependency-configurator.component.jest.spec.ts10Builder configurator
form-engine.jest.spec.ts3+Integration seam