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)
datepickeras 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
fileas controller is partially supported only for presence checks in practice:empty/notEmptyare 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
dependencyUistate object
Key methods:
openDependencyConfigurator()→ switches to dedicated tabcloseDependencyConfigurator()→ returns to main tabonDependencyConfigSave(rules)→ writesselectedItem.dependsOn = {rules}or deletesdependsOnwhen emptycanConfigureDependencies(item)→ checksDEPENDENCY_SUPPORTED_FIELD_TYPEShasDependencyRules(item)→ checksdependsOn.rules.length > 0refreshDependencyUiState()→ recomputes all card state (count, summary, action label)updateDependencyControllerOptions()→ builds controller picker options viacollectFieldsRecursivelyWithPath
4.2 Dependency Configurator Component (826 LOC)
Standalone component handling full authoring lifecycle:
Draft Management:
- Local
draftRulesarray (structuredClone from persisted rules) __isPersistedflag 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 switchsetRequired: Require/Optional toggle- Precomputed UI state (
__uiOperation,__uiSummary,__uiOutcomeText) — no method calls in template
Controller-Aware Value Input:
booleancontrollers (checkbox, toggleswitch) → True/False select, persisted as booleannumbercontrollers → numeric input, persisted as numberstatic-optionscontrollers (dropdown, multiselect) → options select from controller's valuesfree-inputcontrollers (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
| File | Purpose |
|---|---|
right-panel-static.component.ts | Host: card UI, save handler, controller options, rename propagation |
dependency-configurator.component.ts | Full authoring lifecycle |
dependency-configurator.component.html | Configurator template |
dependency-configurator.component.jest.spec.ts | 10 unit tests |
right-panel-static.config.ts | Constants: operators, effects, field types, compatibility |
5. Renderer Architecture
5.1 Build-Time Wiring
Sequence during FormEngineService.buildForm():
- Build form controls recursively from schema
- Build flat control index:
buildControlByJsonKeyIndex(rootGroup)→Map<string, AbstractControl> - Tear down previous dependency engine state
- 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()):
| Index | Type | Purpose |
|---|---|---|
controllerIndex | Map<controllerKey, Array<{targetConfig, rule}>> | Controller → affected targets |
targetIndex | Map<targetKey, {targetConfig, rules}> | Target → all its rules |
controllerToTargetKeys | Map<controllerKey, Set<targetKey>> | Fast lookup for re-evaluation |
controlMap | Map<jsonKey, AbstractControl> | Cached control lookups (O(1) via flat map) |
baseRequiredMap | Map<targetKey, boolean> | Authored baseline required state for fallback |
visibilityMap | Map<targetKey, BehaviorSubject<boolean>> | Visibility streams for template consumption |
evaluationInProgress | Set<controllerKey> | Reentrancy guard for clearOnHide cycles |
Runtime Loop:
- Subscribe to each controller's
valueChanges - On change →
evaluateAndApply(controllerKey) - Look up affected targets via
controllerToTargetKeys - For each target →
evaluateTarget(targetKey) - 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-onlylt/gt/lte/gte: Numeric comparison, returnsfalsefor non-numeric operandsempty/notEmpty: Handlesnull,undefined,'', empty arrays
5.4 Effect Application Semantics
Precedence: Last matched rule per effect type wins.
Evaluation per target:
- Iterate all rules for this target
- Check condition against controller's current value
- Collect matched effects by type (last match wins per type)
- Apply matched effects
- Apply fallback for any effect types with no matching rule
setVisibility effect:
- Pushes boolean to
visibilityMapBehaviorSubject - If
visible=falseandclearOnHide=true:- Resets control value with
emitEvent: true(so formula validation refreshes) - Downstream dependencies re-evaluate via
valueChangessubscription - Reentrancy guard prevents cyclic A→B→A loops
- Resets control value with
- Fallback:
visible=true
setRequired effect:
- Adds/removes
Validators.required(orrequiredTruefor boolean controls) - Fallback: restores
baseRequiredMapbaseline
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.htmlform-renderer-core.component.htmldiv-field.component.htmlsection-field.component.htmlcontainer-field.component.htmltab-field.component.html(2 locations)button-group-field.component.html
5.6 Key Files
| File | Purpose |
|---|---|
dependency-engine.service.ts | Runtime engine: graph, subscriptions, evaluation |
dependency-evaluator.util.ts | Pure condition evaluation |
form-engine.service.ts | Build-time wiring, flat control index |
form-element-config.types.ts | Type definitions |
dependency-engine.service.jest.spec.ts | 6 runtime engine tests |
dependency-evaluator.util.jest.spec.ts | 7 evaluator tests |
form-engine.jest.spec.ts | Integration 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[].controllerKeythat 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[].controllerKeyreferences - Toast notification blocks the delete operation
6.3 Cyclic clearOnHide Protection
The evaluationInProgress Set prevents infinite recursion:
- Before evaluating downstream deps after a
clearOnHidereset, 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
| Decision | Rationale |
|---|---|
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 *ngIf | Preserves FormControl in DOM for downstream formulas and state restoration |
| Last-match precedence per effect type | Deterministic; no ambiguous priority conflicts between rules |
| Inverted index for evaluation | O(affected_targets) per change, not O(all_fields) |
Flat control map via buildControlByJsonKeyIndex | O(1) lookups in buildControlCache instead of O(depth) DFS per key |
emitEvent: true on clearOnHide reset | Ensures formula validation subscriptions refresh; reentrancy guard prevents cycles |
| Operator restrictions by controller type | Prevents nonsensical conditions (e.g., lt on boolean, contains on number) |
| Typed value persistence | Boolean controllers persist true/false, number controllers persist numbers — no string coercion at runtime |
8. Testing Coverage
Unit Tests
| Suite | File | Tests | Coverage |
|---|---|---|---|
| Evaluator operators | dependency-evaluator.util.jest.spec.ts | 7 | All operators, strict typing, empty/array, unsupported ops |
| Engine runtime | dependency-engine.service.jest.spec.ts | 6 | Multi-controller merge, last-match precedence, fallback, clearOnHide downstream, clearOnHide emitEvent, cyclic loops |
| Configurator builder | dependency-configurator.component.jest.spec.ts | 10 | Draft lifecycle, boolean/number typed values, operator restrictions, unsaved guards, reordering, effect operations |
| Engine wiring | form-engine.jest.spec.ts | 3+ | Teardown ordering, wireUp args (flat map), integration seam (persisted JSON → runtime behavior) |
Integration Tests
| Test | File | What It Validates |
|---|---|---|
applies required and visibility effects from persisted dependsOn config at runtime | form-engine.jest.spec.ts | Full 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
| # | Gap | Resolution | Date |
|---|---|---|---|
| 1 | Cycle/reentrancy protection for clearOnHide chains | evaluationInProgress Set in DependencyEngineService | Shipped |
| 2 | Dedicated runtime engine tests | 6 tests in dependency-engine.service.jest.spec.ts | Shipped |
| 3 | Delete blocking for controller fields | Toast guard blocks deleting fields used as controllerKey | Shipped |
| 4 | Builder compatibility mismatch (multiselect etc.) | DEPENDENCY_SUPPORTED_FIELD_TYPES and DEPENDENCY_EFFECT_COMPAT aligned | Shipped |
| 5 | Integration seam tests (builder → renderer) | form-engine.jest.spec.ts — persisted JSON → runtime behavior | Shipped |
| 6 | Renderer-root teardown lifecycle | destroy$ cleanup verified in form-renderer-root.component.ts | Shipped |
| 7 | P0: clearOnHide + emitEvent:false skipped formula validation | reset() now uses emitEvent: true. Test: 'emits cleared target valueChanges on clearOnHide...' | Apr 2026 |
| 8 | P1#2: findControl() DFS bottleneck in buildControlCache | buildControlByJsonKeyIndex() builds flat map, O(1) per key. Test: wireUp receives expect.any(Map) | Apr 2026 |
| 9 | clearOnHide + formula/validation downstream propagation | Documented in flow doc §13-14. Hidden-cleared fields trigger immediate formula re-eval | Apr 2026 |
⚠️ Open — Performance (P1)
| # | Gap | Impact | Priority |
|---|---|---|---|
| P1#3 | applyFallback() recomputes static booleans (hasVisibilityEffect/hasRequiredEffect) every evaluation | Minor perf — .some() scans are O(rules) but could be O(1) if precomputed in compile() | Low-Medium |
| P1#4 | initialEval() evaluates same target N times if it has N controllers | Redundant work at form load; iterate targetIndex.keys() directly instead | Low-Medium |
| P1#5 | Double tree walk on field selection (controller options + formula suggestions) | Two collectFieldsRecursivelyWithPath calls could be merged | Low |
⚠️ Open — Code Health (P2)
| # | Gap | Impact | Priority |
|---|---|---|---|
| P2#6 | providedIn: 'root' singleton scope on DependencyEngineService | Two simultaneous forms would clobber each other. Fine for current single-form usage | Low |
| P2#7 | 3 duplicate tree-walk implementations (walkElements, traverse, collectFieldsRecursivelyWithPath) | Code duplication risk; could extract shared utility | Low |
| P2#8 | Pervasive any in dependency-configurator.component.ts | Refactoring risk in 826-line component; types exist in form-element-config.types.ts but unused | Low |
| P2#9 | Unused DependencyEffect variants in type union (setValueFromMap, reloadOptionsFromApi, setFieldProps) | Forward-compatible stubs; should be annotated // @future | Low |
| P2#10 | No delete-time orphan cleanup for dependency rules | Rename propagation exists but delete only blocks; orphan rules persist if guard is bypassed | Low |
⚠️ Open — Validation & Testing (P2-P3)
| # | Gap | Impact | Priority |
|---|---|---|---|
| P2#11 | Schema validator doesn't validate dependsOn contract (operators, effect payloads, orphan refs) | Invalid dependency JSON silently fails at runtime | Medium |
| P3#12 | Template method call pattern for getVisibility$() in 8 HTML files | Potential change-detection churn; could use precomputed map or pipe | Low |
| P3#13 | No test for buildControlCache with nested isParent:true forms | Could silently break visibility in deeply nested forms | Low |
| P3#14 | No test for updateAllDependencies() rename propagation | Builder-side traversal untested | Low |
| P3#15 | No test for contains operator with null condValue | Edge case — evaluator L26 | Low |
| P3#16 | No negative test for unsupported effect types being silently dropped | Could confuse authors | Low |
10. End-to-End Sequence
11. Quick Reference — Key Files
Builder
| File | LOC | Role |
|---|---|---|
| right-panel-static.component.ts | 3567 | Host: summary card, save handler, rename propagation, controller options |
| dependency-configurator.component.ts | 826 | Full authoring lifecycle: drafts, validation, typed value normalization |
| right-panel-static.config.ts | 244 | Constants: operators, effects, field types, compatibility map |
Renderer
| File | LOC | Role |
|---|---|---|
| dependency-engine.service.ts | 434 | Runtime engine: graph, subscriptions, evaluation, clearOnHide |
| dependency-evaluator.util.ts | 96 | Pure condition evaluation (stateless) |
| form-engine.service.ts | 1082 | Build-time wiring, flat control index |
| form-element-config.types.ts | 562 | Type definitions |
Tests
| File | Tests | Coverage |
|---|---|---|
| dependency-evaluator.util.jest.spec.ts | 7 | All operators |
| dependency-engine.service.jest.spec.ts | 6 | Runtime engine |
| dependency-configurator.component.jest.spec.ts | 10 | Builder configurator |
| form-engine.jest.spec.ts | 3+ | Integration seam |