@ng-forge/dynamic-forms 0.5.0 → 0.5.1

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.
@@ -300,7 +300,7 @@ function resolveField(fieldDef, context) {
300
300
  inputs,
301
301
  };
302
302
  }), catchError((error) => {
303
- // Only handle errors if component hasn't been destroyed
303
+ // Only call onError if component is not destroyed to avoid accessing cleaned-up state
304
304
  if (!context.destroyRef.destroyed) {
305
305
  context.onError?.(fieldDef, error);
306
306
  }
@@ -348,19 +348,22 @@ function createFieldResolutionPipe(getContext) {
348
348
  * Wraps a submission action to handle both Promise and Observable returns.
349
349
  * Converts Observables to Promises for compatibility with Angular Signal Forms' submit().
350
350
  *
351
+ * The return type uses `void` to match Angular Signal Forms' expected action signature.
352
+ * The actual result from the action is preserved but typed as void since Angular's
353
+ * submit() function handles the result internally.
354
+ *
351
355
  * @param action - The submission action function
352
356
  * @returns A wrapped function that always returns a Promise
353
357
  */
354
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Form tree type is dynamic, action signatures vary
355
358
  function wrapSubmissionAction(action) {
356
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
357
- return (formTree) => {
359
+ return async (formTree) => {
358
360
  const result = action(formTree);
359
361
  // If the action returns an Observable, convert it to a Promise
360
362
  if (isObservable(result)) {
361
- return firstValueFrom(result);
363
+ await firstValueFrom(result);
364
+ return;
362
365
  }
363
- return result;
366
+ await Promise.resolve(result);
364
367
  };
365
368
  }
366
369
  /**
@@ -398,6 +401,8 @@ function createSubmissionHandler(options) {
398
401
  if (!submissionConfig?.action) {
399
402
  return EMPTY;
400
403
  }
404
+ // Type assertion needed: submission.action accepts the form tree but its signature
405
+ // is defined broadly in FormConfig. The actual runtime type is FieldTree<TModel>.
401
406
  const wrappedAction = wrapSubmissionAction(submissionConfig.action);
402
407
  // Use Angular Signal Forms' native submit() function
403
408
  // This automatically:
@@ -837,6 +842,36 @@ const DEFAULT_VALIDATION_MESSAGES = new InjectionToken('DEFAULT_VALIDATION_MESSA
837
842
  * ```
838
843
  */
839
844
  const FORM_OPTIONS = new InjectionToken('FORM_OPTIONS');
845
+ /**
846
+ * Injection token for external data signals.
847
+ *
848
+ * External data provides a way to inject application state into form expressions.
849
+ * Each property in the record is a Signal that gets unwrapped and made available
850
+ * in the `EvaluationContext` under `externalData`.
851
+ *
852
+ * Like DEFAULT_PROPS, this token is provided ONCE at the DynamicForm level
853
+ * and inherited by all children via Angular's hierarchical injector.
854
+ *
855
+ * The token provides a Signal containing the external data record, which itself
856
+ * contains Signals. This allows the form to react to changes in which external
857
+ * data is available (outer Signal) and to changes in the external data values
858
+ * (inner Signals).
859
+ *
860
+ * @example
861
+ * ```typescript
862
+ * // In FieldContextRegistryService
863
+ * const externalDataSignal = inject(EXTERNAL_DATA, { optional: true });
864
+ *
865
+ * // When creating evaluation context:
866
+ * const externalDataRecord = externalDataSignal?.();
867
+ * const resolvedExternalData = externalDataRecord
868
+ * ? Object.fromEntries(
869
+ * Object.entries(externalDataRecord).map(([k, v]) => [k, v()])
870
+ * )
871
+ * : {};
872
+ * ```
873
+ */
874
+ const EXTERNAL_DATA = new InjectionToken('EXTERNAL_DATA');
840
875
 
841
876
  /**
842
877
  * Base error class for all Dynamic Forms errors.
@@ -1179,7 +1214,7 @@ class RootFormRegistryService {
1179
1214
  }
1180
1215
  }
1181
1216
  catch {
1182
- // Ignore errors when reading form value
1217
+ // Ignore errors when reading form value - can happen during initialization
1183
1218
  }
1184
1219
  }
1185
1220
  return {};
@@ -1243,6 +1278,7 @@ function isChildFieldContext(context) {
1243
1278
  class FieldContextRegistryService {
1244
1279
  rootFormRegistry = inject(RootFormRegistryService);
1245
1280
  logger = inject(DynamicFormLogger);
1281
+ externalDataSignal = inject(EXTERNAL_DATA, { optional: true });
1246
1282
  /**
1247
1283
  * Creates an evaluation context for a field by combining:
1248
1284
  * - The field's current value
@@ -1277,6 +1313,7 @@ class FieldContextRegistryService {
1277
1313
  formValue,
1278
1314
  fieldPath,
1279
1315
  customFunctions: customFunctions || {},
1316
+ externalData: this.resolveExternalData(false),
1280
1317
  logger: this.logger,
1281
1318
  };
1282
1319
  }
@@ -1295,6 +1332,24 @@ class FieldContextRegistryService {
1295
1332
  // For root fields or when key is not available
1296
1333
  return '';
1297
1334
  }
1335
+ /**
1336
+ * Resolves external data signals to their current values.
1337
+ *
1338
+ * @param reactive - If true, reads signals reactively (creates dependencies).
1339
+ * If false, reads signals with untracked() (no dependencies).
1340
+ * @returns Record of resolved external data values, or undefined if no external data.
1341
+ */
1342
+ resolveExternalData(reactive) {
1343
+ const externalDataRecord = reactive ? this.externalDataSignal?.() : untracked(() => this.externalDataSignal?.());
1344
+ if (!externalDataRecord) {
1345
+ return undefined;
1346
+ }
1347
+ const resolved = {};
1348
+ for (const [key, signal] of Object.entries(externalDataRecord)) {
1349
+ resolved[key] = reactive ? signal() : untracked(() => signal());
1350
+ }
1351
+ return resolved;
1352
+ }
1298
1353
  /**
1299
1354
  * Creates a REACTIVE evaluation context for logic functions.
1300
1355
  *
@@ -1323,6 +1378,7 @@ class FieldContextRegistryService {
1323
1378
  formValue,
1324
1379
  fieldPath,
1325
1380
  customFunctions: customFunctions || {},
1381
+ externalData: this.resolveExternalData(true),
1326
1382
  logger: this.logger,
1327
1383
  };
1328
1384
  }
@@ -1347,6 +1403,7 @@ class FieldContextRegistryService {
1347
1403
  formValue,
1348
1404
  fieldPath,
1349
1405
  customFunctions: customFunctions || {},
1406
+ externalData: this.resolveExternalData(true),
1350
1407
  logger: this.logger,
1351
1408
  };
1352
1409
  }
@@ -2714,14 +2771,18 @@ function evaluateCondition(expression, context) {
2714
2771
  }
2715
2772
  }
2716
2773
  function evaluateFieldValueCondition(expression, context) {
2717
- if (!expression.fieldPath || !expression.operator)
2774
+ if (!expression.fieldPath || !expression.operator) {
2775
+ context.logger.debug('Invalid fieldValue condition config: missing fieldPath or operator', expression);
2718
2776
  return false;
2777
+ }
2719
2778
  const fieldValue = getNestedValue(context.formValue, expression.fieldPath);
2720
2779
  return compareValues(fieldValue, expression.value, expression.operator);
2721
2780
  }
2722
2781
  function evaluateFormValueCondition(expression, context) {
2723
- if (!expression.operator)
2782
+ if (!expression.operator) {
2783
+ context.logger.debug('Invalid formValue condition config: missing operator', expression);
2724
2784
  return false;
2785
+ }
2725
2786
  return compareValues(context.formValue, expression.value, expression.operator);
2726
2787
  }
2727
2788
  function evaluateJavaScriptExpression(expression, context) {
@@ -3463,9 +3524,7 @@ function createFunctionValidator(config) {
3463
3524
  const registry = inject(FunctionRegistryService);
3464
3525
  const validatorFn = registry.getValidator(functionName);
3465
3526
  if (!validatorFn) {
3466
- logger.warn(`Custom validator "${functionName}" not found in registry. ` +
3467
- `Ensure it's registered using customFnConfig.validators or check the function name for typos.`);
3468
- return () => null;
3527
+ throw new DynamicFormError(`Custom validator "${functionName}" not found. Register it with customFnConfig.validators.`);
3469
3528
  }
3470
3529
  return (ctx) => validatorFn(ctx, config.params);
3471
3530
  }
@@ -3517,13 +3576,10 @@ function createExpressionValidator(config) {
3517
3576
  * - onError: Optional handler for resource errors
3518
3577
  */
3519
3578
  function applyAsyncValidator(config, fieldPath) {
3520
- const logger = inject(DynamicFormLogger);
3521
3579
  const registry = inject(FunctionRegistryService);
3522
3580
  const validatorConfig = registry.getAsyncValidator(config.functionName);
3523
3581
  if (!validatorConfig) {
3524
- logger.warn(`Async validator "${config.functionName}" not found in registry. ` +
3525
- `Ensure it's registered using customFnConfig.asyncValidators or check the function name for typos.`);
3526
- return;
3582
+ throw new DynamicFormError(`Async validator "${config.functionName}" not found. Register it with customFnConfig.asyncValidators.`);
3527
3583
  }
3528
3584
  const whenLogic = createConditionalLogic(config.when);
3529
3585
  const asyncOptions = {
@@ -3547,13 +3603,10 @@ function applyAsyncValidator(config, fieldPath) {
3547
3603
  * - onError: Optional handler for HTTP errors
3548
3604
  */
3549
3605
  function applyHttpValidator(config, fieldPath) {
3550
- const logger = inject(DynamicFormLogger);
3551
3606
  const registry = inject(FunctionRegistryService);
3552
3607
  const httpValidatorConfig = registry.getHttpValidator(config.functionName);
3553
3608
  if (!httpValidatorConfig) {
3554
- logger.warn(`HTTP validator "${config.functionName}" not found in registry. ` +
3555
- `Ensure it's registered using customFnConfig.httpValidators or check the function name for typos.`);
3556
- return;
3609
+ throw new DynamicFormError(`HTTP validator "${config.functionName}" not found. Register it with customFnConfig.httpValidators.`);
3557
3610
  }
3558
3611
  const whenLogic = createConditionalLogic(config.when);
3559
3612
  const httpOptions = {
@@ -4275,9 +4328,9 @@ function hasChildFields(field) {
4275
4328
  return 'fields' in field && field.fields != null;
4276
4329
  }
4277
4330
  /** Container field type names */
4278
- const CONTAINER_TYPES = new Set(['page', 'row', 'group', 'array']);
4331
+ const CONTAINER_TYPES$1 = new Set(['page', 'row', 'group', 'array']);
4279
4332
  function isContainerField(field) {
4280
- return CONTAINER_TYPES.has(field.type);
4333
+ return CONTAINER_TYPES$1.has(field.type);
4281
4334
  }
4282
4335
  /**
4283
4336
  * Type guard to check if a field is a leaf field (value or display field)
@@ -4366,8 +4419,27 @@ function createFormLevelSchema(formLevelSchema) {
4366
4419
  }
4367
4420
 
4368
4421
  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4422
+ /**
4423
+ * Gets a FieldTree by key, supporting nested paths with dot notation.
4424
+ *
4425
+ * @example
4426
+ * getFieldTreeByKey(ctx, 'address.street') // Returns FieldTree for nested 'street' field
4427
+ */
4369
4428
  function getFieldTreeByKey(ctx, key) {
4370
- return ctx.fieldTree[key];
4429
+ // Simple case - no nesting
4430
+ if (!key.includes('.')) {
4431
+ return ctx.fieldTree[key];
4432
+ }
4433
+ // Nested path - traverse through the structure
4434
+ const parts = key.split('.');
4435
+ let current = ctx.fieldTree;
4436
+ for (const part of parts) {
4437
+ if (!current || typeof current !== 'object') {
4438
+ return undefined;
4439
+ }
4440
+ current = current[part];
4441
+ }
4442
+ return current;
4371
4443
  }
4372
4444
  /**
4373
4445
  * Creates an Angular signal forms schema from field definitions
@@ -4539,6 +4611,7 @@ function evaluateCustomCrossFieldValidator(config, evaluationContext, sourceFiel
4539
4611
  // Validation failed - create error targeting the source field
4540
4612
  const targetField = getFieldTreeByKey(ctx, sourceFieldKey);
4541
4613
  if (!targetField) {
4614
+ evaluationContext.logger.warn(`Cross-field validator references non-existent field "${sourceFieldKey}"`);
4542
4615
  return null;
4543
4616
  }
4544
4617
  const errorObj = {
@@ -4551,8 +4624,8 @@ function evaluateCustomCrossFieldValidator(config, evaluationContext, sourceFiel
4551
4624
  try {
4552
4625
  errorObj[key] = ExpressionParser.evaluate(expression, evaluationContext);
4553
4626
  }
4554
- catch {
4555
- // Ignore evaluation errors for params
4627
+ catch (error) {
4628
+ evaluationContext.logger.warn(`Failed to evaluate error param expression "${key}": ${expression}`, error);
4556
4629
  }
4557
4630
  }
4558
4631
  }
@@ -4586,6 +4659,7 @@ function evaluateBuiltInCrossFieldValidator(config, evaluationContext, sourceFie
4586
4659
  // Validation failed - create error targeting the source field
4587
4660
  const targetField = getFieldTreeByKey(ctx, sourceFieldKey);
4588
4661
  if (!targetField) {
4662
+ logger.warn(`Cross-field validator references non-existent field "${sourceFieldKey}"`);
4589
4663
  return null;
4590
4664
  }
4591
4665
  return {
@@ -4686,6 +4760,54 @@ function fieldsToDefaultValues(fields, registry) {
4686
4760
  return defaultValues;
4687
4761
  }
4688
4762
 
4763
+ /**
4764
+ * Injection token for globally enabling form value emission on events.
4765
+ *
4766
+ * When set to `true`, all events dispatched through the EventBus will include
4767
+ * the current form value in the `formValue` property.
4768
+ *
4769
+ * This token is configured via `withEventFormValue()` feature function.
4770
+ * Per-form overrides can be set via `options.emitFormValueOnEvents` in the form config.
4771
+ *
4772
+ * @internal
4773
+ */
4774
+ const EMIT_FORM_VALUE_ON_EVENTS = new InjectionToken('EMIT_FORM_VALUE_ON_EVENTS', {
4775
+ providedIn: 'root',
4776
+ factory: () => false,
4777
+ });
4778
+
4779
+ /**
4780
+ * Safely attempts to inject a dependency using an InjectionToken.
4781
+ * Returns the default value if called outside an injection context.
4782
+ */
4783
+ function safeInjectToken(token, defaultValue) {
4784
+ try {
4785
+ return inject(token, { optional: true }) ?? defaultValue;
4786
+ }
4787
+ catch {
4788
+ return defaultValue;
4789
+ }
4790
+ }
4791
+ /**
4792
+ * Safely attempts to inject a service class.
4793
+ * Returns the default value if called outside an injection context.
4794
+ */
4795
+ function safeInjectClass(token, defaultValue) {
4796
+ try {
4797
+ return inject(token, { optional: true }) ?? defaultValue;
4798
+ }
4799
+ catch {
4800
+ return defaultValue;
4801
+ }
4802
+ }
4803
+ /**
4804
+ * Creates a copy of an event with an additional formValue property.
4805
+ * Preserves the event's prototype chain so instanceof checks still work.
4806
+ */
4807
+ function attachFormValue(event, formValue) {
4808
+ const prototype = Object.getPrototypeOf(event);
4809
+ return Object.assign(Object.create(prototype), event, { formValue });
4810
+ }
4689
4811
  /**
4690
4812
  * Event bus service for form-wide event communication.
4691
4813
  *
@@ -4720,6 +4842,9 @@ function fieldsToDefaultValues(fields, registry) {
4720
4842
  */
4721
4843
  class EventBus {
4722
4844
  pipeline$ = new Subject();
4845
+ globalEmitFormValue = safeInjectToken(EMIT_FORM_VALUE_ON_EVENTS, false);
4846
+ rootFormRegistry = safeInjectClass(RootFormRegistryService, null);
4847
+ formOptions = safeInjectToken(FORM_OPTIONS, null);
4723
4848
  events$ = this.pipeline$.asObservable();
4724
4849
  /**
4725
4850
  * Dispatches an event to all subscribers.
@@ -4727,6 +4852,10 @@ class EventBus {
4727
4852
  * Creates an instance of the provided event constructor and broadcasts it
4728
4853
  * through the event pipeline to all active subscribers.
4729
4854
  *
4855
+ * If `withEventFormValue()` is enabled globally or `options.emitFormValueOnEvents`
4856
+ * is set to `true` in the form config, the current form value will be attached
4857
+ * to the event's `formValue` property.
4858
+ *
4730
4859
  * @param eventConstructor - Constructor function for the event to dispatch
4731
4860
  *
4732
4861
  * @example
@@ -4740,7 +4869,28 @@ class EventBus {
4740
4869
  */
4741
4870
  // TypeScript limitation: Must use ConstructorParameters which relies on `any` in FormEventConstructor
4742
4871
  dispatch(eventConstructor, ...args) {
4743
- this.pipeline$.next(new eventConstructor(...args));
4872
+ const event = new eventConstructor(...args);
4873
+ if (this.shouldEmitFormValue()) {
4874
+ const formValue = this.rootFormRegistry?.getFormValue();
4875
+ // Only attach if form value exists and has at least one field.
4876
+ // Empty objects {} are not attached - use hasFormValue() to check.
4877
+ if (formValue && Object.keys(formValue).length > 0) {
4878
+ this.pipeline$.next(attachFormValue(event, formValue));
4879
+ return;
4880
+ }
4881
+ }
4882
+ this.pipeline$.next(event);
4883
+ }
4884
+ /**
4885
+ * Determines whether form value should be attached to events.
4886
+ *
4887
+ * Precedence rules:
4888
+ * 1. Per-form setting (if defined) takes precedence
4889
+ * 2. Falls back to global setting
4890
+ */
4891
+ shouldEmitFormValue() {
4892
+ const formLevelSetting = this.formOptions?.()?.emitFormValueOnEvents;
4893
+ return formLevelSetting ?? this.globalEmitFormValue;
4744
4894
  }
4745
4895
  /**
4746
4896
  * Subscribes to form events with type-safe filtering.
@@ -5313,7 +5463,14 @@ function convertBuiltInToCustomValidator(config) {
5313
5463
  validationExpression = `fieldValue == null || (typeof fieldValue !== 'string' && !Array.isArray(fieldValue)) || fieldValue.length <= (${expression})`;
5314
5464
  break;
5315
5465
  case 'pattern':
5316
- validationExpression = `fieldValue == null || new RegExp(${expression}).test(fieldValue)`;
5466
+ // Validate regex pattern before injection to prevent runtime errors
5467
+ try {
5468
+ new RegExp(expression);
5469
+ }
5470
+ catch {
5471
+ throw new Error(`Invalid regex pattern in cross-field validator: ${expression}`);
5472
+ }
5473
+ validationExpression = `fieldValue == null || new RegExp(${JSON.stringify(expression)}).test(fieldValue)`;
5317
5474
  break;
5318
5475
  default:
5319
5476
  throw new Error(`Cannot convert ${config.type} validator to custom validator`);
@@ -5454,61 +5611,6 @@ function isValidFormConfiguration(fields) {
5454
5611
  return FormModeValidator.validateFormConfiguration(fields).isValid;
5455
5612
  }
5456
5613
 
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';
5511
-
5512
5614
  /**
5513
5615
  * Creates an empty derivation collection.
5514
5616
  *
@@ -5535,32 +5637,31 @@ function createDerivationChainContext() {
5535
5637
  /**
5536
5638
  * Creates a unique key for a derivation entry.
5537
5639
  *
5538
- * Uses a null character delimiter to avoid collision with field names
5539
- * that might contain common delimiters like ':'.
5640
+ * Since derivations are self-targeting, the key is simply the field key.
5641
+ * This function exists for semantic clarity and to provide a single point
5642
+ * of change if the key format needs to be extended in the future.
5540
5643
  *
5541
- * @param sourceKey - The source field key
5542
- * @param targetKey - The target field key
5644
+ * Note: For array derivations, callers must resolve placeholders
5645
+ * (e.g., 'items.$.lineTotal' -> 'items.0.lineTotal') before calling this.
5646
+ *
5647
+ * @param fieldKey - The field key (with array index already resolved)
5543
5648
  * @returns Unique key for the derivation
5544
5649
  *
5545
5650
  * @internal
5546
5651
  */
5547
- function createDerivationKey(sourceKey, targetKey) {
5548
- return `${sourceKey}${DERIVATION_KEY_DELIMITER}${targetKey}`;
5652
+ function createDerivationKey(fieldKey) {
5653
+ return fieldKey;
5549
5654
  }
5550
5655
  /**
5551
- * Parses a derivation key back into source and target field keys.
5656
+ * Parses a derivation key back into field key.
5552
5657
  *
5553
5658
  * @param key - The derivation key to parse
5554
- * @returns Object containing source and target field keys
5659
+ * @returns Object containing the field key
5555
5660
  *
5556
5661
  * @internal
5557
5662
  */
5558
5663
  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
- };
5664
+ return { fieldKey: key };
5564
5665
  }
5565
5666
 
5566
5667
  /**
@@ -5691,9 +5792,12 @@ function extractArrayPath(path) {
5691
5792
  return parseArrayPath(path).arrayPath;
5692
5793
  }
5693
5794
  /**
5694
- * Splits a dot-separated path into segments.
5795
+ * Splits a path into segments, supporting both dot notation and bracket notation.
5695
5796
  *
5696
- * Handles both dot notation and bracket notation for array indices.
5797
+ * Handles:
5798
+ * - Dot notation: `parent.child.grandchild`
5799
+ * - Bracket notation: `items[0].quantity`
5800
+ * - Mixed notation: `items[0].address.city`
5697
5801
  *
5698
5802
  * @param path - The path to split
5699
5803
  * @returns Array of path segments
@@ -5705,6 +5809,12 @@ function extractArrayPath(path) {
5705
5809
  *
5706
5810
  * splitPath('items.0.quantity')
5707
5811
  * // ['items', '0', 'quantity']
5812
+ *
5813
+ * splitPath('items[0].quantity')
5814
+ * // ['items', '0', 'quantity']
5815
+ *
5816
+ * splitPath('a[0][1].b')
5817
+ * // ['a', '0', '1', 'b']
5708
5818
  * ```
5709
5819
  *
5710
5820
  * @public
@@ -5712,7 +5822,12 @@ function extractArrayPath(path) {
5712
5822
  function splitPath(path) {
5713
5823
  if (!path)
5714
5824
  return [];
5715
- return path.split('.');
5825
+ // Convert bracket notation [n] to .n, then split by dots
5826
+ // Filter out empty segments from leading/trailing/consecutive dots
5827
+ return path
5828
+ .replace(/\[(\d+)\]/g, '.$1')
5829
+ .split('.')
5830
+ .filter(Boolean);
5716
5831
  }
5717
5832
  /**
5718
5833
  * Joins path segments into a dot-separated path.
@@ -5912,8 +6027,7 @@ class DerivationLookup {
5912
6027
  // Track the entries array reference to auto-invalidate on change
5913
6028
  _cachedEntriesRef;
5914
6029
  // Cached lookup maps (built lazily on first access)
5915
- _byTarget;
5916
- _bySource;
6030
+ _byField;
5917
6031
  _byDependency;
5918
6032
  _byArrayPath;
5919
6033
  _wildcardEntries;
@@ -5933,18 +6047,12 @@ class DerivationLookup {
5933
6047
  return currentEntries;
5934
6048
  }
5935
6049
  /**
5936
- * Map of target field key to entries that modify it.
6050
+ * Map of field key to entries that target it.
6051
+ * Since derivations are self-targeting, this maps field keys to their derivations.
5937
6052
  * Used for quick lookup when processing derivations.
5938
6053
  */
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());
6054
+ get byField() {
6055
+ return (this._byField ??= this.buildByFieldMap());
5948
6056
  }
5949
6057
  /**
5950
6058
  * Map of dependency field key to entries that depend on it.
@@ -6039,8 +6147,7 @@ class DerivationLookup {
6039
6147
  * Invalidates cached maps. Call this when entries change.
6040
6148
  */
6041
6149
  invalidateCache() {
6042
- this._byTarget = undefined;
6043
- this._bySource = undefined;
6150
+ this._byField = undefined;
6044
6151
  this._byDependency = undefined;
6045
6152
  this._byArrayPath = undefined;
6046
6153
  this._wildcardEntries = undefined;
@@ -6050,21 +6157,12 @@ class DerivationLookup {
6050
6157
  // ─────────────────────────────────────────────────────────────────
6051
6158
  // Private builders
6052
6159
  // ─────────────────────────────────────────────────────────────────
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() {
6160
+ buildByFieldMap() {
6063
6161
  const map = new Map();
6064
6162
  for (const entry of this.entries) {
6065
- const sourceEntries = map.get(entry.sourceFieldKey) ?? [];
6066
- sourceEntries.push(entry);
6067
- map.set(entry.sourceFieldKey, sourceEntries);
6163
+ const fieldEntries = map.get(entry.fieldKey) ?? [];
6164
+ fieldEntries.push(entry);
6165
+ map.set(entry.fieldKey, fieldEntries);
6068
6166
  }
6069
6167
  return map;
6070
6168
  }
@@ -6084,8 +6182,8 @@ class DerivationLookup {
6084
6182
  buildByArrayPathMap() {
6085
6183
  const map = new Map();
6086
6184
  for (const entry of this.entries) {
6087
- if (isArrayPlaceholderPath(entry.targetFieldKey)) {
6088
- const arrayPath = extractArrayPath(entry.targetFieldKey);
6185
+ if (isArrayPlaceholderPath(entry.fieldKey)) {
6186
+ const arrayPath = extractArrayPath(entry.fieldKey);
6089
6187
  if (arrayPath) {
6090
6188
  const arrayEntries = map.get(arrayPath) ?? [];
6091
6189
  arrayEntries.push(entry);
@@ -6158,22 +6256,22 @@ function topologicalSort(entries) {
6158
6256
  graph.set(entry, new Set());
6159
6257
  inDegree.set(entry, 0);
6160
6258
  }
6161
- // Pre-build index: target field -> entries that produce it
6259
+ // Pre-build index: field key -> entries that produce/target it
6162
6260
  // This enables O(1) lookup instead of O(n) scanning
6163
- const producersByTarget = new Map();
6261
+ const derivationsByField = new Map();
6164
6262
  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);
6263
+ // Index by exact field key
6264
+ const fieldEntries = derivationsByField.get(entry.fieldKey) ?? [];
6265
+ fieldEntries.push(entry);
6266
+ derivationsByField.set(entry.fieldKey, fieldEntries);
6169
6267
  // Also index array field relative paths for array derivations
6170
6268
  // If entry targets 'items.$.lineTotal', index under 'lineTotal' too
6171
- if (entry.targetFieldKey.includes('.$.')) {
6172
- const relativePath = entry.targetFieldKey.split('.$.')[1];
6269
+ if (entry.fieldKey.includes('.$.')) {
6270
+ const relativePath = entry.fieldKey.split('.$.')[1];
6173
6271
  if (relativePath) {
6174
- const relativeProducers = producersByTarget.get(relativePath) ?? [];
6175
- relativeProducers.push(entry);
6176
- producersByTarget.set(relativePath, relativeProducers);
6272
+ const relativeEntries = derivationsByField.get(relativePath) ?? [];
6273
+ relativeEntries.push(entry);
6274
+ derivationsByField.set(relativePath, relativeEntries);
6177
6275
  }
6178
6276
  }
6179
6277
  }
@@ -6185,14 +6283,15 @@ function topologicalSort(entries) {
6185
6283
  }
6186
6284
  }
6187
6285
  // Build edges using pre-computed index
6188
- // For each entry B, find all entries A whose target is in B's dependencies
6286
+ // For each entry B, find all entries A whose field is in B's dependencies
6287
+ // If B depends on field X, and A produces X, then A must run before B
6189
6288
  for (const entryB of entries) {
6190
6289
  for (const dep of entryB.dependsOn) {
6191
6290
  // Skip wildcards here - handled separately below
6192
6291
  if (dep === '*')
6193
6292
  continue;
6194
- // O(1) lookup: find all producers of this dependency
6195
- const producers = producersByTarget.get(dep);
6293
+ // O(1) lookup: find all derivations that produce this dependency
6294
+ const producers = derivationsByField.get(dep);
6196
6295
  if (producers) {
6197
6296
  for (const entryA of producers) {
6198
6297
  const entryAEdges = graph.get(entryA);
@@ -6329,7 +6428,7 @@ function collectFromField(field, entries, context) {
6329
6428
  const validationField = field;
6330
6429
  // Collect shorthand derivation property
6331
6430
  if (validationField.derivation) {
6332
- const entry = createShorthandEntry(fieldKey, validationField.derivation);
6431
+ const entry = createShorthandEntry(fieldKey, validationField.derivation, context);
6333
6432
  entries.push(entry);
6334
6433
  }
6335
6434
  // Collect logic array derivations
@@ -6346,16 +6445,17 @@ function collectFromField(field, entries, context) {
6346
6445
  * Creates a derivation entry from the shorthand `derivation` property.
6347
6446
  *
6348
6447
  * Shorthand derivations:
6349
- * - Always target the same field they're defined on
6448
+ * - Target the same field they're defined on (self-targeting)
6350
6449
  * - Always apply (condition defaults to true)
6351
6450
  * - Always trigger on change
6352
6451
  *
6353
6452
  * @internal
6354
6453
  */
6355
- function createShorthandEntry(fieldKey, expression) {
6454
+ function createShorthandEntry(fieldKey, expression, context) {
6455
+ // Build the effective field key, including array path if in array context
6456
+ const effectiveFieldKey = context.arrayPath ? `${context.arrayPath}.$.${fieldKey}` : fieldKey;
6356
6457
  return {
6357
- sourceFieldKey: fieldKey,
6358
- targetFieldKey: fieldKey,
6458
+ fieldKey: effectiveFieldKey,
6359
6459
  dependsOn: extractStringDependencies(expression),
6360
6460
  condition: true,
6361
6461
  expression,
@@ -6366,16 +6466,21 @@ function createShorthandEntry(fieldKey, expression) {
6366
6466
  /**
6367
6467
  * Creates a derivation entry from a full `DerivationLogicConfig`.
6368
6468
  *
6469
+ * All derivations are self-targeting: the derivation is defined on and targets
6470
+ * the same field (fieldKey). For array fields, the context is used to build
6471
+ * the array placeholder path.
6472
+ *
6369
6473
  * Handles:
6370
6474
  * - Condition extraction and dependency analysis
6371
- * - Relative path resolution for array fields
6475
+ * - Array field context for placeholder paths
6372
6476
  * - Multiple value source types (static, expression, function)
6373
6477
  * - Trigger and debounce configuration
6374
6478
  *
6375
6479
  * @internal
6376
6480
  */
6377
6481
  function createLogicEntry(fieldKey, config, context) {
6378
- const targetFieldKey = resolveTargetFieldKey(config.targetField, fieldKey, context);
6482
+ // Build the effective field key, including array path if in array context
6483
+ const effectiveFieldKey = context.arrayPath ? `${context.arrayPath}.$.${fieldKey}` : fieldKey;
6379
6484
  const dependsOn = extractDependencies(config);
6380
6485
  const condition = config.condition ?? true;
6381
6486
  const trigger = config.trigger ?? 'onChange';
@@ -6383,8 +6488,7 @@ function createLogicEntry(fieldKey, config, context) {
6383
6488
  // The type system ensures debounceMs is only present when trigger is 'debounced'
6384
6489
  const debounceMs = trigger === 'debounced' ? config.debounceMs : undefined;
6385
6490
  return {
6386
- sourceFieldKey: fieldKey,
6387
- targetFieldKey,
6491
+ fieldKey: effectiveFieldKey,
6388
6492
  dependsOn,
6389
6493
  condition,
6390
6494
  value: config.value,
@@ -6397,46 +6501,6 @@ function createLogicEntry(fieldKey, config, context) {
6397
6501
  debugName: config.debugName,
6398
6502
  };
6399
6503
  }
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
6504
  /**
6441
6505
  * Extracts all field dependencies from a derivation config.
6442
6506
  *
@@ -6516,11 +6580,15 @@ function detectCycles(collection) {
6516
6580
  return result;
6517
6581
  }
6518
6582
  /**
6519
- * Detects bidirectional derivation pairs (A→B and B→A).
6583
+ * Detects bidirectional derivation pairs (A↔B patterns).
6520
6584
  *
6521
6585
  * These patterns are allowed because they stabilize via equality checks.
6522
6586
  * Example: USD/EUR conversion where both fields derive from each other.
6523
6587
  *
6588
+ * A bidirectional pair exists when:
6589
+ * - Field A derives its value from field B (A depends on B)
6590
+ * - Field B derives its value from field A (B depends on A)
6591
+ *
6524
6592
  * ## Floating-Point Precision Note
6525
6593
  *
6526
6594
  * Bidirectional derivations stabilize via equality checks using exact IEEE 754
@@ -6536,36 +6604,35 @@ function detectCycles(collection) {
6536
6604
  *
6537
6605
  * @example
6538
6606
  * ```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
- * ]
6607
+ * // Bidirectional currency conversion
6608
+ * { key: 'amountUSD', derivation: 'formValue.amountEUR * 1.1' }
6609
+ * { key: 'amountEUR', derivation: 'formValue.amountUSD / 1.1' }
6547
6610
  * ```
6548
6611
  *
6549
6612
  * @internal
6550
6613
  */
6551
6614
  function detectBidirectionalPairs(collection) {
6552
6615
  const pairs = new Set();
6553
- const edgeSet = new Set();
6554
- // Build set of all source→target edges
6616
+ // Build a map of fieldKey -> dependencies (other fields this derivation reads from)
6617
+ const dependencyMap = new Map();
6555
6618
  for (const entry of collection.entries) {
6556
- if (entry.sourceFieldKey !== entry.targetFieldKey) {
6557
- edgeSet.add(`${entry.sourceFieldKey}→${entry.targetFieldKey}`);
6619
+ const deps = dependencyMap.get(entry.fieldKey) ?? new Set();
6620
+ for (const dep of entry.dependsOn) {
6621
+ if (dep !== '*' && dep !== entry.fieldKey) {
6622
+ deps.add(dep);
6623
+ }
6558
6624
  }
6625
+ dependencyMap.set(entry.fieldKey, deps);
6559
6626
  }
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);
6627
+ // Find bidirectional pairs: A depends on B AND B depends on A
6628
+ for (const [fieldA, depsA] of dependencyMap) {
6629
+ for (const fieldB of depsA) {
6630
+ const depsB = dependencyMap.get(fieldB);
6631
+ if (depsB?.has(fieldA)) {
6632
+ // Normalize pair key (alphabetically sorted)
6633
+ const pairKey = fieldA < fieldB ? `${fieldA}↔${fieldB}` : `${fieldB}↔${fieldA}`;
6634
+ pairs.add(pairKey);
6635
+ }
6569
6636
  }
6570
6637
  }
6571
6638
  return pairs;
@@ -6594,8 +6661,9 @@ function isBidirectionalCycle(cyclePath, bidirectionalPairs) {
6594
6661
  * Creates nodes for each field involved in derivations and
6595
6662
  * edges representing the derivation dependencies.
6596
6663
  *
6597
- * Edge direction: sourceField -> targetField
6598
- * (source triggers derivation that modifies target)
6664
+ * Edge direction is based on dependencies:
6665
+ * - If field A's derivation depends on field B, then B -> A (changing B triggers A)
6666
+ * - A cycle exists if: A -> B -> C -> A (circular dependency chain)
6599
6667
  *
6600
6668
  * @internal
6601
6669
  */
@@ -6615,32 +6683,19 @@ function buildDependencyGraph(collection) {
6615
6683
  return node;
6616
6684
  };
6617
6685
  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
6686
+ const fieldNode = ensureNode(entry.fieldKey);
6687
+ // Track dependencies from the derivation expression/condition.
6688
+ // For cycle detection, edges go from dependency -> field:
6689
+ // If field A depends on field B, then when B changes, A gets updated.
6690
+ // A cycle exists when there's a circular chain of such dependencies.
6639
6691
  for (const dep of entry.dependsOn) {
6640
- if (dep !== '*' && dep !== entry.targetFieldKey) {
6692
+ // Skip wildcard dependencies and self-references
6693
+ if (dep !== '*' && dep !== entry.fieldKey) {
6641
6694
  const depNode = ensureNode(dep);
6642
- targetNode.dependsOn.add(dep);
6643
- depNode.dependedOnBy.add(entry.targetFieldKey);
6695
+ // The field depends on 'dep'
6696
+ fieldNode.dependsOn.add(dep);
6697
+ // 'dep' is depended on by the field
6698
+ depNode.dependedOnBy.add(entry.fieldKey);
6644
6699
  }
6645
6700
  }
6646
6701
  }
@@ -6788,6 +6843,61 @@ function validateNoCycles(collection, logger) {
6788
6843
  }
6789
6844
  }
6790
6845
 
6846
+ /**
6847
+ * Centralized constants for the derivation system.
6848
+ *
6849
+ * These constants configure the behavior of derivation processing,
6850
+ * caching, and path handling. Centralizing them enables:
6851
+ * - Easy configuration changes
6852
+ * - Consistent values across all derivation modules
6853
+ * - Clear documentation of magic numbers
6854
+ *
6855
+ * @module
6856
+ */
6857
+ /**
6858
+ * Maximum number of derivation iterations before stopping to prevent infinite loops.
6859
+ *
6860
+ * The value of 10 is chosen based on:
6861
+ * - Most derivation chains are 2-3 levels deep (A→B→C)
6862
+ * - Complex forms with conditional cascades rarely exceed 5-6 levels
6863
+ * - 10 provides headroom for bidirectional sync patterns (A↔B) which may need
6864
+ * 2 iterations per pair to stabilize
6865
+ * - Higher values delay detection of actual infinite loops
6866
+ * - Lower values risk false positives on legitimate deep chains
6867
+ *
6868
+ * If you hit this limit legitimately, consider:
6869
+ * 1. Restructuring derivations to reduce chain depth
6870
+ * 2. Using explicit `dependsOn` to control evaluation order
6871
+ * 3. Breaking complex derivations into computed signals outside the form
6872
+ */
6873
+ const MAX_DERIVATION_ITERATIONS = 10;
6874
+ /**
6875
+ * Placeholder string for array index in derivation paths.
6876
+ *
6877
+ * Used in paths like `items.$.quantity` to represent "each item in the array".
6878
+ * The `$` is resolved to actual indices at runtime during array derivation processing.
6879
+ */
6880
+ const ARRAY_PLACEHOLDER = '.$.';
6881
+ /**
6882
+ * Maximum size of the expression AST cache.
6883
+ *
6884
+ * Expressions are parsed into Abstract Syntax Trees (AST) once and cached
6885
+ * to avoid re-parsing on each evaluation. The cache uses LRU eviction.
6886
+ *
6887
+ * 1000 entries handles:
6888
+ * - Large forms with many derivation expressions
6889
+ * - Multiple form instances with different expressions
6890
+ * - Memory efficiency (AST nodes are relatively small)
6891
+ */
6892
+ const MAX_AST_CACHE_SIZE = 1000;
6893
+ /**
6894
+ * Delimiter used to create unique derivation keys from source and target field keys.
6895
+ *
6896
+ * Uses null character (\x00) which is extremely unlikely to appear in field names,
6897
+ * avoiding collision issues that could occur with common delimiters like ':'.
6898
+ */
6899
+ const DERIVATION_KEY_DELIMITER = '\x00';
6900
+
6791
6901
  /**
6792
6902
  * Applies all pending derivations from a collection.
6793
6903
  *
@@ -6904,9 +7014,9 @@ function getEntriesForChangedFields(entries, changedFields) {
6904
7014
  * ```typescript
6905
7015
  * // Two derivations for conditional value with cleanup:
6906
7016
  * // When country is USA, set phonePrefix to '+1'
6907
- * { condition: { field: 'country', operator: '==', value: 'USA' }, targetField: 'phonePrefix', value: '+1' }
7017
+ * { key: 'phonePrefix', logic: [{ type: 'derivation', condition: { field: 'country', operator: '==', value: 'USA' }, value: '+1' }] }
6908
7018
  * // When country is NOT USA, clear phonePrefix
6909
- * { condition: { field: 'country', operator: '!=', value: 'USA' }, targetField: 'phonePrefix', value: '' }
7019
+ * { key: 'phonePrefix', logic: [{ type: 'derivation', condition: { field: 'country', operator: '!=', value: 'USA' }, value: '' }] }
6910
7020
  * ```
6911
7021
  *
6912
7022
  * @internal
@@ -6914,20 +7024,19 @@ function getEntriesForChangedFields(entries, changedFields) {
6914
7024
  function tryApplyDerivation(entry, context, chainContext) {
6915
7025
  const { derivationLogger } = context;
6916
7026
  // Check if this is an array field derivation (has '$' placeholder)
6917
- if (isArrayPlaceholderPath(entry.targetFieldKey)) {
7027
+ if (isArrayPlaceholderPath(entry.fieldKey)) {
6918
7028
  return tryApplyArrayDerivation(entry, context, chainContext);
6919
7029
  }
6920
- const derivationKey = createDerivationKey(entry.sourceFieldKey, entry.targetFieldKey);
7030
+ const derivationKey = createDerivationKey(entry.fieldKey);
6921
7031
  // Check if already applied in this cycle
6922
7032
  if (chainContext.appliedDerivations.has(derivationKey)) {
6923
7033
  derivationLogger.evaluation({
6924
7034
  debugName: entry.debugName,
6925
- sourceFieldKey: entry.sourceFieldKey,
6926
- targetFieldKey: entry.targetFieldKey,
7035
+ fieldKey: entry.fieldKey,
6927
7036
  result: 'skipped',
6928
7037
  skipReason: 'already-applied',
6929
7038
  });
6930
- return { applied: false, targetFieldKey: entry.targetFieldKey };
7039
+ return { applied: false, fieldKey: entry.fieldKey };
6931
7040
  }
6932
7041
  // Create evaluation context
6933
7042
  const formValue = untracked(() => context.formValue());
@@ -6936,12 +7045,11 @@ function tryApplyDerivation(entry, context, chainContext) {
6936
7045
  if (!evaluateDerivationCondition(entry.condition, evalContext)) {
6937
7046
  derivationLogger.evaluation({
6938
7047
  debugName: entry.debugName,
6939
- sourceFieldKey: entry.sourceFieldKey,
6940
- targetFieldKey: entry.targetFieldKey,
7048
+ fieldKey: entry.fieldKey,
6941
7049
  result: 'skipped',
6942
7050
  skipReason: 'condition-false',
6943
7051
  });
6944
- return { applied: false, targetFieldKey: entry.targetFieldKey };
7052
+ return { applied: false, fieldKey: entry.fieldKey };
6945
7053
  }
6946
7054
  // Compute derived value
6947
7055
  let newValue;
@@ -6953,14 +7061,13 @@ function tryApplyDerivation(entry, context, chainContext) {
6953
7061
  context.logger.error(formatDerivationError(entry, 'compute', errorMessage));
6954
7062
  derivationLogger.evaluation({
6955
7063
  debugName: entry.debugName,
6956
- sourceFieldKey: entry.sourceFieldKey,
6957
- targetFieldKey: entry.targetFieldKey,
7064
+ fieldKey: entry.fieldKey,
6958
7065
  result: 'error',
6959
7066
  error: errorMessage,
6960
7067
  });
6961
7068
  return {
6962
7069
  applied: false,
6963
- targetFieldKey: entry.targetFieldKey,
7070
+ fieldKey: entry.fieldKey,
6964
7071
  error: errorMessage,
6965
7072
  };
6966
7073
  }
@@ -6969,46 +7076,43 @@ function tryApplyDerivation(entry, context, chainContext) {
6969
7076
  // equality for numbers. Bidirectional derivations with floating-point math
6970
7077
  // may oscillate due to rounding errors. Use explicit rounding in expressions
6971
7078
  // or integer arithmetic to avoid this issue.
6972
- const currentValue = getNestedValue(formValue, entry.targetFieldKey);
7079
+ const currentValue = getNestedValue(formValue, entry.fieldKey);
6973
7080
  if (isEqual(currentValue, newValue)) {
6974
7081
  derivationLogger.evaluation({
6975
7082
  debugName: entry.debugName,
6976
- sourceFieldKey: entry.sourceFieldKey,
6977
- targetFieldKey: entry.targetFieldKey,
7083
+ fieldKey: entry.fieldKey,
6978
7084
  result: 'skipped',
6979
7085
  skipReason: 'value-unchanged',
6980
7086
  });
6981
- return { applied: false, targetFieldKey: entry.targetFieldKey };
7087
+ return { applied: false, fieldKey: entry.fieldKey };
6982
7088
  }
6983
7089
  // Apply the value
6984
7090
  try {
6985
- const wasApplied = applyValueToForm(entry.targetFieldKey, newValue, context.rootForm, context.logger, context.warningTracker);
7091
+ const wasApplied = applyValueToForm(entry.fieldKey, newValue, context.rootForm, context.logger, context.warningTracker);
6986
7092
  if (wasApplied) {
6987
7093
  chainContext.appliedDerivations.add(derivationKey);
6988
7094
  derivationLogger.evaluation({
6989
7095
  debugName: entry.debugName,
6990
- sourceFieldKey: entry.sourceFieldKey,
6991
- targetFieldKey: entry.targetFieldKey,
7096
+ fieldKey: entry.fieldKey,
6992
7097
  result: 'applied',
6993
7098
  previousValue: currentValue,
6994
7099
  newValue,
6995
7100
  });
6996
- return { applied: true, targetFieldKey: entry.targetFieldKey, newValue };
7101
+ return { applied: true, fieldKey: entry.fieldKey, newValue };
6997
7102
  }
6998
7103
  else {
6999
7104
  // Field not found - this is a warning, not an error
7000
7105
  // The warning was already logged by warnMissingField
7001
7106
  derivationLogger.evaluation({
7002
7107
  debugName: entry.debugName,
7003
- sourceFieldKey: entry.sourceFieldKey,
7004
- targetFieldKey: entry.targetFieldKey,
7108
+ fieldKey: entry.fieldKey,
7005
7109
  result: 'skipped',
7006
7110
  skipReason: 'target-not-found',
7007
7111
  });
7008
7112
  return {
7009
7113
  applied: false,
7010
- targetFieldKey: entry.targetFieldKey,
7011
- warning: `Target field '${entry.targetFieldKey}' not found`,
7114
+ fieldKey: entry.fieldKey,
7115
+ warning: `Field '${entry.fieldKey}' not found`,
7012
7116
  };
7013
7117
  }
7014
7118
  }
@@ -7017,14 +7121,13 @@ function tryApplyDerivation(entry, context, chainContext) {
7017
7121
  context.logger.error(formatDerivationError(entry, 'apply', errorMessage));
7018
7122
  derivationLogger.evaluation({
7019
7123
  debugName: entry.debugName,
7020
- sourceFieldKey: entry.sourceFieldKey,
7021
- targetFieldKey: entry.targetFieldKey,
7124
+ fieldKey: entry.fieldKey,
7022
7125
  result: 'error',
7023
7126
  error: errorMessage,
7024
7127
  });
7025
7128
  return {
7026
7129
  applied: false,
7027
- targetFieldKey: entry.targetFieldKey,
7130
+ fieldKey: entry.fieldKey,
7028
7131
  error: errorMessage,
7029
7132
  };
7030
7133
  }
@@ -7039,22 +7142,22 @@ function tryApplyDerivation(entry, context, chainContext) {
7039
7142
  */
7040
7143
  function tryApplyArrayDerivation(entry, context, chainContext) {
7041
7144
  const formValue = untracked(() => context.formValue());
7042
- // Parse the target path using path utilities
7043
- const pathInfo = parseArrayPath(entry.targetFieldKey);
7145
+ // Parse the field path using path utilities
7146
+ const pathInfo = parseArrayPath(entry.fieldKey);
7044
7147
  if (!pathInfo.isArrayPath) {
7045
- return { applied: false, targetFieldKey: entry.targetFieldKey, error: 'Invalid array derivation path' };
7148
+ return { applied: false, fieldKey: entry.fieldKey, error: 'Invalid array derivation path' };
7046
7149
  }
7047
7150
  const { arrayPath } = pathInfo;
7048
7151
  // Get the array from form values
7049
7152
  const arrayValue = getNestedValue(formValue, arrayPath);
7050
7153
  if (!Array.isArray(arrayValue)) {
7051
- return { applied: false, targetFieldKey: entry.targetFieldKey };
7154
+ return { applied: false, fieldKey: entry.fieldKey };
7052
7155
  }
7053
7156
  let appliedAny = false;
7054
7157
  // Process each array item
7055
7158
  for (let i = 0; i < arrayValue.length; i++) {
7056
- const resolvedTargetPath = resolveArrayPath(entry.targetFieldKey, i);
7057
- const derivationKey = createDerivationKey(entry.sourceFieldKey, resolvedTargetPath);
7159
+ const resolvedPath = resolveArrayPath(entry.fieldKey, i);
7160
+ const derivationKey = createDerivationKey(resolvedPath);
7058
7161
  // Skip if already applied in this cycle
7059
7162
  if (chainContext.appliedDerivations.has(derivationKey)) {
7060
7163
  continue;
@@ -7077,13 +7180,13 @@ function tryApplyArrayDerivation(entry, context, chainContext) {
7077
7180
  continue;
7078
7181
  }
7079
7182
  // Check if value actually changed
7080
- const currentValue = getNestedValue(formValue, resolvedTargetPath);
7183
+ const currentValue = getNestedValue(formValue, resolvedPath);
7081
7184
  if (isEqual(currentValue, newValue)) {
7082
7185
  continue;
7083
7186
  }
7084
7187
  // Apply the value
7085
7188
  try {
7086
- applyValueToForm(resolvedTargetPath, newValue, context.rootForm, context.logger, context.warningTracker);
7189
+ applyValueToForm(resolvedPath, newValue, context.rootForm, context.logger, context.warningTracker);
7087
7190
  chainContext.appliedDerivations.add(derivationKey);
7088
7191
  appliedAny = true;
7089
7192
  }
@@ -7092,7 +7195,7 @@ function tryApplyArrayDerivation(entry, context, chainContext) {
7092
7195
  context.logger.error(formatDerivationError(entry, 'apply', `[index ${i}] ${errorMessage}`));
7093
7196
  }
7094
7197
  }
7095
- return { applied: appliedAny, targetFieldKey: entry.targetFieldKey };
7198
+ return { applied: appliedAny, fieldKey: entry.fieldKey };
7096
7199
  }
7097
7200
  /**
7098
7201
  * Creates an evaluation context for derivation processing.
@@ -7100,12 +7203,13 @@ function tryApplyArrayDerivation(entry, context, chainContext) {
7100
7203
  * @internal
7101
7204
  */
7102
7205
  function createEvaluationContext(entry, formValue, context) {
7103
- const fieldValue = getNestedValue(formValue, entry.sourceFieldKey);
7206
+ const fieldValue = getNestedValue(formValue, entry.fieldKey);
7104
7207
  return {
7105
7208
  fieldValue,
7106
7209
  formValue,
7107
- fieldPath: entry.sourceFieldKey,
7210
+ fieldPath: entry.fieldKey,
7108
7211
  customFunctions: context.customFunctions,
7212
+ externalData: context.externalData,
7109
7213
  logger: context.logger,
7110
7214
  };
7111
7215
  }
@@ -7126,6 +7230,7 @@ function createArrayItemEvaluationContext(entry, arrayItem, rootFormValue, itemI
7126
7230
  formValue: arrayItem,
7127
7231
  fieldPath: `${arrayPath}.${itemIndex}`,
7128
7232
  customFunctions: context.customFunctions,
7233
+ externalData: context.externalData,
7129
7234
  logger: context.logger,
7130
7235
  // Provide access to root form value for cross-scope references
7131
7236
  rootFormValue,
@@ -7167,7 +7272,7 @@ function computeDerivedValue(entry, evalContext, applicatorContext) {
7167
7272
  return fn(evalContext);
7168
7273
  }
7169
7274
  // No value source specified
7170
- throw new Error(`Derivation for ${entry.targetFieldKey} has no value source. ` + `Specify 'value', 'expression', or 'functionName'.`);
7275
+ throw new Error(`Derivation for ${entry.fieldKey} has no value source. ` + `Specify 'value', 'expression', or 'functionName'.`);
7171
7276
  }
7172
7277
  /**
7173
7278
  * Applies a value to the form at the specified path.
@@ -7225,7 +7330,7 @@ function formatDerivationError(entry, phase, message) {
7225
7330
  : entry.value !== undefined
7226
7331
  ? 'static value'
7227
7332
  : 'unknown source';
7228
- return `${ERROR_PREFIX} Failed to ${phase} derivation (${entry.sourceFieldKey} → ${entry.targetFieldKey}, ${source}): ${message}`;
7333
+ return `${ERROR_PREFIX} Failed to ${phase} derivation (field: ${entry.fieldKey}, ${source}): ${message}`;
7229
7334
  }
7230
7335
  /**
7231
7336
  * Sets a field value using the Angular Signal Forms pattern.
@@ -7413,12 +7518,11 @@ function logMaxIterationsReached(result, trigger, logger) {
7413
7518
  function logDerivationEvaluation(entry, logger, config) {
7414
7519
  if (!shouldLog(config, 'verbose'))
7415
7520
  return;
7416
- const name = entry.debugName ? `"${entry.debugName}"` : `${entry.sourceFieldKey} -> ${entry.targetFieldKey}`;
7521
+ const name = entry.debugName ? `"${entry.debugName}"` : entry.fieldKey;
7417
7522
  switch (entry.result) {
7418
7523
  case 'applied':
7419
7524
  logger.debug(`${LOG_PREFIX} -> Applied: ${name}`, {
7420
- source: entry.sourceFieldKey,
7421
- target: entry.targetFieldKey,
7525
+ field: entry.fieldKey,
7422
7526
  previousValue: entry.previousValue,
7423
7527
  newValue: entry.newValue,
7424
7528
  ...(entry.durationMs !== undefined && { durationMs: entry.durationMs }),
@@ -7426,15 +7530,13 @@ function logDerivationEvaluation(entry, logger, config) {
7426
7530
  break;
7427
7531
  case 'skipped':
7428
7532
  logger.debug(`${LOG_PREFIX} -> Skipped: ${name} (${formatSkipReason(entry.skipReason)})`, {
7429
- source: entry.sourceFieldKey,
7430
- target: entry.targetFieldKey,
7533
+ field: entry.fieldKey,
7431
7534
  reason: entry.skipReason,
7432
7535
  });
7433
7536
  break;
7434
7537
  case 'error':
7435
7538
  logger.debug(`${LOG_PREFIX} -> Error: ${name}`, {
7436
- source: entry.sourceFieldKey,
7437
- target: entry.targetFieldKey,
7539
+ field: entry.fieldKey,
7438
7540
  error: entry.error,
7439
7541
  });
7440
7542
  break;
@@ -7518,12 +7620,11 @@ class ActiveDerivationLogger {
7518
7620
  evaluation(entry) {
7519
7621
  if (!shouldLog(this.config, 'verbose'))
7520
7622
  return;
7521
- const name = entry.debugName ? `"${entry.debugName}"` : `${entry.sourceFieldKey} -> ${entry.targetFieldKey}`;
7623
+ const name = entry.debugName ? `"${entry.debugName}"` : entry.fieldKey;
7522
7624
  switch (entry.result) {
7523
7625
  case 'applied':
7524
7626
  this.logger.debug(`Derivation - Applied ${name}`, {
7525
- source: entry.sourceFieldKey,
7526
- target: entry.targetFieldKey,
7627
+ field: entry.fieldKey,
7527
7628
  previousValue: entry.previousValue,
7528
7629
  newValue: entry.newValue,
7529
7630
  ...(entry.durationMs !== undefined && { durationMs: entry.durationMs }),
@@ -7531,15 +7632,13 @@ class ActiveDerivationLogger {
7531
7632
  break;
7532
7633
  case 'skipped':
7533
7634
  this.logger.debug(`Derivation - Skipped ${name} (${this.formatSkipReason(entry.skipReason)})`, {
7534
- source: entry.sourceFieldKey,
7535
- target: entry.targetFieldKey,
7635
+ field: entry.fieldKey,
7536
7636
  reason: entry.skipReason,
7537
7637
  });
7538
7638
  break;
7539
7639
  case 'error':
7540
7640
  this.logger.debug(`Derivation - Error ${name}`, {
7541
- source: entry.sourceFieldKey,
7542
- target: entry.targetFieldKey,
7641
+ field: entry.fieldKey,
7543
7642
  error: entry.error,
7544
7643
  });
7545
7644
  break;
@@ -7652,6 +7751,7 @@ class DerivationOrchestrator {
7652
7751
  warningTracker = inject(DERIVATION_WARNING_TRACKER);
7653
7752
  functionRegistry = inject(FunctionRegistryService);
7654
7753
  formOptions = inject(FORM_OPTIONS);
7754
+ externalDataSignal = inject(EXTERNAL_DATA, { optional: true });
7655
7755
  /**
7656
7756
  * Computed signal containing the collected and validated derivations.
7657
7757
  * Returns null if no derivations are defined.
@@ -7696,7 +7796,9 @@ class DerivationOrchestrator {
7696
7796
  // Without this, exhaustMap would block indefinitely.
7697
7797
  return scheduled([null], queueScheduler);
7698
7798
  }), takeUntilDestroyed(this.destroyRef))
7699
- .subscribe();
7799
+ .subscribe({
7800
+ error: (err) => this.logger.error('Derivation onChange stream error', err),
7801
+ });
7700
7802
  }
7701
7803
  setupDebouncedStream() {
7702
7804
  toObservable(this.config.formValue, { injector: this.injector })
@@ -7727,7 +7829,9 @@ class DerivationOrchestrator {
7727
7829
  const periodStreams = debouncePeriods.map((debounceMs) => this.createPeriodStream(debounceMs, collection, formAccessor, changedFields));
7728
7830
  return merge(...periodStreams);
7729
7831
  }), takeUntilDestroyed(this.destroyRef))
7730
- .subscribe();
7832
+ .subscribe({
7833
+ error: (err) => this.logger.error('Derivation debounced stream error', err),
7834
+ });
7731
7835
  }
7732
7836
  createPeriodStream(debounceMs, collection, formAccessor, changedFields) {
7733
7837
  const additionalWait = Math.max(0, debounceMs - DEFAULT_DEBOUNCE_MS);
@@ -7750,6 +7854,7 @@ class DerivationOrchestrator {
7750
7854
  rootForm: formAccessor,
7751
7855
  derivationFunctions: this.functionRegistry.getDerivationFunctions(),
7752
7856
  customFunctions: this.functionRegistry.getCustomFunctions(),
7857
+ externalData: this.resolveExternalData(),
7753
7858
  logger: this.logger,
7754
7859
  warningTracker: this.warningTracker,
7755
7860
  derivationLogger: untracked(() => this.config.derivationLogger()),
@@ -7777,6 +7882,7 @@ class DerivationOrchestrator {
7777
7882
  rootForm: formAccessor,
7778
7883
  derivationFunctions: this.functionRegistry.getDerivationFunctions(),
7779
7884
  customFunctions: this.functionRegistry.getCustomFunctions(),
7885
+ externalData: this.resolveExternalData(),
7780
7886
  logger: this.logger,
7781
7887
  warningTracker: this.warningTracker,
7782
7888
  derivationLogger: untracked(() => this.config.derivationLogger()),
@@ -7810,12 +7916,36 @@ class DerivationOrchestrator {
7810
7916
  // Find implicit wildcards (custom functions without explicit dependsOn)
7811
7917
  const implicitWildcards = wildcardEntries.filter((entry) => entry.functionName && (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
7812
7918
  if (implicitWildcards.length > 0) {
7813
- const derivationDescs = implicitWildcards.map((e) => `${e.sourceFieldKey} -> ${e.targetFieldKey} (${e.functionName})`);
7919
+ const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName})`);
7814
7920
  this.logger.warn('[Derivation] Derivations using custom functions without explicit dependsOn detected. ' +
7815
7921
  `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
7816
7922
  'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
7817
7923
  }
7818
7924
  }
7925
+ /**
7926
+ * Resolves external data signals to their current values without creating dependencies.
7927
+ *
7928
+ * Uses `untracked()` to read signals without establishing reactive dependencies,
7929
+ * which is important for derivations that shouldn't re-trigger on every external data change.
7930
+ *
7931
+ * @returns Record of resolved external data values, or undefined if no external data.
7932
+ */
7933
+ resolveExternalData() {
7934
+ const externalDataRecord = untracked(() => this.externalDataSignal?.());
7935
+ if (!externalDataRecord) {
7936
+ return undefined;
7937
+ }
7938
+ const resolved = {};
7939
+ for (const [key, signal] of Object.entries(externalDataRecord)) {
7940
+ if (isSignal(signal)) {
7941
+ resolved[key] = untracked(() => signal());
7942
+ }
7943
+ else {
7944
+ resolved[key] = signal;
7945
+ }
7946
+ }
7947
+ return resolved;
7948
+ }
7819
7949
  }
7820
7950
  /**
7821
7951
  * Creates a DerivationOrchestrator for a dynamic form.
@@ -8336,9 +8466,9 @@ class PageOrchestratorComponent {
8336
8466
  }
8337
8467
  }
8338
8468
  }
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)]] });
8469
+ `, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-RN1hmG69.mjs').then(m => m.default)], () => [import('./ng-forge-dynamic-forms-page-field.component-RN1hmG69.mjs').then(m => m.default)]] });
8340
8470
  }
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: [{
8471
+ i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.1.0", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-RN1hmG69.mjs').then(m => m.default)], resolveMetadata: PageFieldComponent => ({ decorators: [{
8342
8472
  type: Component,
8343
8473
  args: [{ selector: 'div[page-orchestrator]', imports: [PageFieldComponent], template: `
8344
8474
  @for (pageField of pageFields(); track pageField.key; let i = $index) {
@@ -8768,10 +8898,21 @@ class DynamicForm {
8768
8898
  /** Emits when form dirty state changes. */
8769
8899
  dirtyChange = outputFromObservable(toObservable(this.dirty));
8770
8900
  /**
8771
- * Emits form values when submitted (via SubmitEvent).
8901
+ * Emits form values when submitted (via SubmitEvent) and form is valid.
8902
+ *
8903
+ * **Important:** This output only emits when the form is valid. If you need to
8904
+ * handle submit events regardless of validity, use the `(events)` output and
8905
+ * filter for `'submit'` events.
8906
+ *
8772
8907
  * Note: Does not emit when `submission.action` is configured - use one or the other.
8773
8908
  */
8774
- submitted = outputFromObservable(this.eventBus.on('submit').pipe(switchMap(() => {
8909
+ submitted = outputFromObservable(this.eventBus.on('submit').pipe(filter(() => {
8910
+ if (!this.valid()) {
8911
+ this.logger.debug('Form submitted while invalid, not emitting to (submitted) output');
8912
+ return false;
8913
+ }
8914
+ return true;
8915
+ }), switchMap(() => {
8775
8916
  const submissionConfig = this.config().submission;
8776
8917
  if (submissionConfig?.action) {
8777
8918
  this.logger.warn('Both `submission.action` and `(submitted)` output are configured. ' +
@@ -8779,7 +8920,8 @@ class DynamicForm {
8779
8920
  'Use either `submission.action` OR `(submitted)`, not both.');
8780
8921
  return EMPTY;
8781
8922
  }
8782
- return of(this.value());
8923
+ // Form is valid here, cast to TModel (no longer Partial)
8924
+ return of(this.formValue());
8783
8925
  })));
8784
8926
  /** Emits when form is reset to default values. */
8785
8927
  reset = outputFromObservable(this.eventBus.on('form-reset'));
@@ -8896,7 +9038,9 @@ class DynamicForm {
8896
9038
  formSignal: this.form,
8897
9039
  })
8898
9040
  .pipe(takeUntilDestroyed())
8899
- .subscribe();
9041
+ .subscribe({
9042
+ error: (err) => this.logger.error('Submission handler error', err),
9043
+ });
8900
9044
  }
8901
9045
  registerValidatorsFromConfig({ customFnConfig, schemas }) {
8902
9046
  if (schemas) {
@@ -8987,6 +9131,11 @@ class DynamicForm {
8987
9131
  useFactory: (form) => form.effectiveFormOptions,
8988
9132
  deps: [DynamicForm],
8989
9133
  },
9134
+ {
9135
+ provide: EXTERNAL_DATA,
9136
+ useFactory: (form) => computed(() => form.config().externalData),
9137
+ deps: [DynamicForm],
9138
+ },
8990
9139
  { provide: DERIVATION_WARNING_TRACKER, useFactory: createDerivationWarningTracker },
8991
9140
  {
8992
9141
  provide: DERIVATION_ORCHESTRATOR,
@@ -9035,6 +9184,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
9035
9184
  useFactory: (form) => form.effectiveFormOptions,
9036
9185
  deps: [DynamicForm],
9037
9186
  },
9187
+ {
9188
+ provide: EXTERNAL_DATA,
9189
+ useFactory: (form) => computed(() => form.config().externalData),
9190
+ deps: [DynamicForm],
9191
+ },
9038
9192
  { provide: DERIVATION_WARNING_TRACKER, useFactory: createDerivationWarningTracker },
9039
9193
  {
9040
9194
  provide: DERIVATION_ORCHESTRATOR,
@@ -9225,13 +9379,13 @@ const BUILT_IN_FIELDS = [
9225
9379
  },
9226
9380
  {
9227
9381
  name: 'page',
9228
- loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-CrO9WTDw.mjs'),
9382
+ loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-RN1hmG69.mjs'),
9229
9383
  mapper: pageFieldMapper,
9230
9384
  valueHandling: 'flatten',
9231
9385
  },
9232
9386
  {
9233
9387
  name: 'text',
9234
- loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-BcFx_kOz.mjs'),
9388
+ loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-DIVyioMX.mjs'),
9235
9389
  mapper: textFieldMapper,
9236
9390
  valueHandling: 'exclude',
9237
9391
  },
@@ -9335,6 +9489,79 @@ function provideDynamicForm(...items) {
9335
9489
  ]);
9336
9490
  }
9337
9491
 
9492
+ /**
9493
+ * Enables automatic form value attachment to all events dispatched through the EventBus.
9494
+ *
9495
+ * When this feature is enabled, events dispatched via `eventBus.dispatch()` will include
9496
+ * the current form value in the `formValue` property. This is useful for event handlers
9497
+ * that need access to the complete form state at the time of the event.
9498
+ *
9499
+ * @example Global opt-in
9500
+ * ```typescript
9501
+ * provideDynamicForm(
9502
+ * ...withMaterialFields(),
9503
+ * withEventFormValue()
9504
+ * )
9505
+ * ```
9506
+ *
9507
+ * @example Consumer usage
9508
+ * ```typescript
9509
+ * eventBus.on<SubmitEvent>('submit').subscribe(event => {
9510
+ * if (hasFormValue(event)) {
9511
+ * console.log('Form value:', event.formValue);
9512
+ * }
9513
+ * });
9514
+ * ```
9515
+ *
9516
+ * @example Per-form disable (when globally enabled)
9517
+ * ```typescript
9518
+ * const config: FormConfig = {
9519
+ * fields: [...],
9520
+ * options: { emitFormValueOnEvents: false }
9521
+ * };
9522
+ * ```
9523
+ *
9524
+ * @remarks
9525
+ * **Precedence rules:**
9526
+ * 1. Per-form `false` - Disables for this form, regardless of global setting
9527
+ * 2. Per-form `true` - Enables for this form, regardless of global setting
9528
+ * 3. Per-form `undefined` - Uses global setting
9529
+ * 4. Global `withEventFormValue()` - Enables globally
9530
+ * 5. No global feature - Disabled globally (default)
9531
+ *
9532
+ * @returns A DynamicFormFeature that enables form value attachment to events
9533
+ *
9534
+ * @public
9535
+ */
9536
+ function withEventFormValue() {
9537
+ return createFeature('event-form-value', [{ provide: EMIT_FORM_VALUE_ON_EVENTS, useValue: true }]);
9538
+ }
9539
+
9540
+ /**
9541
+ * Type guard to check if a form event has a form value attached.
9542
+ *
9543
+ * Use this to safely access the `formValue` property on events when
9544
+ * `withEventFormValue()` is enabled globally or `options.emitFormValueOnEvents`
9545
+ * is set to `true` in the form config.
9546
+ *
9547
+ * @example
9548
+ * ```typescript
9549
+ * eventBus.on<SubmitEvent>('submit').subscribe(event => {
9550
+ * if (hasFormValue(event)) {
9551
+ * console.log('Form value:', event.formValue);
9552
+ * }
9553
+ * });
9554
+ * ```
9555
+ *
9556
+ * @param event - The form event to check
9557
+ * @returns `true` if the event has a form value attached, narrowing the type
9558
+ *
9559
+ * @public
9560
+ */
9561
+ function hasFormValue(event) {
9562
+ return 'formValue' in event && event.formValue !== undefined;
9563
+ }
9564
+
9338
9565
  /**
9339
9566
  * No-op logger for when no logger is provided.
9340
9567
  * Used as fallback in button logic evaluation to avoid breaking downstream packages.
@@ -9646,6 +9873,176 @@ function formConfig(config) {
9646
9873
  return config;
9647
9874
  }
9648
9875
 
9876
+ /**
9877
+ * Container field types that do NOT support labels or most logic
9878
+ */
9879
+ const CONTAINER_TYPES = ['group', 'row', 'array'];
9880
+ /**
9881
+ * Field types that support options at the field level
9882
+ */
9883
+ const OPTION_FIELD_TYPES = ['select', 'radio', 'multi-checkbox'];
9884
+ /**
9885
+ * Field type that uses minValue/maxValue instead of min/max in props
9886
+ */
9887
+ const SLIDER_TYPE = 'slider';
9888
+ /**
9889
+ * Hidden field type - no logic, no validators, no label
9890
+ */
9891
+ const HIDDEN_TYPE = 'hidden';
9892
+ /**
9893
+ * Page field type - only supports hidden logic
9894
+ */
9895
+ const PAGE_TYPE = 'page';
9896
+ /**
9897
+ * Creates a typed field configuration with helpful error messages for common mistakes.
9898
+ *
9899
+ * This helper function provides early validation and clear error messages
9900
+ * for common configuration pitfalls that would otherwise cause runtime errors.
9901
+ *
9902
+ * @example
9903
+ * ```typescript
9904
+ * // Basic usage
9905
+ * const nameField = createField('input', {
9906
+ * key: 'name',
9907
+ * label: 'Name',
9908
+ * value: ''
9909
+ * });
9910
+ *
9911
+ * // With validation
9912
+ * const emailField = createField('input', {
9913
+ * key: 'email',
9914
+ * label: 'Email',
9915
+ * required: true,
9916
+ * email: true,
9917
+ * props: { type: 'email' }
9918
+ * });
9919
+ *
9920
+ * // Select with options (at field level)
9921
+ * const countryField = createField('select', {
9922
+ * key: 'country',
9923
+ * label: 'Country',
9924
+ * options: [{ label: 'USA', value: 'us' }]
9925
+ * });
9926
+ *
9927
+ * // Slider with minValue/maxValue (at field level)
9928
+ * const ratingField = createField('slider', {
9929
+ * key: 'rating',
9930
+ * label: 'Rating',
9931
+ * minValue: 1,
9932
+ * maxValue: 10,
9933
+ * step: 1,
9934
+ * value: 5
9935
+ * });
9936
+ * ```
9937
+ *
9938
+ * @param type - The field type (e.g., 'input', 'select', 'group')
9939
+ * @param config - The field configuration (excluding the 'type' property)
9940
+ * @returns A properly typed field configuration
9941
+ * @throws DynamicFormError if the configuration contains common mistakes
9942
+ */
9943
+ function createField(type, config) {
9944
+ const configWithProps = config;
9945
+ // Validate key is present
9946
+ if (!configWithProps['key']) {
9947
+ throw new DynamicFormError(`createField('${type}'): 'key' property is required`);
9948
+ }
9949
+ // Container validation: no label allowed
9950
+ if (CONTAINER_TYPES.includes(type)) {
9951
+ if ('label' in configWithProps && configWithProps['label'] !== undefined) {
9952
+ throw new DynamicFormError(`createField('${type}'): Container fields (${CONTAINER_TYPES.join(', ')}) do NOT support 'label'. ` +
9953
+ `Labels go on the child fields inside the container.`);
9954
+ }
9955
+ }
9956
+ // Container validation: no logic allowed (except page with hidden)
9957
+ if (CONTAINER_TYPES.includes(type)) {
9958
+ if ('logic' in configWithProps && configWithProps['logic'] !== undefined) {
9959
+ throw new DynamicFormError(`createField('${type}'): Container fields (${CONTAINER_TYPES.join(', ')}) do NOT support 'logic'. ` +
9960
+ `Apply logic to child fields instead, or use 'page' containers which support 'hidden' logic only.`);
9961
+ }
9962
+ }
9963
+ // Page validation: only hidden logic allowed
9964
+ if (type === PAGE_TYPE && 'logic' in configWithProps) {
9965
+ const logic = configWithProps['logic'];
9966
+ if (Array.isArray(logic)) {
9967
+ const nonHiddenLogic = logic.filter((l) => l.type !== 'hidden');
9968
+ if (nonHiddenLogic.length > 0) {
9969
+ throw new DynamicFormError(`createField('page'): Pages only support 'hidden' logic type. ` +
9970
+ `Found unsupported logic types: ${nonHiddenLogic.map((l) => l.type).join(', ')}. ` +
9971
+ `For other logic types, apply them to child fields instead.`);
9972
+ }
9973
+ }
9974
+ }
9975
+ // Options placement validation
9976
+ if (OPTION_FIELD_TYPES.includes(type)) {
9977
+ const props = configWithProps['props'];
9978
+ if (props && 'options' in props) {
9979
+ throw new DynamicFormError(`createField('${type}'): 'options' must be at FIELD level, NOT inside 'props'. ` +
9980
+ `Move 'options' from props to the field root: { type: '${type}', key: '...', options: [...] }`);
9981
+ }
9982
+ }
9983
+ // Slider props validation
9984
+ if (type === SLIDER_TYPE) {
9985
+ const props = configWithProps['props'];
9986
+ if (props) {
9987
+ if ('min' in props || 'max' in props) {
9988
+ throw new DynamicFormError(`createField('slider'): Use 'minValue' and 'maxValue' at FIELD level, NOT 'min'/'max' in props. ` +
9989
+ `Example: { type: 'slider', key: '...', minValue: 0, maxValue: 100, step: 1 }`);
9990
+ }
9991
+ if ('step' in props) {
9992
+ throw new DynamicFormError(`createField('slider'): Use 'step' at FIELD level, NOT in props. ` +
9993
+ `Example: { type: 'slider', key: '...', minValue: 0, maxValue: 100, step: 1 }`);
9994
+ }
9995
+ }
9996
+ }
9997
+ // Hidden field validation
9998
+ if (type === HIDDEN_TYPE) {
9999
+ if ('logic' in configWithProps && configWithProps['logic'] !== undefined) {
10000
+ throw new DynamicFormError(`createField('hidden'): Hidden fields do NOT support 'logic'. ` + `Hidden fields are purely for passing values through the form.`);
10001
+ }
10002
+ if ('validators' in configWithProps && configWithProps['validators'] !== undefined) {
10003
+ throw new DynamicFormError(`createField('hidden'): Hidden fields do NOT support 'validators'. ` +
10004
+ `Hidden fields are purely for passing values through the form.`);
10005
+ }
10006
+ if ('required' in configWithProps && configWithProps['required']) {
10007
+ throw new DynamicFormError(`createField('hidden'): Hidden fields do NOT support 'required' validation. ` +
10008
+ `Hidden fields are purely for passing values through the form.`);
10009
+ }
10010
+ if ('label' in configWithProps && configWithProps['label'] !== undefined) {
10011
+ throw new DynamicFormError(`createField('hidden'): Hidden fields do NOT support 'label'. ` +
10012
+ `Hidden fields are not rendered and have no visual representation.`);
10013
+ }
10014
+ }
10015
+ // Array field validation
10016
+ if (type === 'array') {
10017
+ if ('template' in configWithProps) {
10018
+ throw new DynamicFormError(`createField('array'): Use 'fields' (NOT 'template') to define the array item structure. ` +
10019
+ `Example: { type: 'array', key: '...', fields: [{ type: 'group', key: 'item', fields: [...] }] }`);
10020
+ }
10021
+ if ('minItems' in configWithProps || 'maxItems' in configWithProps) {
10022
+ throw new DynamicFormError(`createField('array'): 'minItems' and 'maxItems' properties are NOT supported. ` +
10023
+ `Use custom validation if you need to enforce array length constraints.`);
10024
+ }
10025
+ }
10026
+ // Generic button validation
10027
+ if (type === 'button') {
10028
+ if (!('event' in configWithProps) || configWithProps['event'] === undefined) {
10029
+ throw new DynamicFormError(`createField('button'): Generic buttons REQUIRE the 'event' property with a FormEventConstructor. ` +
10030
+ `For common actions, use 'submit', 'next', or 'previous' instead which don't require event configuration. ` +
10031
+ `Example: { type: 'submit', key: 'submit', label: 'Submit' }`);
10032
+ }
10033
+ }
10034
+ return { type, ...config };
10035
+ }
10036
+ /**
10037
+ * Shorthand alias for createField
10038
+ *
10039
+ * @example
10040
+ * ```typescript
10041
+ * const nameField = field('input', { key: 'name', label: 'Name', value: '' });
10042
+ * ```
10043
+ */
10044
+ const field = createField;
10045
+
9649
10046
  /**
9650
10047
  * Converts DynamicText (string | Observable | Signal) to Observable<string>
9651
10048
  * Unifies all three types into a consistent Observable stream
@@ -10392,49 +10789,65 @@ class ArrayFieldComponent {
10392
10789
  this.resolvedItemsSignal.set([]);
10393
10790
  break;
10394
10791
  case 'initial':
10395
- this.resolveAllItems(fieldTrees, updateId);
10792
+ void this.resolveAllItems(fieldTrees, updateId);
10396
10793
  break;
10397
10794
  case 'append':
10398
- this.appendItems(fieldTrees, operation.startIndex, operation.endIndex, updateId);
10795
+ void this.appendItems(fieldTrees, operation.startIndex, operation.endIndex, updateId);
10399
10796
  break;
10400
10797
  case 'pop':
10401
10798
  this.resolvedItemsSignal.set(resolvedItems.slice(0, operation.newLength));
10402
10799
  break;
10403
10800
  case 'recreate':
10404
10801
  this.resolvedItemsSignal.set([]);
10405
- this.resolveAllItems(fieldTrees, updateId);
10802
+ void this.resolveAllItems(fieldTrees, updateId);
10406
10803
  break;
10407
10804
  case 'none':
10408
10805
  break;
10409
10806
  }
10410
10807
  }
10411
- resolveAllItems(fieldTrees, updateId) {
10808
+ async resolveAllItems(fieldTrees, updateId) {
10412
10809
  if (fieldTrees.length === 0) {
10413
10810
  this.resolvedItemsSignal.set([]);
10414
10811
  return;
10415
10812
  }
10416
- const itemObservables = fieldTrees.map((fieldTree, i) => this.createResolveItemObservable(fieldTree, i));
10417
- forkJoin(itemObservables)
10418
- .pipe(takeUntilDestroyed(this.destroyRef), map((items) => items.filter((item) => item !== undefined)))
10419
- .subscribe((newItems) => {
10813
+ // Wrap each item observable to catch individual errors
10814
+ const safeItemObservables = fieldTrees.map((fieldTree, i) => this.createResolveItemObservable(fieldTree, i).pipe(catchError((error) => {
10815
+ this.logger.error(`Failed to resolve array item at index ${i}:`, error);
10816
+ return of(undefined);
10817
+ })));
10818
+ try {
10819
+ const items = await firstValueFrom(forkJoin(safeItemObservables).pipe(map((items) => items.filter((item) => item !== undefined))));
10420
10820
  if (updateId === this.updateVersion()) {
10421
- this.resolvedItemsSignal.set(newItems);
10821
+ this.resolvedItemsSignal.set(items);
10422
10822
  emitComponentInitialized(this.eventBus, 'array', this.field().key, this.parentInjector);
10423
10823
  }
10424
- });
10824
+ }
10825
+ catch (err) {
10826
+ this.logger.error('Failed to resolve array items:', err);
10827
+ this.resolvedItemsSignal.set([]);
10828
+ }
10425
10829
  }
10426
- appendItems(fieldTrees, startIndex, endIndex, updateId) {
10830
+ async appendItems(fieldTrees, startIndex, endIndex, updateId) {
10427
10831
  const itemsToResolve = fieldTrees.slice(startIndex, endIndex);
10428
10832
  if (itemsToResolve.length === 0)
10429
10833
  return;
10430
- const itemObservables = itemsToResolve.map((fieldTree, i) => this.createResolveItemObservable(fieldTree, startIndex + i));
10431
- forkJoin(itemObservables)
10432
- .pipe(takeUntilDestroyed(this.destroyRef), map((items) => items.filter((item) => item !== undefined)))
10433
- .subscribe((newItems) => {
10834
+ // Wrap each item observable to catch individual errors
10835
+ const safeItemObservables = itemsToResolve.map((fieldTree, i) => {
10836
+ const index = startIndex + i;
10837
+ return this.createResolveItemObservable(fieldTree, index).pipe(catchError((error) => {
10838
+ this.logger.error(`Failed to resolve array item at index ${index}:`, error);
10839
+ return of(undefined);
10840
+ }));
10841
+ });
10842
+ try {
10843
+ const newItems = await firstValueFrom(forkJoin(safeItemObservables).pipe(map((items) => items.filter((item) => item !== undefined))));
10434
10844
  if (updateId === this.updateVersion()) {
10435
10845
  this.resolvedItemsSignal.update((current) => [...current, ...newItems]);
10436
10846
  }
10437
- });
10847
+ }
10848
+ catch (err) {
10849
+ this.logger.error('Failed to append array items:', err);
10850
+ }
10438
10851
  }
10439
10852
  createResolveItemObservable(fieldTree, index) {
10440
10853
  const template = this.fieldTemplate();
@@ -10466,9 +10879,11 @@ class ArrayFieldComponent {
10466
10879
  const insertIndex = index !== undefined ? Math.min(index, currentArray.length) : currentArray.length;
10467
10880
  const rawValue = getFieldDefaultValue(fieldTemplate, this.rawFieldRegistry());
10468
10881
  const valueHandling = getFieldValueHandling(fieldTemplate.type, this.rawFieldRegistry());
10469
- // For fields with 'include' value handling (bare fields like input, checkbox, etc.),
10470
- // wrap the value in an object using the field's key since arrays contain objects
10471
- const value = valueHandling === 'include' && fieldTemplate.key ? { [fieldTemplate.key]: rawValue } : rawValue;
10882
+ const templateType = fieldTemplate.type;
10883
+ // Container fields (group, row) already have their own structure, so use rawValue as-is.
10884
+ // For value fields with 'include' handling, wrap in an object using the field's key.
10885
+ const isContainer = templateType === 'group' || templateType === 'row';
10886
+ const value = isContainer ? rawValue : valueHandling === 'include' && fieldTemplate.key ? { [fieldTemplate.key]: rawValue } : rawValue;
10472
10887
  const newArray = [...currentArray];
10473
10888
  newArray.splice(insertIndex, 0, value);
10474
10889
  // Update the parent form with the new array value
@@ -10656,5 +11071,5 @@ function extractErrorParams(error) {
10656
11071
  * Generated bundle index. Do not edit.
10657
11072
  */
10658
11073
 
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
11074
+ export { dynamicTextToObservable as $, AddArrayItemEvent as A, PreviousPageEvent as B, ConsoleLogger as C, DynamicFormLogger as D, EventBus as E, FormClearEvent as F, createField as G, field as H, formConfig as I, DynamicFormError as J, baseFieldMapper as K, buildBaseInputs as L, arrayFieldMapper as M, NoopLogger as N, groupFieldMapper as O, PageChangeEvent as P, pageFieldMapper as Q, RemoveArrayItemEvent as R, SubmitEvent as S, rowFieldMapper as T, textFieldMapper as U, FIELD_REGISTRY as V, ARRAY_CONTEXT as W, DEFAULT_PROPS as X, DEFAULT_VALIDATION_MESSAGES as Y, FIELD_SIGNAL_CONTEXT as Z, FORM_OPTIONS as _, DynamicTextPipe as a, ArrayFieldComponent as a0, GroupFieldComponent as a1, RowFieldComponent as a2, applyValidator as a3, applyValidators as a4, getArrayLength as a5, resolveTokens as a6, BUILT_IN_FIELDS as a7, isEqual as a8, omit as a9, interpolateParams as aa, applyMetaToElement as ab, RootFormRegistryService as ac, DynamicForm as b, createFieldResolutionPipe as c, derivedFromDeferred as d, emitComponentInitialized as e, withEventFormValue as f, isFormStateCondition as g, hasFormValue as h, injectFieldRegistry as i, resolveNextButtonDisabled as j, isCheckedField as k, isValueField as l, isRowField as m, isArrayField as n, isContainerField as o, provideDynamicForm as p, isDisplayOnlyField as q, resolveSubmitButtonDisabled as r, isGroupField as s, isLeafField as t, isPageField as u, validatePageNesting as v, withLoggerConfig as w, isValueBearingField as x, FormResetEvent as y, NextPageEvent as z };
11075
+ //# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-BWFdMPyw.mjs.map