2026-02-26
Phase 1 Completions & Builder UI Adjustments
- Refined the
displayKeygeneration pattern for formula validation rules within the builder UI. The updated format uses a clean dot notation (df.error.[jsonKey].[errorKey]) and removes cluttered UUID postfixing. Defaulted the auto-generated display label to "Validation failed". - Performance optimizations in Builder Template: Eliminated inline getter method bindings like
getDisplayKey()andgetErrorLabel()from theerror-i18n-parent.component.htmltemplate. Such methods run excessively during standard Angular change-detection cycles. They were replaced with direct optional chaining property access (selectedItem.errorMsgMetadata[rule.errorKey]?.displayKey), leading to cleaner HTML and fewer TS methods.
Design Dilemma & Architectural Decisions
Today we faced an architectural dilemma in the UI implementation: Where should the error label and error key data bindings live for Formula Validation rules, and what data model should we use?
Context
The existing error-i18n-group component natively handles fields like "required" and "min" using FormGroup and FormControl models, which naturally integrates heavily with standard reactive form lifecycle events. When we added dynamic formula-based validation, we considered rewriting error-i18n-group to accommodate dynamic form iterations for rules.
The Decision
We chose to keep the validation rule error bindings contained within error-i18n-parent, and we specifically opted to stick with [(ngModel)] mapping rather than refactoring the underlying architecture to track dynamic strings with FormControl.
Why We Made This Decision
- Dynamic Data Source vs Static Forms:
error-i18n-groupis highly optimized for static fields tied to traditional Angular validation events mappings explicitly initialized up front. Formula validations, conversely, are entirely dynamic user-defined JSON metadata traits configured viaselectedItem.errorMsgMetadata. - Right tool for the job:
ngModelhandles primitive two-way data mapping perfectly. AFormControlis excellent for tracking "dirty" or "touched" state logic natively on field data entry forms, but simply syncing text from an admin builder tool over into a metadata JSON block makesFormControlan overly complex abstraction that yields zero practical benefits. - Future-proofing against the Renderer: Form configuration objects managed and dispatched into our Form Renderer runtime are very likely to be handled through divergent mapping mechanics in the future. By maintaining loose coupling from
error-i18n-group(and recognizing that we may want a completely independent component for validation labels later), we prevent regressions that might otherwise break native components. At runtime, the actual engine will simply check standard errors object keys againsterrorMsgMetadatawithout caring how the builder formulated them.
Phase 2: Runtime Validation — Implementation & Issues
What We Built
Added formula-based runtime validation to FormEngineService. When a field has validationRules configured in its JSON, the engine now subscribes to the formula's dependent fields and evaluates the formula via the /evaluate API. Errors are set/cleared on the Angular FormControl using the rule's errorKey, which the existing FieldWrapperComponent automatically picks up and displays via errorMsgMetadata — no changes needed to the renderer display logic.
Key Method Added: attachFormulaValidators()
Called at the end of buildForm() after all controls exist. Takes a flat list of elements with validationRules that was collected during addControlsRecursive() (no second tree traversal needed). For each rule:
- Extracts dependency field keys from the formula string using the same regex as
formula-field.component.ts - Subscribes to dependency field
valueChanges→ calls/evaluate→ sets/clears errors - Also subscribes to the field's own
valueChanges— not to evaluate against itself, but so the control becomesdirtywhen the user interacts with it (required forshowErrorsinFieldWrapperComponent)
Issues Encountered & Solutions
1. Error only showed on submit, not on dirty
Problem: FieldWrapperComponent.showErrors requires dirty || touched. The formula validation error was being SET correctly on the control but not SHOWN because the validated field wasn't dirty.
Root cause: We were only subscribing to the dependency fields' valueChanges (e.g. yearsOfExperience). When yearsOfExperience changed, the error was set on bonusPercentage but bonusPercentage was still pristine → showErrors returned false.
Solution: Added a separate subscription to the validated field's own valueChanges. When the user types in bonusPercentage, the field becomes dirty AND evaluation re-runs. This is NOT a self-referential formula — the own control's value is never included in the /evaluate payload. It purely triggers dirtiness.
2. Redundant second tree traversal
Problem: After buildForm() we needed a second pass through all elements to find ones with validationRules. Creating a separate forEachElement() recursive method meant walking the entire tree twice.
Solution: Added a formulaValidationElements: FormElementConfig[] field. During addControlsRecursive() at the leaf node step (step 4), any element with validationRules is pushed into this list. attachFormulaValidators() then just iterates that flat list — zero extra tree traversal.
3. form.get(jsonKey) flat lookup fails for nested fields (isParent: true)
Problem: When a field like bonusPercentage is inside a section with isParent: true, its FormControl lives at form.get('sectionKey.bonusPercentage') not form.get('bonusPercentage'). All three places where we looked up controls (finding the validated field, finding dependency field values, subscribing to dependency changes) used flat lookups and silently returned null for nested fields.
The same bug existed in formula-field.component.ts — if bonusPercentage was nested, the formula would get undefined variable: bonusPercentage from the /evaluate API.
Solution: Created findControl(group, jsonKey) — a recursive depth-first search through the FormGroup tree. Added to formula-rule-evaluator.util.ts (the existing engine utility file, avoiding a standalone file for one function). Updated all flat form.get() usages in both FormEngineService and FormulaFieldComponent.
Why formula-rule-evaluator.util.ts: Already the shared home for formula-related engine utilities (findMatchingRule, evaluateRuleCondition, evaluateComparison). Both the formula-field component and the engine service already imported from it, so no new cross-module dependency was introduced.
4. Subscription cleanup
Problem: On form rebuild (e.g. navigating between forms), buildForm() is called again. Without cleanup, old subscriptions accumulate, causing stale evaluations and memory leaks.
Solution: Single formulaValidationSub = new Subscription() aggregate. On each buildForm() call, this.formulaValidationSub.unsubscribe(); this.formulaValidationSub = new Subscription() resets it cleanly. Using .add() to register each listener instead of maintaining an array.
Why Error Display Already Worked Without Changes
FieldWrapperComponent iterates control.errors and for each error key, checks errorMsgMetadata[errorKey]?.displayLabel. Since every formula validation rule we create in the builder always populates errorMsgMetadata[rule.errorKey], the display logic found the label automatically — no switch/case additions needed. The switch/case is only a fallback for built-in Angular error keys (like required, minlength) that have no metadata entry.