@ng-forge/dynamic-forms 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/README.md +3 -0
  2. package/fesm2022/ng-forge-dynamic-forms-integration.mjs +18 -52
  3. package/fesm2022/ng-forge-dynamic-forms-integration.mjs.map +1 -1
  4. package/fesm2022/{ng-forge-dynamic-forms-ng-forge-dynamic-forms-DHxoGouT.mjs → ng-forge-dynamic-forms-ng-forge-dynamic-forms-WGww2nXx.mjs} +3401 -294
  5. package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-WGww2nXx.mjs.map +1 -0
  6. package/fesm2022/{ng-forge-dynamic-forms-page-field.component-Bt6blv9I.mjs → ng-forge-dynamic-forms-page-field.component-CrO9WTDw.mjs} +2 -2
  7. package/fesm2022/{ng-forge-dynamic-forms-page-field.component-Bt6blv9I.mjs.map → ng-forge-dynamic-forms-page-field.component-CrO9WTDw.mjs.map} +1 -1
  8. package/fesm2022/ng-forge-dynamic-forms-schema.mjs +92 -0
  9. package/fesm2022/ng-forge-dynamic-forms-schema.mjs.map +1 -0
  10. package/fesm2022/{ng-forge-dynamic-forms-text-field.component-DKCx7Z_U.mjs → ng-forge-dynamic-forms-text-field.component-BcFx_kOz.mjs} +2 -2
  11. package/fesm2022/{ng-forge-dynamic-forms-text-field.component-DKCx7Z_U.mjs.map → ng-forge-dynamic-forms-text-field.component-BcFx_kOz.mjs.map} +1 -1
  12. package/fesm2022/ng-forge-dynamic-forms.mjs +1 -1
  13. package/package.json +5 -4
  14. package/types/ng-forge-dynamic-forms-integration.d.ts +3 -14
  15. package/types/ng-forge-dynamic-forms-schema.d.ts +178 -0
  16. package/types/ng-forge-dynamic-forms.d.ts +766 -35
  17. package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-DHxoGouT.mjs.map +0 -1
  18. package/fesm2022/ng-forge-dynamic-forms-testing-test-button.harness-C79EBW0o.mjs +0 -31
  19. package/fesm2022/ng-forge-dynamic-forms-testing-test-button.harness-C79EBW0o.mjs.map +0 -1
  20. package/fesm2022/ng-forge-dynamic-forms-testing.mjs +0 -973
  21. package/fesm2022/ng-forge-dynamic-forms-testing.mjs.map +0 -1
  22. package/types/ng-forge-dynamic-forms-testing.d.ts +0 -603
@@ -1,21 +1,24 @@
1
1
  import * as i0 from '@angular/core';
2
- import { isSignal, computed, runInInjectionContext, InjectionToken, inject, signal, Injectable, untracked, input, linkedSignal, ChangeDetectionStrategy, Component, DestroyRef, Injector, model, afterNextRender, makeEnvironmentProviders, Pipe } from '@angular/core';
2
+ import { isSignal, computed, runInInjectionContext, InjectionToken, inject, signal, Injectable, untracked, Injector, isDevMode, isWritableSignal, DestroyRef, input, linkedSignal, ChangeDetectionStrategy, Component, model, afterNextRender, makeEnvironmentProviders, Pipe } from '@angular/core';
3
3
  import { NgComponentOutlet } from '@angular/common';
4
- import { submit, email, min, max, minLength, maxLength, pattern, required, validate, validateAsync, validateHttp, disabled, readonly, hidden, applyEach, applyWhenValue, applyWhen, apply, schema, validateTree, form, provideSignalFormsConfig } from '@angular/forms/signals';
4
+ import { submit, email, min, max, minLength, maxLength, pattern, required, validate, validateAsync, validateHttp, disabled, readonly, hidden, applyEach, applyWhenValue, applyWhen, apply, schema, validateStandardSchema, validateTree, form, provideSignalFormsConfig } from '@angular/forms/signals';
5
5
  import { toObservable, toSignal, takeUntilDestroyed, outputFromObservable } from '@angular/core/rxjs-interop';
6
- import { defer, combineLatest, from, map, catchError, of, pipe, switchMap, forkJoin, scan, isObservable, firstValueFrom, EMPTY, Subject, filter } from 'rxjs';
6
+ import { defer, combineLatest, from, map, catchError, of, pipe, switchMap, forkJoin, scan, isObservable, firstValueFrom, EMPTY, debounceTime, distinctUntilChanged, startWith, tap, Subject, filter, combineLatestWith, auditTime, exhaustMap, scheduled, queueScheduler, pairwise, merge, timer } from 'rxjs';
7
+ import { isStandardSchemaMarker } from '@ng-forge/dynamic-forms/schema';
7
8
  import { scan as scan$1, map as map$1, filter as filter$1, take, switchMap as switchMap$1, shareReplay } from 'rxjs/operators';
8
9
  import { explicitEffect } from 'ngxtension/explicit-effect';
9
10
  import { NG_STATUS_CLASSES } from '@angular/forms/signals/compat';
10
11
 
11
12
  /**
12
13
  * Type guard to check if a value is an array of Signals.
14
+ * @internal
13
15
  */
14
16
  function isSignalArray(value) {
15
17
  return Array.isArray(value) && value.every((item) => isSignal(item));
16
18
  }
17
19
  /**
18
- * Type guard to check if a value is an object of Signals (record).
20
+ * Type guard to check if a value is a record (object) of Signals.
21
+ * @internal
19
22
  */
20
23
  function isSignalRecord(value) {
21
24
  return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.values(value).every((item) => isSignal(item));
@@ -409,6 +412,133 @@ function createSubmissionHandler(options) {
409
412
  * Native JavaScript utility functions to replace lodash-es
410
413
  * These are simple implementations for common object manipulation patterns
411
414
  */
415
+ /**
416
+ * Performs a deep equality comparison between two values.
417
+ *
418
+ * Handles:
419
+ * - Primitives (including NaN via Object.is)
420
+ * - Dates (by timestamp)
421
+ * - Arrays (deep element comparison)
422
+ * - Plain objects (deep property comparison)
423
+ * - RegExp (by source and flags)
424
+ * - Map and Set (by entries)
425
+ * - Circular references (via WeakMap tracking)
426
+ *
427
+ * @param a - First value
428
+ * @param b - Second value
429
+ * @returns true if values are deeply equal
430
+ *
431
+ * @example
432
+ * ```typescript
433
+ * isEqual({ a: 1 }, { a: 1 }); // true
434
+ * isEqual([1, 2], [1, 2]); // true
435
+ * isEqual({ a: 1 }, { a: 2 }); // false
436
+ * isEqual(new Date('2024-01-01'), new Date('2024-01-01')); // true
437
+ * isEqual(/abc/gi, /abc/gi); // true
438
+ * ```
439
+ */
440
+ function isEqual(a, b) {
441
+ return isEqualInternal(a, b, new WeakMap(), new WeakMap());
442
+ }
443
+ /**
444
+ * Internal implementation with circular reference tracking.
445
+ * @internal
446
+ */
447
+ function isEqualInternal(a, b, seenA, seenB) {
448
+ // Same reference or both null/undefined
449
+ if (a === b)
450
+ return true;
451
+ // Handle NaN (NaN === NaN is false, but we want it to be equal)
452
+ if (typeof a === 'number' && typeof b === 'number') {
453
+ return Object.is(a, b);
454
+ }
455
+ // One is null/undefined but not both
456
+ if (a == null || b == null)
457
+ return false;
458
+ // Different types
459
+ if (typeof a !== typeof b)
460
+ return false;
461
+ // Non-object primitives (strings, booleans, symbols, bigints)
462
+ if (typeof a !== 'object') {
463
+ return Object.is(a, b);
464
+ }
465
+ // At this point, both a and b are objects (typeof a === 'object')
466
+ const objA = a;
467
+ const objB = b;
468
+ // Check for circular references
469
+ if (seenA.has(objA) || seenB.has(objB)) {
470
+ // If we've seen these objects before, check if they were paired together
471
+ return seenA.get(objA) === objB && seenB.get(objB) === objA;
472
+ }
473
+ // Mark these objects as seen (paired together)
474
+ seenA.set(objA, objB);
475
+ seenB.set(objB, objA);
476
+ // Different constructors means different types (e.g., Date vs Object)
477
+ if (objA.constructor !== objB.constructor)
478
+ return false;
479
+ // Handle dates
480
+ if (a instanceof Date && b instanceof Date) {
481
+ return a.getTime() === b.getTime();
482
+ }
483
+ // Handle RegExp
484
+ if (a instanceof RegExp && b instanceof RegExp) {
485
+ return a.source === b.source && a.flags === b.flags;
486
+ }
487
+ // Handle Map
488
+ if (a instanceof Map && b instanceof Map) {
489
+ if (a.size !== b.size)
490
+ return false;
491
+ for (const [key, value] of a) {
492
+ if (!b.has(key) || !isEqualInternal(value, b.get(key), seenA, seenB)) {
493
+ return false;
494
+ }
495
+ }
496
+ return true;
497
+ }
498
+ // Handle Set
499
+ if (a instanceof Set && b instanceof Set) {
500
+ if (a.size !== b.size)
501
+ return false;
502
+ for (const value of a) {
503
+ if (!b.has(value)) {
504
+ // For non-primitive values in Sets, we need deep comparison
505
+ // This is O(n^2) but Sets rarely contain objects
506
+ if (typeof value === 'object' && value !== null) {
507
+ let found = false;
508
+ for (const bValue of b) {
509
+ if (typeof bValue === 'object' && bValue !== null && isEqualInternal(value, bValue, seenA, seenB)) {
510
+ found = true;
511
+ break;
512
+ }
513
+ }
514
+ if (!found)
515
+ return false;
516
+ }
517
+ else {
518
+ return false;
519
+ }
520
+ }
521
+ }
522
+ return true;
523
+ }
524
+ // Handle arrays
525
+ if (Array.isArray(a) && Array.isArray(b)) {
526
+ if (a.length !== b.length)
527
+ return false;
528
+ return a.every((item, index) => isEqualInternal(item, b[index], seenA, seenB));
529
+ }
530
+ // Handle plain objects
531
+ const recordA = a;
532
+ const recordB = b;
533
+ // Get both string and symbol keys
534
+ const keysA = [...Object.keys(recordA), ...Object.getOwnPropertySymbols(recordA)];
535
+ const keysB = [...Object.keys(recordB), ...Object.getOwnPropertySymbols(recordB)];
536
+ if (keysA.length !== keysB.length)
537
+ return false;
538
+ // Create a Set for O(1) lookup
539
+ const keySetB = new Set(keysB);
540
+ return keysA.every((key) => keySetB.has(key) && isEqualInternal(recordA[key], recordB[key], seenA, seenB));
541
+ }
412
542
  /**
413
543
  * Creates a new object without the specified keys
414
544
  * Native replacement for lodash omit()
@@ -516,54 +646,6 @@ function memoize(fn, resolverOrOptions) {
516
646
  return result;
517
647
  });
518
648
  }
519
- /**
520
- * Performs a deep equality comparison between two values
521
- * Native replacement for lodash isEqual()
522
- *
523
- * @param a - First value
524
- * @param b - Second value
525
- * @returns true if values are deeply equal
526
- *
527
- * @example
528
- * ```typescript
529
- * isEqual({ a: 1 }, { a: 1 }); // true
530
- * isEqual([1, 2], [1, 2]); // true
531
- * isEqual({ a: 1 }, { a: 2 }); // false
532
- * ```
533
- */
534
- function isEqual(a, b) {
535
- // Same reference or both null/undefined
536
- if (a === b)
537
- return true;
538
- // One is null/undefined but not both
539
- if (a == null || b == null)
540
- return false;
541
- // Different types
542
- if (typeof a !== typeof b)
543
- return false;
544
- // Handle dates
545
- if (a instanceof Date && b instanceof Date) {
546
- return a.getTime() === b.getTime();
547
- }
548
- // Handle arrays
549
- if (Array.isArray(a) && Array.isArray(b)) {
550
- if (a.length !== b.length)
551
- return false;
552
- return a.every((item, index) => isEqual(item, b[index]));
553
- }
554
- // Handle objects
555
- if (typeof a === 'object' && typeof b === 'object') {
556
- const objA = a;
557
- const objB = b;
558
- const keysA = Object.keys(objA);
559
- const keysB = Object.keys(objB);
560
- if (keysA.length !== keysB.length)
561
- return false;
562
- return keysA.every((key) => keysB.includes(key) && isEqual(objA[key], objB[key]));
563
- }
564
- // Primitives - use Object.is to handle NaN correctly (NaN === NaN is false, but Object.is(NaN, NaN) is true)
565
- return Object.is(a, b);
566
- }
567
649
  /**
568
650
  * Normalizes a fields collection to an array.
569
651
  * Handles both array format and object format (keyed by field key).
@@ -574,6 +656,52 @@ function isEqual(a, b) {
574
656
  function normalizeFieldsArray(fields) {
575
657
  return Array.isArray(fields) ? [...fields] : Object.values(fields);
576
658
  }
659
+ /**
660
+ * Gets the keys that differ between two objects.
661
+ *
662
+ * Compares top-level keys and returns those whose values are different.
663
+ * Uses deep equality comparison for nested values.
664
+ *
665
+ * @param previous - Previous object state
666
+ * @param current - Current object state
667
+ * @returns Set of keys that have different values
668
+ *
669
+ * @example
670
+ * ```typescript
671
+ * const prev = { a: 1, b: 2, c: 3 };
672
+ * const curr = { a: 1, b: 5, d: 4 };
673
+ * getChangedKeys(prev, curr); // Set { 'b', 'c', 'd' }
674
+ * ```
675
+ */
676
+ function getChangedKeys(previous, current) {
677
+ const changedKeys = new Set();
678
+ // Handle null/undefined cases
679
+ if (!previous && !current)
680
+ return changedKeys;
681
+ if (!previous) {
682
+ return new Set(Object.keys(current ?? {}));
683
+ }
684
+ if (!current) {
685
+ return new Set(Object.keys(previous));
686
+ }
687
+ // Collect all keys from both objects
688
+ const allKeys = new Set([...Object.keys(previous), ...Object.keys(current)]);
689
+ // Check each key for differences
690
+ for (const key of allKeys) {
691
+ const prevValue = previous[key];
692
+ const currValue = current[key];
693
+ // Key added or removed
694
+ if (!(key in previous) || !(key in current)) {
695
+ changedKeys.add(key);
696
+ continue;
697
+ }
698
+ // Value changed
699
+ if (!isEqual(prevValue, currValue)) {
700
+ changedKeys.add(key);
701
+ }
702
+ }
703
+ return changedKeys;
704
+ }
577
705
 
578
706
  /**
579
707
  * Injection token for providing field signal context to mappers and components.
@@ -1286,6 +1414,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
1286
1414
  class FunctionRegistryService {
1287
1415
  customFunctions = new Map();
1288
1416
  customFunctionScopes = new Map();
1417
+ derivationFunctions = new Map();
1289
1418
  validators = new Map();
1290
1419
  asyncValidators = new Map();
1291
1420
  httpValidators = new Map();
@@ -1344,6 +1473,56 @@ class FunctionRegistryService {
1344
1473
  this.customFunctions.clear();
1345
1474
  this.customFunctionScopes.clear();
1346
1475
  }
1476
+ // ─────────────────────────────────────────────────────────────────────────────
1477
+ // Derivation Functions
1478
+ // ─────────────────────────────────────────────────────────────────────────────
1479
+ /**
1480
+ * Register a derivation function for value derivation logic.
1481
+ *
1482
+ * Derivation functions compute derived values and are called when a
1483
+ * `DerivationLogicConfig` references them by `functionName`.
1484
+ *
1485
+ * @param name - Unique identifier for the function
1486
+ * @param fn - Function that receives EvaluationContext and returns the derived value
1487
+ *
1488
+ * @example
1489
+ * ```typescript
1490
+ * registry.registerDerivationFunction('getCurrencyForCountry', (ctx) => {
1491
+ * const countryToCurrency = { 'USA': 'USD', 'Germany': 'EUR', 'UK': 'GBP' };
1492
+ * return countryToCurrency[ctx.formValue.country] ?? 'USD';
1493
+ * });
1494
+ * ```
1495
+ */
1496
+ registerDerivationFunction(name, fn) {
1497
+ this.derivationFunctions.set(name, fn);
1498
+ }
1499
+ /**
1500
+ * Get a derivation function by name
1501
+ */
1502
+ getDerivationFunction(name) {
1503
+ return this.derivationFunctions.get(name);
1504
+ }
1505
+ /**
1506
+ * Get all derivation functions as an object
1507
+ */
1508
+ getDerivationFunctions() {
1509
+ return Object.fromEntries(this.derivationFunctions);
1510
+ }
1511
+ /**
1512
+ * Set derivation functions from a config object.
1513
+ * Only updates functions if their references have changed.
1514
+ *
1515
+ * @param derivations - Object mapping function names to derivation functions
1516
+ */
1517
+ setDerivationFunctions(derivations) {
1518
+ this.setRegistryIfChanged(this.derivationFunctions, derivations);
1519
+ }
1520
+ /**
1521
+ * Clear all derivation functions
1522
+ */
1523
+ clearDerivationFunctions() {
1524
+ this.derivationFunctions.clear();
1525
+ }
1347
1526
  /**
1348
1527
  * Register a custom validator using Angular's public FieldContext API
1349
1528
  *
@@ -1561,6 +1740,7 @@ class FunctionRegistryService {
1561
1740
  */
1562
1741
  clearAll() {
1563
1742
  this.clearCustomFunctions();
1743
+ this.clearDerivationFunctions();
1564
1744
  this.clearValidators();
1565
1745
  this.clearAsyncValidators();
1566
1746
  this.clearHttpValidators();
@@ -2404,7 +2584,7 @@ class Evaluator {
2404
2584
  /**
2405
2585
  * Maximum cache size to prevent memory issues
2406
2586
  */
2407
- const MAX_AST_CACHE_SIZE = 1000;
2587
+ const MAX_AST_CACHE_SIZE$1 = 1000;
2408
2588
  /**
2409
2589
  * LRU Cache implementation for AST nodes
2410
2590
  * Tracks access order to evict least-recently-used entries
@@ -2449,7 +2629,7 @@ class LRUCache {
2449
2629
  /**
2450
2630
  * Cache for parsed AST nodes to improve performance
2451
2631
  */
2452
- const astCache = new LRUCache(MAX_AST_CACHE_SIZE);
2632
+ const astCache = new LRUCache(MAX_AST_CACHE_SIZE$1);
2453
2633
  /**
2454
2634
  * Secure expression parser that uses AST-based evaluation instead of eval() or new Function()
2455
2635
  *
@@ -2506,7 +2686,7 @@ class ExpressionParser {
2506
2686
  static getCacheStats() {
2507
2687
  return {
2508
2688
  size: astCache.size,
2509
- maxSize: MAX_AST_CACHE_SIZE,
2689
+ maxSize: MAX_AST_CACHE_SIZE$1,
2510
2690
  };
2511
2691
  }
2512
2692
  }
@@ -2674,6 +2854,105 @@ function createLogicFunction(expression) {
2674
2854
  contextCache.set(cacheKey, fn);
2675
2855
  return fn;
2676
2856
  }
2857
+ /**
2858
+ * Cache for debounced logic functions.
2859
+ * Keyed by injection context similar to logicFunctionCache, but includes debounceMs in the key.
2860
+ */
2861
+ const debouncedLogicFunctionCache = new WeakMap();
2862
+ /**
2863
+ * Gets or creates the cache map for debounced functions in a specific injection context.
2864
+ */
2865
+ function getDebouncedContextCache(ctx) {
2866
+ let outerCache = debouncedLogicFunctionCache.get(ctx.functionRegistry);
2867
+ if (!outerCache) {
2868
+ outerCache = new WeakMap();
2869
+ debouncedLogicFunctionCache.set(ctx.functionRegistry, outerCache);
2870
+ }
2871
+ let innerCache = outerCache.get(ctx.fieldContextRegistry);
2872
+ if (!innerCache) {
2873
+ innerCache = new Map();
2874
+ outerCache.set(ctx.fieldContextRegistry, innerCache);
2875
+ }
2876
+ return innerCache;
2877
+ }
2878
+ /**
2879
+ * Store for debounced signals per field context.
2880
+ * Uses WeakMap with FieldContext objects as keys to automatically clean up
2881
+ * when the context is garbage collected.
2882
+ */
2883
+ const debouncedSignalStore = new WeakMap();
2884
+ /**
2885
+ * Gets or creates the debounced signal store for a function registry.
2886
+ */
2887
+ function getDebouncedSignalStore(functionRegistry) {
2888
+ let store = debouncedSignalStore.get(functionRegistry);
2889
+ if (!store) {
2890
+ store = new WeakMap();
2891
+ debouncedSignalStore.set(functionRegistry, store);
2892
+ }
2893
+ return store;
2894
+ }
2895
+ /**
2896
+ * Create a debounced logic function from a conditional expression.
2897
+ *
2898
+ * This function wraps the condition evaluation in a debounce mechanism,
2899
+ * so state changes only take effect after the specified delay.
2900
+ * Useful for avoiding UI flicker during rapid typing.
2901
+ *
2902
+ * @param expression The conditional expression to evaluate
2903
+ * @param debounceMs The debounce duration in milliseconds
2904
+ * @returns A LogicFn that evaluates the condition with debouncing
2905
+ */
2906
+ function createDebouncedLogicFunction(expression, debounceMs) {
2907
+ // Inject services during factory creation, not during execution
2908
+ const functionRegistry = inject(FunctionRegistryService);
2909
+ const fieldContextRegistry = inject(FieldContextRegistryService);
2910
+ const injector = inject(Injector);
2911
+ // Get cache for this injection context
2912
+ const contextCache = getDebouncedContextCache({ functionRegistry, fieldContextRegistry });
2913
+ // Generate cache key including debounceMs
2914
+ const cacheKey = `${serializeExpression(expression)}:${debounceMs}`;
2915
+ // Check cache first
2916
+ const cached = contextCache.get(cacheKey);
2917
+ if (cached) {
2918
+ return cached;
2919
+ }
2920
+ // Get or create the signal store for this context
2921
+ const signalStore = getDebouncedSignalStore(functionRegistry);
2922
+ const fn = (ctx) => {
2923
+ // Create a unique key using the context object itself as identifier
2924
+ // Since FieldContext doesn't have a path property, we use a WeakMap approach
2925
+ // by storing the debounced signal directly on the context
2926
+ const contextKey = ctx;
2927
+ let signalPair = signalStore.get(contextKey);
2928
+ if (!signalPair) {
2929
+ // Create a signal to hold the immediate evaluation result
2930
+ const immediateValue = signal(false, ...(ngDevMode ? [{ debugName: "immediateValue" }] : []));
2931
+ // Create a debounced signal using RxJS
2932
+ const immediateValue$ = toObservable(immediateValue, { injector }).pipe(debounceTime(debounceMs), distinctUntilChanged(), startWith(false));
2933
+ const debouncedValue = toSignal(immediateValue$, { injector, initialValue: false });
2934
+ signalPair = { immediateValue, debouncedValue };
2935
+ signalStore.set(contextKey, signalPair);
2936
+ }
2937
+ // Create REACTIVE evaluation context for logic functions
2938
+ const evaluationContext = fieldContextRegistry.createReactiveEvaluationContext(ctx, functionRegistry.getCustomFunctions());
2939
+ // Evaluate the condition and update the immediate signal
2940
+ const result = evaluateCondition(expression, evaluationContext);
2941
+ // Update immediate value (this triggers the debounce chain)
2942
+ // signalPair is guaranteed to exist here due to the initialization above
2943
+ const { immediateValue, debouncedValue } = signalPair;
2944
+ untracked(() => {
2945
+ if (immediateValue() !== result) {
2946
+ immediateValue.set(result);
2947
+ }
2948
+ });
2949
+ // Return the debounced value
2950
+ return debouncedValue() ?? false;
2951
+ };
2952
+ // Cache the function
2953
+ contextCache.set(cacheKey, fn);
2954
+ return fn;
2955
+ }
2677
2956
 
2678
2957
  /**
2679
2958
  * Cache for memoized dynamic value functions, keyed by service instance.
@@ -2721,6 +3000,36 @@ function createDynamicValueFunction(expression) {
2721
3000
  return fn;
2722
3001
  }
2723
3002
 
3003
+ /**
3004
+ * Creates the default derivation log configuration.
3005
+ *
3006
+ * Defaults to 'none' (silent). Users can enable logging via `withLoggerConfig`.
3007
+ *
3008
+ * @returns Default DerivationLogConfig
3009
+ *
3010
+ * @public
3011
+ */
3012
+ function createDefaultDerivationLogConfig() {
3013
+ return {
3014
+ level: 'none',
3015
+ };
3016
+ }
3017
+ /**
3018
+ * Checks if logging should occur at the specified level.
3019
+ *
3020
+ * @param config - Current log configuration
3021
+ * @param minLevel - Minimum level required for logging
3022
+ * @returns True if logging should occur
3023
+ *
3024
+ * @public
3025
+ */
3026
+ function shouldLog(config, minLevel) {
3027
+ if (config.level === 'none')
3028
+ return false;
3029
+ if (minLevel === 'summary')
3030
+ return config.level === 'summary' || config.level === 'verbose';
3031
+ return config.level === 'verbose';
3032
+ }
2724
3033
  /**
2725
3034
  * Type guard to check if a condition is a FormStateCondition.
2726
3035
  *
@@ -2732,6 +3041,28 @@ function createDynamicValueFunction(expression) {
2732
3041
  function isFormStateCondition(condition) {
2733
3042
  return typeof condition === 'string' && ['formInvalid', 'formSubmitting', 'pageInvalid'].includes(condition);
2734
3043
  }
3044
+ /**
3045
+ * Type guard to check if a logic config is a StateLogicConfig.
3046
+ *
3047
+ * @param config - The logic config to check
3048
+ * @returns true if the config is for field state logic
3049
+ *
3050
+ * @public
3051
+ */
3052
+ function isStateLogicConfig(config) {
3053
+ return config.type === 'hidden' || config.type === 'readonly' || config.type === 'disabled' || config.type === 'required';
3054
+ }
3055
+ /**
3056
+ * Type guard to check if a logic config is a DerivationLogicConfig.
3057
+ *
3058
+ * @param config - The logic config to check
3059
+ * @returns true if the config is for value derivation
3060
+ *
3061
+ * @public
3062
+ */
3063
+ function isDerivationLogicConfig(config) {
3064
+ return config.type === 'derivation';
3065
+ }
2735
3066
 
2736
3067
  /**
2737
3068
  * Regular expression to detect formValue property access in expressions.
@@ -2740,10 +3071,19 @@ function isFormStateCondition(condition) {
2740
3071
  const FORM_VALUE_ACCESS_PATTERN = /\bformValue\s*(?:\.|\[)/;
2741
3072
  /**
2742
3073
  * Regular expressions to extract field paths from formValue expressions.
3074
+ *
3075
+ * Enhanced to capture full nested paths including dot notation chains:
3076
+ * - formValue.fieldName → captures 'fieldName'
3077
+ * - formValue.parent.child.grandchild → captures 'parent.child.grandchild'
3078
+ * - formValue['field-name'] → captures 'field-name'
3079
+ * - formValue["field.with.dots"] → captures 'field.with.dots'
3080
+ *
3081
+ * Note: Computed property access (formValue[variableName]) is NOT supported
3082
+ * and must use explicit dependsOn configuration.
2743
3083
  */
2744
- const FORM_VALUE_DOT_PATTERN = /\bformValue\.(\w+)/g;
2745
- const FORM_VALUE_BRACKET_SINGLE_PATTERN = /\bformValue\s*\[\s*'(\w+)'\s*\]/g;
2746
- const FORM_VALUE_BRACKET_DOUBLE_PATTERN = /\bformValue\s*\[\s*"(\w+)"\s*\]/g;
3084
+ const FORM_VALUE_DOT_PATTERN = /\bformValue\.([\w.]+)/g;
3085
+ const FORM_VALUE_BRACKET_SINGLE_PATTERN = /\bformValue\s*\[\s*'([\w.-]+)'\s*\]/g;
3086
+ const FORM_VALUE_BRACKET_DOUBLE_PATTERN = /\bformValue\s*\[\s*"([\w.-]+)"\s*\]/g;
2747
3087
  /**
2748
3088
  * Detects if a ConditionalExpression references other fields (cross-field).
2749
3089
  *
@@ -2851,22 +3191,45 @@ function extractStringDependencies(expression) {
2851
3191
  extractFromExpressionString(expression, deps);
2852
3192
  return Array.from(deps);
2853
3193
  }
2854
- /** Internal helper that populates a Set with dependencies from an expression string. */
3194
+ /**
3195
+ * Internal helper that populates a Set with dependencies from an expression string.
3196
+ *
3197
+ * Extracts both root fields and full nested paths for precise dependency tracking:
3198
+ * - 'formValue.parent.child' → adds 'parent' (root) and 'parent.child' (full path)
3199
+ * - 'formValue.simple' → adds 'simple'
3200
+ */
2855
3201
  function extractFromExpressionString(expression, deps) {
2856
- // Extract from dot notation: formValue.fieldName
3202
+ // Extract from dot notation: formValue.fieldName or formValue.parent.child.grandchild
2857
3203
  const dotMatches = expression.matchAll(FORM_VALUE_DOT_PATTERN);
2858
3204
  for (const match of dotMatches) {
2859
- deps.add(match[1]);
3205
+ const fullPath = match[1];
3206
+ // Always add the root field (first segment) as the primary dependency
3207
+ const rootField = fullPath.split('.')[0];
3208
+ deps.add(rootField);
3209
+ // Also add the full path for more precise dependency tracking if nested
3210
+ if (fullPath.includes('.')) {
3211
+ deps.add(fullPath);
3212
+ }
2860
3213
  }
2861
3214
  // Extract from bracket notation with single quotes: formValue['fieldName']
2862
3215
  const bracketSingleMatches = expression.matchAll(FORM_VALUE_BRACKET_SINGLE_PATTERN);
2863
3216
  for (const match of bracketSingleMatches) {
2864
- deps.add(match[1]);
3217
+ const fullPath = match[1];
3218
+ const rootField = fullPath.split('.')[0];
3219
+ deps.add(rootField);
3220
+ if (fullPath.includes('.')) {
3221
+ deps.add(fullPath);
3222
+ }
2865
3223
  }
2866
3224
  // Extract from bracket notation with double quotes: formValue["fieldName"]
2867
3225
  const bracketDoubleMatches = expression.matchAll(FORM_VALUE_BRACKET_DOUBLE_PATTERN);
2868
3226
  for (const match of bracketDoubleMatches) {
2869
- deps.add(match[1]);
3227
+ const fullPath = match[1];
3228
+ const rootField = fullPath.split('.')[0];
3229
+ deps.add(rootField);
3230
+ if (fullPath.includes('.')) {
3231
+ deps.add(fullPath);
3232
+ }
2870
3233
  }
2871
3234
  }
2872
3235
  // ============================================================================
@@ -2919,14 +3282,34 @@ function hasCrossFieldWhenCondition(config, context) {
2919
3282
  // ============================================================================
2920
3283
  // Logic Detection
2921
3284
  // ============================================================================
3285
+ /**
3286
+ * Detects if a StateLogicConfig has a cross-field condition.
3287
+ *
3288
+ * Note: This function only handles state logic (hidden, readonly, disabled, required).
3289
+ * Derivation logic is handled separately by the derivation system.
3290
+ *
3291
+ * @param config The state logic configuration to check
3292
+ * @param context Optional context providing function scope lookup
3293
+ * @returns true if the logic condition references other fields
3294
+ */
3295
+ function isCrossFieldStateLogic(config, context) {
3296
+ return isCrossFieldExpression(config.condition, context);
3297
+ }
2922
3298
  /**
2923
3299
  * Detects if a LogicConfig has a cross-field condition.
2924
3300
  *
2925
3301
  * @param config The logic configuration to check
2926
3302
  * @param context Optional context providing function scope lookup
2927
3303
  * @returns true if the logic condition references other fields
3304
+ *
3305
+ * @deprecated Use `isCrossFieldStateLogic` for state logic configs.
3306
+ * Derivation logic is handled by the derivation system.
2928
3307
  */
2929
3308
  function isCrossFieldLogic(config, context) {
3309
+ // Only check state logic configs
3310
+ if (!isStateLogicConfig(config)) {
3311
+ return false;
3312
+ }
2930
3313
  return isCrossFieldExpression(config.condition, context);
2931
3314
  }
2932
3315
  // ============================================================================
@@ -3189,26 +3572,124 @@ function applyValidators(configs, fieldPath) {
3189
3572
  configs.forEach((config) => applyValidator(config, fieldPath));
3190
3573
  }
3191
3574
 
3192
- function applyLogic(config, fieldPath) {
3193
- const path = fieldPath;
3194
- if (typeof config.condition === 'boolean') {
3195
- applyLogicFn(config.type, path, () => config.condition);
3196
- return;
3197
- }
3198
- const logicFn = createLogicFunction(config.condition);
3199
- applyLogicFn(config.type, path, logicFn);
3575
+ /**
3576
+ * Default debounce duration in milliseconds.
3577
+ */
3578
+ const DEFAULT_DEBOUNCE_MS = 500;
3579
+ /**
3580
+ * Creates a debounced effect that triggers a callback after a signal's value
3581
+ * has stabilized for the specified duration.
3582
+ *
3583
+ * Uses Angular's `toObservable` and `toSignal` with RxJS `debounceTime` for proper debouncing.
3584
+ * This approach is cleaner than manual setTimeout management and integrates
3585
+ * well with Angular's signal-based reactivity.
3586
+ *
3587
+ * @param source - The source signal to watch for changes
3588
+ * @param callback - Function to call with the debounced value
3589
+ * @param options - Configuration options
3590
+ * @param options.ms - Debounce duration in milliseconds (default: 500)
3591
+ * @param options.injector - Angular injector for the effect context
3592
+ *
3593
+ * @returns A signal that emits the debounced value (can be ignored if only side effects are needed)
3594
+ *
3595
+ * @example
3596
+ * ```typescript
3597
+ * // In a component or service
3598
+ * const searchInput = signal('');
3599
+ *
3600
+ * createDebouncedEffect(
3601
+ * searchInput,
3602
+ * (value) => console.log('Debounced search:', value),
3603
+ * { ms: 300, injector: this.injector }
3604
+ * );
3605
+ * ```
3606
+ *
3607
+ * @public
3608
+ */
3609
+ function createDebouncedEffect(source, callback, options) {
3610
+ const { ms = DEFAULT_DEBOUNCE_MS, injector } = options;
3611
+ const source$ = toObservable(source, { injector }).pipe(debounceTime(ms), distinctUntilChanged(), tap((value) => callback(value)));
3612
+ return toSignal(source$, { injector });
3200
3613
  }
3201
- function applyLogicFn(type, path, logicFn) {
3202
- switch (type) {
3203
- case 'hidden':
3204
- hidden(path, logicFn);
3205
- break;
3206
- case 'readonly':
3207
- readonly(path, logicFn);
3208
- break;
3209
- case 'disabled':
3210
- disabled(path, logicFn);
3211
- break;
3614
+ /**
3615
+ * Creates a debounced signal that emits values from the source signal
3616
+ * only after the value has stabilized for the specified duration.
3617
+ *
3618
+ * Unlike `createDebouncedEffect`, this doesn't execute a side effect callback.
3619
+ * Use this when you just need a debounced version of a signal.
3620
+ *
3621
+ * @param source - The source signal to debounce
3622
+ * @param options - Configuration options
3623
+ * @param options.ms - Debounce duration in milliseconds (default: 500)
3624
+ * @param options.injector - Angular injector for the effect context
3625
+ *
3626
+ * @returns A signal that emits the debounced value
3627
+ *
3628
+ * @example
3629
+ * ```typescript
3630
+ * const input = signal('');
3631
+ * const debouncedInput = createDebouncedSignal(input, { ms: 300, injector });
3632
+ *
3633
+ * effect(() => {
3634
+ * console.log('Debounced value:', debouncedInput());
3635
+ * });
3636
+ * ```
3637
+ *
3638
+ * @public
3639
+ */
3640
+ function createDebouncedSignal(source, options) {
3641
+ const { ms = DEFAULT_DEBOUNCE_MS, injector } = options;
3642
+ const source$ = toObservable(source, { injector }).pipe(debounceTime(ms), distinctUntilChanged());
3643
+ return toSignal(source$, { injector });
3644
+ }
3645
+
3646
+ /**
3647
+ * Extracts the trigger from a StateLogicConfig, handling the discriminated union.
3648
+ */
3649
+ function getConfigTrigger(config) {
3650
+ if (!isStateLogicConfig(config)) {
3651
+ return 'onChange';
3652
+ }
3653
+ return config.trigger ?? 'onChange';
3654
+ }
3655
+ /**
3656
+ * Extracts debounceMs from a StateLogicConfig if trigger is 'debounced'.
3657
+ */
3658
+ function getConfigDebounceMs(config) {
3659
+ if (!isStateLogicConfig(config)) {
3660
+ return undefined;
3661
+ }
3662
+ // Type narrowing: debounceMs only exists when trigger is 'debounced'
3663
+ if (config.trigger === 'debounced') {
3664
+ return config.debounceMs;
3665
+ }
3666
+ return undefined;
3667
+ }
3668
+ function applyLogic(config, fieldPath) {
3669
+ const path = fieldPath;
3670
+ const trigger = getConfigTrigger(config);
3671
+ const debounceMs = getConfigDebounceMs(config) ?? DEFAULT_DEBOUNCE_MS;
3672
+ if (typeof config.condition === 'boolean') {
3673
+ applyLogicFn(config.type, path, () => config.condition);
3674
+ return;
3675
+ }
3676
+ // Create appropriate logic function based on trigger
3677
+ const logicFn = trigger === 'debounced'
3678
+ ? createDebouncedLogicFunction(config.condition, debounceMs)
3679
+ : createLogicFunction(config.condition);
3680
+ applyLogicFn(config.type, path, logicFn);
3681
+ }
3682
+ function applyLogicFn(type, path, logicFn) {
3683
+ switch (type) {
3684
+ case 'hidden':
3685
+ hidden(path, logicFn);
3686
+ break;
3687
+ case 'readonly':
3688
+ readonly(path, logicFn);
3689
+ break;
3690
+ case 'disabled':
3691
+ disabled(path, logicFn);
3692
+ break;
3212
3693
  case 'required':
3213
3694
  required(path, { when: logicFn });
3214
3695
  break;
@@ -3821,6 +4302,69 @@ function isDisplayOnlyField(field) {
3821
4302
  return field.type === 'text' || isContainerField(field);
3822
4303
  }
3823
4304
 
4305
+ /**
4306
+ * Applies a form-level schema validation to a schema path.
4307
+ * Supports both Standard Schema (Zod, Valibot, ArkType) and raw Angular schema callbacks.
4308
+ *
4309
+ * This is a helper function used internally by `createSchemaFromFields`
4310
+ * to apply form-level validation after field-level validation.
4311
+ *
4312
+ * @typeParam TModel - The form value type
4313
+ * @param path - The schema path to validate (from Angular's schema callback)
4314
+ * @param formLevelSchema - Form-level schema (Standard Schema marker or Angular callback)
4315
+ *
4316
+ * @remarks
4317
+ * Type assertions are required due to Angular's complex generic constraints:
4318
+ * - `validateStandardSchema` uses `IgnoreUnknownProperties<TSchema>` to accommodate Zod's strict types
4319
+ * - `SchemaPathTree` includes `SchemaPath` via conditional types that TypeScript can't narrow
4320
+ * These assertions are safe because the path originates from Angular's schema() function.
4321
+ *
4322
+ * @internal
4323
+ */
4324
+ function applyFormLevelSchema(path, formLevelSchema) {
4325
+ if (isStandardSchemaMarker(formLevelSchema)) {
4326
+ // Standard Schema (Zod, Valibot, ArkType, etc.)
4327
+ // Cast via Parameters: validateStandardSchema's IgnoreUnknownProperties<TSchema> creates type incompatibility
4328
+ // that TypeScript cannot resolve. The cast is safe as path comes from Angular's schema() callback.
4329
+ validateStandardSchema(path, formLevelSchema.schema);
4330
+ }
4331
+ else if (typeof formLevelSchema === 'function') {
4332
+ // Angular schema callback - execute directly with the path
4333
+ // Cast via Function: AngularSchemaCallback expects intersection type SchemaPath & SchemaPathTree.
4334
+ // SchemaPathTree structurally includes SchemaPath, so this is safe at runtime.
4335
+ formLevelSchema(path);
4336
+ }
4337
+ }
4338
+ /**
4339
+ * Creates a schema from only form-level schema (when no field-level schema exists).
4340
+ *
4341
+ * Use this when the form has fields without validation rules but still
4342
+ * needs form-level validation via a Standard Schema.
4343
+ *
4344
+ * @typeParam TModel - The form value type
4345
+ * @param formLevelSchema - Form-level Standard Schema
4346
+ * @returns Schema that applies form-level validation
4347
+ *
4348
+ * @example
4349
+ * ```typescript
4350
+ * import { z } from 'zod';
4351
+ * import { standardSchema } from '@ng-forge/dynamic-forms/schema';
4352
+ *
4353
+ * const FormSchema = z.object({
4354
+ * email: z.string().email(),
4355
+ * password: z.string().min(8),
4356
+ * });
4357
+ *
4358
+ * // Create schema from form-level schema only
4359
+ * const formSchema = createFormLevelSchema(standardSchema(FormSchema));
4360
+ * ```
4361
+ */
4362
+ function createFormLevelSchema(formLevelSchema) {
4363
+ return schema((path) => {
4364
+ applyFormLevelSchema(path, formLevelSchema);
4365
+ });
4366
+ }
4367
+
3824
4368
  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3825
4369
  function getFieldTreeByKey(ctx, key) {
3826
4370
  return ctx.fieldTree[key];
@@ -3837,13 +4381,18 @@ function getFieldTreeByKey(ctx, key) {
3837
4381
  *
3838
4382
  * @param fields Field definitions to create schema from
3839
4383
  * @param registry Field type registry
3840
- * @param crossFieldValidators Optional array of collected cross-field validators
4384
+ * @param optionsOrValidators Optional configuration object or array of cross-field validators (for backwards compatibility)
3841
4385
  */
3842
- function createSchemaFromFields(fields, registry, crossFieldValidators) {
4386
+ function createSchemaFromFields(fields, registry, optionsOrValidators) {
3843
4387
  // Inject services for cross-field validation
3844
4388
  // These will be available because createSchemaFromFields is called within runInInjectionContext
3845
4389
  const functionRegistry = inject(FunctionRegistryService);
3846
4390
  const logger = inject(DynamicFormLogger);
4391
+ // Normalize options - support both old array signature and new options object
4392
+ const options = Array.isArray(optionsOrValidators)
4393
+ ? { crossFieldValidators: optionsOrValidators }
4394
+ : (optionsOrValidators ?? {});
4395
+ const { crossFieldValidators, formLevelSchema } = options;
3847
4396
  return schema((path) => {
3848
4397
  for (const fieldDef of fields) {
3849
4398
  const valueHandling = getFieldValueHandling(fieldDef.type, registry);
@@ -3877,6 +4426,10 @@ function createSchemaFromFields(fields, registry, crossFieldValidators) {
3877
4426
  if (crossFieldValidators && crossFieldValidators.length > 0) {
3878
4427
  applyCrossFieldTreeValidator(path, crossFieldValidators, functionRegistry, logger);
3879
4428
  }
4429
+ // Apply form-level Standard Schema validation
4430
+ if (formLevelSchema) {
4431
+ applyFormLevelSchema(path, formLevelSchema);
4432
+ }
3880
4433
  });
3881
4434
  }
3882
4435
  /**
@@ -4617,19 +5170,19 @@ function createEmptyCollection() {
4617
5170
  /** Traverses field definitions and collects cross-field entries (validators, logic, schemas). */
4618
5171
  function collectCrossFieldEntries(fields) {
4619
5172
  const collection = createEmptyCollection();
4620
- traverseFields(fields, collection);
5173
+ traverseFields$1(fields, collection);
4621
5174
  return collection;
4622
5175
  }
4623
- function traverseFields(fields, collection) {
5176
+ function traverseFields$1(fields, collection) {
4624
5177
  for (const field of fields) {
4625
- collectFromField(field, collection);
5178
+ collectFromField$1(field, collection);
4626
5179
  // Recursively process container fields (page, row, group, array)
4627
5180
  if (hasChildFields(field)) {
4628
- traverseFields(normalizeFieldsArray(field.fields), collection);
5181
+ traverseFields$1(normalizeFieldsArray(field.fields), collection);
4629
5182
  }
4630
5183
  }
4631
5184
  }
4632
- function collectFromField(field, collection) {
5185
+ function collectFromField$1(field, collection) {
4633
5186
  const fieldKey = field.key;
4634
5187
  if (!fieldKey)
4635
5188
  return;
@@ -4700,9 +5253,19 @@ function tryCreateValidatorEntry(fieldKey, config) {
4700
5253
  }
4701
5254
  return null;
4702
5255
  }
4703
- /** Returns a logic entry if cross-field, null otherwise. */
5256
+ /**
5257
+ * Returns a logic entry if cross-field state logic, null otherwise.
5258
+ *
5259
+ * Note: Derivation logic is handled separately by the derivation system
5260
+ * and is not collected here.
5261
+ */
4704
5262
  function tryCreateLogicEntry(fieldKey, config) {
4705
- if (!isCrossFieldLogic(config)) {
5263
+ // Only process state logic (hidden, readonly, disabled, required)
5264
+ // Derivation logic is handled by the derivation collector
5265
+ if (!isStateLogicConfig(config)) {
5266
+ return null;
5267
+ }
5268
+ if (!isCrossFieldStateLogic(config)) {
4706
5269
  return null;
4707
5270
  }
4708
5271
  const condition = config.condition;
@@ -4710,7 +5273,7 @@ function tryCreateLogicEntry(fieldKey, config) {
4710
5273
  sourceFieldKey: fieldKey,
4711
5274
  logicType: config.type,
4712
5275
  condition,
4713
- config,
5276
+ config: config,
4714
5277
  dependsOn: extractExpressionDependencies(condition),
4715
5278
  category: 'logic',
4716
5279
  };
@@ -4891,149 +5454,2542 @@ function isValidFormConfiguration(fields) {
4891
5454
  return FormModeValidator.validateFormConfiguration(fields).isValid;
4892
5455
  }
4893
5456
 
4894
- class NextPageEvent {
4895
- type = 'next-page';
4896
- }
5457
+ /**
5458
+ * Centralized constants for the derivation system.
5459
+ *
5460
+ * These constants configure the behavior of derivation processing,
5461
+ * caching, and path handling. Centralizing them enables:
5462
+ * - Easy configuration changes
5463
+ * - Consistent values across all derivation modules
5464
+ * - Clear documentation of magic numbers
5465
+ *
5466
+ * @module
5467
+ */
5468
+ /**
5469
+ * Maximum number of derivation iterations before stopping to prevent infinite loops.
5470
+ *
5471
+ * The value of 10 is chosen based on:
5472
+ * - Most derivation chains are 2-3 levels deep (A→B→C)
5473
+ * - Complex forms with conditional cascades rarely exceed 5-6 levels
5474
+ * - 10 provides headroom for bidirectional sync patterns (A↔B) which may need
5475
+ * 2 iterations per pair to stabilize
5476
+ * - Higher values delay detection of actual infinite loops
5477
+ * - Lower values risk false positives on legitimate deep chains
5478
+ *
5479
+ * If you hit this limit legitimately, consider:
5480
+ * 1. Restructuring derivations to reduce chain depth
5481
+ * 2. Using explicit `dependsOn` to control evaluation order
5482
+ * 3. Breaking complex derivations into computed signals outside the form
5483
+ */
5484
+ const MAX_DERIVATION_ITERATIONS = 10;
5485
+ /**
5486
+ * Placeholder string for array index in derivation paths.
5487
+ *
5488
+ * Used in paths like `items.$.quantity` to represent "each item in the array".
5489
+ * The `$` is resolved to actual indices at runtime during array derivation processing.
5490
+ */
5491
+ const ARRAY_PLACEHOLDER = '.$.';
5492
+ /**
5493
+ * Maximum size of the expression AST cache.
5494
+ *
5495
+ * Expressions are parsed into Abstract Syntax Trees (AST) once and cached
5496
+ * to avoid re-parsing on each evaluation. The cache uses LRU eviction.
5497
+ *
5498
+ * 1000 entries handles:
5499
+ * - Large forms with many derivation expressions
5500
+ * - Multiple form instances with different expressions
5501
+ * - Memory efficiency (AST nodes are relatively small)
5502
+ */
5503
+ const MAX_AST_CACHE_SIZE = 1000;
5504
+ /**
5505
+ * Delimiter used to create unique derivation keys from source and target field keys.
5506
+ *
5507
+ * Uses null character (\x00) which is extremely unlikely to appear in field names,
5508
+ * avoiding collision issues that could occur with common delimiters like ':'.
5509
+ */
5510
+ const DERIVATION_KEY_DELIMITER = '\x00';
4897
5511
 
4898
- class PageChangeEvent {
4899
- currentPageIndex;
4900
- totalPages;
4901
- previousPageIndex;
4902
- type = 'page-change';
4903
- constructor(
4904
- /** The current page index (0-based) */
4905
- currentPageIndex,
4906
- /** Total number of pages */
4907
- totalPages,
4908
- /** Previous page index (0-based), undefined if first navigation */
4909
- previousPageIndex) {
4910
- this.currentPageIndex = currentPageIndex;
4911
- this.totalPages = totalPages;
4912
- this.previousPageIndex = previousPageIndex;
4913
- }
5512
+ /**
5513
+ * Creates an empty derivation collection.
5514
+ *
5515
+ * @returns Empty collection ready for population
5516
+ *
5517
+ * @internal
5518
+ */
5519
+ function createEmptyDerivationCollection() {
5520
+ return { entries: [] };
4914
5521
  }
4915
-
4916
- class PreviousPageEvent {
4917
- type = 'previous-page';
5522
+ /**
5523
+ * Creates a new derivation chain context for tracking applied derivations.
5524
+ *
5525
+ * @returns Fresh context for a new derivation cycle
5526
+ *
5527
+ * @internal
5528
+ */
5529
+ function createDerivationChainContext() {
5530
+ return {
5531
+ appliedDerivations: new Set(),
5532
+ iteration: 0,
5533
+ };
4918
5534
  }
4919
-
4920
5535
  /**
4921
- * Event dispatched to add a new item to an array field.
5536
+ * Creates a unique key for a derivation entry.
4922
5537
  *
4923
- * This event is used to dynamically add items to array fields at runtime.
4924
- * The array field component listens for this event and adds a new item
4925
- * based on the provided field template, or uses the array's own template if not provided.
5538
+ * Uses a null character delimiter to avoid collision with field names
5539
+ * that might contain common delimiters like ':'.
4926
5540
  *
4927
- * @example
4928
- * ```typescript
4929
- * // Add item using array's own template
4930
- * eventBus.dispatch(AddArrayItemEvent, 'contacts');
5541
+ * @param sourceKey - The source field key
5542
+ * @param targetKey - The target field key
5543
+ * @returns Unique key for the derivation
4931
5544
  *
4932
- * // Add item with explicit field template
4933
- * const fieldTemplate = { key: 'email', type: 'input' };
4934
- * eventBus.dispatch(AddArrayItemEvent, 'contacts', fieldTemplate);
5545
+ * @internal
5546
+ */
5547
+ function createDerivationKey(sourceKey, targetKey) {
5548
+ return `${sourceKey}${DERIVATION_KEY_DELIMITER}${targetKey}`;
5549
+ }
5550
+ /**
5551
+ * Parses a derivation key back into source and target field keys.
4935
5552
  *
4936
- * // Add item at specific index
4937
- * eventBus.dispatch(AddArrayItemEvent, 'contacts', fieldTemplate, 2);
4938
- * ```
5553
+ * @param key - The derivation key to parse
5554
+ * @returns Object containing source and target field keys
5555
+ *
5556
+ * @internal
4939
5557
  */
4940
- class AddArrayItemEvent {
4941
- arrayKey;
4942
- field;
4943
- index;
4944
- type = 'add-array-item';
4945
- constructor(
4946
- /** The key of the array field to add an item to */
4947
- arrayKey,
4948
- /**
4949
- * Optional field template definition to render for the new array item.
4950
- * If not provided, the array field component will use its own template.
4951
- */
4952
- field,
4953
- /** Optional index where to insert the item (defaults to end of array) */
4954
- index) {
4955
- this.arrayKey = arrayKey;
4956
- this.field = field;
4957
- this.index = index;
4958
- }
5558
+ function parseDerivationKey(key) {
5559
+ const delimiterIndex = key.indexOf(DERIVATION_KEY_DELIMITER);
5560
+ return {
5561
+ sourceKey: key.substring(0, delimiterIndex),
5562
+ targetKey: key.substring(delimiterIndex + DERIVATION_KEY_DELIMITER.length),
5563
+ };
4959
5564
  }
4960
5565
 
4961
5566
  /**
4962
- * Event dispatched to remove an item from an array field.
5567
+ * Injection token for the collected derivation entries.
4963
5568
  *
4964
- * This event is used to dynamically remove items from array fields at runtime.
4965
- * The array field component listens for this event and removes the item
4966
- * at the specified index.
5569
+ * Provided at form level, contains the topologically sorted entries
5570
+ * after validation. This is the "core data" that other derivation
5571
+ * components consume.
4967
5572
  *
4968
5573
  * @example
4969
5574
  * ```typescript
4970
- * // Remove item at index 1 from 'contacts' array
4971
- * eventBus.dispatch(RemoveArrayItemEvent, 'contacts', 1);
5575
+ * // In a component or service
5576
+ * private readonly entries = inject(DERIVATION_ENTRIES);
4972
5577
  *
4973
- * // Remove last item
4974
- * eventBus.dispatch(RemoveArrayItemEvent, 'contacts');
5578
+ * // Access the current entries
5579
+ * const currentEntries = this.entries();
4975
5580
  * ```
4976
5581
  */
4977
- class RemoveArrayItemEvent {
4978
- arrayKey;
4979
- index;
4980
- type = 'remove-array-item';
4981
- constructor(
4982
- /** The key of the array field to remove an item from */
4983
- arrayKey,
4984
- /** Index of the item to remove (defaults to last item) */
4985
- index) {
4986
- this.arrayKey = arrayKey;
4987
- this.index = index;
4988
- }
4989
- }
5582
+ const DERIVATION_ENTRIES = new InjectionToken('DerivationEntries');
4990
5583
 
4991
5584
  /**
4992
- * Event dispatched when the form should be reset to its default values.
5585
+ * Utilities for parsing and manipulating field paths in dynamic forms.
4993
5586
  *
4994
- * This event instructs the dynamic form component to restore all field values
4995
- * to their initial default values as defined in the form configuration.
5587
+ * Field paths follow these formats:
5588
+ * - Simple: "fieldName"
5589
+ * - Nested: "parent.child.grandchild"
5590
+ * - Array indexed: "items.0.quantity" or "items[0].quantity"
5591
+ * - Array placeholder: "items.$.quantity" ($ = any index)
5592
+ *
5593
+ * @module path-utils
5594
+ */
5595
+ /**
5596
+ * Parses an array derivation path into its components.
5597
+ *
5598
+ * Array paths use the `$` placeholder to represent "any index".
5599
+ * Format: "arrayPath.$.relativePath"
5600
+ *
5601
+ * @param path - The path to parse (e.g., "items.$.quantity")
5602
+ * @returns Parsed path info with arrayPath and relativePath
4996
5603
  *
4997
5604
  * @example
4998
5605
  * ```typescript
4999
- * // Dispatch from a button component
5000
- * eventBus.dispatch(FormResetEvent);
5606
+ * parseArrayPath('items.$.quantity')
5607
+ * // { arrayPath: 'items', relativePath: 'quantity', isArrayPath: true }
5608
+ *
5609
+ * parseArrayPath('orders.lineItems.$.total')
5610
+ * // { arrayPath: 'orders.lineItems', relativePath: 'total', isArrayPath: true }
5611
+ *
5612
+ * parseArrayPath('simpleField')
5613
+ * // { arrayPath: '', relativePath: '', isArrayPath: false }
5001
5614
  * ```
5002
5615
  *
5616
+ * @public
5617
+ */
5618
+ function parseArrayPath(path) {
5619
+ const ARRAY_PLACEHOLDER = '.$.';
5620
+ const placeholderIndex = path.indexOf(ARRAY_PLACEHOLDER);
5621
+ if (placeholderIndex === -1) {
5622
+ return {
5623
+ arrayPath: '',
5624
+ relativePath: '',
5625
+ isArrayPath: false,
5626
+ };
5627
+ }
5628
+ return {
5629
+ arrayPath: path.substring(0, placeholderIndex),
5630
+ relativePath: path.substring(placeholderIndex + ARRAY_PLACEHOLDER.length),
5631
+ isArrayPath: true,
5632
+ };
5633
+ }
5634
+ /**
5635
+ * Resolves an array placeholder path to a concrete indexed path.
5636
+ *
5637
+ * @param path - The path with $ placeholder (e.g., "items.$.quantity")
5638
+ * @param index - The array index to substitute
5639
+ * @returns Resolved path with index (e.g., "items.0.quantity")
5640
+ *
5003
5641
  * @example
5004
5642
  * ```typescript
5005
- * // Listen for reset events
5006
- * eventBus.on<FormResetEvent>('form-reset').subscribe(() => {
5007
- * console.log('Form was reset to defaults');
5008
- * });
5643
+ * resolveArrayPath('items.$.quantity', 2)
5644
+ * // 'items.2.quantity'
5645
+ *
5646
+ * resolveArrayPath('orders.lineItems.$.total', 0)
5647
+ * // 'orders.lineItems.0.total'
5009
5648
  * ```
5649
+ *
5650
+ * @public
5010
5651
  */
5011
- class FormResetEvent {
5012
- type = 'form-reset';
5652
+ function resolveArrayPath(path, index) {
5653
+ const info = parseArrayPath(path);
5654
+ if (!info.isArrayPath) {
5655
+ return path;
5656
+ }
5657
+ return `${info.arrayPath}.${index}.${info.relativePath}`;
5013
5658
  }
5014
-
5015
5659
  /**
5016
- * Event dispatched when the form should be cleared.
5660
+ * Checks if a path contains an array placeholder.
5017
5661
  *
5018
- * This event instructs the dynamic form component to clear all field values,
5019
- * resetting them to empty/undefined state regardless of their default values.
5662
+ * @param path - The path to check
5663
+ * @returns True if the path contains .$.
5664
+ *
5665
+ * @public
5666
+ */
5667
+ function isArrayPlaceholderPath(path) {
5668
+ return path.includes('.$.');
5669
+ }
5670
+ /**
5671
+ * Extracts the array path from a path that may contain an array placeholder.
5672
+ *
5673
+ * @param path - The path to extract from
5674
+ * @returns The array path, or empty string if not an array path
5020
5675
  *
5021
5676
  * @example
5022
5677
  * ```typescript
5023
- * // Dispatch from a button component
5024
- * eventBus.dispatch(FormClearEvent);
5678
+ * extractArrayPath('items.$.quantity')
5679
+ * // 'items'
5680
+ *
5681
+ * extractArrayPath('orders.lineItems.$.total')
5682
+ * // 'orders.lineItems'
5683
+ *
5684
+ * extractArrayPath('simpleField')
5685
+ * // ''
5025
5686
  * ```
5026
5687
  *
5688
+ * @public
5689
+ */
5690
+ function extractArrayPath(path) {
5691
+ return parseArrayPath(path).arrayPath;
5692
+ }
5693
+ /**
5694
+ * Splits a dot-separated path into segments.
5695
+ *
5696
+ * Handles both dot notation and bracket notation for array indices.
5697
+ *
5698
+ * @param path - The path to split
5699
+ * @returns Array of path segments
5700
+ *
5027
5701
  * @example
5028
5702
  * ```typescript
5029
- * // Listen for clear events
5030
- * eventBus.on<FormClearEvent>('form-clear').subscribe(() => {
5031
- * console.log('Form was cleared');
5032
- * });
5703
+ * splitPath('parent.child.grandchild')
5704
+ * // ['parent', 'child', 'grandchild']
5705
+ *
5706
+ * splitPath('items.0.quantity')
5707
+ * // ['items', '0', 'quantity']
5033
5708
  * ```
5709
+ *
5710
+ * @public
5034
5711
  */
5035
- class FormClearEvent {
5036
- type = 'form-clear';
5712
+ function splitPath(path) {
5713
+ if (!path)
5714
+ return [];
5715
+ return path.split('.');
5716
+ }
5717
+ /**
5718
+ * Joins path segments into a dot-separated path.
5719
+ *
5720
+ * @param segments - The path segments to join
5721
+ * @returns Joined path string
5722
+ *
5723
+ * @public
5724
+ */
5725
+ function joinPath(segments) {
5726
+ return segments.join('.');
5727
+ }
5728
+ /**
5729
+ * Gets the parent path from a nested path.
5730
+ *
5731
+ * @param path - The path to get parent from
5732
+ * @returns The parent path, or empty string if no parent
5733
+ *
5734
+ * @example
5735
+ * ```typescript
5736
+ * getParentPath('parent.child.grandchild')
5737
+ * // 'parent.child'
5738
+ *
5739
+ * getParentPath('topLevel')
5740
+ * // ''
5741
+ * ```
5742
+ *
5743
+ * @public
5744
+ */
5745
+ function getParentPath(path) {
5746
+ const segments = splitPath(path);
5747
+ if (segments.length <= 1)
5748
+ return '';
5749
+ return joinPath(segments.slice(0, -1));
5750
+ }
5751
+ /**
5752
+ * Gets the last segment (leaf) from a path.
5753
+ *
5754
+ * @param path - The path to get leaf from
5755
+ * @returns The last segment
5756
+ *
5757
+ * @example
5758
+ * ```typescript
5759
+ * getLeafPath('parent.child.grandchild')
5760
+ * // 'grandchild'
5761
+ *
5762
+ * getLeafPath('topLevel')
5763
+ * // 'topLevel'
5764
+ * ```
5765
+ *
5766
+ * @public
5767
+ */
5768
+ function getLeafPath(path) {
5769
+ const segments = splitPath(path);
5770
+ return segments[segments.length - 1] ?? '';
5771
+ }
5772
+ /**
5773
+ * Parses a path that may contain multiple array placeholders.
5774
+ *
5775
+ * This supports deeply nested array structures like `orders.$.items.$.quantity`
5776
+ * where multiple levels of arrays need to be traversed.
5777
+ *
5778
+ * @param path - Path with zero or more $ placeholders
5779
+ * @returns Parsed path information
5780
+ *
5781
+ * @example
5782
+ * ```typescript
5783
+ * parseMultiArrayPath('orders.$.items.$.quantity')
5784
+ * // {
5785
+ * // isArrayPath: true,
5786
+ * // placeholderCount: 2,
5787
+ * // segments: ['orders', 'items', 'quantity'],
5788
+ * // placeholderPositions: [1, 3]
5789
+ * // }
5790
+ *
5791
+ * parseMultiArrayPath('items.$.name')
5792
+ * // {
5793
+ * // isArrayPath: true,
5794
+ * // placeholderCount: 1,
5795
+ * // segments: ['items', 'name'],
5796
+ * // placeholderPositions: [1]
5797
+ * // }
5798
+ *
5799
+ * parseMultiArrayPath('simpleField')
5800
+ * // {
5801
+ * // isArrayPath: false,
5802
+ * // placeholderCount: 0,
5803
+ * // segments: ['simpleField'],
5804
+ * // placeholderPositions: []
5805
+ * // }
5806
+ * ```
5807
+ *
5808
+ * @public
5809
+ */
5810
+ function parseMultiArrayPath(path) {
5811
+ const parts = path.split('.');
5812
+ const segments = [];
5813
+ const placeholderPositions = [];
5814
+ for (let i = 0; i < parts.length; i++) {
5815
+ if (parts[i] === '$') {
5816
+ placeholderPositions.push(i);
5817
+ }
5818
+ else {
5819
+ segments.push(parts[i]);
5820
+ }
5821
+ }
5822
+ return {
5823
+ isArrayPath: placeholderPositions.length > 0,
5824
+ placeholderCount: placeholderPositions.length,
5825
+ segments,
5826
+ placeholderPositions,
5827
+ };
5828
+ }
5829
+ /**
5830
+ * Resolves a path with multiple array placeholders using provided indices.
5831
+ *
5832
+ * Each `$` placeholder is replaced with the corresponding index from the
5833
+ * indices array, in order.
5834
+ *
5835
+ * @param path - Path with $ placeholders (e.g., 'orders.$.items.$.quantity')
5836
+ * @param indices - Array of indices to substitute (e.g., [0, 2])
5837
+ * @returns Resolved path (e.g., 'orders.0.items.2.quantity')
5838
+ *
5839
+ * @throws Error if number of indices doesn't match number of placeholders
5840
+ *
5841
+ * @example
5842
+ * ```typescript
5843
+ * resolveMultiArrayPath('orders.$.items.$.quantity', [0, 2])
5844
+ * // 'orders.0.items.2.quantity'
5845
+ *
5846
+ * resolveMultiArrayPath('items.$.name', [5])
5847
+ * // 'items.5.name'
5848
+ * ```
5849
+ *
5850
+ * @public
5851
+ */
5852
+ function resolveMultiArrayPath(path, indices) {
5853
+ const parts = path.split('.');
5854
+ let indexPointer = 0;
5855
+ const resolved = parts.map((part) => {
5856
+ if (part === '$') {
5857
+ if (indexPointer >= indices.length) {
5858
+ throw new Error(`Not enough indices provided for path '${path}'. ` +
5859
+ `Expected ${parts.filter((p) => p === '$').length} indices, got ${indices.length}.`);
5860
+ }
5861
+ return String(indices[indexPointer++]);
5862
+ }
5863
+ return part;
5864
+ });
5865
+ if (indexPointer < indices.length) {
5866
+ throw new Error(`Too many indices provided for path '${path}'. ` + `Expected ${indexPointer} indices, got ${indices.length}.`);
5867
+ }
5868
+ return resolved.join('.');
5869
+ }
5870
+ /**
5871
+ * Counts the number of array placeholders in a path.
5872
+ *
5873
+ * @param path - The path to analyze
5874
+ * @returns Number of $ placeholders in the path
5875
+ *
5876
+ * @example
5877
+ * ```typescript
5878
+ * countArrayPlaceholders('orders.$.items.$.quantity')
5879
+ * // 2
5880
+ *
5881
+ * countArrayPlaceholders('items.$.name')
5882
+ * // 1
5883
+ *
5884
+ * countArrayPlaceholders('simpleField')
5885
+ * // 0
5886
+ * ```
5887
+ *
5888
+ * @public
5889
+ */
5890
+ function countArrayPlaceholders(path) {
5891
+ return path.split('.').filter((part) => part === '$').length;
5892
+ }
5893
+
5894
+ /**
5895
+ * Provides lazy-computed lookup maps for derivation entries.
5896
+ *
5897
+ * Instead of pre-computing all lookup maps at collection time (which caused
5898
+ * hidden mutation and bloated the DerivationCollection god object), this class
5899
+ * computes maps lazily on first access and caches them.
5900
+ *
5901
+ * @example
5902
+ * ```typescript
5903
+ * const lookup = inject(DerivationLookup);
5904
+ *
5905
+ * // Lazy-computed on first access
5906
+ * const entriesForQuantity = lookup.byDependency.get('quantity');
5907
+ * const onChangeEntries = lookup.getOnChangeEntries();
5908
+ * ```
5909
+ */
5910
+ class DerivationLookup {
5911
+ entriesSignal = inject(DERIVATION_ENTRIES);
5912
+ // Track the entries array reference to auto-invalidate on change
5913
+ _cachedEntriesRef;
5914
+ // Cached lookup maps (built lazily on first access)
5915
+ _byTarget;
5916
+ _bySource;
5917
+ _byDependency;
5918
+ _byArrayPath;
5919
+ _wildcardEntries;
5920
+ _onChangeEntries;
5921
+ _debouncedEntriesByMs;
5922
+ /**
5923
+ * Current derivation entries (from signal).
5924
+ * Auto-invalidates cache if entries reference changed.
5925
+ */
5926
+ get entries() {
5927
+ const currentEntries = this.entriesSignal();
5928
+ // Auto-invalidate if entries reference changed (schema was updated)
5929
+ if (this._cachedEntriesRef !== currentEntries) {
5930
+ this.invalidateCache();
5931
+ this._cachedEntriesRef = currentEntries;
5932
+ }
5933
+ return currentEntries;
5934
+ }
5935
+ /**
5936
+ * Map of target field key to entries that modify it.
5937
+ * Used for quick lookup when processing derivations.
5938
+ */
5939
+ get byTarget() {
5940
+ return (this._byTarget ??= this.buildByTargetMap());
5941
+ }
5942
+ /**
5943
+ * Map of source field key to entries defined on it.
5944
+ * Used for finding derivations when a field's value changes.
5945
+ */
5946
+ get bySource() {
5947
+ return (this._bySource ??= this.buildBySourceMap());
5948
+ }
5949
+ /**
5950
+ * Map of dependency field key to entries that depend on it.
5951
+ * Used for efficient O(1) lookup when filtering by changed fields.
5952
+ */
5953
+ get byDependency() {
5954
+ return (this._byDependency ??= this.buildByDependencyMap());
5955
+ }
5956
+ /**
5957
+ * Map of array path to entries that derive array item values.
5958
+ * Used for O(1) lookup when an array field changes.
5959
+ */
5960
+ get byArrayPath() {
5961
+ return (this._byArrayPath ??= this.buildByArrayPathMap());
5962
+ }
5963
+ /**
5964
+ * Entries that have wildcard (*) dependency.
5965
+ * These entries depend on all form values and must always be considered.
5966
+ */
5967
+ get wildcardEntries() {
5968
+ return (this._wildcardEntries ??= this.buildWildcardEntries());
5969
+ }
5970
+ /**
5971
+ * Gets entries that should be processed based on changed fields.
5972
+ *
5973
+ * Handles nested path matching: if 'address' changed, entries depending on
5974
+ * 'address.city' will also be included (parent change implies child changed).
5975
+ *
5976
+ * Uses O(k) lookup via indexed maps for exact matches, plus O(d) scan for
5977
+ * nested dependencies, where k = changed fields, d = unique dependencies.
5978
+ */
5979
+ getEntriesForChangedFields(changedFields) {
5980
+ const entrySet = new Set();
5981
+ // Add entries triggered by each changed field via byDependency map
5982
+ for (const fieldKey of changedFields) {
5983
+ const entries = this.byDependency.get(fieldKey);
5984
+ if (entries) {
5985
+ for (const entry of entries) {
5986
+ entrySet.add(entry);
5987
+ }
5988
+ }
5989
+ // Check if this changed field is an array path - O(1) lookup
5990
+ const arrayEntries = this.byArrayPath.get(fieldKey);
5991
+ if (arrayEntries) {
5992
+ for (const entry of arrayEntries) {
5993
+ entrySet.add(entry);
5994
+ }
5995
+ }
5996
+ // Handle nested dependencies: if 'address' changed, include entries
5997
+ // that depend on 'address.city', 'address.street', etc.
5998
+ // This is O(d) where d = number of unique dependency keys in the map.
5999
+ const fieldKeyPrefix = fieldKey + '.';
6000
+ for (const [depKey, depEntries] of this.byDependency) {
6001
+ if (depKey.startsWith(fieldKeyPrefix)) {
6002
+ for (const entry of depEntries) {
6003
+ entrySet.add(entry);
6004
+ }
6005
+ }
6006
+ }
6007
+ }
6008
+ // Add all wildcard entries - O(w) where w = number of wildcard entries
6009
+ for (const entry of this.wildcardEntries) {
6010
+ entrySet.add(entry);
6011
+ }
6012
+ return Array.from(entrySet);
6013
+ }
6014
+ /**
6015
+ * Gets entries with trigger 'onChange' (or no trigger, which defaults to onChange).
6016
+ */
6017
+ getOnChangeEntries() {
6018
+ return (this._onChangeEntries ??= this.entries.filter((e) => e.trigger !== 'debounced'));
6019
+ }
6020
+ /**
6021
+ * Gets entries with trigger 'debounced' for a specific debounce period.
6022
+ */
6023
+ getDebouncedEntries(debounceMs) {
6024
+ if (!this._debouncedEntriesByMs) {
6025
+ this._debouncedEntriesByMs = this.buildDebouncedEntriesMap();
6026
+ }
6027
+ return this._debouncedEntriesByMs.get(debounceMs) ?? [];
6028
+ }
6029
+ /**
6030
+ * Gets all unique debounce periods that have entries.
6031
+ */
6032
+ getDebouncePeriods() {
6033
+ if (!this._debouncedEntriesByMs) {
6034
+ this._debouncedEntriesByMs = this.buildDebouncedEntriesMap();
6035
+ }
6036
+ return Array.from(this._debouncedEntriesByMs.keys());
6037
+ }
6038
+ /**
6039
+ * Invalidates cached maps. Call this when entries change.
6040
+ */
6041
+ invalidateCache() {
6042
+ this._byTarget = undefined;
6043
+ this._bySource = undefined;
6044
+ this._byDependency = undefined;
6045
+ this._byArrayPath = undefined;
6046
+ this._wildcardEntries = undefined;
6047
+ this._onChangeEntries = undefined;
6048
+ this._debouncedEntriesByMs = undefined;
6049
+ }
6050
+ // ─────────────────────────────────────────────────────────────────
6051
+ // Private builders
6052
+ // ─────────────────────────────────────────────────────────────────
6053
+ buildByTargetMap() {
6054
+ const map = new Map();
6055
+ for (const entry of this.entries) {
6056
+ const targetEntries = map.get(entry.targetFieldKey) ?? [];
6057
+ targetEntries.push(entry);
6058
+ map.set(entry.targetFieldKey, targetEntries);
6059
+ }
6060
+ return map;
6061
+ }
6062
+ buildBySourceMap() {
6063
+ const map = new Map();
6064
+ for (const entry of this.entries) {
6065
+ const sourceEntries = map.get(entry.sourceFieldKey) ?? [];
6066
+ sourceEntries.push(entry);
6067
+ map.set(entry.sourceFieldKey, sourceEntries);
6068
+ }
6069
+ return map;
6070
+ }
6071
+ buildByDependencyMap() {
6072
+ const map = new Map();
6073
+ for (const entry of this.entries) {
6074
+ for (const dep of entry.dependsOn) {
6075
+ if (dep !== '*') {
6076
+ const depEntries = map.get(dep) ?? [];
6077
+ depEntries.push(entry);
6078
+ map.set(dep, depEntries);
6079
+ }
6080
+ }
6081
+ }
6082
+ return map;
6083
+ }
6084
+ buildByArrayPathMap() {
6085
+ const map = new Map();
6086
+ for (const entry of this.entries) {
6087
+ if (isArrayPlaceholderPath(entry.targetFieldKey)) {
6088
+ const arrayPath = extractArrayPath(entry.targetFieldKey);
6089
+ if (arrayPath) {
6090
+ const arrayEntries = map.get(arrayPath) ?? [];
6091
+ arrayEntries.push(entry);
6092
+ map.set(arrayPath, arrayEntries);
6093
+ }
6094
+ }
6095
+ }
6096
+ return map;
6097
+ }
6098
+ buildWildcardEntries() {
6099
+ return this.entries.filter((entry) => entry.dependsOn.includes('*'));
6100
+ }
6101
+ buildDebouncedEntriesMap() {
6102
+ const map = new Map();
6103
+ for (const entry of this.entries) {
6104
+ if (entry.trigger === 'debounced') {
6105
+ const ms = entry.debounceMs ?? DEFAULT_DEBOUNCE_MS;
6106
+ const group = map.get(ms) ?? [];
6107
+ group.push(entry);
6108
+ map.set(ms, group);
6109
+ }
6110
+ }
6111
+ return map;
6112
+ }
6113
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: DerivationLookup, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
6114
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: DerivationLookup });
6115
+ }
6116
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: DerivationLookup, decorators: [{
6117
+ type: Injectable
6118
+ }] });
6119
+
6120
+ /**
6121
+ * Sorts derivation entries in topological order based on their dependencies.
6122
+ *
6123
+ * This ensures that derivations are processed in the correct order:
6124
+ * if derivation B depends on field A, and derivation A->A' modifies A,
6125
+ * then A->A' is processed before B.
6126
+ *
6127
+ * Uses Kahn's algorithm for topological sorting, which also provides
6128
+ * natural handling of entries with no dependencies (they come first).
6129
+ *
6130
+ * @param entries - The derivation entries to sort
6131
+ * @returns A new array of entries in topological order
6132
+ *
6133
+ * @example
6134
+ * ```typescript
6135
+ * // Given derivations:
6136
+ * // 1. quantity -> lineTotal (depends on quantity, unitPrice)
6137
+ * // 2. unitPrice -> lineTotal (depends on quantity, unitPrice)
6138
+ * // 3. lineTotal -> grandTotal (depends on lineTotal)
6139
+ * //
6140
+ * // Topological sort ensures lineTotal is computed before grandTotal
6141
+ * const sorted = topologicalSort(entries);
6142
+ * // Result: [1, 2, 3] - derivations that produce lineTotal come before
6143
+ * // those that consume it
6144
+ * ```
6145
+ *
6146
+ * @public
6147
+ */
6148
+ function topologicalSort(entries) {
6149
+ if (entries.length <= 1) {
6150
+ return [...entries];
6151
+ }
6152
+ // Build adjacency list and in-degree count
6153
+ // An edge A -> B exists if B depends on the target of A
6154
+ const graph = new Map();
6155
+ const inDegree = new Map();
6156
+ // Initialize
6157
+ for (const entry of entries) {
6158
+ graph.set(entry, new Set());
6159
+ inDegree.set(entry, 0);
6160
+ }
6161
+ // Pre-build index: target field -> entries that produce it
6162
+ // This enables O(1) lookup instead of O(n) scanning
6163
+ const producersByTarget = new Map();
6164
+ for (const entry of entries) {
6165
+ // Index by exact target field
6166
+ const producers = producersByTarget.get(entry.targetFieldKey) ?? [];
6167
+ producers.push(entry);
6168
+ producersByTarget.set(entry.targetFieldKey, producers);
6169
+ // Also index array field relative paths for array derivations
6170
+ // If entry targets 'items.$.lineTotal', index under 'lineTotal' too
6171
+ if (entry.targetFieldKey.includes('.$.')) {
6172
+ const relativePath = entry.targetFieldKey.split('.$.')[1];
6173
+ if (relativePath) {
6174
+ const relativeProducers = producersByTarget.get(relativePath) ?? [];
6175
+ relativeProducers.push(entry);
6176
+ producersByTarget.set(relativePath, relativeProducers);
6177
+ }
6178
+ }
6179
+ }
6180
+ // Collect entries with wildcard dependencies (need special handling)
6181
+ const wildcardDependents = [];
6182
+ for (const entry of entries) {
6183
+ if (entry.dependsOn.includes('*')) {
6184
+ wildcardDependents.push(entry);
6185
+ }
6186
+ }
6187
+ // Build edges using pre-computed index
6188
+ // For each entry B, find all entries A whose target is in B's dependencies
6189
+ for (const entryB of entries) {
6190
+ for (const dep of entryB.dependsOn) {
6191
+ // Skip wildcards here - handled separately below
6192
+ if (dep === '*')
6193
+ continue;
6194
+ // O(1) lookup: find all producers of this dependency
6195
+ const producers = producersByTarget.get(dep);
6196
+ if (producers) {
6197
+ for (const entryA of producers) {
6198
+ const entryAEdges = graph.get(entryA);
6199
+ if (entryA !== entryB && entryAEdges && !entryAEdges.has(entryB)) {
6200
+ entryAEdges.add(entryB);
6201
+ inDegree.set(entryB, (inDegree.get(entryB) ?? 0) + 1);
6202
+ }
6203
+ }
6204
+ }
6205
+ }
6206
+ }
6207
+ // Handle wildcard dependencies: they depend on ALL producers
6208
+ for (const entryB of wildcardDependents) {
6209
+ for (const entryA of entries) {
6210
+ const entryAEdges = graph.get(entryA);
6211
+ if (entryA !== entryB && entryAEdges && !entryAEdges.has(entryB)) {
6212
+ entryAEdges.add(entryB);
6213
+ inDegree.set(entryB, (inDegree.get(entryB) ?? 0) + 1);
6214
+ }
6215
+ }
6216
+ }
6217
+ // Kahn's algorithm: process nodes with in-degree 0
6218
+ const queue = [];
6219
+ const sorted = [];
6220
+ // Start with entries that have no dependencies on other derivation outputs
6221
+ for (const entry of entries) {
6222
+ if (inDegree.get(entry) === 0) {
6223
+ queue.push(entry);
6224
+ }
6225
+ }
6226
+ while (queue.length > 0) {
6227
+ const current = queue.shift();
6228
+ if (!current)
6229
+ break; // TypeScript guard (shouldn't happen due to while condition)
6230
+ sorted.push(current);
6231
+ // Reduce in-degree for all neighbors
6232
+ const neighbors = graph.get(current) ?? new Set();
6233
+ for (const neighbor of neighbors) {
6234
+ const newDegree = (inDegree.get(neighbor) ?? 0) - 1;
6235
+ inDegree.set(neighbor, newDegree);
6236
+ if (newDegree === 0) {
6237
+ queue.push(neighbor);
6238
+ }
6239
+ }
6240
+ }
6241
+ // If we couldn't sort all entries, there's a cycle
6242
+ // (but cycles should be caught by cycle-detector, so this is defensive)
6243
+ if (sorted.length !== entries.length) {
6244
+ // Return original order for entries in cycles
6245
+ const sortedSet = new Set(sorted);
6246
+ for (const entry of entries) {
6247
+ if (!sortedSet.has(entry)) {
6248
+ sorted.push(entry);
6249
+ }
6250
+ }
6251
+ }
6252
+ return sorted;
6253
+ }
6254
+
6255
+ /**
6256
+ * Collects all derivation entries from field definitions.
6257
+ *
6258
+ * This function traverses the field definition tree and extracts:
6259
+ * - Shorthand `derivation` properties on fields
6260
+ * - Full `logic` array entries with `type: 'derivation'`
6261
+ *
6262
+ * The collected entries include dependency information for cycle detection
6263
+ * and reactive evaluation. Entries are sorted topologically so derivations
6264
+ * are processed in dependency order.
6265
+ *
6266
+ * Lookup maps (byTarget, byDependency, etc.) are NOT built here - they are
6267
+ * computed lazily via {@link DerivationLookup} to avoid hidden mutation
6268
+ * and reduce the size of the returned collection.
6269
+ *
6270
+ * @param fields - Array of field definitions to traverse
6271
+ * @returns Collection containing sorted derivation entries
6272
+ *
6273
+ * @example
6274
+ * ```typescript
6275
+ * const fields = [
6276
+ * { key: 'quantity', type: 'number' },
6277
+ * { key: 'unitPrice', type: 'number' },
6278
+ * {
6279
+ * key: 'total',
6280
+ * type: 'number',
6281
+ * derivation: 'formValue.quantity * formValue.unitPrice',
6282
+ * },
6283
+ * ];
6284
+ *
6285
+ * const collection = collectDerivations(fields);
6286
+ * // collection.entries has 1 entry for the 'total' field derivation
6287
+ * ```
6288
+ *
6289
+ * @public
6290
+ */
6291
+ function collectDerivations(fields) {
6292
+ const entries = [];
6293
+ const context = {};
6294
+ traverseFields(fields, entries, context);
6295
+ // Sort entries in topological order for efficient processing.
6296
+ // This ensures derivations are processed in dependency order,
6297
+ // reducing the number of iterations needed in the applicator.
6298
+ const sortedEntries = topologicalSort(entries);
6299
+ return { entries: sortedEntries };
6300
+ }
6301
+ /**
6302
+ * Recursively traverses field definitions to collect derivations.
6303
+ *
6304
+ * @internal
6305
+ */
6306
+ function traverseFields(fields, entries, context) {
6307
+ for (const field of fields) {
6308
+ collectFromField(field, entries, context);
6309
+ // Recursively process container fields (page, row, group, array)
6310
+ if (hasChildFields(field)) {
6311
+ const childContext = { ...context };
6312
+ // Update array path context if this is an array field
6313
+ if (field.type === 'array') {
6314
+ childContext.arrayPath = field.key;
6315
+ }
6316
+ traverseFields(normalizeFieldsArray(field.fields), entries, childContext);
6317
+ }
6318
+ }
6319
+ }
6320
+ /**
6321
+ * Collects derivation entries from a single field.
6322
+ *
6323
+ * @internal
6324
+ */
6325
+ function collectFromField(field, entries, context) {
6326
+ const fieldKey = field.key;
6327
+ if (!fieldKey)
6328
+ return;
6329
+ const validationField = field;
6330
+ // Collect shorthand derivation property
6331
+ if (validationField.derivation) {
6332
+ const entry = createShorthandEntry(fieldKey, validationField.derivation);
6333
+ entries.push(entry);
6334
+ }
6335
+ // Collect logic array derivations
6336
+ if (validationField.logic) {
6337
+ for (const logicConfig of validationField.logic) {
6338
+ if (isDerivationLogicConfig(logicConfig)) {
6339
+ const entry = createLogicEntry(fieldKey, logicConfig, context);
6340
+ entries.push(entry);
6341
+ }
6342
+ }
6343
+ }
6344
+ }
6345
+ /**
6346
+ * Creates a derivation entry from the shorthand `derivation` property.
6347
+ *
6348
+ * Shorthand derivations:
6349
+ * - Always target the same field they're defined on
6350
+ * - Always apply (condition defaults to true)
6351
+ * - Always trigger on change
6352
+ *
6353
+ * @internal
6354
+ */
6355
+ function createShorthandEntry(fieldKey, expression) {
6356
+ return {
6357
+ sourceFieldKey: fieldKey,
6358
+ targetFieldKey: fieldKey,
6359
+ dependsOn: extractStringDependencies(expression),
6360
+ condition: true,
6361
+ expression,
6362
+ trigger: 'onChange',
6363
+ isShorthand: true,
6364
+ };
6365
+ }
6366
+ /**
6367
+ * Creates a derivation entry from a full `DerivationLogicConfig`.
6368
+ *
6369
+ * Handles:
6370
+ * - Condition extraction and dependency analysis
6371
+ * - Relative path resolution for array fields
6372
+ * - Multiple value source types (static, expression, function)
6373
+ * - Trigger and debounce configuration
6374
+ *
6375
+ * @internal
6376
+ */
6377
+ function createLogicEntry(fieldKey, config, context) {
6378
+ const targetFieldKey = resolveTargetFieldKey(config.targetField, fieldKey, context);
6379
+ const dependsOn = extractDependencies(config);
6380
+ const condition = config.condition ?? true;
6381
+ const trigger = config.trigger ?? 'onChange';
6382
+ // Extract debounceMs from debounced configs
6383
+ // The type system ensures debounceMs is only present when trigger is 'debounced'
6384
+ const debounceMs = trigger === 'debounced' ? config.debounceMs : undefined;
6385
+ return {
6386
+ sourceFieldKey: fieldKey,
6387
+ targetFieldKey,
6388
+ dependsOn,
6389
+ condition,
6390
+ value: config.value,
6391
+ expression: config.expression,
6392
+ functionName: config.functionName,
6393
+ trigger,
6394
+ debounceMs,
6395
+ isShorthand: false,
6396
+ originalConfig: config,
6397
+ debugName: config.debugName,
6398
+ };
6399
+ }
6400
+ /**
6401
+ * Resolves a target field key, handling relative paths for array fields.
6402
+ *
6403
+ * Relative paths start with '$.' and reference siblings at the same array index.
6404
+ *
6405
+ * @param targetField - The target field specification from config
6406
+ * @param sourceFieldKey - The source field key
6407
+ * @param context - Current collection context with array path info
6408
+ * @returns Resolved target field key
6409
+ *
6410
+ * @example
6411
+ * ```typescript
6412
+ * // Absolute path - returned as-is
6413
+ * resolveTargetFieldKey('phonePrefix', 'country', {})
6414
+ * // Returns: 'phonePrefix'
6415
+ *
6416
+ * // Relative path in array context
6417
+ * resolveTargetFieldKey('$.lineTotal', 'items[0].quantity', { arrayPath: 'items' })
6418
+ * // Returns: 'items.$.lineTotal'
6419
+ * ```
6420
+ *
6421
+ * @internal
6422
+ */
6423
+ function resolveTargetFieldKey(targetField, _sourceFieldKey, context) {
6424
+ // Check for relative path indicator
6425
+ if (targetField.startsWith('$.')) {
6426
+ // Extract the relative field name (after '$.')
6427
+ const relativePath = targetField.substring(2);
6428
+ if (context.arrayPath) {
6429
+ // Replace '$' with the array path for pattern matching
6430
+ return `${context.arrayPath}.$.${relativePath}`;
6431
+ }
6432
+ // Not in array context - keep relative path as-is.
6433
+ // This indicates a configuration error (relative path used outside array).
6434
+ // The runtime applicator will handle this gracefully.
6435
+ return targetField;
6436
+ }
6437
+ // Absolute path - return as-is
6438
+ return targetField;
6439
+ }
6440
+ /**
6441
+ * Extracts all field dependencies from a derivation config.
6442
+ *
6443
+ * Combines dependencies from:
6444
+ * - Explicit `dependsOn` array (if provided, takes precedence)
6445
+ * - Condition expression
6446
+ * - Value expression
6447
+ * - Function name (defaults to '*' if no explicit dependsOn)
6448
+ *
6449
+ * @internal
6450
+ */
6451
+ function extractDependencies(config) {
6452
+ const deps = new Set();
6453
+ // If explicit dependsOn is provided, use it as the primary source
6454
+ // This allows users to override automatic detection and control
6455
+ // when derivations are triggered
6456
+ if (config.dependsOn && config.dependsOn.length > 0) {
6457
+ config.dependsOn.forEach((dep) => deps.add(dep));
6458
+ }
6459
+ else {
6460
+ // Extract from expression (automatic dependency detection)
6461
+ if (config.expression) {
6462
+ const exprDeps = extractStringDependencies(config.expression);
6463
+ exprDeps.forEach((dep) => deps.add(dep));
6464
+ }
6465
+ // Custom functions assume full form access if no explicit dependsOn
6466
+ if (config.functionName) {
6467
+ deps.add('*');
6468
+ }
6469
+ }
6470
+ // Always extract from condition (these are additional runtime guards)
6471
+ if (config.condition && config.condition !== true) {
6472
+ const conditionDeps = extractExpressionDependencies(config.condition);
6473
+ conditionDeps.forEach((dep) => deps.add(dep));
6474
+ }
6475
+ return Array.from(deps);
6476
+ }
6477
+
6478
+ /**
6479
+ * Detects cycles in the derivation dependency graph.
6480
+ *
6481
+ * Uses Kahn's algorithm variant with DFS to detect cycles.
6482
+ * Bidirectional sync patterns (A→B→A) are allowed because they stabilize
6483
+ * via the equality check at runtime.
6484
+ *
6485
+ * @param collection - The collected derivation entries
6486
+ * @returns Result indicating whether a cycle exists and details if found
6487
+ *
6488
+ * @example
6489
+ * ```typescript
6490
+ * const collection = collectDerivations(fields);
6491
+ * const result = detectCycles(collection);
6492
+ *
6493
+ * if (result.hasCycle) {
6494
+ * console.error(`Cycle detected: ${result.cyclePath?.join(' -> ')}`);
6495
+ * throw new Error(result.errorMessage);
6496
+ * }
6497
+ * ```
6498
+ *
6499
+ * @public
6500
+ */
6501
+ function detectCycles(collection) {
6502
+ // Build directed graph from derivation entries
6503
+ const graph = buildDependencyGraph(collection);
6504
+ // No entries means no cycles
6505
+ if (graph.size === 0) {
6506
+ return { hasCycle: false };
6507
+ }
6508
+ // Build bidirectional pairs set for cycle exemption
6509
+ const bidirectionalPairs = detectBidirectionalPairs(collection);
6510
+ // Run DFS to detect cycles
6511
+ const result = detectCyclesWithDFS(graph, bidirectionalPairs);
6512
+ // Include bidirectional pairs in result for warnings
6513
+ if (bidirectionalPairs.size > 0) {
6514
+ result.bidirectionalPairs = Array.from(bidirectionalPairs);
6515
+ }
6516
+ return result;
6517
+ }
6518
+ /**
6519
+ * Detects bidirectional derivation pairs (A→B and B→A).
6520
+ *
6521
+ * These patterns are allowed because they stabilize via equality checks.
6522
+ * Example: USD/EUR conversion where both fields derive from each other.
6523
+ *
6524
+ * ## Floating-Point Precision Note
6525
+ *
6526
+ * Bidirectional derivations stabilize via equality checks using exact IEEE 754
6527
+ * comparison with no tolerance. This means:
6528
+ *
6529
+ * - **Integer math**: Safe (e.g., `A = B * 2`, `B = A / 2` where A is even)
6530
+ * - **Floating-point math**: May oscillate due to rounding errors
6531
+ *
6532
+ * For currency conversions or other floating-point operations, consider:
6533
+ * 1. Rounding values in your expression (e.g., `Math.round(value * 100) / 100`)
6534
+ * 2. Using integer cents instead of decimal dollars
6535
+ * 3. Using one-way derivation instead of bidirectional
6536
+ *
6537
+ * @example
6538
+ * ```typescript
6539
+ * // Safe - uses rounding to avoid oscillation
6540
+ * logic: [
6541
+ * {
6542
+ * type: 'derivation',
6543
+ * targetField: 'eur',
6544
+ * expression: 'Math.round(formValue.usd * 0.92 * 100) / 100'
6545
+ * }
6546
+ * ]
6547
+ * ```
6548
+ *
6549
+ * @internal
6550
+ */
6551
+ function detectBidirectionalPairs(collection) {
6552
+ const pairs = new Set();
6553
+ const edgeSet = new Set();
6554
+ // Build set of all source→target edges
6555
+ for (const entry of collection.entries) {
6556
+ if (entry.sourceFieldKey !== entry.targetFieldKey) {
6557
+ edgeSet.add(`${entry.sourceFieldKey}→${entry.targetFieldKey}`);
6558
+ }
6559
+ }
6560
+ // Find bidirectional pairs (A→B exists AND B→A exists)
6561
+ for (const entry of collection.entries) {
6562
+ const reverseEdge = `${entry.targetFieldKey}→${entry.sourceFieldKey}`;
6563
+ if (edgeSet.has(reverseEdge)) {
6564
+ // Normalize pair key (alphabetically sorted)
6565
+ const pairKey = entry.sourceFieldKey < entry.targetFieldKey
6566
+ ? `${entry.sourceFieldKey}↔${entry.targetFieldKey}`
6567
+ : `${entry.targetFieldKey}↔${entry.sourceFieldKey}`;
6568
+ pairs.add(pairKey);
6569
+ }
6570
+ }
6571
+ return pairs;
6572
+ }
6573
+ /**
6574
+ * Checks if a cycle path represents a bidirectional pair.
6575
+ *
6576
+ * @internal
6577
+ */
6578
+ function isBidirectionalCycle(cyclePath, bidirectionalPairs) {
6579
+ // Bidirectional cycles are exactly: [A, B, A] (length 3 with first=last)
6580
+ if (cyclePath.length !== 3) {
6581
+ return false;
6582
+ }
6583
+ const [first, second, third] = cyclePath;
6584
+ if (first !== third) {
6585
+ return false;
6586
+ }
6587
+ // Check if this pair is in our bidirectional set
6588
+ const pairKey = first < second ? `${first}↔${second}` : `${second}↔${first}`;
6589
+ return bidirectionalPairs.has(pairKey);
6590
+ }
6591
+ /**
6592
+ * Builds a dependency graph from derivation entries.
6593
+ *
6594
+ * Creates nodes for each field involved in derivations and
6595
+ * edges representing the derivation dependencies.
6596
+ *
6597
+ * Edge direction: sourceField -> targetField
6598
+ * (source triggers derivation that modifies target)
6599
+ *
6600
+ * @internal
6601
+ */
6602
+ function buildDependencyGraph(collection) {
6603
+ const graph = new Map();
6604
+ // Helper to ensure a node exists
6605
+ const ensureNode = (fieldKey) => {
6606
+ let node = graph.get(fieldKey);
6607
+ if (!node) {
6608
+ node = {
6609
+ fieldKey,
6610
+ dependsOn: new Set(),
6611
+ dependedOnBy: new Set(),
6612
+ };
6613
+ graph.set(fieldKey, node);
6614
+ }
6615
+ return node;
6616
+ };
6617
+ for (const entry of collection.entries) {
6618
+ const sourceNode = ensureNode(entry.sourceFieldKey);
6619
+ const targetNode = ensureNode(entry.targetFieldKey);
6620
+ // For cycle detection, we care about the data flow:
6621
+ // When sourceField changes -> targetField gets updated
6622
+ // So the edge is: sourceField -> targetField
6623
+ //
6624
+ // A cycle exists if: A -> B -> A
6625
+ // (A triggers B, B triggers A)
6626
+ // For self-referential derivations (where source === target), don't add source->target edge.
6627
+ // This includes:
6628
+ // 1. Shorthand derivations (field.derivation = 'expression')
6629
+ // 2. Self-transform derivations (logic: [{ targetField: 'self', ... }])
6630
+ // These are legitimate patterns for computed fields and value transformations,
6631
+ // not actual cycles. The real dependencies come from the expression (tracked in dependsOn).
6632
+ const isSelfReferential = entry.sourceFieldKey === entry.targetFieldKey;
6633
+ if (!isSelfReferential) {
6634
+ // The target depends on the source for its value
6635
+ targetNode.dependsOn.add(entry.sourceFieldKey);
6636
+ sourceNode.dependedOnBy.add(entry.targetFieldKey);
6637
+ }
6638
+ // Also track dependencies from the derivation expression/condition
6639
+ for (const dep of entry.dependsOn) {
6640
+ if (dep !== '*' && dep !== entry.targetFieldKey) {
6641
+ const depNode = ensureNode(dep);
6642
+ targetNode.dependsOn.add(dep);
6643
+ depNode.dependedOnBy.add(entry.targetFieldKey);
6644
+ }
6645
+ }
6646
+ }
6647
+ return graph;
6648
+ }
6649
+ /**
6650
+ * Detects cycles using depth-first search.
6651
+ *
6652
+ * Uses three-color marking:
6653
+ * - Unvisited (white): Not yet processed
6654
+ * - InProgress (gray): Currently in the DFS stack
6655
+ * - Completed (black): Fully processed
6656
+ *
6657
+ * A cycle is detected when we visit a node that's InProgress,
6658
+ * unless it's a bidirectional sync pattern (allowed).
6659
+ *
6660
+ * @internal
6661
+ */
6662
+ function detectCyclesWithDFS(graph, bidirectionalPairs) {
6663
+ const visitState = new Map();
6664
+ const parent = new Map();
6665
+ // Initialize all nodes as unvisited
6666
+ for (const fieldKey of graph.keys()) {
6667
+ visitState.set(fieldKey, 0 /* VisitState.Unvisited */);
6668
+ }
6669
+ // Process each unvisited node
6670
+ for (const fieldKey of graph.keys()) {
6671
+ if (visitState.get(fieldKey) === 0 /* VisitState.Unvisited */) {
6672
+ const cycleResult = dfsVisit(fieldKey, graph, visitState, parent, [], bidirectionalPairs);
6673
+ if (cycleResult) {
6674
+ return cycleResult;
6675
+ }
6676
+ }
6677
+ }
6678
+ return { hasCycle: false };
6679
+ }
6680
+ /**
6681
+ * DFS visit function that detects back edges (cycles).
6682
+ *
6683
+ * @internal
6684
+ */
6685
+ function dfsVisit(fieldKey, graph, visitState, parent, path, bidirectionalPairs) {
6686
+ visitState.set(fieldKey, 1 /* VisitState.InProgress */);
6687
+ path.push(fieldKey);
6688
+ const node = graph.get(fieldKey);
6689
+ if (!node) {
6690
+ visitState.set(fieldKey, 2 /* VisitState.Completed */);
6691
+ path.pop();
6692
+ return null;
6693
+ }
6694
+ // Check all nodes that this field's changes would trigger
6695
+ for (const targetFieldKey of node.dependedOnBy) {
6696
+ const targetState = visitState.get(targetFieldKey);
6697
+ if (targetState === 1 /* VisitState.InProgress */) {
6698
+ // Back edge found - potential cycle
6699
+ const cyclePath = extractCyclePath(path, targetFieldKey);
6700
+ // Allow bidirectional sync patterns (A→B→A)
6701
+ // These stabilize via equality checks at runtime
6702
+ if (isBidirectionalCycle(cyclePath, bidirectionalPairs)) {
6703
+ // Continue DFS without reporting this as a cycle
6704
+ continue;
6705
+ }
6706
+ return {
6707
+ hasCycle: true,
6708
+ cyclePath,
6709
+ errorMessage: formatCycleError(cyclePath),
6710
+ };
6711
+ }
6712
+ if (targetState === 0 /* VisitState.Unvisited */) {
6713
+ parent.set(targetFieldKey, fieldKey);
6714
+ const result = dfsVisit(targetFieldKey, graph, visitState, parent, path, bidirectionalPairs);
6715
+ if (result) {
6716
+ return result;
6717
+ }
6718
+ }
6719
+ }
6720
+ visitState.set(fieldKey, 2 /* VisitState.Completed */);
6721
+ path.pop();
6722
+ return null;
6723
+ }
6724
+ /**
6725
+ * Extracts the cycle path from the DFS path.
6726
+ *
6727
+ * @internal
6728
+ */
6729
+ function extractCyclePath(path, cycleStart) {
6730
+ const cycleStartIndex = path.indexOf(cycleStart);
6731
+ if (cycleStartIndex === -1) {
6732
+ // Shouldn't happen, but handle gracefully
6733
+ return [...path, cycleStart];
6734
+ }
6735
+ // Extract from cycle start to current position, then back to start
6736
+ const cyclePath = path.slice(cycleStartIndex);
6737
+ cyclePath.push(cycleStart); // Close the cycle
6738
+ return cyclePath;
6739
+ }
6740
+ /**
6741
+ * Formats a human-readable error message for a detected cycle.
6742
+ *
6743
+ * @internal
6744
+ */
6745
+ function formatCycleError(cyclePath) {
6746
+ const pathStr = cyclePath.join(' -> ');
6747
+ return (`Derivation cycle detected: ${pathStr}\n` +
6748
+ `This would cause an infinite loop at runtime. ` +
6749
+ `Remove one of the derivations to break the cycle.`);
6750
+ }
6751
+ /**
6752
+ * Validates a derivation collection and throws if cycles are detected.
6753
+ *
6754
+ * This is the main entry point for cycle validation during form initialization.
6755
+ * Should be called after collecting derivations and before setting up effects.
6756
+ *
6757
+ * In dev mode, logs a warning when bidirectional derivation pairs are detected.
6758
+ * These patterns are allowed but may oscillate with floating-point values.
6759
+ *
6760
+ * @param collection - The collected derivation entries to validate
6761
+ * @param logger - Optional logger for dev-mode warnings
6762
+ * @throws Error if a cycle is detected, with details about the cycle
6763
+ *
6764
+ * @example
6765
+ * ```typescript
6766
+ * const collection = collectDerivations(fields);
6767
+ *
6768
+ * // This will throw if cycles exist
6769
+ * validateNoCycles(collection, logger);
6770
+ *
6771
+ * // Safe to set up derivation effects now
6772
+ * setupDerivationEffects(collection);
6773
+ * ```
6774
+ *
6775
+ * @public
6776
+ */
6777
+ function validateNoCycles(collection, logger) {
6778
+ const result = detectCycles(collection);
6779
+ if (result.hasCycle) {
6780
+ throw new Error(result.errorMessage);
6781
+ }
6782
+ // Warn about bidirectional patterns in dev mode
6783
+ if (isDevMode() && result.bidirectionalPairs && result.bidirectionalPairs.length > 0 && logger) {
6784
+ logger.warn('[Derivation] Bidirectional derivation patterns detected. ' +
6785
+ 'These patterns stabilize via equality checks, but may oscillate with floating-point values ' +
6786
+ '(e.g., currency conversions with rounding). ' +
6787
+ 'Consider adding tolerance-based comparisons for numeric values.', result.bidirectionalPairs);
6788
+ }
6789
+ }
6790
+
6791
+ /**
6792
+ * Applies all pending derivations from a collection.
6793
+ *
6794
+ * This function processes derivations in order, evaluating conditions
6795
+ * and computing values. It handles loop prevention through:
6796
+ * 1. Chain tracking (prevents same derivation from running twice in one cycle)
6797
+ * 2. Value equality checks (skips if target already has computed value)
6798
+ * 3. Max iteration limit (safety fallback)
6799
+ *
6800
+ * @param collection - The collected derivation entries
6801
+ * @param context - Context for applying derivations
6802
+ * @param changedFields - Set of field keys that changed (for filtering)
6803
+ * @returns Result of the derivation processing
6804
+ *
6805
+ * @example
6806
+ * ```typescript
6807
+ * const result = applyDerivations(collection, {
6808
+ * formValue: formValueSignal,
6809
+ * rootForm: form(),
6810
+ * derivationFunctions: customFnConfig?.derivations,
6811
+ * logger: inject(DynamicFormLogger),
6812
+ * });
6813
+ *
6814
+ * if (result.maxIterationsReached) {
6815
+ * console.warn('Possible derivation loop detected');
6816
+ * }
6817
+ * ```
6818
+ *
6819
+ * @public
6820
+ */
6821
+ function applyDerivations(collection, context, changedFields) {
6822
+ const chainContext = createDerivationChainContext();
6823
+ const { derivationLogger } = context;
6824
+ const maxIterations = context.maxIterations ?? MAX_DERIVATION_ITERATIONS;
6825
+ let appliedCount = 0;
6826
+ let skippedCount = 0;
6827
+ let errorCount = 0;
6828
+ let warnCount = 0;
6829
+ // Filter entries based on changed fields
6830
+ const entriesToProcess = changedFields ? getEntriesForChangedFields(collection.entries, changedFields) : collection.entries;
6831
+ // Log cycle start in verbose mode
6832
+ derivationLogger.cycleStart('onChange', entriesToProcess.length);
6833
+ // Process derivations iteratively until no more changes
6834
+ let hasChanges = true;
6835
+ while (hasChanges && chainContext.iteration < maxIterations) {
6836
+ chainContext.iteration++;
6837
+ hasChanges = false;
6838
+ // Log iteration in verbose mode
6839
+ derivationLogger.iteration(chainContext.iteration);
6840
+ for (const entry of entriesToProcess) {
6841
+ const result = tryApplyDerivation(entry, context, chainContext);
6842
+ if (result.applied) {
6843
+ appliedCount++;
6844
+ hasChanges = true;
6845
+ }
6846
+ else if (result.error) {
6847
+ errorCount++;
6848
+ }
6849
+ else if (result.warning) {
6850
+ warnCount++;
6851
+ skippedCount++;
6852
+ }
6853
+ else {
6854
+ skippedCount++;
6855
+ }
6856
+ }
6857
+ }
6858
+ // Build result
6859
+ const processingResult = {
6860
+ appliedCount,
6861
+ skippedCount,
6862
+ errorCount,
6863
+ warnCount,
6864
+ iterations: chainContext.iteration,
6865
+ maxIterationsReached: chainContext.iteration >= maxIterations,
6866
+ };
6867
+ if (processingResult.maxIterationsReached) {
6868
+ derivationLogger.maxIterationsReached(processingResult, 'onChange');
6869
+ }
6870
+ // Log summary
6871
+ derivationLogger.summary(processingResult, 'onChange');
6872
+ return processingResult;
6873
+ }
6874
+ /**
6875
+ * Gets entries that should be processed based on changed fields.
6876
+ *
6877
+ * Filters entries by checking if any of their dependencies are in the changed fields set.
6878
+ * Also includes all wildcard (*) entries since they depend on any form change.
6879
+ *
6880
+ * Note: For optimal O(k) performance where k = changed fields count, use
6881
+ * DerivationLookup.getEntriesForChangedFields() which uses pre-built indexes.
6882
+ *
6883
+ * @internal
6884
+ */
6885
+ function getEntriesForChangedFields(entries, changedFields) {
6886
+ return entries.filter((entry) => {
6887
+ // Wildcard entries are always included
6888
+ if (entry.dependsOn.includes('*')) {
6889
+ return true;
6890
+ }
6891
+ // Check if any dependency is in changed fields
6892
+ return entry.dependsOn.some((dep) => changedFields.has(dep));
6893
+ });
6894
+ }
6895
+ /**
6896
+ * Attempts to apply a single derivation.
6897
+ *
6898
+ * **Important:** When a derivation's condition evaluates to `false`, the previously
6899
+ * derived value is NOT automatically cleared. If you need to reset a field when a
6900
+ * condition becomes false, use a separate derivation with an inverted condition
6901
+ * that sets the desired fallback value.
6902
+ *
6903
+ * @example
6904
+ * ```typescript
6905
+ * // Two derivations for conditional value with cleanup:
6906
+ * // When country is USA, set phonePrefix to '+1'
6907
+ * { condition: { field: 'country', operator: '==', value: 'USA' }, targetField: 'phonePrefix', value: '+1' }
6908
+ * // When country is NOT USA, clear phonePrefix
6909
+ * { condition: { field: 'country', operator: '!=', value: 'USA' }, targetField: 'phonePrefix', value: '' }
6910
+ * ```
6911
+ *
6912
+ * @internal
6913
+ */
6914
+ function tryApplyDerivation(entry, context, chainContext) {
6915
+ const { derivationLogger } = context;
6916
+ // Check if this is an array field derivation (has '$' placeholder)
6917
+ if (isArrayPlaceholderPath(entry.targetFieldKey)) {
6918
+ return tryApplyArrayDerivation(entry, context, chainContext);
6919
+ }
6920
+ const derivationKey = createDerivationKey(entry.sourceFieldKey, entry.targetFieldKey);
6921
+ // Check if already applied in this cycle
6922
+ if (chainContext.appliedDerivations.has(derivationKey)) {
6923
+ derivationLogger.evaluation({
6924
+ debugName: entry.debugName,
6925
+ sourceFieldKey: entry.sourceFieldKey,
6926
+ targetFieldKey: entry.targetFieldKey,
6927
+ result: 'skipped',
6928
+ skipReason: 'already-applied',
6929
+ });
6930
+ return { applied: false, targetFieldKey: entry.targetFieldKey };
6931
+ }
6932
+ // Create evaluation context
6933
+ const formValue = untracked(() => context.formValue());
6934
+ const evalContext = createEvaluationContext(entry, formValue, context);
6935
+ // Evaluate condition
6936
+ if (!evaluateDerivationCondition(entry.condition, evalContext)) {
6937
+ derivationLogger.evaluation({
6938
+ debugName: entry.debugName,
6939
+ sourceFieldKey: entry.sourceFieldKey,
6940
+ targetFieldKey: entry.targetFieldKey,
6941
+ result: 'skipped',
6942
+ skipReason: 'condition-false',
6943
+ });
6944
+ return { applied: false, targetFieldKey: entry.targetFieldKey };
6945
+ }
6946
+ // Compute derived value
6947
+ let newValue;
6948
+ try {
6949
+ newValue = computeDerivedValue(entry, evalContext, context);
6950
+ }
6951
+ catch (error) {
6952
+ const errorMessage = error instanceof Error ? error.message : String(error);
6953
+ context.logger.error(formatDerivationError(entry, 'compute', errorMessage));
6954
+ derivationLogger.evaluation({
6955
+ debugName: entry.debugName,
6956
+ sourceFieldKey: entry.sourceFieldKey,
6957
+ targetFieldKey: entry.targetFieldKey,
6958
+ result: 'error',
6959
+ error: errorMessage,
6960
+ });
6961
+ return {
6962
+ applied: false,
6963
+ targetFieldKey: entry.targetFieldKey,
6964
+ error: errorMessage,
6965
+ };
6966
+ }
6967
+ // Check if value actually changed using exact equality.
6968
+ // Note: This uses isEqual which performs deep comparison with exact IEEE 754
6969
+ // equality for numbers. Bidirectional derivations with floating-point math
6970
+ // may oscillate due to rounding errors. Use explicit rounding in expressions
6971
+ // or integer arithmetic to avoid this issue.
6972
+ const currentValue = getNestedValue(formValue, entry.targetFieldKey);
6973
+ if (isEqual(currentValue, newValue)) {
6974
+ derivationLogger.evaluation({
6975
+ debugName: entry.debugName,
6976
+ sourceFieldKey: entry.sourceFieldKey,
6977
+ targetFieldKey: entry.targetFieldKey,
6978
+ result: 'skipped',
6979
+ skipReason: 'value-unchanged',
6980
+ });
6981
+ return { applied: false, targetFieldKey: entry.targetFieldKey };
6982
+ }
6983
+ // Apply the value
6984
+ try {
6985
+ const wasApplied = applyValueToForm(entry.targetFieldKey, newValue, context.rootForm, context.logger, context.warningTracker);
6986
+ if (wasApplied) {
6987
+ chainContext.appliedDerivations.add(derivationKey);
6988
+ derivationLogger.evaluation({
6989
+ debugName: entry.debugName,
6990
+ sourceFieldKey: entry.sourceFieldKey,
6991
+ targetFieldKey: entry.targetFieldKey,
6992
+ result: 'applied',
6993
+ previousValue: currentValue,
6994
+ newValue,
6995
+ });
6996
+ return { applied: true, targetFieldKey: entry.targetFieldKey, newValue };
6997
+ }
6998
+ else {
6999
+ // Field not found - this is a warning, not an error
7000
+ // The warning was already logged by warnMissingField
7001
+ derivationLogger.evaluation({
7002
+ debugName: entry.debugName,
7003
+ sourceFieldKey: entry.sourceFieldKey,
7004
+ targetFieldKey: entry.targetFieldKey,
7005
+ result: 'skipped',
7006
+ skipReason: 'target-not-found',
7007
+ });
7008
+ return {
7009
+ applied: false,
7010
+ targetFieldKey: entry.targetFieldKey,
7011
+ warning: `Target field '${entry.targetFieldKey}' not found`,
7012
+ };
7013
+ }
7014
+ }
7015
+ catch (error) {
7016
+ const errorMessage = error instanceof Error ? error.message : String(error);
7017
+ context.logger.error(formatDerivationError(entry, 'apply', errorMessage));
7018
+ derivationLogger.evaluation({
7019
+ debugName: entry.debugName,
7020
+ sourceFieldKey: entry.sourceFieldKey,
7021
+ targetFieldKey: entry.targetFieldKey,
7022
+ result: 'error',
7023
+ error: errorMessage,
7024
+ });
7025
+ return {
7026
+ applied: false,
7027
+ targetFieldKey: entry.targetFieldKey,
7028
+ error: errorMessage,
7029
+ };
7030
+ }
7031
+ }
7032
+ /**
7033
+ * Handles array field derivations with '$' placeholder.
7034
+ *
7035
+ * Iterates over all array items and applies the derivation for each,
7036
+ * resolving '$' to the actual index and creating scoped evaluation contexts.
7037
+ *
7038
+ * @internal
7039
+ */
7040
+ function tryApplyArrayDerivation(entry, context, chainContext) {
7041
+ const formValue = untracked(() => context.formValue());
7042
+ // Parse the target path using path utilities
7043
+ const pathInfo = parseArrayPath(entry.targetFieldKey);
7044
+ if (!pathInfo.isArrayPath) {
7045
+ return { applied: false, targetFieldKey: entry.targetFieldKey, error: 'Invalid array derivation path' };
7046
+ }
7047
+ const { arrayPath } = pathInfo;
7048
+ // Get the array from form values
7049
+ const arrayValue = getNestedValue(formValue, arrayPath);
7050
+ if (!Array.isArray(arrayValue)) {
7051
+ return { applied: false, targetFieldKey: entry.targetFieldKey };
7052
+ }
7053
+ let appliedAny = false;
7054
+ // Process each array item
7055
+ for (let i = 0; i < arrayValue.length; i++) {
7056
+ const resolvedTargetPath = resolveArrayPath(entry.targetFieldKey, i);
7057
+ const derivationKey = createDerivationKey(entry.sourceFieldKey, resolvedTargetPath);
7058
+ // Skip if already applied in this cycle
7059
+ if (chainContext.appliedDerivations.has(derivationKey)) {
7060
+ continue;
7061
+ }
7062
+ // Create evaluation context scoped to this array item
7063
+ const arrayItem = arrayValue[i];
7064
+ const evalContext = createArrayItemEvaluationContext(entry, arrayItem, formValue, i, arrayPath, context);
7065
+ // Evaluate condition
7066
+ if (!evaluateDerivationCondition(entry.condition, evalContext)) {
7067
+ continue;
7068
+ }
7069
+ // Compute derived value
7070
+ let newValue;
7071
+ try {
7072
+ newValue = computeDerivedValue(entry, evalContext, context);
7073
+ }
7074
+ catch (error) {
7075
+ const errorMessage = error instanceof Error ? error.message : String(error);
7076
+ context.logger.error(formatDerivationError(entry, 'compute', `[index ${i}] ${errorMessage}`));
7077
+ continue;
7078
+ }
7079
+ // Check if value actually changed
7080
+ const currentValue = getNestedValue(formValue, resolvedTargetPath);
7081
+ if (isEqual(currentValue, newValue)) {
7082
+ continue;
7083
+ }
7084
+ // Apply the value
7085
+ try {
7086
+ applyValueToForm(resolvedTargetPath, newValue, context.rootForm, context.logger, context.warningTracker);
7087
+ chainContext.appliedDerivations.add(derivationKey);
7088
+ appliedAny = true;
7089
+ }
7090
+ catch (error) {
7091
+ const errorMessage = error instanceof Error ? error.message : String(error);
7092
+ context.logger.error(formatDerivationError(entry, 'apply', `[index ${i}] ${errorMessage}`));
7093
+ }
7094
+ }
7095
+ return { applied: appliedAny, targetFieldKey: entry.targetFieldKey };
7096
+ }
7097
+ /**
7098
+ * Creates an evaluation context for derivation processing.
7099
+ *
7100
+ * @internal
7101
+ */
7102
+ function createEvaluationContext(entry, formValue, context) {
7103
+ const fieldValue = getNestedValue(formValue, entry.sourceFieldKey);
7104
+ return {
7105
+ fieldValue,
7106
+ formValue,
7107
+ fieldPath: entry.sourceFieldKey,
7108
+ customFunctions: context.customFunctions,
7109
+ logger: context.logger,
7110
+ };
7111
+ }
7112
+ /**
7113
+ * Creates an evaluation context scoped to a specific array item.
7114
+ *
7115
+ * For array derivations, `formValue` in the expression should reference
7116
+ * the current array item's values, not the root form values.
7117
+ *
7118
+ * @internal
7119
+ */
7120
+ function createArrayItemEvaluationContext(entry, arrayItem, rootFormValue, itemIndex, arrayPath, context) {
7121
+ // For array item expressions, formValue should be the array item
7122
+ // This allows expressions like 'formValue.quantity * formValue.unitPrice'
7123
+ // to work within the context of each array item
7124
+ return {
7125
+ fieldValue: arrayItem,
7126
+ formValue: arrayItem,
7127
+ fieldPath: `${arrayPath}.${itemIndex}`,
7128
+ customFunctions: context.customFunctions,
7129
+ logger: context.logger,
7130
+ // Provide access to root form value for cross-scope references
7131
+ rootFormValue,
7132
+ arrayIndex: itemIndex,
7133
+ arrayPath,
7134
+ };
7135
+ }
7136
+ /**
7137
+ * Evaluates the derivation condition.
7138
+ *
7139
+ * @internal
7140
+ */
7141
+ function evaluateDerivationCondition(condition, context) {
7142
+ if (typeof condition === 'boolean') {
7143
+ return condition;
7144
+ }
7145
+ return evaluateCondition(condition, context);
7146
+ }
7147
+ /**
7148
+ * Computes the derived value based on the entry configuration.
7149
+ *
7150
+ * @internal
7151
+ */
7152
+ function computeDerivedValue(entry, evalContext, applicatorContext) {
7153
+ // Static value
7154
+ if (entry.value !== undefined) {
7155
+ return entry.value;
7156
+ }
7157
+ // Expression
7158
+ if (entry.expression) {
7159
+ return ExpressionParser.evaluate(entry.expression, evalContext);
7160
+ }
7161
+ // Custom function
7162
+ if (entry.functionName) {
7163
+ const fn = applicatorContext.derivationFunctions?.[entry.functionName];
7164
+ if (!fn) {
7165
+ throw new Error(`Derivation function '${entry.functionName}' not found in customFnConfig.derivations`);
7166
+ }
7167
+ return fn(evalContext);
7168
+ }
7169
+ // No value source specified
7170
+ throw new Error(`Derivation for ${entry.targetFieldKey} has no value source. ` + `Specify 'value', 'expression', or 'functionName'.`);
7171
+ }
7172
+ /**
7173
+ * Applies a value to the form at the specified path.
7174
+ *
7175
+ * Uses bracket notation to access child FieldTrees, following the same pattern
7176
+ * as group-field and array-field components. Fields are accessed as signals
7177
+ * and their value is set via the `.value.set()` method.
7178
+ *
7179
+ * Handles nested paths and array paths with '$' placeholder.
7180
+ *
7181
+ * @internal
7182
+ */
7183
+ function applyValueToForm(targetPath, value, rootForm, logger, warningTracker) {
7184
+ // Handle simple top-level fields
7185
+ if (!targetPath.includes('.')) {
7186
+ return setFieldValue(rootForm, targetPath, value, logger, warningTracker);
7187
+ }
7188
+ // Handle nested paths (e.g., 'address.city' or 'items.$.quantity')
7189
+ const parts = targetPath.split('.');
7190
+ let current = rootForm;
7191
+ for (let i = 0; i < parts.length - 1; i++) {
7192
+ const part = parts[i];
7193
+ // Handle array index placeholder '$'
7194
+ if (part === '$') {
7195
+ // This case should be resolved at collection time
7196
+ // If we still have '$', we need to skip for now
7197
+ return false;
7198
+ }
7199
+ // Navigate to the next level using bracket notation
7200
+ const next = current[part];
7201
+ if (next === undefined || next === null) {
7202
+ warnMissingField(targetPath, logger, warningTracker);
7203
+ return false; // Path doesn't exist
7204
+ }
7205
+ current = next;
7206
+ }
7207
+ // Set the final value
7208
+ const finalPart = parts[parts.length - 1];
7209
+ return setFieldValue(current, finalPart, value, logger, warningTracker);
7210
+ }
7211
+ /**
7212
+ * Error message prefix for derivation-related errors.
7213
+ * @internal
7214
+ */
7215
+ const ERROR_PREFIX = '[Derivation]';
7216
+ /**
7217
+ * Creates a standardized error message for derivation errors.
7218
+ * @internal
7219
+ */
7220
+ function formatDerivationError(entry, phase, message) {
7221
+ const source = entry.expression
7222
+ ? `expression: "${entry.expression.substring(0, 50)}${entry.expression.length > 50 ? '...' : ''}"`
7223
+ : entry.functionName
7224
+ ? `function: "${entry.functionName}"`
7225
+ : entry.value !== undefined
7226
+ ? 'static value'
7227
+ : 'unknown source';
7228
+ return `${ERROR_PREFIX} Failed to ${phase} derivation (${entry.sourceFieldKey} → ${entry.targetFieldKey}, ${source}): ${message}`;
7229
+ }
7230
+ /**
7231
+ * Sets a field value using the Angular Signal Forms pattern.
7232
+ *
7233
+ * Accesses the child field via bracket notation, drills down to find
7234
+ * the WritableSignal, and uses isWritableSignal for type-safe signal detection.
7235
+ *
7236
+ * Angular Signal Forms structure:
7237
+ * - form[key] is a callable function (field accessor)
7238
+ * - form[key]() returns the field instance with { value: WritableSignal<T> }
7239
+ * - form[key]().value.set(newValue) sets the value
7240
+ *
7241
+ * @returns true if the value was successfully set, false if field not found
7242
+ *
7243
+ * @internal
7244
+ */
7245
+ function setFieldValue(parent, fieldKey, value, logger, warningTracker) {
7246
+ // Access child field via bracket notation (same pattern as group-field/array-field)
7247
+ const fieldAccessor = parent[fieldKey];
7248
+ if (fieldAccessor === undefined || fieldAccessor === null) {
7249
+ warnMissingField(fieldKey, logger, warningTracker);
7250
+ return false;
7251
+ }
7252
+ // Angular Signal Forms: field accessor is a callable function
7253
+ if (typeof fieldAccessor !== 'function') {
7254
+ warnMissingField(fieldKey, logger, warningTracker);
7255
+ return false;
7256
+ }
7257
+ // Call the accessor to get the field instance
7258
+ const fieldInstance = fieldAccessor();
7259
+ if (!fieldInstance || typeof fieldInstance !== 'object' || !('value' in fieldInstance)) {
7260
+ warnMissingField(fieldKey, logger, warningTracker);
7261
+ return false;
7262
+ }
7263
+ // Get the value signal and verify it's a WritableSignal
7264
+ const valueSignal = fieldInstance.value;
7265
+ if (isWritableSignal(valueSignal)) {
7266
+ valueSignal.set(value);
7267
+ return true;
7268
+ }
7269
+ else {
7270
+ warnMissingField(fieldKey, logger, warningTracker);
7271
+ return false;
7272
+ }
7273
+ }
7274
+ /**
7275
+ * Logs a warning for a missing target field (once per field per form instance).
7276
+ *
7277
+ * Uses the provided warning tracker to avoid log spam. If no tracker is provided,
7278
+ * the warning will be logged every time.
7279
+ *
7280
+ * @internal
7281
+ */
7282
+ function warnMissingField(fieldKey, logger, warningTracker) {
7283
+ // If tracker is provided, check if we've already warned about this field
7284
+ if (warningTracker) {
7285
+ if (warningTracker.warnedFields.has(fieldKey)) {
7286
+ return;
7287
+ }
7288
+ warningTracker.warnedFields.add(fieldKey);
7289
+ }
7290
+ logger?.warn(`${ERROR_PREFIX} Target field '${fieldKey}' not found in form. ` +
7291
+ `Ensure the field is defined in your form configuration. ` +
7292
+ `This warning is shown once per field.`);
7293
+ }
7294
+ /**
7295
+ * Processes derivations for a specific trigger type.
7296
+ *
7297
+ * Filters entries by trigger type and applies them.
7298
+ *
7299
+ * @param collection - The collected derivation entries
7300
+ * @param trigger - The trigger type to filter by
7301
+ * @param context - Context for applying derivations
7302
+ * @param changedFields - Set of field keys that changed (for filtering)
7303
+ * @returns Result of the derivation processing
7304
+ *
7305
+ * @public
7306
+ */
7307
+ function applyDerivationsForTrigger(collection, trigger, context, changedFields) {
7308
+ // Filter entries by trigger type
7309
+ const filteredEntries = collection.entries.filter((entry) => {
7310
+ if (trigger === 'onChange') {
7311
+ return !entry.trigger || entry.trigger === 'onChange';
7312
+ }
7313
+ return entry.trigger === trigger;
7314
+ });
7315
+ // Create a minimal collection with filtered entries
7316
+ const filteredCollection = {
7317
+ entries: filteredEntries,
7318
+ };
7319
+ return applyDerivations(filteredCollection, context, changedFields);
7320
+ }
7321
+ /**
7322
+ * Gets all debounced derivation entries from a collection.
7323
+ *
7324
+ * Use this to extract debounced entries for separate processing with debounce timers.
7325
+ *
7326
+ * @param collection - The collected derivation entries
7327
+ * @returns Array of debounced derivation entries
7328
+ *
7329
+ * @public
7330
+ */
7331
+ function getDebouncedDerivationEntries(collection) {
7332
+ return collection.entries.filter((entry) => entry.trigger === 'debounced');
7333
+ }
7334
+
7335
+ /**
7336
+ * Injection token for the derivation warning tracker.
7337
+ * Provided at form component level for instance-scoped tracking.
7338
+ *
7339
+ * @public
7340
+ */
7341
+ const DERIVATION_WARNING_TRACKER = new InjectionToken('DerivationWarningTracker', {
7342
+ providedIn: null,
7343
+ factory: () => ({ warnedFields: new Set() }),
7344
+ });
7345
+ /**
7346
+ * Creates a fresh warning tracker instance.
7347
+ *
7348
+ * @returns A new DerivationWarningTracker with an empty warnedFields Set
7349
+ *
7350
+ * @public
7351
+ */
7352
+ function createDerivationWarningTracker() {
7353
+ return { warnedFields: new Set() };
7354
+ }
7355
+
7356
+ /**
7357
+ * Prefix for derivation debug log messages.
7358
+ * @internal
7359
+ */
7360
+ const LOG_PREFIX = '[Derivation]';
7361
+ /**
7362
+ * Logs a summary of derivation processing results.
7363
+ *
7364
+ * Only logs when:
7365
+ * - Config level is 'summary' or 'verbose'
7366
+ * - At least one derivation was applied or an error occurred
7367
+ *
7368
+ * @param result - The derivation processing result
7369
+ * @param trigger - The trigger type ('onChange' or 'debounced')
7370
+ * @param logger - Logger instance
7371
+ * @param config - Debug logging configuration
7372
+ *
7373
+ * @public
7374
+ */
7375
+ function logDerivationSummary(result, trigger, logger, config) {
7376
+ if (!shouldLog(config, 'summary'))
7377
+ return;
7378
+ // Only log if something interesting happened
7379
+ if (result.appliedCount === 0 && result.errorCount === 0)
7380
+ return;
7381
+ logger.debug(`${LOG_PREFIX} Cycle complete (${trigger})`, {
7382
+ applied: result.appliedCount,
7383
+ skipped: result.skippedCount,
7384
+ errors: result.errorCount,
7385
+ iterations: result.iterations,
7386
+ });
7387
+ }
7388
+ /**
7389
+ * Logs when max iterations are reached (always logged as warning).
7390
+ *
7391
+ * @param result - The derivation processing result
7392
+ * @param trigger - The trigger type
7393
+ * @param logger - Logger instance
7394
+ *
7395
+ * @public
7396
+ */
7397
+ function logMaxIterationsReached(result, trigger, logger) {
7398
+ logger.warn(`${LOG_PREFIX} Processing reached max iterations (${trigger}). ` +
7399
+ `This may indicate a loop in derivation logic. ` +
7400
+ `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`);
7401
+ }
7402
+ /**
7403
+ * Logs a verbose entry for a single derivation evaluation.
7404
+ *
7405
+ * Only logs when config level is 'verbose'.
7406
+ *
7407
+ * @param entry - The derivation log entry with evaluation details
7408
+ * @param logger - Logger instance
7409
+ * @param config - Debug logging configuration
7410
+ *
7411
+ * @internal
7412
+ */
7413
+ function logDerivationEvaluation(entry, logger, config) {
7414
+ if (!shouldLog(config, 'verbose'))
7415
+ return;
7416
+ const name = entry.debugName ? `"${entry.debugName}"` : `${entry.sourceFieldKey} -> ${entry.targetFieldKey}`;
7417
+ switch (entry.result) {
7418
+ case 'applied':
7419
+ logger.debug(`${LOG_PREFIX} -> Applied: ${name}`, {
7420
+ source: entry.sourceFieldKey,
7421
+ target: entry.targetFieldKey,
7422
+ previousValue: entry.previousValue,
7423
+ newValue: entry.newValue,
7424
+ ...(entry.durationMs !== undefined && { durationMs: entry.durationMs }),
7425
+ });
7426
+ break;
7427
+ case 'skipped':
7428
+ logger.debug(`${LOG_PREFIX} -> Skipped: ${name} (${formatSkipReason(entry.skipReason)})`, {
7429
+ source: entry.sourceFieldKey,
7430
+ target: entry.targetFieldKey,
7431
+ reason: entry.skipReason,
7432
+ });
7433
+ break;
7434
+ case 'error':
7435
+ logger.debug(`${LOG_PREFIX} -> Error: ${name}`, {
7436
+ source: entry.sourceFieldKey,
7437
+ target: entry.targetFieldKey,
7438
+ error: entry.error,
7439
+ });
7440
+ break;
7441
+ }
7442
+ }
7443
+ /**
7444
+ * Formats the skip reason for human-readable output.
7445
+ *
7446
+ * @internal
7447
+ */
7448
+ function formatSkipReason(reason) {
7449
+ switch (reason) {
7450
+ case 'condition-false':
7451
+ return 'condition not met';
7452
+ case 'value-unchanged':
7453
+ return 'value unchanged';
7454
+ case 'already-applied':
7455
+ return 'already applied this cycle';
7456
+ default:
7457
+ return 'unknown';
7458
+ }
7459
+ }
7460
+ /**
7461
+ * Logs the start of a derivation processing cycle.
7462
+ *
7463
+ * Only logs when config level is 'verbose'.
7464
+ *
7465
+ * @param trigger - The trigger type ('onChange' or 'debounced')
7466
+ * @param entryCount - Number of derivations to process in this cycle
7467
+ * @param logger - Logger instance
7468
+ * @param config - Debug logging configuration
7469
+ *
7470
+ * @internal
7471
+ */
7472
+ function logDerivationCycleStart(trigger, entryCount, logger, config) {
7473
+ if (!shouldLog(config, 'verbose'))
7474
+ return;
7475
+ logger.debug(`${LOG_PREFIX} Starting cycle (${trigger}) with ${entryCount} derivation(s)`);
7476
+ }
7477
+ /**
7478
+ * Logs the start of a derivation iteration within a cycle.
7479
+ *
7480
+ * Only logs when config level is 'verbose'.
7481
+ *
7482
+ * @param iteration - Current iteration number (1-based)
7483
+ * @param logger - Logger instance
7484
+ * @param config - Debug logging configuration
7485
+ *
7486
+ * @internal
7487
+ */
7488
+ function logDerivationIteration(iteration, logger, config) {
7489
+ if (!shouldLog(config, 'verbose'))
7490
+ return;
7491
+ logger.debug(`${LOG_PREFIX} Iteration ${iteration}`);
7492
+ }
7493
+
7494
+ /**
7495
+ * Active implementation of DerivationLogger that performs actual logging.
7496
+ *
7497
+ * @internal
7498
+ */
7499
+ class ActiveDerivationLogger {
7500
+ logger;
7501
+ config = createDefaultDerivationLogConfig();
7502
+ /**
7503
+ * Updates the log configuration.
7504
+ */
7505
+ setConfig(config) {
7506
+ this.config = config;
7507
+ }
7508
+ cycleStart(trigger, entryCount) {
7509
+ if (!shouldLog(this.config, 'verbose'))
7510
+ return;
7511
+ this.logger.debug(`Derivation - Starting cycle (${trigger}) with ${entryCount} derivation(s)`);
7512
+ }
7513
+ iteration(iterationNumber) {
7514
+ if (!shouldLog(this.config, 'verbose'))
7515
+ return;
7516
+ this.logger.debug(`Derivation - Iteration ${iterationNumber}`);
7517
+ }
7518
+ evaluation(entry) {
7519
+ if (!shouldLog(this.config, 'verbose'))
7520
+ return;
7521
+ const name = entry.debugName ? `"${entry.debugName}"` : `${entry.sourceFieldKey} -> ${entry.targetFieldKey}`;
7522
+ switch (entry.result) {
7523
+ case 'applied':
7524
+ this.logger.debug(`Derivation - Applied ${name}`, {
7525
+ source: entry.sourceFieldKey,
7526
+ target: entry.targetFieldKey,
7527
+ previousValue: entry.previousValue,
7528
+ newValue: entry.newValue,
7529
+ ...(entry.durationMs !== undefined && { durationMs: entry.durationMs }),
7530
+ });
7531
+ break;
7532
+ case 'skipped':
7533
+ this.logger.debug(`Derivation - Skipped ${name} (${this.formatSkipReason(entry.skipReason)})`, {
7534
+ source: entry.sourceFieldKey,
7535
+ target: entry.targetFieldKey,
7536
+ reason: entry.skipReason,
7537
+ });
7538
+ break;
7539
+ case 'error':
7540
+ this.logger.debug(`Derivation - Error ${name}`, {
7541
+ source: entry.sourceFieldKey,
7542
+ target: entry.targetFieldKey,
7543
+ error: entry.error,
7544
+ });
7545
+ break;
7546
+ }
7547
+ }
7548
+ summary(result, trigger) {
7549
+ if (!shouldLog(this.config, 'summary'))
7550
+ return;
7551
+ // Only log if something interesting happened
7552
+ if (result.appliedCount === 0 && result.errorCount === 0)
7553
+ return;
7554
+ this.logger.debug(`Derivation - Cycle complete (${trigger})`, {
7555
+ applied: result.appliedCount,
7556
+ skipped: result.skippedCount,
7557
+ errors: result.errorCount,
7558
+ iterations: result.iterations,
7559
+ });
7560
+ }
7561
+ maxIterationsReached(result, trigger) {
7562
+ this.logger.warn(`Derivation - Max iterations reached (${trigger}). ` +
7563
+ `This may indicate a loop in derivation logic. ` +
7564
+ `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`);
7565
+ }
7566
+ formatSkipReason(reason) {
7567
+ switch (reason) {
7568
+ case 'condition-false':
7569
+ return 'condition not met';
7570
+ case 'value-unchanged':
7571
+ return 'value unchanged';
7572
+ case 'already-applied':
7573
+ return 'already applied this cycle';
7574
+ default:
7575
+ return 'unknown';
7576
+ }
7577
+ }
7578
+ }
7579
+ /**
7580
+ * No-op implementation of DerivationLogger.
7581
+ * All methods are empty - no logging overhead when disabled.
7582
+ *
7583
+ * @internal
7584
+ */
7585
+ class NoopDerivationLogger {
7586
+ cycleStart() {
7587
+ /* no-op */
7588
+ }
7589
+ iteration() {
7590
+ /* no-op */
7591
+ }
7592
+ evaluation() {
7593
+ /* no-op */
7594
+ }
7595
+ summary() {
7596
+ /* no-op */
7597
+ }
7598
+ maxIterationsReached() {
7599
+ /* no-op */
7600
+ }
7601
+ }
7602
+ /** Singleton no-op instance to avoid creating new objects */
7603
+ const NOOP_INSTANCE = new NoopDerivationLogger();
7604
+ /**
7605
+ * Injection token for the derivation logger service.
7606
+ *
7607
+ * @public
7608
+ */
7609
+ const DERIVATION_LOGGER = new InjectionToken('DerivationLogger');
7610
+ /**
7611
+ * Factory function to create a DerivationLogger.
7612
+ *
7613
+ * Returns a no-op implementation if config level is 'none',
7614
+ * otherwise returns an active logger with the given config.
7615
+ *
7616
+ * @param config - The derivation log configuration
7617
+ * @param logger - The underlying logger instance
7618
+ * @returns A DerivationLogger instance
7619
+ *
7620
+ * @public
7621
+ */
7622
+ function createDerivationLogger(config, logger) {
7623
+ const effectiveConfig = config ?? createDefaultDerivationLogConfig();
7624
+ // Return no-op if logging is completely disabled
7625
+ if (effectiveConfig.level === 'none') {
7626
+ return NOOP_INSTANCE;
7627
+ }
7628
+ // Create active logger with config
7629
+ const service = new ActiveDerivationLogger();
7630
+ service.logger = logger;
7631
+ service.setConfig(effectiveConfig);
7632
+ return service;
7633
+ }
7634
+
7635
+ /**
7636
+ * Orchestrates derivation processing for a dynamic form.
7637
+ *
7638
+ * Uses RxJS streams for reactive derivation processing:
7639
+ * - `exhaustMap` prevents re-entry during onChange derivation application
7640
+ * - `pairwise` tracks value changes without mutable state
7641
+ * - `takeUntilDestroyed` handles automatic cleanup
7642
+ *
7643
+ * Injects common services directly, requiring only form-specific signals in config.
7644
+ *
7645
+ * @public
7646
+ */
7647
+ class DerivationOrchestrator {
7648
+ config;
7649
+ injector = inject(Injector);
7650
+ destroyRef = inject(DestroyRef);
7651
+ logger = inject(DynamicFormLogger);
7652
+ warningTracker = inject(DERIVATION_WARNING_TRACKER);
7653
+ functionRegistry = inject(FunctionRegistryService);
7654
+ formOptions = inject(FORM_OPTIONS);
7655
+ /**
7656
+ * Computed signal containing the collected and validated derivations.
7657
+ * Returns null if no derivations are defined.
7658
+ */
7659
+ derivationCollection;
7660
+ constructor(config) {
7661
+ this.config = config;
7662
+ this.derivationCollection = computed(() => {
7663
+ const fields = config.schemaFields();
7664
+ if (!fields || fields.length === 0) {
7665
+ return null;
7666
+ }
7667
+ const collection = collectDerivations(fields);
7668
+ if (collection.entries.length === 0) {
7669
+ return null;
7670
+ }
7671
+ validateNoCycles(collection, this.logger);
7672
+ this.warnAboutWildcardDependencies(collection.entries, fields.length);
7673
+ return collection;
7674
+ }, ...(ngDevMode ? [{ debugName: "derivationCollection" }] : []));
7675
+ this.setupOnChangeStream();
7676
+ this.setupDebouncedStream();
7677
+ }
7678
+ setupOnChangeStream() {
7679
+ const collection$ = toObservable(this.derivationCollection, { injector: this.injector });
7680
+ const formValue$ = toObservable(this.config.formValue, { injector: this.injector });
7681
+ const form$ = toObservable(this.config.form, { injector: this.injector });
7682
+ collection$
7683
+ .pipe(filter((collection) => collection !== null), combineLatestWith(formValue$, form$),
7684
+ // auditTime(0): Batch synchronous emissions from Angular's change detection.
7685
+ // When a single user action triggers multiple signal updates, this ensures
7686
+ // we only process derivations once after all updates complete (microtask timing).
7687
+ auditTime(0),
7688
+ // exhaustMap: Prevents re-entry while processing derivations.
7689
+ // If form value changes DURING derivation processing (from our own setValue calls),
7690
+ // we ignore those emissions and complete the current cycle first.
7691
+ // switchMap would cancel mid-processing, causing incomplete derivation chains.
7692
+ exhaustMap(([collection, , formAccessor]) => {
7693
+ this.applyOnChangeDerivations(collection, formAccessor);
7694
+ // scheduled with queueScheduler: Ensures the observable completes
7695
+ // in the next microtask, allowing exhaustMap to accept new emissions.
7696
+ // Without this, exhaustMap would block indefinitely.
7697
+ return scheduled([null], queueScheduler);
7698
+ }), takeUntilDestroyed(this.destroyRef))
7699
+ .subscribe();
7700
+ }
7701
+ setupDebouncedStream() {
7702
+ toObservable(this.config.formValue, { injector: this.injector })
7703
+ .pipe(
7704
+ // debounceTime: Wait for value to stabilize before detecting changes.
7705
+ // Uses DEFAULT_DEBOUNCE_MS as the minimum debounce period.
7706
+ debounceTime(DEFAULT_DEBOUNCE_MS),
7707
+ // startWith + pairwise: Track previous and current values to detect changes.
7708
+ // startWith(null) ensures pairwise has an initial value to pair with.
7709
+ startWith(null), pairwise(), filter((pair) => pair[1] !== null), map(([previous, current]) => ({
7710
+ current,
7711
+ changedFields: getChangedKeys(previous, current),
7712
+ })), filter(({ changedFields }) => changedFields.size > 0),
7713
+ // switchMap: For debounced derivations, it's OK to cancel pending work
7714
+ // if new changes come in - we want the latest debounced values.
7715
+ // (Unlike onChange which uses exhaustMap to prevent cancellation)
7716
+ switchMap(({ changedFields }) => {
7717
+ const collection = untracked(() => this.derivationCollection());
7718
+ const formAccessor = untracked(() => this.config.form());
7719
+ if (!collection || !formAccessor)
7720
+ return of(null);
7721
+ // Get unique debounce periods from entries with trigger 'debounced'
7722
+ const debouncePeriods = this.getDebouncePeriods(collection.entries);
7723
+ if (debouncePeriods.length === 0)
7724
+ return of(null);
7725
+ // merge: Process multiple debounce periods concurrently.
7726
+ // Each period stream handles its own timing independently.
7727
+ const periodStreams = debouncePeriods.map((debounceMs) => this.createPeriodStream(debounceMs, collection, formAccessor, changedFields));
7728
+ return merge(...periodStreams);
7729
+ }), takeUntilDestroyed(this.destroyRef))
7730
+ .subscribe();
7731
+ }
7732
+ createPeriodStream(debounceMs, collection, formAccessor, changedFields) {
7733
+ const additionalWait = Math.max(0, debounceMs - DEFAULT_DEBOUNCE_MS);
7734
+ if (additionalWait === 0) {
7735
+ return of(null).pipe(map(() => {
7736
+ this.applyDebouncedEntriesForPeriod(debounceMs, collection, formAccessor, changedFields);
7737
+ }));
7738
+ }
7739
+ return timer(additionalWait).pipe(map(() => {
7740
+ const currentCollection = untracked(() => this.derivationCollection());
7741
+ const currentFormAccessor = untracked(() => this.config.form());
7742
+ if (currentCollection && currentFormAccessor) {
7743
+ this.applyDebouncedEntriesForPeriod(debounceMs, currentCollection, currentFormAccessor, changedFields);
7744
+ }
7745
+ }));
7746
+ }
7747
+ applyOnChangeDerivations(collection, formAccessor) {
7748
+ const applicatorContext = {
7749
+ formValue: this.config.formValue,
7750
+ rootForm: formAccessor,
7751
+ derivationFunctions: this.functionRegistry.getDerivationFunctions(),
7752
+ customFunctions: this.functionRegistry.getCustomFunctions(),
7753
+ logger: this.logger,
7754
+ warningTracker: this.warningTracker,
7755
+ derivationLogger: untracked(() => this.config.derivationLogger()),
7756
+ maxIterations: untracked(() => this.formOptions()?.maxDerivationIterations),
7757
+ };
7758
+ const result = applyDerivationsForTrigger(collection, 'onChange', applicatorContext);
7759
+ if (result.maxIterationsReached) {
7760
+ this.logger.warn(`Derivation processing reached max iterations. ` +
7761
+ `This may indicate a loop in derivation logic that wasn't caught at build time. ` +
7762
+ `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`);
7763
+ }
7764
+ }
7765
+ applyDebouncedEntriesForPeriod(debounceMs, collection, formAccessor, changedFields) {
7766
+ // Filter entries to just those with the specific debounce period
7767
+ const debouncedEntries = collection.entries.filter((entry) => entry.trigger === 'debounced' && (entry.debounceMs ?? DEFAULT_DEBOUNCE_MS) === debounceMs);
7768
+ if (debouncedEntries.length === 0) {
7769
+ return;
7770
+ }
7771
+ // Create a minimal collection with filtered entries
7772
+ const filteredCollection = {
7773
+ entries: debouncedEntries,
7774
+ };
7775
+ const applicatorContext = {
7776
+ formValue: this.config.formValue,
7777
+ rootForm: formAccessor,
7778
+ derivationFunctions: this.functionRegistry.getDerivationFunctions(),
7779
+ customFunctions: this.functionRegistry.getCustomFunctions(),
7780
+ logger: this.logger,
7781
+ warningTracker: this.warningTracker,
7782
+ derivationLogger: untracked(() => this.config.derivationLogger()),
7783
+ maxIterations: untracked(() => this.formOptions()?.maxDerivationIterations),
7784
+ };
7785
+ const result = applyDerivationsForTrigger(filteredCollection, 'debounced', applicatorContext, changedFields);
7786
+ if (result.maxIterationsReached) {
7787
+ this.logger.warn(`Debounced derivation processing reached max iterations (${debounceMs}ms). ` +
7788
+ `Applied: ${result.appliedCount}, Skipped: ${result.skippedCount}, Errors: ${result.errorCount}`);
7789
+ }
7790
+ }
7791
+ /**
7792
+ * Gets all unique debounce periods from entries with trigger 'debounced'.
7793
+ */
7794
+ getDebouncePeriods(entries) {
7795
+ const periods = new Set();
7796
+ for (const entry of entries) {
7797
+ if (entry.trigger === 'debounced') {
7798
+ periods.add(entry.debounceMs ?? DEFAULT_DEBOUNCE_MS);
7799
+ }
7800
+ }
7801
+ return Array.from(periods);
7802
+ }
7803
+ warnAboutWildcardDependencies(entries, fieldCount) {
7804
+ if (!isDevMode())
7805
+ return;
7806
+ // Find entries with wildcard dependency
7807
+ const wildcardEntries = entries.filter((entry) => entry.dependsOn.includes('*'));
7808
+ if (wildcardEntries.length === 0)
7809
+ return;
7810
+ // Find implicit wildcards (custom functions without explicit dependsOn)
7811
+ const implicitWildcards = wildcardEntries.filter((entry) => entry.functionName && (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
7812
+ if (implicitWildcards.length > 0) {
7813
+ const derivationDescs = implicitWildcards.map((e) => `${e.sourceFieldKey} -> ${e.targetFieldKey} (${e.functionName})`);
7814
+ this.logger.warn('[Derivation] Derivations using custom functions without explicit dependsOn detected. ' +
7815
+ `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
7816
+ 'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
7817
+ }
7818
+ }
7819
+ }
7820
+ /**
7821
+ * Creates a DerivationOrchestrator for a dynamic form.
7822
+ * Must be called within an injection context.
7823
+ *
7824
+ * @param config - Form-specific signals configuration
7825
+ * @returns The created DerivationOrchestrator
7826
+ *
7827
+ * @public
7828
+ */
7829
+ function createDerivationOrchestrator(config) {
7830
+ return new DerivationOrchestrator(config);
7831
+ }
7832
+ /**
7833
+ * Injection token for the DerivationOrchestrator.
7834
+ *
7835
+ * @public
7836
+ */
7837
+ const DERIVATION_ORCHESTRATOR = new InjectionToken('DERIVATION_ORCHESTRATOR');
7838
+ /**
7839
+ * Injects the DerivationOrchestrator from the current injection context.
7840
+ *
7841
+ * @returns The DerivationOrchestrator instance
7842
+ * @throws Error if called outside of an injection context or if orchestrator is not provided
7843
+ *
7844
+ * @public
7845
+ */
7846
+ function injectDerivationOrchestrator() {
7847
+ return inject(DERIVATION_ORCHESTRATOR);
7848
+ }
7849
+
7850
+ class NextPageEvent {
7851
+ type = 'next-page';
7852
+ }
7853
+
7854
+ class PageChangeEvent {
7855
+ currentPageIndex;
7856
+ totalPages;
7857
+ previousPageIndex;
7858
+ type = 'page-change';
7859
+ constructor(
7860
+ /** The current page index (0-based) */
7861
+ currentPageIndex,
7862
+ /** Total number of pages */
7863
+ totalPages,
7864
+ /** Previous page index (0-based), undefined if first navigation */
7865
+ previousPageIndex) {
7866
+ this.currentPageIndex = currentPageIndex;
7867
+ this.totalPages = totalPages;
7868
+ this.previousPageIndex = previousPageIndex;
7869
+ }
7870
+ }
7871
+
7872
+ class PreviousPageEvent {
7873
+ type = 'previous-page';
7874
+ }
7875
+
7876
+ /**
7877
+ * Event dispatched to add a new item to an array field.
7878
+ *
7879
+ * This event is used to dynamically add items to array fields at runtime.
7880
+ * The array field component listens for this event and adds a new item
7881
+ * based on the provided field template, or uses the array's own template if not provided.
7882
+ *
7883
+ * @example
7884
+ * ```typescript
7885
+ * // Add item using array's own template
7886
+ * eventBus.dispatch(AddArrayItemEvent, 'contacts');
7887
+ *
7888
+ * // Add item with explicit field template
7889
+ * const fieldTemplate = { key: 'email', type: 'input' };
7890
+ * eventBus.dispatch(AddArrayItemEvent, 'contacts', fieldTemplate);
7891
+ *
7892
+ * // Add item at specific index
7893
+ * eventBus.dispatch(AddArrayItemEvent, 'contacts', fieldTemplate, 2);
7894
+ * ```
7895
+ */
7896
+ class AddArrayItemEvent {
7897
+ arrayKey;
7898
+ field;
7899
+ index;
7900
+ type = 'add-array-item';
7901
+ constructor(
7902
+ /** The key of the array field to add an item to */
7903
+ arrayKey,
7904
+ /**
7905
+ * Optional field template definition to render for the new array item.
7906
+ * If not provided, the array field component will use its own template.
7907
+ */
7908
+ field,
7909
+ /** Optional index where to insert the item (defaults to end of array) */
7910
+ index) {
7911
+ this.arrayKey = arrayKey;
7912
+ this.field = field;
7913
+ this.index = index;
7914
+ }
7915
+ }
7916
+
7917
+ /**
7918
+ * Event dispatched to remove an item from an array field.
7919
+ *
7920
+ * This event is used to dynamically remove items from array fields at runtime.
7921
+ * The array field component listens for this event and removes the item
7922
+ * at the specified index.
7923
+ *
7924
+ * @example
7925
+ * ```typescript
7926
+ * // Remove item at index 1 from 'contacts' array
7927
+ * eventBus.dispatch(RemoveArrayItemEvent, 'contacts', 1);
7928
+ *
7929
+ * // Remove last item
7930
+ * eventBus.dispatch(RemoveArrayItemEvent, 'contacts');
7931
+ * ```
7932
+ */
7933
+ class RemoveArrayItemEvent {
7934
+ arrayKey;
7935
+ index;
7936
+ type = 'remove-array-item';
7937
+ constructor(
7938
+ /** The key of the array field to remove an item from */
7939
+ arrayKey,
7940
+ /** Index of the item to remove (defaults to last item) */
7941
+ index) {
7942
+ this.arrayKey = arrayKey;
7943
+ this.index = index;
7944
+ }
7945
+ }
7946
+
7947
+ /**
7948
+ * Event dispatched when the form should be reset to its default values.
7949
+ *
7950
+ * This event instructs the dynamic form component to restore all field values
7951
+ * to their initial default values as defined in the form configuration.
7952
+ *
7953
+ * @example
7954
+ * ```typescript
7955
+ * // Dispatch from a button component
7956
+ * eventBus.dispatch(FormResetEvent);
7957
+ * ```
7958
+ *
7959
+ * @example
7960
+ * ```typescript
7961
+ * // Listen for reset events
7962
+ * eventBus.on<FormResetEvent>('form-reset').subscribe(() => {
7963
+ * console.log('Form was reset to defaults');
7964
+ * });
7965
+ * ```
7966
+ */
7967
+ class FormResetEvent {
7968
+ type = 'form-reset';
7969
+ }
7970
+
7971
+ /**
7972
+ * Event dispatched when the form should be cleared.
7973
+ *
7974
+ * This event instructs the dynamic form component to clear all field values,
7975
+ * resetting them to empty/undefined state regardless of their default values.
7976
+ *
7977
+ * @example
7978
+ * ```typescript
7979
+ * // Dispatch from a button component
7980
+ * eventBus.dispatch(FormClearEvent);
7981
+ * ```
7982
+ *
7983
+ * @example
7984
+ * ```typescript
7985
+ * // Listen for clear events
7986
+ * eventBus.on<FormClearEvent>('form-clear').subscribe(() => {
7987
+ * console.log('Form was cleared');
7988
+ * });
7989
+ * ```
7990
+ */
7991
+ class FormClearEvent {
7992
+ type = 'form-clear';
5037
7993
  }
5038
7994
 
5039
7995
  class PageNavigationStateChangeEvent {
@@ -5380,9 +8336,9 @@ class PageOrchestratorComponent {
5380
8336
  }
5381
8337
  }
5382
8338
  }
5383
- `, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-Bt6blv9I.mjs').then(m => m.default)], () => [import('./ng-forge-dynamic-forms-page-field.component-Bt6blv9I.mjs').then(m => m.default)]] });
8339
+ `, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-CrO9WTDw.mjs').then(m => m.default)], () => [import('./ng-forge-dynamic-forms-page-field.component-CrO9WTDw.mjs').then(m => m.default)]] });
5384
8340
  }
5385
- i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.1.0", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-Bt6blv9I.mjs').then(m => m.default)], resolveMetadata: PageFieldComponent => ({ decorators: [{
8341
+ i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.1.0", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-CrO9WTDw.mjs').then(m => m.default)], resolveMetadata: PageFieldComponent => ({ decorators: [{
5386
8342
  type: Component,
5387
8343
  args: [{ selector: 'div[page-orchestrator]', imports: [PageFieldComponent], template: `
5388
8344
  @for (pageField of pageFields(); track pageField.key; let i = $index) {
@@ -5421,6 +8377,136 @@ i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.1.0", ng
5421
8377
  ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:block;position:relative;width:100%}\n"] }]
5422
8378
  }], ctorParameters: () => [], propDecorators: { pageFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageFields", required: true }] }], form: [{ type: i0.Input, args: [{ isSignal: true, alias: "form", required: true }] }], fieldSignalContext: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldSignalContext", required: true }] }] } }) });
5423
8379
 
8380
+ /**
8381
+ * Type guard to check if a value is a DynamicFormFeature
8382
+ */
8383
+ function isDynamicFormFeature(value) {
8384
+ return (typeof value === 'object' &&
8385
+ value !== null &&
8386
+ 'ɵkind' in value &&
8387
+ typeof value.ɵkind === 'string' &&
8388
+ 'ɵproviders' in value &&
8389
+ Array.isArray(value.ɵproviders));
8390
+ }
8391
+ /**
8392
+ * Helper to create a feature with proper typing
8393
+ */
8394
+ function createFeature(kind, providers) {
8395
+ return {
8396
+ ɵkind: kind,
8397
+ ɵproviders: providers,
8398
+ };
8399
+ }
8400
+
8401
+ /**
8402
+ * Console-based logger implementation.
8403
+ */
8404
+ class ConsoleLogger {
8405
+ prefix = '[Dynamic Forms]';
8406
+ debug(message, ...args) {
8407
+ console.debug(this.prefix, message, ...args);
8408
+ }
8409
+ info(message, ...args) {
8410
+ console.info(this.prefix, message, ...args);
8411
+ }
8412
+ warn(message, ...args) {
8413
+ console.warn(this.prefix, message, ...args);
8414
+ }
8415
+ error(message, ...args) {
8416
+ console.error(this.prefix, message, ...args);
8417
+ }
8418
+ }
8419
+
8420
+ /**
8421
+ * No-operation logger implementation.
8422
+ * All methods are no-ops - used in production by default.
8423
+ */
8424
+ class NoopLogger {
8425
+ debug() {
8426
+ // Intentionally empty
8427
+ }
8428
+ info() {
8429
+ // Intentionally empty
8430
+ }
8431
+ warn() {
8432
+ // Intentionally empty
8433
+ }
8434
+ error() {
8435
+ // Intentionally empty
8436
+ }
8437
+ }
8438
+
8439
+ /**
8440
+ * Injection token for derivation logging configuration.
8441
+ *
8442
+ * @internal
8443
+ */
8444
+ const DERIVATION_LOG_CONFIG = new InjectionToken('DERIVATION_LOG_CONFIG', {
8445
+ providedIn: 'root',
8446
+ factory: createDefaultDerivationLogConfig,
8447
+ });
8448
+ /**
8449
+ * Configure logging for dynamic forms.
8450
+ *
8451
+ * By default, general logging is enabled (ConsoleLogger) and derivation
8452
+ * logging is disabled ('none'). Use this feature to enable derivation debugging.
8453
+ *
8454
+ * @example
8455
+ * ```typescript
8456
+ * // Disable all logging
8457
+ * provideDynamicForm(
8458
+ * ...withMaterialFields(),
8459
+ * withLoggerConfig(false)
8460
+ * )
8461
+ *
8462
+ * // Enable verbose derivation logging
8463
+ * provideDynamicForm(
8464
+ * ...withMaterialFields(),
8465
+ * withLoggerConfig({ derivations: 'verbose' })
8466
+ * )
8467
+ *
8468
+ * // Disable derivation logging but keep general logging
8469
+ * provideDynamicForm(
8470
+ * ...withMaterialFields(),
8471
+ * withLoggerConfig({ derivations: 'none' })
8472
+ * )
8473
+ *
8474
+ * // Conditional logging (e.g., only in dev mode)
8475
+ * provideDynamicForm(
8476
+ * ...withMaterialFields(),
8477
+ * withLoggerConfig(() => isDevMode())
8478
+ * )
8479
+ * ```
8480
+ *
8481
+ * @param config - Boolean to enable/disable logging, a function returning boolean, or a config object
8482
+ * @returns A DynamicFormFeature that configures logging
8483
+ *
8484
+ * @public
8485
+ */
8486
+ function withLoggerConfig(config = true) {
8487
+ // Handle boolean or function
8488
+ if (typeof config === 'boolean' || typeof config === 'function') {
8489
+ const isEnabled = typeof config === 'function' ? config() : config;
8490
+ const logger = isEnabled ? new ConsoleLogger() : new NoopLogger();
8491
+ return createFeature('logger', [{ provide: DynamicFormLogger, useValue: logger }]);
8492
+ }
8493
+ // Handle config object
8494
+ const isEnabled = config.enabled !== false;
8495
+ const logger = isEnabled ? new ConsoleLogger() : new NoopLogger();
8496
+ const providers = [{ provide: DynamicFormLogger, useValue: logger }];
8497
+ // Add derivation log config if specified
8498
+ if (config.derivations !== undefined) {
8499
+ providers.push({
8500
+ provide: DERIVATION_LOG_CONFIG,
8501
+ useValue: { level: config.derivations },
8502
+ });
8503
+ }
8504
+ return createFeature('logger', providers);
8505
+ }
8506
+
8507
+ function derivationOrchestratorFactoryWrapper(dynamicForm) {
8508
+ return createDerivationOrchestrator(dynamicForm.derivationOrchestratorConfig);
8509
+ }
5424
8510
  /**
5425
8511
  * Dynamic form component that renders a complete form based on configuration.
5426
8512
  *
@@ -5452,6 +8538,7 @@ class DynamicForm {
5452
8538
  functionRegistry = inject(FunctionRegistryService);
5453
8539
  schemaRegistry = inject(SchemaRegistryService);
5454
8540
  logger = inject(DynamicFormLogger);
8541
+ derivationLogConfig = inject(DERIVATION_LOG_CONFIG);
5455
8542
  // ─────────────────────────────────────────────────────────────────────────────
5456
8543
  // Inputs
5457
8544
  // ─────────────────────────────────────────────────────────────────────────────
@@ -5542,6 +8629,15 @@ class DynamicForm {
5542
8629
  const inputOptions = this.formOptions();
5543
8630
  return { ...configOptions, ...inputOptions };
5544
8631
  }, ...(ngDevMode ? [{ debugName: "effectiveFormOptions" }] : []));
8632
+ /**
8633
+ * Computed derivation logger based on injected config.
8634
+ *
8635
+ * Uses factory pattern to return no-op logger when logging is disabled,
8636
+ * avoiding any logging overhead in production.
8637
+ */
8638
+ derivationLogger = computed(() => {
8639
+ return createDerivationLogger(this.derivationLogConfig, this.logger);
8640
+ }, ...(ngDevMode ? [{ debugName: "derivationLogger" }] : []));
5545
8641
  fieldSignalContext = computed(() => ({
5546
8642
  injector: this.injector,
5547
8643
  value: this.value,
@@ -5577,12 +8673,27 @@ class DynamicForm {
5577
8673
  form = computed(() => {
5578
8674
  return runInInjectionContext(this.injector, () => {
5579
8675
  const setup = this.formSetup();
8676
+ const config = this.config();
5580
8677
  let formInstance;
5581
8678
  untracked(() => this.rootFormRegistry.registerFormValueSignal(this.entity));
5582
- if (setup.schemaFields && setup.schemaFields.length > 0) {
8679
+ const hasFields = setup.schemaFields && setup.schemaFields.length > 0;
8680
+ const hasFormSchema = config.schema !== undefined;
8681
+ if (hasFields) {
5583
8682
  const crossFieldCollection = collectCrossFieldEntries(setup.schemaFields);
5584
- const schema = createSchemaFromFields(setup.schemaFields, setup.registry, crossFieldCollection.validators);
5585
- formInstance = untracked(() => form(this.entity, schema));
8683
+ // Create schema with both field-level and form-level validation
8684
+ // Type assertion needed due to complex generic type inference between TModel and InferFormValue
8685
+ const combinedSchema = createSchemaFromFields(setup.schemaFields, setup.registry, {
8686
+ crossFieldValidators: crossFieldCollection.validators,
8687
+ formLevelSchema: config.schema,
8688
+ });
8689
+ formInstance = untracked(() => form(this.entity, combinedSchema));
8690
+ }
8691
+ else if (hasFormSchema) {
8692
+ // Only form-level schema (no fields with validation)
8693
+ // Non-null assertion safe: hasFormSchema guarantees config.schema is defined
8694
+ // Type assertion needed due to generic type inference
8695
+ const formSchema = createFormLevelSchema(config.schema);
8696
+ formInstance = untracked(() => form(this.entity, formSchema));
5586
8697
  }
5587
8698
  else {
5588
8699
  formInstance = untracked(() => form(this.entity));
@@ -5628,6 +8739,18 @@ class DynamicForm {
5628
8739
  }, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
5629
8740
  /** Whether the form is currently submitting. */
5630
8741
  submitting = computed(() => this.form()().submitting(), ...(ngDevMode ? [{ debugName: "submitting" }] : []));
8742
+ /**
8743
+ * Configuration for the derivation orchestrator.
8744
+ * Bundles the form-specific signals needed by the orchestrator.
8745
+ *
8746
+ * @internal Used by DERIVATION_ORCHESTRATOR provider.
8747
+ */
8748
+ derivationOrchestratorConfig = {
8749
+ schemaFields: computed(() => this.formSetup().schemaFields),
8750
+ formValue: this.formValue,
8751
+ form: computed(() => this.form()),
8752
+ derivationLogger: this.derivationLogger,
8753
+ };
5631
8754
  // ─────────────────────────────────────────────────────────────────────────────
5632
8755
  // Initialization
5633
8756
  // ─────────────────────────────────────────────────────────────────────────────
@@ -5707,6 +8830,11 @@ class DynamicForm {
5707
8830
  constructor() {
5708
8831
  this.setupEffects();
5709
8832
  this.setupEventHandlers();
8833
+ // Lazily inject the derivation orchestrator to avoid circular dependency.
8834
+ // The orchestrator's constructor sets up RxJS subscriptions that process derivations.
8835
+ afterNextRender(() => {
8836
+ this.injector.get(DERIVATION_ORCHESTRATOR);
8837
+ });
5710
8838
  }
5711
8839
  /**
5712
8840
  * Handles native form submission triggered by:
@@ -5784,6 +8912,10 @@ class DynamicForm {
5784
8912
  this.functionRegistry.registerCustomFunction(name, fn);
5785
8913
  });
5786
8914
  }
8915
+ // Register derivation functions (stored separately for use by derivation applicator)
8916
+ if (customFnConfig.derivations) {
8917
+ this.functionRegistry.setDerivationFunctions(customFnConfig.derivations);
8918
+ }
5787
8919
  this.functionRegistry.setValidators(customFnConfig.validators);
5788
8920
  this.functionRegistry.setAsyncValidators(customFnConfig.asyncValidators);
5789
8921
  this.functionRegistry.setHttpValidators(customFnConfig.httpValidators);
@@ -5855,6 +8987,12 @@ class DynamicForm {
5855
8987
  useFactory: (form) => form.effectiveFormOptions,
5856
8988
  deps: [DynamicForm],
5857
8989
  },
8990
+ { provide: DERIVATION_WARNING_TRACKER, useFactory: createDerivationWarningTracker },
8991
+ {
8992
+ provide: DERIVATION_ORCHESTRATOR,
8993
+ useFactory: derivationOrchestratorFactoryWrapper,
8994
+ deps: [DynamicForm],
8995
+ },
5858
8996
  ], ngImport: i0, template: `
5859
8997
  @if (formModeDetection().mode === 'paged') {
5860
8998
  <div page-orchestrator [pageFields]="pageFieldDefinitions()" [form]="form()" [fieldSignalContext]="fieldSignalContext()"></div>
@@ -5897,6 +9035,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
5897
9035
  useFactory: (form) => form.effectiveFormOptions,
5898
9036
  deps: [DynamicForm],
5899
9037
  },
9038
+ { provide: DERIVATION_WARNING_TRACKER, useFactory: createDerivationWarningTracker },
9039
+ {
9040
+ provide: DERIVATION_ORCHESTRATOR,
9041
+ useFactory: derivationOrchestratorFactoryWrapper,
9042
+ deps: [DynamicForm],
9043
+ },
5900
9044
  ], host: {
5901
9045
  class: 'df-dynamic-form df-form',
5902
9046
  novalidate: '', // Disable browser validation - Angular Signal Forms handles validation
@@ -6081,13 +9225,13 @@ const BUILT_IN_FIELDS = [
6081
9225
  },
6082
9226
  {
6083
9227
  name: 'page',
6084
- loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-Bt6blv9I.mjs'),
9228
+ loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-CrO9WTDw.mjs'),
6085
9229
  mapper: pageFieldMapper,
6086
9230
  valueHandling: 'flatten',
6087
9231
  },
6088
9232
  {
6089
9233
  name: 'text',
6090
- loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-DKCx7Z_U.mjs'),
9234
+ loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-BcFx_kOz.mjs'),
6091
9235
  mapper: textFieldMapper,
6092
9236
  valueHandling: 'exclude',
6093
9237
  },
@@ -6098,46 +9242,6 @@ const BUILT_IN_FIELDS = [
6098
9242
  },
6099
9243
  ];
6100
9244
 
6101
- /**
6102
- * Type guard to check if a value is a DynamicFormFeature
6103
- */
6104
- function isDynamicFormFeature(value) {
6105
- return (typeof value === 'object' &&
6106
- value !== null &&
6107
- 'ɵkind' in value &&
6108
- typeof value.ɵkind === 'string' &&
6109
- 'ɵproviders' in value &&
6110
- Array.isArray(value.ɵproviders));
6111
- }
6112
- /**
6113
- * Helper to create a feature with proper typing
6114
- */
6115
- function createFeature(kind, providers) {
6116
- return {
6117
- ɵkind: kind,
6118
- ɵproviders: providers,
6119
- };
6120
- }
6121
-
6122
- /**
6123
- * Console-based logger implementation.
6124
- */
6125
- class ConsoleLogger {
6126
- prefix = '[Dynamic Forms]';
6127
- debug(message, ...args) {
6128
- console.debug(this.prefix, message, ...args);
6129
- }
6130
- info(message, ...args) {
6131
- console.info(this.prefix, message, ...args);
6132
- }
6133
- warn(message, ...args) {
6134
- console.warn(this.prefix, message, ...args);
6135
- }
6136
- error(message, ...args) {
6137
- console.error(this.prefix, message, ...args);
6138
- }
6139
- }
6140
-
6141
9245
  /**
6142
9246
  * Provider function to configure the dynamic form system with field types and options.
6143
9247
  *
@@ -6231,55 +9335,6 @@ function provideDynamicForm(...items) {
6231
9335
  ]);
6232
9336
  }
6233
9337
 
6234
- /**
6235
- * No-operation logger implementation.
6236
- * All methods are no-ops - used in production by default.
6237
- */
6238
- class NoopLogger {
6239
- debug() {
6240
- // Intentionally empty
6241
- }
6242
- info() {
6243
- // Intentionally empty
6244
- }
6245
- warn() {
6246
- // Intentionally empty
6247
- }
6248
- error() {
6249
- // Intentionally empty
6250
- }
6251
- }
6252
-
6253
- /**
6254
- * Configure logging for dynamic forms.
6255
- *
6256
- * By default, logging is enabled (ConsoleLogger). Use this feature to disable logging
6257
- * or configure it conditionally.
6258
- *
6259
- * @example
6260
- * ```typescript
6261
- * // Disable logging
6262
- * provideDynamicForm(
6263
- * ...withMaterialFields(),
6264
- * withLoggerConfig(false)
6265
- * )
6266
- *
6267
- * // Conditional logging (e.g., only in dev mode)
6268
- * provideDynamicForm(
6269
- * ...withMaterialFields(),
6270
- * withLoggerConfig(() => isDevMode())
6271
- * )
6272
- * ```
6273
- *
6274
- * @param enabled - Whether logging is enabled. Can be a boolean or a function that returns a boolean. Defaults to true.
6275
- * @returns A DynamicFormFeature that configures logging
6276
- */
6277
- function withLoggerConfig(enabled = true) {
6278
- const isEnabled = typeof enabled === 'function' ? enabled() : enabled;
6279
- const logger = isEnabled ? new ConsoleLogger() : new NoopLogger();
6280
- return createFeature('logger', [{ provide: DynamicFormLogger, useValue: logger }]);
6281
- }
6282
-
6283
9338
  /**
6284
9339
  * No-op logger for when no logger is provided.
6285
9340
  * Used as fallback in button logic evaluation to avoid breaking downstream packages.
@@ -6535,6 +9590,62 @@ function resolveTokens(args, context) {
6535
9590
  });
6536
9591
  }
6537
9592
 
9593
+ /**
9594
+ * Type-safe form config builder that ensures schema matches field structure.
9595
+ *
9596
+ * This helper function provides type safety when using form-level schemas by
9597
+ * automatically inferring the form value type from fields and constraining
9598
+ * the schema type parameter accordingly.
9599
+ *
9600
+ * **When to use:**
9601
+ * - When you have a form-level `schema` and want type safety between fields and schema
9602
+ * - When you prefer function syntax over `as const satisfies FormConfig`
9603
+ *
9604
+ * **Note:** This is optional. Users who don't need schema type safety can
9605
+ * continue using `as const satisfies FormConfig` directly.
9606
+ *
9607
+ * @typeParam TFields - The field definitions array (narrowed via `as const`)
9608
+ * @typeParam TProps - Optional form-level default props type
9609
+ *
9610
+ * @param config - The form configuration object
9611
+ * @returns The same configuration with proper type inference
9612
+ *
9613
+ * @example Basic usage with Zod schema
9614
+ * ```typescript
9615
+ * import { z } from 'zod';
9616
+ * import { formConfig } from '@ng-forge/dynamic-forms';
9617
+ * import { standardSchema } from '@ng-forge/dynamic-forms/schema';
9618
+ *
9619
+ * const passwordSchema = z.object({
9620
+ * password: z.string().min(8),
9621
+ * confirmPassword: z.string(),
9622
+ * }).refine(
9623
+ * (data) => data.password === data.confirmPassword,
9624
+ * { message: 'Passwords must match', path: ['confirmPassword'] }
9625
+ * );
9626
+ *
9627
+ * const config = formConfig({
9628
+ * schema: standardSchema(passwordSchema),
9629
+ * fields: [
9630
+ * { key: 'password', type: 'input', label: 'Password', required: true, props: { type: 'password' } },
9631
+ * { key: 'confirmPassword', type: 'input', label: 'Confirm', required: true, props: { type: 'password' } },
9632
+ * { key: 'submit', type: 'submit', label: 'Register' },
9633
+ * ] as const,
9634
+ * });
9635
+ * ```
9636
+ *
9637
+ * @example Equivalent without helper
9638
+ * ```typescript
9639
+ * const config = {
9640
+ * schema: standardSchema(passwordSchema),
9641
+ * fields: [...],
9642
+ * } as const satisfies FormConfig;
9643
+ * ```
9644
+ */
9645
+ function formConfig(config) {
9646
+ return config;
9647
+ }
9648
+
6538
9649
  /**
6539
9650
  * Converts DynamicText (string | Observable | Signal) to Observable<string>
6540
9651
  * Unifies all three types into a consistent Observable stream
@@ -7535,10 +10646,6 @@ function extractErrorParams(error) {
7535
10646
  * - Field mappers (valueFieldMapper, checkboxFieldMapper, etc.)
7536
10647
  * - Error utilities (createResolvedErrorsSignal, shouldShowErrors)
7537
10648
  *
7538
- * ## Testing API
7539
- * Import from '@ng-forge/dynamic-forms/testing' for:
7540
- * - Test utilities and harnesses
7541
- * - Form config builders for testing
7542
10649
  */
7543
10650
  // ============================================================
7544
10651
  // PUBLIC API - For end users
@@ -7549,5 +10656,5 @@ function extractErrorParams(error) {
7549
10656
  * Generated bundle index. Do not edit.
7550
10657
  */
7551
10658
 
7552
- export { applyValidators as $, AddArrayItemEvent as A, baseFieldMapper as B, ConsoleLogger as C, DynamicFormLogger as D, EventBus as E, FormClearEvent as F, buildBaseInputs as G, arrayFieldMapper as H, groupFieldMapper as I, pageFieldMapper as J, rowFieldMapper as K, textFieldMapper as L, FIELD_REGISTRY as M, NoopLogger as N, ARRAY_CONTEXT as O, PageChangeEvent as P, DEFAULT_PROPS as Q, RemoveArrayItemEvent as R, SubmitEvent as S, DEFAULT_VALIDATION_MESSAGES as T, FIELD_SIGNAL_CONTEXT as U, FORM_OPTIONS as V, dynamicTextToObservable as W, ArrayFieldComponent as X, GroupFieldComponent as Y, RowFieldComponent as Z, applyValidator as _, DynamicTextPipe as a, getArrayLength as a0, resolveTokens as a1, BUILT_IN_FIELDS as a2, omit as a3, interpolateParams as a4, applyMetaToElement as a5, RootFormRegistryService as a6, DynamicForm as b, createFieldResolutionPipe as c, derivedFromDeferred as d, emitComponentInitialized as e, isFormStateCondition as f, resolveNextButtonDisabled as g, isCheckedField as h, injectFieldRegistry as i, isValueField as j, isRowField as k, isArrayField as l, isContainerField as m, isDisplayOnlyField as n, isGroupField as o, provideDynamicForm as p, isLeafField as q, resolveSubmitButtonDisabled as r, isPageField as s, isValueBearingField as t, FormResetEvent as u, validatePageNesting as v, withLoggerConfig as w, NextPageEvent as x, PreviousPageEvent as y, DynamicFormError as z };
7553
- //# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-DHxoGouT.mjs.map
10659
+ export { applyValidator as $, AddArrayItemEvent as A, DynamicFormError as B, ConsoleLogger as C, DynamicFormLogger as D, EventBus as E, FormClearEvent as F, baseFieldMapper as G, buildBaseInputs as H, arrayFieldMapper as I, groupFieldMapper as J, pageFieldMapper as K, rowFieldMapper as L, textFieldMapper as M, NoopLogger as N, FIELD_REGISTRY as O, PageChangeEvent as P, ARRAY_CONTEXT as Q, RemoveArrayItemEvent as R, SubmitEvent as S, DEFAULT_PROPS as T, DEFAULT_VALIDATION_MESSAGES as U, FIELD_SIGNAL_CONTEXT as V, FORM_OPTIONS as W, dynamicTextToObservable as X, ArrayFieldComponent as Y, GroupFieldComponent as Z, RowFieldComponent as _, DynamicTextPipe as a, applyValidators as a0, getArrayLength as a1, resolveTokens as a2, BUILT_IN_FIELDS as a3, isEqual as a4, omit as a5, interpolateParams as a6, applyMetaToElement as a7, RootFormRegistryService as a8, DynamicForm as b, createFieldResolutionPipe as c, derivedFromDeferred as d, emitComponentInitialized as e, isFormStateCondition as f, resolveNextButtonDisabled as g, isCheckedField as h, injectFieldRegistry as i, isValueField as j, isRowField as k, isArrayField as l, isContainerField as m, isDisplayOnlyField as n, isGroupField as o, provideDynamicForm as p, isLeafField as q, resolveSubmitButtonDisabled as r, isPageField as s, isValueBearingField as t, FormResetEvent as u, validatePageNesting as v, withLoggerConfig as w, NextPageEvent as x, PreviousPageEvent as y, formConfig as z };
10660
+ //# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-WGww2nXx.mjs.map