@ng-forge/dynamic-forms 0.9.0-next.10 → 0.9.0-next.12
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.
- package/fesm2022/{ng-forge-dynamic-forms-array-field.component-DbWes42V.mjs → ng-forge-dynamic-forms-array-field.component-BgCrTME1.mjs} +167 -69
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-BgCrTME1.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-container-field.component-CMhqFGJZ.mjs → ng-forge-dynamic-forms-container-field.component-VHaBk_1l.mjs} +9 -5
- package/fesm2022/ng-forge-dynamic-forms-container-field.component-VHaBk_1l.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-container-utils-D_nNzKo1.mjs → ng-forge-dynamic-forms-container-utils-CGJiiKBY.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-container-utils-D_nNzKo1.mjs.map → ng-forge-dynamic-forms-container-utils-CGJiiKBY.mjs.map} +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-css-wrapper.component-htvP_o95.mjs → ng-forge-dynamic-forms-css-wrapper.component-BOdw40ZF.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-css-wrapper.component-htvP_o95.mjs.map → ng-forge-dynamic-forms-css-wrapper.component-BOdw40ZF.mjs.map} +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-emit-initialization-DtkxP7pE.mjs → ng-forge-dynamic-forms-emit-initialization-D72mQxlX.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-emit-initialization-DtkxP7pE.mjs.map → ng-forge-dynamic-forms-emit-initialization-D72mQxlX.mjs.map} +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-group-field.component-BhYFIDA1.mjs → ng-forge-dynamic-forms-group-field.component-BvTQwoLg.mjs} +30 -6
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-BvTQwoLg.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-ng-forge-dynamic-forms-DWPPLOrV.mjs → ng-forge-dynamic-forms-ng-forge-dynamic-forms-BAPGCjUs.mjs} +713 -318
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-BAPGCjUs.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-page-field.component-B9v_1shD.mjs → ng-forge-dynamic-forms-page-field.component-DDzmhJNn.mjs} +9 -5
- package/fesm2022/ng-forge-dynamic-forms-page-field.component-DDzmhJNn.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-B4LnTnIA.mjs → ng-forge-dynamic-forms-text-field.component-Bo4zNJXY.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-B4LnTnIA.mjs.map → ng-forge-dynamic-forms-text-field.component-Bo4zNJXY.mjs.map} +1 -1
- package/fesm2022/ng-forge-dynamic-forms.mjs +1 -1
- package/package.json +1 -1
- package/types/ng-forge-dynamic-forms.d.ts +2042 -1803
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-DbWes42V.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-container-field.component-CMhqFGJZ.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-BhYFIDA1.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-DWPPLOrV.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-page-field.component-B9v_1shD.mjs.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, reflectComponentType, Injector, inject, EnvironmentInjector, DestroyRef, computed, input, ViewContainerRef,
|
|
2
|
+
import { InjectionToken, reflectComponentType, Injector, inject, EnvironmentInjector, DestroyRef, computed, signal, input, ViewContainerRef, Directive, Injectable, isSignal, untracked, linkedSignal, ChangeDetectionStrategy, Component, resourceFromSnapshots, runInInjectionContext, afterNextRender, isWritableSignal, model, makeEnvironmentProviders, Pipe } from '@angular/core';
|
|
3
3
|
import { explicitEffect } from 'ngxtension/explicit-effect';
|
|
4
|
-
import { of, forkJoin, from, catchError, map, switchMap, isObservable, firstValueFrom, exhaustMap, EMPTY, Subject, filter, TimeoutError, throwError, defer, combineLatest, pipe, debounceTime, distinctUntilChanged, tap, startWith, scan as scan$1, Observable, concatMap, take as take$1, pairwise, takeUntil, combineLatestWith, auditTime, scheduled, queueScheduler, merge, timer } from 'rxjs';
|
|
5
4
|
import { takeUntilDestroyed, toObservable, toSignal, rxResource, outputFromObservable } from '@angular/core/rxjs-interop';
|
|
5
|
+
import { of, forkJoin, from, catchError, map, switchMap, isObservable, firstValueFrom, exhaustMap, EMPTY, Subject, filter, TimeoutError, throwError, defer, combineLatest, pipe, debounceTime, distinctUntilChanged, tap, startWith, scan as scan$1, Observable, concatMap, take as take$1, pairwise, takeUntil, combineLatestWith, auditTime, scheduled, queueScheduler, merge, timer } from 'rxjs';
|
|
6
6
|
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';
|
|
7
7
|
import { scan, map as map$1, filter as filter$1, take, switchMap as switchMap$1, timeout, catchError as catchError$1, shareReplay } from 'rxjs/operators';
|
|
8
8
|
import { HttpClient } from '@angular/common/http';
|
|
@@ -139,6 +139,32 @@ const FIELD_SIGNAL_CONTEXT = new InjectionToken('FIELD_SIGNAL_CONTEXT', {
|
|
|
139
139
|
* ```
|
|
140
140
|
*/
|
|
141
141
|
const ARRAY_CONTEXT = new InjectionToken('ARRAY_CONTEXT');
|
|
142
|
+
/**
|
|
143
|
+
* Injection token for providing group ancestry to mappers and components.
|
|
144
|
+
*
|
|
145
|
+
* Re-provided in two places:
|
|
146
|
+
* 1. `GroupFieldComponent` provides a context whose `groupPath()` is the
|
|
147
|
+
* parent path extended by its own key (e.g. `'address'` or `'user.address'`).
|
|
148
|
+
* 2. `createArrayItemInjector` provides a sentinel with an empty `groupPath`,
|
|
149
|
+
* resetting the chain at array boundaries — descendants of an array item
|
|
150
|
+
* that re-enter a group compose paths INSIDE the array item, matching the
|
|
151
|
+
* property-derivation collector's keying.
|
|
152
|
+
*
|
|
153
|
+
* `mapFieldToInputs` reads this to scope DOM IDs and override-store keys so
|
|
154
|
+
* the same leaf key can appear in different groups without colliding (#401).
|
|
155
|
+
*
|
|
156
|
+
* Inject with `{ optional: true }` because top-level fields with no group
|
|
157
|
+
* ancestor (and no enclosing array) won't have the token provided. The token
|
|
158
|
+
* IS present for fields inside an array even when no inner group exists — the
|
|
159
|
+
* array-boundary sentinel makes injection succeed with an empty `groupPath`.
|
|
160
|
+
*/
|
|
161
|
+
// No factory: GROUP_CONTEXT is optional-by-design — `GroupFieldComponent` and
|
|
162
|
+
// `createArrayItemInjector` re-provide it where it makes sense, and consumers
|
|
163
|
+
// inject with `{ optional: true }` to handle the top-level case. Mirrors the
|
|
164
|
+
// ARRAY_CONTEXT pattern. Adding a throwing factory here fires even when
|
|
165
|
+
// injected optionally (Angular still evaluates the tree-shakable factory
|
|
166
|
+
// before honoring `optional`), so non-group fields would crash on every render.
|
|
167
|
+
const GROUP_CONTEXT = new InjectionToken('GROUP_CONTEXT');
|
|
142
168
|
/**
|
|
143
169
|
* Injection token for form-level default props.
|
|
144
170
|
*
|
|
@@ -226,65 +252,57 @@ const FORM_OPTIONS = new InjectionToken('FORM_OPTIONS');
|
|
|
226
252
|
* and inherited by all children via Angular's hierarchical injector.
|
|
227
253
|
*/
|
|
228
254
|
const EXTERNAL_DATA = new InjectionToken('EXTERNAL_DATA');
|
|
255
|
+
|
|
229
256
|
/**
|
|
230
|
-
* Injection token for
|
|
231
|
-
*
|
|
232
|
-
* This registry tracks which template was used to create each array item,
|
|
233
|
-
* keyed by the item's unique ID. This is essential for "recreate" operations
|
|
234
|
-
* (e.g., after removing items from the middle) where we need to re-resolve
|
|
235
|
-
* items using their original templates, not a fallback.
|
|
236
|
-
*
|
|
237
|
-
* The token is provided by ArrayFieldComponent at its level and shared
|
|
238
|
-
* across all items in that array. Each nested array gets its own registry.
|
|
239
|
-
*
|
|
240
|
-
* @example
|
|
241
|
-
* ```typescript
|
|
242
|
-
* // In ArrayFieldComponent
|
|
243
|
-
* providers: [
|
|
244
|
-
* { provide: ARRAY_TEMPLATE_REGISTRY, useValue: new Map() }
|
|
245
|
-
* ]
|
|
246
|
-
*
|
|
247
|
-
* // When adding items
|
|
248
|
-
* const registry = inject(ARRAY_TEMPLATE_REGISTRY);
|
|
249
|
-
* registry.set(itemId, templates);
|
|
257
|
+
* Injection token for the dynamic forms logger.
|
|
250
258
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* ```
|
|
259
|
+
* Provided by provideDynamicForm() with ConsoleLogger as default.
|
|
260
|
+
* Override with withLoggerConfig(false) to disable logging.
|
|
254
261
|
*/
|
|
255
|
-
const
|
|
262
|
+
const DynamicFormLogger = new InjectionToken('DynamicFormLogger');
|
|
263
|
+
|
|
256
264
|
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* Provides a function that generates unique IDs for array items. Each array
|
|
260
|
-
* component instance gets its own generator (via useFactory), ensuring:
|
|
261
|
-
* - SSR hydration compatibility (server and client generate same IDs for same array)
|
|
262
|
-
* - No global state pollution between form instances
|
|
263
|
-
* - Deterministic IDs within each array's lifecycle
|
|
264
|
-
*
|
|
265
|
-
* The token is provided by ArrayFieldComponent at its level with a factory
|
|
266
|
-
* that creates a fresh counter for each array instance.
|
|
267
|
-
*
|
|
268
|
-
* @example
|
|
269
|
-
* ```typescript
|
|
270
|
-
* // In ArrayFieldComponent
|
|
271
|
-
* providers: [
|
|
272
|
-
* { provide: ARRAY_ITEM_ID_GENERATOR, useFactory: createArrayItemIdGenerator }
|
|
273
|
-
* ]
|
|
274
|
-
*
|
|
275
|
-
* // Usage
|
|
276
|
-
* const generateId = inject(ARRAY_ITEM_ID_GENERATOR);
|
|
277
|
-
* const itemId = generateId(); // 'item-0', 'item-1', etc.
|
|
278
|
-
* ```
|
|
265
|
+
* Shared empty chain — returned when no wrappers apply so callers get a
|
|
266
|
+
* stable reference. Freezing makes accidental mutation observable.
|
|
279
267
|
*/
|
|
280
|
-
const
|
|
268
|
+
const EMPTY_WRAPPERS = Object.freeze([]);
|
|
281
269
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
270
|
+
* Element-wise identity (`===`) comparator for wrapper chains. Used as the
|
|
271
|
+
* `equal` option on signal memoisation so reconciled `FieldDef`s with the
|
|
272
|
+
* same chain don't churn downstream — relies on `WrapperConfig` objects
|
|
273
|
+
* being stable across ticks, which holds for configs declared in
|
|
274
|
+
* `FormConfig` / `createWrappers(...)`.
|
|
284
275
|
*/
|
|
285
|
-
function
|
|
286
|
-
|
|
287
|
-
|
|
276
|
+
function isSameWrapperChain(a, b) {
|
|
277
|
+
if (a === b)
|
|
278
|
+
return true;
|
|
279
|
+
if (a.length !== b.length)
|
|
280
|
+
return false;
|
|
281
|
+
for (let i = 0; i < a.length; i++) {
|
|
282
|
+
if (a[i] !== b[i])
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Resolves the wrapper chain for a field.
|
|
289
|
+
*
|
|
290
|
+
* Merge order (outermost → innermost): auto-associations, form defaults,
|
|
291
|
+
* field-level wrappers. `wrappers: null` skips all three; `skipAutoWrappers` /
|
|
292
|
+
* `skipDefaultWrappers` on the field opt out of individual layers. Fresh
|
|
293
|
+
* array per call — downstream memoisation is on the consuming computed.
|
|
294
|
+
*/
|
|
295
|
+
function resolveWrappers(field, defaultWrappers, autoAssociations) {
|
|
296
|
+
if (field.wrappers === null) {
|
|
297
|
+
return EMPTY_WRAPPERS;
|
|
298
|
+
}
|
|
299
|
+
const autoWrappers = field.skipAutoWrappers ? EMPTY_WRAPPERS : (autoAssociations.get(field.type) ?? EMPTY_WRAPPERS);
|
|
300
|
+
const defaults = field.skipDefaultWrappers ? EMPTY_WRAPPERS : (defaultWrappers ?? EMPTY_WRAPPERS);
|
|
301
|
+
const fieldLevel = field.wrappers ?? EMPTY_WRAPPERS;
|
|
302
|
+
if (autoWrappers.length === 0 && defaults.length === 0 && fieldLevel.length === 0) {
|
|
303
|
+
return EMPTY_WRAPPERS;
|
|
304
|
+
}
|
|
305
|
+
return [...autoWrappers, ...defaults, ...fieldLevel];
|
|
288
306
|
}
|
|
289
307
|
|
|
290
308
|
/**
|
|
@@ -481,58 +499,6 @@ class WrapperAwareInjector extends Injector {
|
|
|
481
499
|
}
|
|
482
500
|
}
|
|
483
501
|
|
|
484
|
-
/**
|
|
485
|
-
* Injection token for the dynamic forms logger.
|
|
486
|
-
*
|
|
487
|
-
* Provided by provideDynamicForm() with ConsoleLogger as default.
|
|
488
|
-
* Override with withLoggerConfig(false) to disable logging.
|
|
489
|
-
*/
|
|
490
|
-
const DynamicFormLogger = new InjectionToken('DynamicFormLogger');
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* Shared empty chain — returned when no wrappers apply so callers get a
|
|
494
|
-
* stable reference. Freezing makes accidental mutation observable.
|
|
495
|
-
*/
|
|
496
|
-
const EMPTY_WRAPPERS = Object.freeze([]);
|
|
497
|
-
/**
|
|
498
|
-
* Element-wise identity (`===`) comparator for wrapper chains. Used as the
|
|
499
|
-
* `equal` option on signal memoisation so reconciled `FieldDef`s with the
|
|
500
|
-
* same chain don't churn downstream — relies on `WrapperConfig` objects
|
|
501
|
-
* being stable across ticks, which holds for configs declared in
|
|
502
|
-
* `FormConfig` / `createWrappers(...)`.
|
|
503
|
-
*/
|
|
504
|
-
function isSameWrapperChain(a, b) {
|
|
505
|
-
if (a === b)
|
|
506
|
-
return true;
|
|
507
|
-
if (a.length !== b.length)
|
|
508
|
-
return false;
|
|
509
|
-
for (let i = 0; i < a.length; i++) {
|
|
510
|
-
if (a[i] !== b[i])
|
|
511
|
-
return false;
|
|
512
|
-
}
|
|
513
|
-
return true;
|
|
514
|
-
}
|
|
515
|
-
/**
|
|
516
|
-
* Resolves the wrapper chain for a field.
|
|
517
|
-
*
|
|
518
|
-
* Merge order (outermost → innermost): auto-associations, form defaults,
|
|
519
|
-
* field-level wrappers. `wrappers: null` skips all three; `skipAutoWrappers` /
|
|
520
|
-
* `skipDefaultWrappers` on the field opt out of individual layers. Fresh
|
|
521
|
-
* array per call — downstream memoisation is on the consuming computed.
|
|
522
|
-
*/
|
|
523
|
-
function resolveWrappers(field, defaultWrappers, autoAssociations) {
|
|
524
|
-
if (field.wrappers === null) {
|
|
525
|
-
return EMPTY_WRAPPERS;
|
|
526
|
-
}
|
|
527
|
-
const autoWrappers = field.skipAutoWrappers ? EMPTY_WRAPPERS : (autoAssociations.get(field.type) ?? EMPTY_WRAPPERS);
|
|
528
|
-
const defaults = field.skipDefaultWrappers ? EMPTY_WRAPPERS : (defaultWrappers ?? EMPTY_WRAPPERS);
|
|
529
|
-
const fieldLevel = field.wrappers ?? EMPTY_WRAPPERS;
|
|
530
|
-
if (autoWrappers.length === 0 && defaults.length === 0 && fieldLevel.length === 0) {
|
|
531
|
-
return EMPTY_WRAPPERS;
|
|
532
|
-
}
|
|
533
|
-
return [...autoWrappers, ...defaults, ...fieldLevel];
|
|
534
|
-
}
|
|
535
|
-
|
|
536
502
|
/**
|
|
537
503
|
* Owns the wrapper-chain lifecycle for a field or container: async component
|
|
538
504
|
* loading, switchMap-based cancellation of stale loads, flicker-tolerant
|
|
@@ -723,6 +689,155 @@ function toReadonlyFieldTreeCached(cache, field) {
|
|
|
723
689
|
return view;
|
|
724
690
|
}
|
|
725
691
|
|
|
692
|
+
const EMPTY_STATE = Object.freeze({ phase: 'empty' });
|
|
693
|
+
/**
|
|
694
|
+
* Encapsulates the imperative `ViewContainerRef` / `ComponentRef` lifecycle
|
|
695
|
+
* for the innermost field component. Pulled out of `DfFieldOutlet` so the
|
|
696
|
+
* directive itself stays declarative — signals + constructor wiring — while
|
|
697
|
+
* the unavoidable mutable bookkeeping (Angular's view container API is
|
|
698
|
+
* imperative) lives in one place behind a small, focused surface.
|
|
699
|
+
*
|
|
700
|
+
* State is held in a single `signal<FieldSlotState>` discriminated union;
|
|
701
|
+
* every mutation is an explicit `state.set(...)` transition. No nullable
|
|
702
|
+
* instance fields, no `undefined`-initialized refs — each phase carries
|
|
703
|
+
* exactly the data it owns.
|
|
704
|
+
*
|
|
705
|
+
* Responsibilities:
|
|
706
|
+
* - Create the component (or reuse the same-class instance across a rebuild,
|
|
707
|
+
* preserving its DOM state, focus, and caret).
|
|
708
|
+
* - Push mapper inputs via `setInput`, dedup'd per-key so dev-mode NG0303
|
|
709
|
+
* warnings for undeclared mapper keys don't replay on every emission.
|
|
710
|
+
* - Detach cleanly before the outer VCR clears, so same-class reuse can
|
|
711
|
+
* re-insert the preserved `hostView`.
|
|
712
|
+
* - Destroy on directive teardown when the host view is still detached.
|
|
713
|
+
*/
|
|
714
|
+
class FieldComponentSlot {
|
|
715
|
+
state = signal(EMPTY_STATE, ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
|
|
716
|
+
/**
|
|
717
|
+
* Per-key cache for `pushInputs`. Lives outside the lifecycle state machine on
|
|
718
|
+
* purpose — it's a `setInput`-call optimization that changes on every keystroke,
|
|
719
|
+
* not a phase transition. Keeping it here means the `state` signal only emits
|
|
720
|
+
* on real `empty ⇄ mounted ⇄ detached` transitions (≈ once per
|
|
721
|
+
* mount/destroy cycle), not once per input emission.
|
|
722
|
+
*/
|
|
723
|
+
lastInputs = null;
|
|
724
|
+
/** Read-only view of the lifecycle phase. Useful for tests and assertions. */
|
|
725
|
+
phase = () => this.state().phase;
|
|
726
|
+
/** Read-only view of the full state. Exposed so tests can assert ref stability. */
|
|
727
|
+
snapshot = () => this.state();
|
|
728
|
+
/**
|
|
729
|
+
* Mount the field component into `slot`. Reuses the existing `ComponentRef`
|
|
730
|
+
* when its component type matches (preserving DOM/focus/caret across a
|
|
731
|
+
* wrapper-chain rebuild); otherwise discards the old ref and creates a fresh
|
|
732
|
+
* one.
|
|
733
|
+
*/
|
|
734
|
+
mountOrReuse(slot, component, fieldInjector, environmentInjector, rawInputs) {
|
|
735
|
+
const current = this.state();
|
|
736
|
+
if (current.phase !== 'empty' && current.ref.componentType === component) {
|
|
737
|
+
slot.insert(current.ref.hostView);
|
|
738
|
+
this.lastInputs = null;
|
|
739
|
+
this.state.set(Object.freeze({ phase: 'mounted', ref: current.ref, slot }));
|
|
740
|
+
this.pushInputs(rawInputs);
|
|
741
|
+
if (current.phase === 'detached')
|
|
742
|
+
this.replayFocus(current.focusSnapshot);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
// Different component class — destroy the prior ref (if any) and create
|
|
746
|
+
// a fresh one. The `empty` transition before `createComponent` ensures a
|
|
747
|
+
// throw inside the factory doesn't leave a dangling ref the next mount
|
|
748
|
+
// would try to re-insert.
|
|
749
|
+
if (current.phase !== 'empty')
|
|
750
|
+
current.ref.destroy();
|
|
751
|
+
this.lastInputs = null;
|
|
752
|
+
this.state.set(EMPTY_STATE);
|
|
753
|
+
const ref = slot.createComponent(component, {
|
|
754
|
+
environmentInjector,
|
|
755
|
+
injector: createWrapperAwareInjector(fieldInjector, slot.injector),
|
|
756
|
+
});
|
|
757
|
+
this.state.set(Object.freeze({ phase: 'mounted', ref, slot }));
|
|
758
|
+
this.pushInputs(rawInputs);
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Push mapper inputs onto the mounted component. Per-key reference compare
|
|
762
|
+
* skips `setInput` for unchanged values — necessary in dev mode where
|
|
763
|
+
* `setInput` on unknown keys logs NG0303 per call (Angular 21 is lenient
|
|
764
|
+
* at runtime but warns); without the cache, every emission would re-warn
|
|
765
|
+
* for every undeclared mapper key.
|
|
766
|
+
*
|
|
767
|
+
* Updates the `lastInputs` cache (a plain field, not part of `state`) so the
|
|
768
|
+
* `state` signal doesn't churn its reference on every keystroke.
|
|
769
|
+
*
|
|
770
|
+
* `reflectComponentType` does NOT include hostDirective-forwarded inputs
|
|
771
|
+
* (angular/angular#49734), so a declared-input filter here would skip every
|
|
772
|
+
* value flowing through `NgForgeFieldShell` / `NgForgeField` /
|
|
773
|
+
* `NgForgeAction`. Mapper-as-contract is enforced by the `*_INPUTS`
|
|
774
|
+
* lockstep type assertions, not at runtime here.
|
|
775
|
+
*/
|
|
776
|
+
pushInputs(rawInputs) {
|
|
777
|
+
const current = this.state();
|
|
778
|
+
if (current.phase !== 'mounted')
|
|
779
|
+
return;
|
|
780
|
+
const prev = this.lastInputs;
|
|
781
|
+
if (prev === rawInputs)
|
|
782
|
+
return;
|
|
783
|
+
for (const [key, value] of Object.entries(rawInputs)) {
|
|
784
|
+
if (prev && prev[key] === value)
|
|
785
|
+
continue;
|
|
786
|
+
current.ref.setInput(key, value);
|
|
787
|
+
}
|
|
788
|
+
this.lastInputs = rawInputs;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Detach the host view from its current slot, capturing focus + caret so the
|
|
792
|
+
* next `mountOrReuse()` can replay them after re-inserting into the new
|
|
793
|
+
* slot. A stranded `mounted` state with a bad slot index (re-entrancy during
|
|
794
|
+
* a partial rebuild) is treated like `empty` to avoid leaks.
|
|
795
|
+
*/
|
|
796
|
+
detach() {
|
|
797
|
+
const current = this.state();
|
|
798
|
+
if (current.phase !== 'mounted')
|
|
799
|
+
return;
|
|
800
|
+
const focusSnapshot = this.captureFocus(current.ref);
|
|
801
|
+
const idx = current.slot.indexOf(current.ref.hostView);
|
|
802
|
+
if (idx >= 0)
|
|
803
|
+
current.slot.detach(idx);
|
|
804
|
+
this.lastInputs = null;
|
|
805
|
+
this.state.set(Object.freeze({ phase: 'detached', ref: current.ref, focusSnapshot }));
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Called on directive teardown. Only destroys when the host view is still
|
|
809
|
+
* detached — otherwise the outer `vcr.clear()` cascade has already destroyed
|
|
810
|
+
* it (calling `.destroy()` again would throw).
|
|
811
|
+
*/
|
|
812
|
+
destroyOnTeardown() {
|
|
813
|
+
const current = this.state();
|
|
814
|
+
if (current.phase === 'detached')
|
|
815
|
+
current.ref.destroy();
|
|
816
|
+
this.lastInputs = null;
|
|
817
|
+
this.state.set(EMPTY_STATE);
|
|
818
|
+
}
|
|
819
|
+
captureFocus(ref) {
|
|
820
|
+
const hostEl = ref.location.nativeElement;
|
|
821
|
+
const active = document.activeElement;
|
|
822
|
+
if (!(active instanceof HTMLElement) || !hostEl.contains(active))
|
|
823
|
+
return null;
|
|
824
|
+
const sel = active;
|
|
825
|
+
return {
|
|
826
|
+
element: active,
|
|
827
|
+
selectionStart: typeof sel.selectionStart === 'number' ? sel.selectionStart : null,
|
|
828
|
+
selectionEnd: typeof sel.selectionEnd === 'number' ? sel.selectionEnd : null,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
replayFocus(snap) {
|
|
832
|
+
if (!snap || !snap.element.isConnected)
|
|
833
|
+
return;
|
|
834
|
+
snap.element.focus();
|
|
835
|
+
if (snap.selectionStart !== null && snap.selectionEnd !== null && 'setSelectionRange' in snap.element) {
|
|
836
|
+
snap.element.setSelectionRange(snap.selectionStart, snap.selectionEnd);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
726
841
|
/**
|
|
727
842
|
* Structural directive that renders a `ResolvedField` with its effective
|
|
728
843
|
* wrapper chain.
|
|
@@ -743,12 +858,16 @@ function toReadonlyFieldTreeCached(cache, field) {
|
|
|
743
858
|
* includes the field's mapper outputs and a `ReadonlyFieldTree` view of its
|
|
744
859
|
* form state.
|
|
745
860
|
*
|
|
746
|
-
* Rendering is gated by `field.renderReady()` — the
|
|
747
|
-
* the mapper produces the required inputs
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
* a rebuild.
|
|
861
|
+
* Rendering is gated by `field.renderReady() && !field.hidden()` — the
|
|
862
|
+
* directive waits until the mapper produces the required inputs AND the
|
|
863
|
+
* field isn't hidden before instantiating the component. Once rendered, a
|
|
864
|
+
* transient `renderReady → false` does *not* tear the chain down; the
|
|
865
|
+
* controller keeps the mounted components alive and ignores the flicker.
|
|
866
|
+
* Only a structural change (wrappers or component class) triggers a rebuild.
|
|
867
|
+
*
|
|
868
|
+
* Imperative `ComponentRef` / `ViewContainerRef` lifecycle is encapsulated
|
|
869
|
+
* in `FieldComponentSlot` — the directive itself is signal definitions
|
|
870
|
+
* plus three method calls (`mountOrReuse`, `detach`, `destroyOnTeardown`).
|
|
752
871
|
*
|
|
753
872
|
* @example
|
|
754
873
|
* ```html
|
|
@@ -768,25 +887,16 @@ class DfFieldOutlet {
|
|
|
768
887
|
wrapperAutoAssociations = inject(WRAPPER_AUTO_ASSOCIATIONS);
|
|
769
888
|
defaultWrappersSignal = inject(DEFAULT_WRAPPERS, { optional: true });
|
|
770
889
|
readonlyFieldCache = inject(READONLY_FIELD_TREE_CACHE);
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
* VCR when there are no wrappers, or the innermost wrapper's `#fieldComponent`
|
|
775
|
-
* slot). Tracked so `beforeRebuild` can detach the view before the outer VCR
|
|
776
|
-
* cascade-destroys it.
|
|
777
|
-
*/
|
|
778
|
-
fieldSlot;
|
|
779
|
-
/** Focus + caret captured during detach, replayed after re-insert. */
|
|
780
|
-
focusSnapshot;
|
|
890
|
+
/** Encapsulates Angular's imperative ComponentRef / ViewContainerRef lifecycle. */
|
|
891
|
+
fieldComponent = new FieldComponentSlot();
|
|
892
|
+
componentIdentity = computed(() => this.dfFieldOutlet().component, ...(ngDevMode ? [{ debugName: "componentIdentity" }] : /* istanbul ignore next */ []));
|
|
781
893
|
/**
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
894
|
+
* Gate for the wrapper chain controller: true only when required inputs are populated AND the
|
|
895
|
+
* field isn't hidden. Combining the two prevents an initial-mount race where the chain mounts
|
|
896
|
+
* during the brief window between `renderReady` flipping true and `hidden()` settling — that
|
|
897
|
+
* window is exactly where Angular Signal Forms' `[formField]` directive emits NG01916.
|
|
786
898
|
*/
|
|
787
|
-
|
|
788
|
-
componentIdentity = computed(() => this.dfFieldOutlet().component, ...(ngDevMode ? [{ debugName: "componentIdentity" }] : /* istanbul ignore next */ []));
|
|
789
|
-
renderReady = computed(() => this.dfFieldOutlet().renderReady(), ...(ngDevMode ? [{ debugName: "renderReady" }] : /* istanbul ignore next */ []));
|
|
899
|
+
renderReady = computed(() => this.dfFieldOutlet().renderReady() && !this.dfFieldOutlet().hidden(), ...(ngDevMode ? [{ debugName: "renderReady" }] : /* istanbul ignore next */ []));
|
|
790
900
|
rawInputs = computed(() => this.dfFieldOutlet().inputs(), ...(ngDevMode ? [{ debugName: "rawInputs" }] : /* istanbul ignore next */ []));
|
|
791
901
|
/**
|
|
792
902
|
* Effective wrapper chain. Element-wise identity comparison keeps the signal
|
|
@@ -813,109 +923,17 @@ class DfFieldOutlet {
|
|
|
813
923
|
rebuildKey: this.componentIdentity,
|
|
814
924
|
fieldInputs: this.fieldInputs,
|
|
815
925
|
fieldInjector: this.fieldInjector,
|
|
816
|
-
beforeRebuild: () => this.
|
|
926
|
+
beforeRebuild: () => this.fieldComponent.detach(),
|
|
817
927
|
renderInnermost: (slot) => {
|
|
818
928
|
const resolved = this.dfFieldOutlet();
|
|
819
|
-
|
|
820
|
-
// Same component class — re-insert the preserved hostView. Browser
|
|
821
|
-
// loses focus on detach, so we replay the snapshot captured in
|
|
822
|
-
// detachFieldRef. Input value persists on the DOM node itself.
|
|
823
|
-
slot.insert(this.fieldRef.hostView);
|
|
824
|
-
this.fieldSlot = slot;
|
|
825
|
-
this.pushRawInputs(this.fieldRef, this.rawInputs());
|
|
826
|
-
this.restoreFocusSnapshot();
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
// Different component class — discard the old ref and create fresh.
|
|
830
|
-
// Merge field + element injectors so the field can inject ancestor
|
|
831
|
-
// wrappers AND form-context tokens (ARRAY_CONTEXT etc.).
|
|
832
|
-
// Clear state before createComponent — if it throws (bad provider,
|
|
833
|
-
// invalid class) we don't want to leak a reference to a destroyed ref
|
|
834
|
-
// that the same-component branch would later try to re-insert.
|
|
835
|
-
this.focusSnapshot = undefined;
|
|
836
|
-
this.lastPushedInputs = undefined;
|
|
837
|
-
const previousRef = this.fieldRef;
|
|
838
|
-
this.fieldRef = undefined;
|
|
839
|
-
this.fieldSlot = undefined;
|
|
840
|
-
previousRef?.destroy();
|
|
841
|
-
this.fieldRef = slot.createComponent(resolved.component, {
|
|
842
|
-
environmentInjector: this.fieldEnvInjector(),
|
|
843
|
-
injector: createWrapperAwareInjector(resolved.injector, slot.injector),
|
|
844
|
-
});
|
|
845
|
-
this.fieldSlot = slot;
|
|
846
|
-
// setInput dirties the signal synchronously; the next CD pass reads
|
|
847
|
-
// it. Safe outside CD — see wrapper-chain-controller scheduling.
|
|
848
|
-
this.pushRawInputs(this.fieldRef, this.rawInputs());
|
|
929
|
+
this.fieldComponent.mountOrReuse(slot, resolved.component, resolved.injector, this.fieldEnvInjector(), this.rawInputs());
|
|
849
930
|
},
|
|
850
931
|
});
|
|
851
|
-
// Push rawInputs to the innermost field.
|
|
852
|
-
//
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
if (this.lastPushedInputs === rawInputs)
|
|
857
|
-
return;
|
|
858
|
-
this.pushRawInputs(this.fieldRef, rawInputs);
|
|
859
|
-
});
|
|
860
|
-
this.destroyRef.onDestroy(() => {
|
|
861
|
-
// Only destroy when detached — vcr.clear() cascades otherwise.
|
|
862
|
-
if (this.fieldRef && !this.fieldSlot)
|
|
863
|
-
this.fieldRef.destroy();
|
|
864
|
-
this.fieldRef = undefined;
|
|
865
|
-
this.fieldSlot = undefined;
|
|
866
|
-
this.lastPushedInputs = undefined;
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
detachFieldRef() {
|
|
870
|
-
if (!this.fieldRef)
|
|
871
|
-
return;
|
|
872
|
-
if (!this.fieldSlot) {
|
|
873
|
-
// Stranded fieldRef — destroy to prevent a leak.
|
|
874
|
-
this.fieldRef.destroy();
|
|
875
|
-
this.fieldRef = undefined;
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
this.captureFocusSnapshot();
|
|
879
|
-
const idx = this.fieldSlot.indexOf(this.fieldRef.hostView);
|
|
880
|
-
if (idx >= 0)
|
|
881
|
-
this.fieldSlot.detach(idx);
|
|
882
|
-
this.fieldSlot = undefined;
|
|
883
|
-
}
|
|
884
|
-
/** Capture active element + selection before the hostView is detached from DOM. */
|
|
885
|
-
captureFocusSnapshot() {
|
|
886
|
-
if (!this.fieldRef)
|
|
887
|
-
return;
|
|
888
|
-
const hostEl = this.fieldRef.location.nativeElement;
|
|
889
|
-
const active = document.activeElement;
|
|
890
|
-
if (!(active instanceof HTMLElement) || !hostEl.contains(active))
|
|
891
|
-
return;
|
|
892
|
-
const sel = active;
|
|
893
|
-
this.focusSnapshot = {
|
|
894
|
-
element: active,
|
|
895
|
-
selectionStart: typeof sel.selectionStart === 'number' ? sel.selectionStart : null,
|
|
896
|
-
selectionEnd: typeof sel.selectionEnd === 'number' ? sel.selectionEnd : null,
|
|
897
|
-
};
|
|
898
|
-
}
|
|
899
|
-
/** Re-apply the pre-detach focus + caret after the hostView is back in the DOM. */
|
|
900
|
-
restoreFocusSnapshot() {
|
|
901
|
-
const snap = this.focusSnapshot;
|
|
902
|
-
this.focusSnapshot = undefined;
|
|
903
|
-
if (!snap || !snap.element.isConnected)
|
|
904
|
-
return;
|
|
905
|
-
snap.element.focus();
|
|
906
|
-
if (snap.selectionStart !== null && snap.selectionEnd !== null && 'setSelectionRange' in snap.element) {
|
|
907
|
-
snap.element.setSelectionRange(snap.selectionStart, snap.selectionEnd);
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
pushRawInputs(ref, rawInputs) {
|
|
911
|
-
// setInput is lenient in Angular 21 — unknown keys silently drop (not NG0303). reflectComponentType does NOT include hostDirective forwarded inputs (angular/angular#49734), so a declared-input filter here would skip every value flowing through NgForgeFieldShell / NgForgeField / NgForgeAction. Mapper-as-contract is enforced by the *_INPUTS lockstep type assertions, not at runtime here.
|
|
912
|
-
const last = this.lastPushedInputs;
|
|
913
|
-
for (const [key, value] of Object.entries(rawInputs)) {
|
|
914
|
-
if (last && last[key] === value)
|
|
915
|
-
continue;
|
|
916
|
-
ref.setInput(key, value);
|
|
917
|
-
}
|
|
918
|
-
this.lastPushedInputs = rawInputs;
|
|
932
|
+
// Push rawInputs to the innermost field on every change. The slot dedupes per-key, so the
|
|
933
|
+
// initial render's synchronous push from `renderInnermost` and the effect's first fire
|
|
934
|
+
// converge cleanly with no double-pushing.
|
|
935
|
+
explicitEffect([this.rawInputs], ([rawInputs]) => this.fieldComponent.pushInputs(rawInputs));
|
|
936
|
+
this.destroyRef.onDestroy(() => this.fieldComponent.destroyOnTeardown());
|
|
919
937
|
}
|
|
920
938
|
buildFieldInputs(rawInputs) {
|
|
921
939
|
const fieldTreeCandidate = rawInputs['field'];
|
|
@@ -2748,16 +2766,25 @@ function evaluateCondition(expression, context) {
|
|
|
2748
2766
|
case 'javascript':
|
|
2749
2767
|
return evaluateJavaScriptExpression(expression, context);
|
|
2750
2768
|
case 'custom': {
|
|
2751
|
-
|
|
2769
|
+
// Read both keys via cast — the XOR type narrows to `never` when both
|
|
2770
|
+
// would be truthy, but JSON-loaded configs can produce that state at
|
|
2771
|
+
// runtime. We accept it here, warn, and let inline win.
|
|
2772
|
+
const bothSet = expression;
|
|
2773
|
+
const inlineFn = bothSet.fn;
|
|
2774
|
+
const registeredName = bothSet.functionName;
|
|
2775
|
+
if (inlineFn && registeredName) {
|
|
2776
|
+
context.logger.warn('Both "fn" and "functionName" are set on custom condition. Inline "fn" takes precedence; "functionName" is ignored.');
|
|
2777
|
+
}
|
|
2778
|
+
const customFn = inlineFn ?? (registeredName ? context.customFunctions?.[registeredName] : undefined);
|
|
2752
2779
|
if (!customFn) {
|
|
2753
|
-
context.logger.error('Custom function not found:',
|
|
2780
|
+
context.logger.error('Custom function not found:', registeredName);
|
|
2754
2781
|
return false;
|
|
2755
2782
|
}
|
|
2756
2783
|
try {
|
|
2757
2784
|
return !!customFn(context);
|
|
2758
2785
|
}
|
|
2759
2786
|
catch (error) {
|
|
2760
|
-
context.logger.error('Error executing custom function:',
|
|
2787
|
+
context.logger.error('Error executing custom function:', registeredName ?? '<inline>', error);
|
|
2761
2788
|
return false;
|
|
2762
2789
|
}
|
|
2763
2790
|
}
|
|
@@ -2766,7 +2793,7 @@ function evaluateCondition(expression, context) {
|
|
|
2766
2793
|
case 'or':
|
|
2767
2794
|
return evaluateOrCondition(expression, context);
|
|
2768
2795
|
case 'http':
|
|
2769
|
-
context.logger.warn('
|
|
2796
|
+
context.logger.warn('HTTP conditions are resolved asynchronously via createHttpConditionLogicFunction(). ' +
|
|
2770
2797
|
'When used inside and/or composites, the HTTP result is not available synchronously. Returning false.');
|
|
2771
2798
|
return false;
|
|
2772
2799
|
case 'async':
|
|
@@ -4145,9 +4172,9 @@ class PageOrchestratorComponent {
|
|
|
4145
4172
|
}
|
|
4146
4173
|
}
|
|
4147
4174
|
}
|
|
4148
|
-
`, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-
|
|
4175
|
+
`, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-DDzmhJNn.mjs').then(m => m.default)], () => [import('./ng-forge-dynamic-forms-page-field.component-DDzmhJNn.mjs').then(m => m.default)]] });
|
|
4149
4176
|
}
|
|
4150
|
-
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.2.8", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-
|
|
4177
|
+
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.2.8", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-DDzmhJNn.mjs').then(m => m.default)], resolveMetadata: PageFieldComponent => ({ decorators: [{
|
|
4151
4178
|
type: Component,
|
|
4152
4179
|
args: [{ selector: 'div[page-orchestrator]', imports: [PageFieldComponent], template: `
|
|
4153
4180
|
@for (pageField of pageFields(); track pageField.key; let i = $index) {
|
|
@@ -4579,6 +4606,85 @@ function isValidNonPagedForm(fields) {
|
|
|
4579
4606
|
return result.mode === 'non-paged' && result.isValid;
|
|
4580
4607
|
}
|
|
4581
4608
|
|
|
4609
|
+
function createSlot() {
|
|
4610
|
+
let counter = 0;
|
|
4611
|
+
const templates = new Map();
|
|
4612
|
+
const itemOrder = [];
|
|
4613
|
+
const primitiveFieldKey = signal(undefined, ...(ngDevMode ? [{ debugName: "primitiveFieldKey" }] : /* istanbul ignore next */ []));
|
|
4614
|
+
return {
|
|
4615
|
+
templates,
|
|
4616
|
+
itemOrder,
|
|
4617
|
+
primitiveFieldKey,
|
|
4618
|
+
nextId: () => `item-${counter++}`,
|
|
4619
|
+
setTemplate(itemId, t) {
|
|
4620
|
+
templates.set(itemId, t);
|
|
4621
|
+
},
|
|
4622
|
+
getTemplate(itemId) {
|
|
4623
|
+
return templates.get(itemId);
|
|
4624
|
+
},
|
|
4625
|
+
hasTemplate(itemId) {
|
|
4626
|
+
return templates.has(itemId);
|
|
4627
|
+
},
|
|
4628
|
+
setIdAt(index, itemId) {
|
|
4629
|
+
itemOrder[index] = itemId;
|
|
4630
|
+
},
|
|
4631
|
+
removeAt(index) {
|
|
4632
|
+
const [removed] = itemOrder.splice(index, 1);
|
|
4633
|
+
if (removed !== undefined)
|
|
4634
|
+
templates.delete(removed);
|
|
4635
|
+
},
|
|
4636
|
+
moveItem(fromIndex, toIndex) {
|
|
4637
|
+
const [id] = itemOrder.splice(fromIndex, 1);
|
|
4638
|
+
if (id !== undefined)
|
|
4639
|
+
itemOrder.splice(toIndex, 0, id);
|
|
4640
|
+
},
|
|
4641
|
+
truncate(length) {
|
|
4642
|
+
const removed = itemOrder.splice(length);
|
|
4643
|
+
for (const id of removed)
|
|
4644
|
+
templates.delete(id);
|
|
4645
|
+
},
|
|
4646
|
+
clear() {
|
|
4647
|
+
templates.clear();
|
|
4648
|
+
itemOrder.length = 0;
|
|
4649
|
+
counter = 0;
|
|
4650
|
+
primitiveFieldKey.set(undefined);
|
|
4651
|
+
},
|
|
4652
|
+
};
|
|
4653
|
+
}
|
|
4654
|
+
/**
|
|
4655
|
+
* Form-scoped registry of per-array durable state. One slot per array path
|
|
4656
|
+
* (e.g. 'members', 'team.members', 'orders.lineItems'). Survives
|
|
4657
|
+
* `ArrayFieldComponent` lifecycle, dies with the `DynamicForm`. Cleared on
|
|
4658
|
+
* schema swap by the `formSetup` effect in `FormStateManager`.
|
|
4659
|
+
*
|
|
4660
|
+
* `ArrayFieldComponent` injects this service and obtains its slot via
|
|
4661
|
+
* `slotFor(arrayPath)` on mount. Replaces the prior per-component
|
|
4662
|
+
* `ARRAY_TEMPLATE_REGISTRY` + `ARRAY_ITEM_ID_GENERATOR` providers.
|
|
4663
|
+
*/
|
|
4664
|
+
class ArrayItemRegistryService {
|
|
4665
|
+
slots = new Map();
|
|
4666
|
+
/** Get-or-create a slot for the array at the given path. */
|
|
4667
|
+
slotFor(arrayPath) {
|
|
4668
|
+
let slot = this.slots.get(arrayPath);
|
|
4669
|
+
if (!slot) {
|
|
4670
|
+
slot = createSlot();
|
|
4671
|
+
this.slots.set(arrayPath, slot);
|
|
4672
|
+
}
|
|
4673
|
+
return slot;
|
|
4674
|
+
}
|
|
4675
|
+
/** Drop all slots — called from the `formSetup` effect on schema change. */
|
|
4676
|
+
clearAll() {
|
|
4677
|
+
for (const slot of this.slots.values())
|
|
4678
|
+
slot.clear();
|
|
4679
|
+
this.slots.clear();
|
|
4680
|
+
}
|
|
4681
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: ArrayItemRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
4682
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: ArrayItemRegistryService });
|
|
4683
|
+
}
|
|
4684
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: ArrayItemRegistryService, decorators: [{
|
|
4685
|
+
type: Injectable
|
|
4686
|
+
}] });
|
|
4687
|
+
|
|
4582
4688
|
class SchemaRegistryService {
|
|
4583
4689
|
registeredSchemas = new Map();
|
|
4584
4690
|
/**
|
|
@@ -4628,7 +4734,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
|
|
|
4628
4734
|
* Use this guard when you need type-safe access to `FunctionHttpValidatorConfig` properties.
|
|
4629
4735
|
*/
|
|
4630
4736
|
function isFunctionHttpValidator(config) {
|
|
4631
|
-
|
|
4737
|
+
const c = config;
|
|
4738
|
+
return typeof c.functionName === 'string' || (c.fn !== null && typeof c.fn === 'object');
|
|
4632
4739
|
}
|
|
4633
4740
|
|
|
4634
4741
|
/**
|
|
@@ -4824,8 +4931,6 @@ class HttpConditionFunctionCacheService {
|
|
|
4824
4931
|
* );
|
|
4825
4932
|
* // user.value() keeps the old user data during loading transitions
|
|
4826
4933
|
* ```
|
|
4827
|
-
*
|
|
4828
|
-
* @experimental Uses Angular's experimental resource composition APIs.
|
|
4829
4934
|
*/
|
|
4830
4935
|
function withPreviousValue(input) {
|
|
4831
4936
|
const derived = linkedSignal({ ...(ngDevMode ? { debugName: "derived" } : /* istanbul ignore next */ {}), source: input.snapshot,
|
|
@@ -5047,11 +5152,18 @@ function createAsyncConditionLogicFunction(condition) {
|
|
|
5047
5152
|
const cacheService = inject(AsyncConditionFunctionCacheService);
|
|
5048
5153
|
const pendingValue = condition.pendingValue ?? false;
|
|
5049
5154
|
const debounceMs = condition.debounceMs ?? 300;
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5155
|
+
if (condition.asyncFn && condition.asyncFunctionName) {
|
|
5156
|
+
logger.warn('Both "asyncFn" and "asyncFunctionName" are set on async condition. Inline "asyncFn" takes precedence; "asyncFunctionName" is ignored.');
|
|
5157
|
+
}
|
|
5158
|
+
// Skip cross-condition caching when an inline function is used: stableStringify
|
|
5159
|
+
// cannot distinguish two different inline functions, which would cause cache hits
|
|
5160
|
+
// to wrongly reuse a LogicFn closing over a different function reference.
|
|
5161
|
+
const cacheKey = condition.asyncFn ? undefined : stableStringify(condition);
|
|
5162
|
+
if (cacheKey !== undefined) {
|
|
5163
|
+
const cached = cacheService.asyncConditionFunctionCache.get(cacheKey);
|
|
5164
|
+
if (cached) {
|
|
5165
|
+
return cached;
|
|
5166
|
+
}
|
|
5055
5167
|
}
|
|
5056
5168
|
// Each condition needs its own per-field signal store.
|
|
5057
5169
|
const perFunctionSignalStore = new Map();
|
|
@@ -5076,8 +5188,10 @@ function createAsyncConditionLogicFunction(condition) {
|
|
|
5076
5188
|
const asyncResource = rxResource({
|
|
5077
5189
|
params: () => debouncedTrigger() ?? undefined,
|
|
5078
5190
|
stream: ({ params: triggerKey }) => {
|
|
5079
|
-
//
|
|
5080
|
-
|
|
5191
|
+
// Inline `asyncFn` wins; otherwise look up the registered function at call time
|
|
5192
|
+
// (fresh reference, in case the registry was updated after LogicFn creation).
|
|
5193
|
+
const asyncFn = condition.asyncFn ??
|
|
5194
|
+
(condition.asyncFunctionName ? functionRegistry.getAsyncConditionFunction(condition.asyncFunctionName) : undefined);
|
|
5081
5195
|
if (!asyncFn) {
|
|
5082
5196
|
logger.warn(`Async Condition - function '${condition.asyncFunctionName}' not found. ` +
|
|
5083
5197
|
`Register it in customFnConfig.asyncConditions.`);
|
|
@@ -5129,7 +5243,9 @@ function createAsyncConditionLogicFunction(condition) {
|
|
|
5129
5243
|
});
|
|
5130
5244
|
return resultResource.value();
|
|
5131
5245
|
};
|
|
5132
|
-
|
|
5246
|
+
if (cacheKey !== undefined) {
|
|
5247
|
+
cacheService.asyncConditionFunctionCache.set(cacheKey, fn);
|
|
5248
|
+
}
|
|
5133
5249
|
return fn;
|
|
5134
5250
|
}
|
|
5135
5251
|
|
|
@@ -5764,12 +5880,12 @@ function applyCustomValidator(config, fieldPath) {
|
|
|
5764
5880
|
if (config.expression) {
|
|
5765
5881
|
validatorFn = createExpressionValidator(config);
|
|
5766
5882
|
}
|
|
5767
|
-
else if (config.functionName) {
|
|
5883
|
+
else if (config.fn || config.functionName) {
|
|
5768
5884
|
validatorFn = createFunctionValidator(config);
|
|
5769
5885
|
}
|
|
5770
5886
|
else {
|
|
5771
5887
|
const logger = inject(DynamicFormLogger);
|
|
5772
|
-
logger.warn('Custom validator must have
|
|
5888
|
+
logger.warn('Custom validator must have one of "expression", "functionName", or "fn"');
|
|
5773
5889
|
return;
|
|
5774
5890
|
}
|
|
5775
5891
|
const whenLogic = createConditionalLogic(config.when);
|
|
@@ -5781,6 +5897,13 @@ function applyCustomValidator(config, fieldPath) {
|
|
|
5781
5897
|
}
|
|
5782
5898
|
function createFunctionValidator(config) {
|
|
5783
5899
|
const logger = inject(DynamicFormLogger);
|
|
5900
|
+
if (config.fn && config.functionName) {
|
|
5901
|
+
logger.warn('Both "fn" and "functionName" are set on custom validator. Inline "fn" takes precedence; "functionName" is ignored.');
|
|
5902
|
+
}
|
|
5903
|
+
if (config.fn) {
|
|
5904
|
+
const inlineFn = config.fn;
|
|
5905
|
+
return (ctx) => inlineFn(ctx, config.params);
|
|
5906
|
+
}
|
|
5784
5907
|
const functionName = config.functionName;
|
|
5785
5908
|
if (!functionName) {
|
|
5786
5909
|
logger.warn('Custom validator missing functionName');
|
|
@@ -5841,10 +5964,24 @@ function createExpressionValidator(config) {
|
|
|
5841
5964
|
* - onError: Optional handler for resource errors
|
|
5842
5965
|
*/
|
|
5843
5966
|
function applyAsyncValidator(config, fieldPath) {
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5967
|
+
if (config.fn && config.functionName) {
|
|
5968
|
+
const logger = inject(DynamicFormLogger);
|
|
5969
|
+
logger.warn('Both "fn" and "functionName" are set on async validator. Inline "fn" takes precedence; "functionName" is ignored.');
|
|
5970
|
+
}
|
|
5971
|
+
let validatorConfig;
|
|
5972
|
+
if (config.fn) {
|
|
5973
|
+
validatorConfig = config.fn;
|
|
5974
|
+
}
|
|
5975
|
+
else if (config.functionName) {
|
|
5976
|
+
const registry = inject(FunctionRegistryService);
|
|
5977
|
+
validatorConfig = registry.getAsyncValidator(config.functionName);
|
|
5978
|
+
if (!validatorConfig) {
|
|
5979
|
+
throw new DynamicFormError(`Async validator "${config.functionName}" not found. Register it with customFnConfig.asyncValidators.`);
|
|
5980
|
+
}
|
|
5981
|
+
}
|
|
5982
|
+
else {
|
|
5983
|
+
// XOR type rejects this at compile time; JSON-loaded configs can still reach here at runtime.
|
|
5984
|
+
throw new DynamicFormError('Async validator requires one of "functionName" or "fn".');
|
|
5848
5985
|
}
|
|
5849
5986
|
const whenLogic = createConditionalLogic(config.when);
|
|
5850
5987
|
const asyncOptions = {
|
|
@@ -5866,10 +6003,16 @@ function applyAsyncValidator(config, fieldPath) {
|
|
|
5866
6003
|
function applyUnifiedHttpValidator(config, fieldPath) {
|
|
5867
6004
|
if (isFunctionHttpValidator(config)) {
|
|
5868
6005
|
applyFunctionHttpValidator(config, fieldPath);
|
|
6006
|
+
return;
|
|
5869
6007
|
}
|
|
5870
|
-
|
|
5871
|
-
|
|
6008
|
+
// Declarative path requires both `http` and `responseMapping` — reject early
|
|
6009
|
+
// for JSON-loaded configs that have neither side fully populated. The
|
|
6010
|
+
// discriminated union forbids this at compile time.
|
|
6011
|
+
const declarative = config;
|
|
6012
|
+
if (!declarative.http || !declarative.responseMapping) {
|
|
6013
|
+
throw new DynamicFormError('HTTP validator requires one of "functionName" / "fn" (function-based) or "http" + "responseMapping" (declarative).');
|
|
5872
6014
|
}
|
|
6015
|
+
applyDeclarativeHttpValidator(config, fieldPath);
|
|
5873
6016
|
}
|
|
5874
6017
|
/**
|
|
5875
6018
|
* Apply function-based HTTP validator to field path using Angular's public validateHttp() API.
|
|
@@ -5880,10 +6023,24 @@ function applyUnifiedHttpValidator(config, fieldPath) {
|
|
|
5880
6023
|
* - onError: Optional handler for HTTP errors
|
|
5881
6024
|
*/
|
|
5882
6025
|
function applyFunctionHttpValidator(config, fieldPath) {
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
6026
|
+
if (config.fn && config.functionName) {
|
|
6027
|
+
const logger = inject(DynamicFormLogger);
|
|
6028
|
+
logger.warn('Both "fn" and "functionName" are set on HTTP validator. Inline "fn" takes precedence; "functionName" is ignored.');
|
|
6029
|
+
}
|
|
6030
|
+
let httpValidatorConfig;
|
|
6031
|
+
if (config.fn) {
|
|
6032
|
+
httpValidatorConfig = config.fn;
|
|
6033
|
+
}
|
|
6034
|
+
else if (config.functionName) {
|
|
6035
|
+
const registry = inject(FunctionRegistryService);
|
|
6036
|
+
httpValidatorConfig = registry.getHttpValidator(config.functionName);
|
|
6037
|
+
if (!httpValidatorConfig) {
|
|
6038
|
+
throw new DynamicFormError(`HTTP validator "${config.functionName}" not found. Register it with customFnConfig.httpValidators.`);
|
|
6039
|
+
}
|
|
6040
|
+
}
|
|
6041
|
+
else {
|
|
6042
|
+
// XOR type rejects this at compile time; JSON-loaded configs can still reach here at runtime.
|
|
6043
|
+
throw new DynamicFormError('HTTP validator requires one of "functionName" or "fn".');
|
|
5887
6044
|
}
|
|
5888
6045
|
const whenLogic = createConditionalLogic(config.when);
|
|
5889
6046
|
const httpOptions = {
|
|
@@ -8467,22 +8624,31 @@ const PLACEHOLDER_INDEX = '$';
|
|
|
8467
8624
|
/**
|
|
8468
8625
|
* Builds a property override store key for a field.
|
|
8469
8626
|
*
|
|
8470
|
-
* For non-array fields, the key is
|
|
8471
|
-
*
|
|
8472
|
-
*
|
|
8627
|
+
* For non-array fields, the key is the dot-joined group ancestors plus the
|
|
8628
|
+
* field's leaf key (e.g., `address.street` for `street` inside `address`).
|
|
8629
|
+
* For array item fields, the key includes the array path, either a concrete
|
|
8630
|
+
* index or the `$` placeholder, and any group ancestors INSIDE the array
|
|
8631
|
+
* item (e.g., `contacts.0.email`, `contacts.$.email`, or
|
|
8632
|
+
* `contacts.0.profile.email` for a group nested under each contact).
|
|
8633
|
+
*
|
|
8634
|
+
* `groupPath` matches the form-value path of nested groups (collector resets
|
|
8635
|
+
* it at array boundaries — see `property-derivation-collector.ts`), so the
|
|
8636
|
+
* resulting key always mirrors the field's location in `formValue`.
|
|
8473
8637
|
*
|
|
8474
8638
|
* @param arrayKey - The array field key (e.g., 'contacts'), or undefined for non-array fields
|
|
8475
8639
|
* @param index - The array item index, `PLACEHOLDER_INDEX` for the wildcard format, or undefined for non-array fields
|
|
8476
8640
|
* @param fieldKey - The leaf field key (e.g., 'email')
|
|
8477
|
-
* @
|
|
8641
|
+
* @param groupPath - Dot-joined group ancestors scoped to the current array item (or top-level), or undefined when not inside any group
|
|
8642
|
+
* @returns The store key (e.g., 'email', 'address.street', 'contacts.0.email', 'contacts.$.profile.email')
|
|
8478
8643
|
*
|
|
8479
8644
|
* @public
|
|
8480
8645
|
*/
|
|
8481
|
-
function buildPropertyOverrideKey(arrayKey, index, fieldKey) {
|
|
8646
|
+
function buildPropertyOverrideKey(arrayKey, index, fieldKey, groupPath) {
|
|
8647
|
+
const leafScope = groupPath ? `${groupPath}.${fieldKey}` : fieldKey;
|
|
8482
8648
|
if (arrayKey != null && index != null) {
|
|
8483
|
-
return `${arrayKey}.${index}.${
|
|
8649
|
+
return `${arrayKey}.${index}.${leafScope}`;
|
|
8484
8650
|
}
|
|
8485
|
-
return
|
|
8651
|
+
return leafScope;
|
|
8486
8652
|
}
|
|
8487
8653
|
|
|
8488
8654
|
/**
|
|
@@ -8563,6 +8729,25 @@ function applyIndexSuffix(inputs, index) {
|
|
|
8563
8729
|
key: `${key}_${index}`,
|
|
8564
8730
|
};
|
|
8565
8731
|
}
|
|
8732
|
+
/**
|
|
8733
|
+
* Prefixes the key with the underscored group ancestor path so that the same
|
|
8734
|
+
* leaf key inside different groups produces distinct DOM IDs (issue #401).
|
|
8735
|
+
* Form-schema keys remain clean — only the rendered `key` input is rewritten.
|
|
8736
|
+
*
|
|
8737
|
+
* Dots in the group path become underscores, then the original leaf key is
|
|
8738
|
+
* appended: `(groupPath='user.address', inputs.key='street')` →
|
|
8739
|
+
* `inputs.key = 'user_address_street'`.
|
|
8740
|
+
*/
|
|
8741
|
+
function applyGroupPrefix(inputs, groupPath) {
|
|
8742
|
+
const key = inputs['key'];
|
|
8743
|
+
if (typeof key !== 'string' || groupPath.length === 0) {
|
|
8744
|
+
return inputs;
|
|
8745
|
+
}
|
|
8746
|
+
return {
|
|
8747
|
+
...inputs,
|
|
8748
|
+
key: `${groupPath.replace(/\./g, '_')}_${key}`,
|
|
8749
|
+
};
|
|
8750
|
+
}
|
|
8566
8751
|
/**
|
|
8567
8752
|
* Main field mapper function that uses the field registry to get the appropriate mapper
|
|
8568
8753
|
* based on the field's type property.
|
|
@@ -8599,6 +8784,10 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
|
|
|
8599
8784
|
// Check if we're inside an array item context - if so, we need to suffix keys
|
|
8600
8785
|
// Optional because ARRAY_CONTEXT is only provided inside array item injectors
|
|
8601
8786
|
const arrayContext = inject(ARRAY_CONTEXT, { optional: true });
|
|
8787
|
+
// Check if we're inside a group context - if so, prefix the key with the group path
|
|
8788
|
+
// so overlapping leaf keys across groups produce distinct DOM IDs (issue #401).
|
|
8789
|
+
// Optional because GROUP_CONTEXT is only provided by GroupFieldComponent.
|
|
8790
|
+
const groupContext = inject(GROUP_CONTEXT, { optional: true });
|
|
8602
8791
|
// Inject the property override store for property derivation overrides
|
|
8603
8792
|
// Always available — provided at the DynamicForm component level via provideDynamicFormDI
|
|
8604
8793
|
const store = inject(PROPERTY_OVERRIDE_STORE);
|
|
@@ -8609,6 +8798,7 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
|
|
|
8609
8798
|
// Check that arrayContext exists and has a valid index signal (guards against mock injectors)
|
|
8610
8799
|
const indexSignal = arrayContext?.index;
|
|
8611
8800
|
const hasArrayContext = indexSignal !== undefined && isSignal(indexSignal);
|
|
8801
|
+
const hasGroupContext = groupContext != null;
|
|
8612
8802
|
// Fast-path check for property overrides: only fields whose definition declares
|
|
8613
8803
|
// a property-derivation enter the computed() wrapper. We inspect the FieldDef
|
|
8614
8804
|
// directly rather than asking the store, because the store's registration
|
|
@@ -8617,7 +8807,7 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
|
|
|
8617
8807
|
// collector apply the same inclusion rule, so the answers always agree.
|
|
8618
8808
|
const hasOverrides = fieldHasPropertyDerivations(fieldDef);
|
|
8619
8809
|
// Fast path: no transformations needed
|
|
8620
|
-
if (!hasPropsForwarding && !hasArrayContext && !hasOverrides) {
|
|
8810
|
+
if (!hasPropsForwarding && !hasArrayContext && !hasGroupContext && !hasOverrides) {
|
|
8621
8811
|
return mapperResult;
|
|
8622
8812
|
}
|
|
8623
8813
|
// Wrap in computed to apply transformations
|
|
@@ -8627,18 +8817,34 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
|
|
|
8627
8817
|
if (hasPropsForwarding) {
|
|
8628
8818
|
inputs = mergeForwardedPropsToMeta(inputs, propsToMeta);
|
|
8629
8819
|
}
|
|
8820
|
+
// Apply group prefix BEFORE array suffix so the resulting key reads as
|
|
8821
|
+
// `{groupPath}_{key}_{index}` (e.g. `address_street_0`) — matching the
|
|
8822
|
+
// form-value path order: group → leaf → array index.
|
|
8823
|
+
if (hasGroupContext) {
|
|
8824
|
+
// Non-null after the hasGroupContext guard.
|
|
8825
|
+
inputs = applyGroupPrefix(inputs, groupContext.groupPath());
|
|
8826
|
+
}
|
|
8630
8827
|
// Apply index suffix for array items
|
|
8631
8828
|
if (hasArrayContext) {
|
|
8632
8829
|
const index = indexSignal();
|
|
8633
8830
|
inputs = applyIndexSuffix(inputs, index);
|
|
8634
8831
|
}
|
|
8635
|
-
// Apply property overrides from the store (AFTER all static transformations)
|
|
8832
|
+
// Apply property overrides from the store (AFTER all static transformations).
|
|
8833
|
+
//
|
|
8834
|
+
// INVARIANT: the store key MUST be built from `fieldDef.key` + the group/array
|
|
8835
|
+
// ancestry (via buildPropertyOverrideKey) — NEVER from the DOM-scoped
|
|
8836
|
+
// `inputs['key']` produced by applyGroupPrefix/applyIndexSuffix above. The
|
|
8837
|
+
// collector (property-derivation-collector.ts) registers using the same
|
|
8838
|
+
// shape; if these drift, overrides silently miss their target.
|
|
8636
8839
|
if (hasOverrides) {
|
|
8637
|
-
// Build
|
|
8638
|
-
//
|
|
8840
|
+
// Build inside computed() so the index/group signals establish reactive deps.
|
|
8841
|
+
// GROUP_CONTEXT resets at array boundaries (see create-array-item-injector.ts),
|
|
8842
|
+
// so groupPath() represents ancestors inside the current array item (or
|
|
8843
|
+
// top-level groups when not in an array) — matching the collector.
|
|
8844
|
+
const groupPath = hasGroupContext ? groupContext.groupPath() : undefined;
|
|
8639
8845
|
const key = hasArrayContext
|
|
8640
|
-
? buildPropertyOverrideKey(arrayContext.arrayKey, indexSignal(), fieldDef.key)
|
|
8641
|
-
: fieldDef.key;
|
|
8846
|
+
? buildPropertyOverrideKey(arrayContext.arrayKey, indexSignal(), fieldDef.key, groupPath || undefined)
|
|
8847
|
+
: buildPropertyOverrideKey(undefined, undefined, fieldDef.key, groupPath || undefined);
|
|
8642
8848
|
const overrides = store.getOverrides(key)();
|
|
8643
8849
|
inputs = applyPropertyOverrides(inputs, overrides);
|
|
8644
8850
|
}
|
|
@@ -8657,6 +8863,40 @@ function createRenderReadySignal(inputs, definition) {
|
|
|
8657
8863
|
return requiredInputs.every((inputName) => currentInputs[inputName] !== undefined);
|
|
8658
8864
|
});
|
|
8659
8865
|
}
|
|
8866
|
+
/**
|
|
8867
|
+
* Reads the current hidden state from mapper inputs. Uniform across all field
|
|
8868
|
+
* types — value-bearing leaves source from the `FieldTree`'s `state.hidden()`
|
|
8869
|
+
* (where Angular Signal Forms cascades `hidden(path, …)`), and non-field outputs
|
|
8870
|
+
* (button/text/containers) source from the mapper's explicit `inputs.hidden`.
|
|
8871
|
+
*
|
|
8872
|
+
* The `@if (!field.hidden())` gate at every `*dfFieldOutlet` consumption site uses
|
|
8873
|
+
* this signal, removing hidden fields from the DOM. That's required to silence
|
|
8874
|
+
* Angular's `NG01916: Field 'X' is hidden but is being rendered` warning — and
|
|
8875
|
+
* is the prescribed pattern from the
|
|
8876
|
+
* [`hidden` API docs](https://angular.dev/api/forms/signals/hidden).
|
|
8877
|
+
*
|
|
8878
|
+
* Container internal state (e.g. an `ArrayFieldComponent`'s items + dynamic
|
|
8879
|
+
* templates + id counter) survives this destroy/recreate via the form-scoped
|
|
8880
|
+
* `ArrayItemRegistryService` keyed by array path.
|
|
8881
|
+
*/
|
|
8882
|
+
function createHiddenSignal(inputs) {
|
|
8883
|
+
return computed(() => {
|
|
8884
|
+
const currentInputs = inputs();
|
|
8885
|
+
const fieldCandidate = currentInputs['field'];
|
|
8886
|
+
// Value-bearing leaves expose `field` as a callable `FieldTree` whose invocation returns a
|
|
8887
|
+
// `FieldState` with a `hidden` accessor. `isSignal()` doesn't recognise the Proxy-wrapped
|
|
8888
|
+
// FieldTree (no SIGNAL symbol), so we duck-type the shape and call `state.hidden()` directly.
|
|
8889
|
+
if (typeof fieldCandidate === 'function') {
|
|
8890
|
+
const state = fieldCandidate();
|
|
8891
|
+
if (state && typeof state.hidden === 'function') {
|
|
8892
|
+
return state.hidden() === true;
|
|
8893
|
+
}
|
|
8894
|
+
}
|
|
8895
|
+
// Non-value fields (button, text, containers) carry an explicit `hidden` input set by
|
|
8896
|
+
// `applyHiddenLogic` based on the mapper-side evaluation.
|
|
8897
|
+
return currentInputs['hidden'] === true;
|
|
8898
|
+
});
|
|
8899
|
+
}
|
|
8660
8900
|
/**
|
|
8661
8901
|
* Resolves a single field definition to a ResolvedField using RxJS.
|
|
8662
8902
|
* Loads the component asynchronously and maps inputs in the injection context.
|
|
@@ -8693,6 +8933,7 @@ function resolveField(fieldDef, context) {
|
|
|
8693
8933
|
injector: context.injector,
|
|
8694
8934
|
inputs,
|
|
8695
8935
|
renderReady: createRenderReadySignal(inputs, definition),
|
|
8936
|
+
hidden: createHiddenSignal(inputs),
|
|
8696
8937
|
};
|
|
8697
8938
|
}), catchError((error) => {
|
|
8698
8939
|
// Only call onError if component is not destroyed to avoid accessing cleaned-up state
|
|
@@ -8729,6 +8970,7 @@ function resolveFieldSync(fieldDef, context) {
|
|
|
8729
8970
|
injector: context.injector,
|
|
8730
8971
|
inputs,
|
|
8731
8972
|
renderReady: createRenderReadySignal(inputs, definition),
|
|
8973
|
+
hidden: createHiddenSignal(inputs),
|
|
8732
8974
|
};
|
|
8733
8975
|
}
|
|
8734
8976
|
/**
|
|
@@ -8755,13 +8997,14 @@ function reconcileFields(prev, curr) {
|
|
|
8755
8997
|
}
|
|
8756
8998
|
// Same component instance + injector, but the field definition changed.
|
|
8757
8999
|
// Preserve component identity (so ngComponentOutlet keeps the same instance)
|
|
8758
|
-
// while swapping in the freshly-mapped inputs/renderReady signals
|
|
8759
|
-
// updates (e.g. select options) reach the component.
|
|
9000
|
+
// while swapping in the freshly-mapped inputs/renderReady/hidden signals
|
|
9001
|
+
// so prop updates (e.g. select options) reach the component.
|
|
8760
9002
|
return {
|
|
8761
9003
|
...existing,
|
|
8762
9004
|
fieldDef: field.fieldDef,
|
|
8763
9005
|
inputs: field.inputs,
|
|
8764
9006
|
renderReady: field.renderReady,
|
|
9007
|
+
hidden: field.hidden,
|
|
8765
9008
|
};
|
|
8766
9009
|
}
|
|
8767
9010
|
// New field, type changed, or context changed (new injector) - use new field
|
|
@@ -9079,15 +9322,32 @@ function injectFieldRegistry() {
|
|
|
9079
9322
|
* Collects all field keys, types, and validates regex patterns from a field tree
|
|
9080
9323
|
* by recursively traversing containers (page, row, group, array).
|
|
9081
9324
|
*
|
|
9082
|
-
*
|
|
9083
|
-
*
|
|
9084
|
-
*
|
|
9085
|
-
*
|
|
9325
|
+
* Keys are scoped by their group ancestors (e.g. `address.city` instead of `city`),
|
|
9326
|
+
* so the same leaf key can appear inside different groups without conflict. Page and
|
|
9327
|
+
* row containers do not contribute to the path because they flatten their children
|
|
9328
|
+
* into the parent scope. Array items are excluded entirely from key collection
|
|
9329
|
+
* because they share template keys across items by design.
|
|
9330
|
+
*
|
|
9331
|
+
* Fields whose registered `valueHandling` is anything other than `include` (e.g.
|
|
9332
|
+
* buttons with `exclude`, page/row with `flatten`) are not collected: they don't
|
|
9333
|
+
* bind to a form-value path, so duplicate-key uniqueness isn't meaningful for them.
|
|
9334
|
+
*
|
|
9335
|
+
* @param collectKeys - When false, the entire subtree skips key collection (used
|
|
9336
|
+
* when descending into an array container).
|
|
9086
9337
|
*/
|
|
9087
|
-
function collectFieldData(fields, data, collectKeys = true) {
|
|
9338
|
+
function collectFieldData(fields, data, registry, pathPrefix, collectKeys = true) {
|
|
9088
9339
|
for (const field of fields) {
|
|
9089
|
-
|
|
9090
|
-
|
|
9340
|
+
// Defensively skip only known non-include modes — unexpected/missing
|
|
9341
|
+
// valueHandling defaults back to participating in duplicate detection so a
|
|
9342
|
+
// mis-registered field type doesn't silently drop from collision checks.
|
|
9343
|
+
const valueHandling = field.type ? getFieldValueHandling(field.type, registry) : 'include';
|
|
9344
|
+
const participatesInValue = valueHandling !== 'exclude' && valueHandling !== 'flatten';
|
|
9345
|
+
if (collectKeys && field.key && participatesInValue) {
|
|
9346
|
+
const scopedKey = pathPrefix ? `${pathPrefix}.${field.key}` : field.key;
|
|
9347
|
+
data.keys.push(scopedKey);
|
|
9348
|
+
// Project to the DOM-ID format (dots → underscores) so we can detect
|
|
9349
|
+
// collisions like top-level `'foo_bar'` vs group `'foo'` + leaf `'bar'`.
|
|
9350
|
+
data.domIds.push(scopedKey.replace(/\./g, '_'));
|
|
9091
9351
|
}
|
|
9092
9352
|
if (field.type) {
|
|
9093
9353
|
data.types.add(field.type);
|
|
@@ -9107,13 +9367,16 @@ function collectFieldData(fields, data, collectKeys = true) {
|
|
|
9107
9367
|
// Array item fields share template keys across items by design — stop collecting keys
|
|
9108
9368
|
// once inside an array. Types and regex patterns are still validated.
|
|
9109
9369
|
const childCollectKeys = collectKeys && field.type !== 'array';
|
|
9370
|
+
// Only group containers extend the scoping path — page/row flatten into the parent
|
|
9371
|
+
// scope, and array items use dynamic indices that aren't statically knowable.
|
|
9372
|
+
const childPrefix = isGroupField(field) && field.key ? (pathPrefix ? `${pathPrefix}.${field.key}` : field.key) : pathPrefix;
|
|
9110
9373
|
const children = normalizeFieldsArray(field.fields);
|
|
9111
9374
|
for (const child of children) {
|
|
9112
9375
|
if (Array.isArray(child)) {
|
|
9113
|
-
collectFieldData(child, data, childCollectKeys);
|
|
9376
|
+
collectFieldData(child, data, registry, childPrefix, childCollectKeys);
|
|
9114
9377
|
}
|
|
9115
9378
|
else {
|
|
9116
|
-
collectFieldData([child], data, childCollectKeys);
|
|
9379
|
+
collectFieldData([child], data, registry, childPrefix, childCollectKeys);
|
|
9117
9380
|
}
|
|
9118
9381
|
}
|
|
9119
9382
|
}
|
|
@@ -9149,7 +9412,38 @@ function validateNoDuplicateKeys(allKeys) {
|
|
|
9149
9412
|
const duplicateList = Array.from(duplicates)
|
|
9150
9413
|
.map((k) => `'${k}'`)
|
|
9151
9414
|
.join(', ');
|
|
9152
|
-
throw new DynamicFormError(`Duplicate field keys detected: ${duplicateList}.
|
|
9415
|
+
throw new DynamicFormError(`Duplicate field keys detected: ${duplicateList}. Field keys must be unique within their group scope (the same key may appear inside different groups).`);
|
|
9416
|
+
}
|
|
9417
|
+
}
|
|
9418
|
+
/**
|
|
9419
|
+
* Validates that the underscore-projected DOM-ID namespace has no collisions.
|
|
9420
|
+
*
|
|
9421
|
+
* DOM IDs are built by underscore-joining the group ancestor path with the
|
|
9422
|
+
* leaf key (e.g. `address_street`). Two scoped paths can land on the same
|
|
9423
|
+
* DOM ID even when their form-value paths differ — e.g. a top-level key
|
|
9424
|
+
* `'foo_bar'` and a leaf `'bar'` inside group `'foo'` both render as
|
|
9425
|
+
* `id="foo_bar"`. The form-value-path check accepts this (paths are
|
|
9426
|
+
* `'foo_bar'` vs `'foo.bar'` — distinct) but the rendered DOM would have
|
|
9427
|
+
* duplicate IDs, breaking aria associations and CSS selectors.
|
|
9428
|
+
*
|
|
9429
|
+
* @throws {DynamicFormError} When two scoped paths project to the same DOM ID
|
|
9430
|
+
*/
|
|
9431
|
+
function validateNoDomIdCollisions(domIds) {
|
|
9432
|
+
const seen = new Set();
|
|
9433
|
+
const duplicates = new Set();
|
|
9434
|
+
for (const id of domIds) {
|
|
9435
|
+
if (seen.has(id)) {
|
|
9436
|
+
duplicates.add(id);
|
|
9437
|
+
}
|
|
9438
|
+
seen.add(id);
|
|
9439
|
+
}
|
|
9440
|
+
if (duplicates.size > 0) {
|
|
9441
|
+
const duplicateList = Array.from(duplicates)
|
|
9442
|
+
.map((k) => `'${k}'`)
|
|
9443
|
+
.join(', ');
|
|
9444
|
+
throw new DynamicFormError(`DOM id collision detected for: ${duplicateList}. Group-nested keys are joined with '_' to form DOM ids ` +
|
|
9445
|
+
`(e.g. group 'foo' + leaf 'bar' renders as id='foo_bar'). A separate top-level key like 'foo_bar' or a different ` +
|
|
9446
|
+
`group/leaf pair that underscores to the same string will produce duplicate ids. Rename one of the colliding keys.`);
|
|
9153
9447
|
}
|
|
9154
9448
|
}
|
|
9155
9449
|
/**
|
|
@@ -9187,11 +9481,13 @@ function validateFieldTypesRegistered(allTypes, registry, logger) {
|
|
|
9187
9481
|
function validateFormConfig(fields, registry, logger) {
|
|
9188
9482
|
const data = {
|
|
9189
9483
|
keys: [],
|
|
9484
|
+
domIds: [],
|
|
9190
9485
|
types: new Set(),
|
|
9191
9486
|
regexErrors: [],
|
|
9192
9487
|
};
|
|
9193
|
-
collectFieldData(fields, data);
|
|
9488
|
+
collectFieldData(fields, data, registry, '');
|
|
9194
9489
|
validateNoDuplicateKeys(data.keys);
|
|
9490
|
+
validateNoDomIdCollisions(data.domIds);
|
|
9195
9491
|
validateFieldTypesRegistered(data.types, registry, logger);
|
|
9196
9492
|
if (data.regexErrors.length > 0) {
|
|
9197
9493
|
throw new DynamicFormError(data.regexErrors.length === 1
|
|
@@ -9893,6 +10189,12 @@ class FormStateManager {
|
|
|
9893
10189
|
eventBus = inject(EventBus);
|
|
9894
10190
|
functionRegistry = inject(FunctionRegistryService);
|
|
9895
10191
|
schemaRegistry = inject(SchemaRegistryService);
|
|
10192
|
+
// Optional so unit-test setups that drive FormStateManager directly without provideDynamicFormDI()
|
|
10193
|
+
// (e.g. mocked RootFormRegistryService specs) don't have to manually wire the registry. Real
|
|
10194
|
+
// DynamicForm usage always provides it via coreProviders; if you hit the dev warning below in
|
|
10195
|
+
// your own setup, you're using DynamicForm without provideDynamicFormDI() — fix the providers
|
|
10196
|
+
// chain rather than ignoring it.
|
|
10197
|
+
arrayItemRegistry = inject(ArrayItemRegistryService, { optional: true });
|
|
9896
10198
|
/** Host component dependencies (config, formOptions, value). */
|
|
9897
10199
|
deps = (() => {
|
|
9898
10200
|
const raw = inject(FORM_STATE_DEPS);
|
|
@@ -10519,12 +10821,24 @@ class FormStateManager {
|
|
|
10519
10821
|
}
|
|
10520
10822
|
});
|
|
10521
10823
|
// Schema change resets the saved store + transition tracker so values from a stale schema
|
|
10522
|
-
// can't leak into the new one.
|
|
10824
|
+
// can't leak into the new one. Also drops every per-array slot in the registry so a fresh
|
|
10825
|
+
// schema starts with an empty template/itemOrder/id-counter state.
|
|
10826
|
+
let warnedAboutMissingArrayRegistry = false;
|
|
10523
10827
|
explicitEffect([this.formSetup], () => {
|
|
10524
10828
|
this.prevFieldStateSnapshot.set({});
|
|
10525
10829
|
if (Object.keys(this.excludedValueStore()).length > 0) {
|
|
10526
10830
|
this.excludedValueStore.set({});
|
|
10527
10831
|
}
|
|
10832
|
+
if (this.arrayItemRegistry) {
|
|
10833
|
+
this.arrayItemRegistry.clearAll();
|
|
10834
|
+
}
|
|
10835
|
+
else if (DEV_MODE && !warnedAboutMissingArrayRegistry) {
|
|
10836
|
+
warnedAboutMissingArrayRegistry = true;
|
|
10837
|
+
this.logger.warn('ArrayItemRegistryService was not provided to FormStateManager. ' +
|
|
10838
|
+
'Array fields will lose dynamically-added item templates across hide/show cycles. ' +
|
|
10839
|
+
'This usually means DynamicForm was bootstrapped without provideDynamicFormDI() — ' +
|
|
10840
|
+
'check your providers chain.');
|
|
10841
|
+
}
|
|
10528
10842
|
});
|
|
10529
10843
|
// Outward sync uses boundFormValue (honors only explicit excludeValueIf* opt-ins) so
|
|
10530
10844
|
// global defaults that target submission output don't reshape the host's bound model.
|
|
@@ -11036,15 +11350,18 @@ function computeValueFromEntry(entry, evalContext, options) {
|
|
|
11036
11350
|
if (entry.expression) {
|
|
11037
11351
|
return ExpressionParser.evaluate(entry.expression, evalContext);
|
|
11038
11352
|
}
|
|
11039
|
-
if (entry.functionName) {
|
|
11040
|
-
|
|
11353
|
+
if (entry.fn || entry.functionName) {
|
|
11354
|
+
if (entry.fn && entry.functionName) {
|
|
11355
|
+
evalContext.logger.warn(`Both "fn" and "functionName" are set on derivation for '${options.subject}'. Inline "fn" takes precedence; "functionName" is ignored.`);
|
|
11356
|
+
}
|
|
11357
|
+
const fn = entry.fn ?? (entry.functionName ? options.derivationFunctions?.[entry.functionName] : undefined);
|
|
11041
11358
|
if (!fn) {
|
|
11042
11359
|
const kind = options.functionKind ?? 'Derivation function';
|
|
11043
11360
|
throw new DynamicFormError(`${kind} '${entry.functionName}' not found in customFnConfig.derivations`);
|
|
11044
11361
|
}
|
|
11045
11362
|
return fn(evalContext);
|
|
11046
11363
|
}
|
|
11047
|
-
throw new DynamicFormError(`Derivation for ${options.subject} has no value source. Specify 'value', 'expression', or '
|
|
11364
|
+
throw new DynamicFormError(`Derivation for ${options.subject} has no value source. Specify 'value', 'expression', 'functionName', or 'fn'.`);
|
|
11048
11365
|
}
|
|
11049
11366
|
|
|
11050
11367
|
/**
|
|
@@ -11447,7 +11764,7 @@ function getEntriesForChangedFields$1(entries, changedFields) {
|
|
|
11447
11764
|
function tryApplyDerivation(entry, context, chainContext) {
|
|
11448
11765
|
const { derivationLogger } = context;
|
|
11449
11766
|
// HTTP and async entries are processed asynchronously in their own streams — skip in sync loop
|
|
11450
|
-
if (entry.http || entry.asyncFunctionName) {
|
|
11767
|
+
if (entry.http || entry.asyncFunctionName || entry.asyncFn) {
|
|
11451
11768
|
return { applied: false, fieldKey: entry.fieldKey };
|
|
11452
11769
|
}
|
|
11453
11770
|
// Check if this is an array field derivation (has '$' placeholder)
|
|
@@ -11754,9 +12071,11 @@ function formatDerivationError(entry, phase, message) {
|
|
|
11754
12071
|
? `expression: "${entry.expression.substring(0, 50)}${entry.expression.length > 50 ? '...' : ''}"`
|
|
11755
12072
|
: entry.functionName
|
|
11756
12073
|
? `function: "${entry.functionName}"`
|
|
11757
|
-
: entry.
|
|
11758
|
-
? '
|
|
11759
|
-
:
|
|
12074
|
+
: entry.fn
|
|
12075
|
+
? 'inline fn'
|
|
12076
|
+
: entry.value !== undefined
|
|
12077
|
+
? 'static value'
|
|
12078
|
+
: 'unknown source';
|
|
11760
12079
|
return `${ERROR_PREFIX$1} Failed to ${phase} derivation (field: ${entry.fieldKey}, ${source}): ${message}`;
|
|
11761
12080
|
}
|
|
11762
12081
|
/**
|
|
@@ -11825,7 +12144,7 @@ function shouldSkipForUserOverride(entry, resolvedFieldKey, context, chainContex
|
|
|
11825
12144
|
function applyDerivationsForTrigger(collection, trigger, context, changedFields) {
|
|
11826
12145
|
// Filter entries by trigger type, excluding HTTP and async entries (processed in async streams)
|
|
11827
12146
|
const filteredEntries = collection.entries.filter((entry) => {
|
|
11828
|
-
if (entry.http || entry.asyncFunctionName)
|
|
12147
|
+
if (entry.http || entry.asyncFunctionName || entry.asyncFn)
|
|
11829
12148
|
return false;
|
|
11830
12149
|
if (trigger === 'onChange') {
|
|
11831
12150
|
return !entry.trigger || entry.trigger === 'onChange';
|
|
@@ -11994,7 +12313,7 @@ function topologicalSort(entries) {
|
|
|
11994
12313
|
* Combines:
|
|
11995
12314
|
* - Explicit `dependsOn` (when provided, it takes precedence over auto-detection)
|
|
11996
12315
|
* - Expression-detected dependencies (only used when `dependsOn` is empty)
|
|
11997
|
-
* - `'*'` wildcard for `functionName` (only when `dependsOn` is empty)
|
|
12316
|
+
* - `'*'` wildcard for `functionName` / inline `fn` (only when `dependsOn` is empty)
|
|
11998
12317
|
* - Condition expression dependencies (always included)
|
|
11999
12318
|
*
|
|
12000
12319
|
* @internal
|
|
@@ -12008,7 +12327,9 @@ function extractDependenciesFromConfig(config) {
|
|
|
12008
12327
|
if (config.expression) {
|
|
12009
12328
|
extractStringDependencies(config.expression).forEach((dep) => deps.add(dep));
|
|
12010
12329
|
}
|
|
12011
|
-
|
|
12330
|
+
// Inline `fn` mirrors `functionName` semantics: without explicit dependsOn,
|
|
12331
|
+
// assume the function may depend on anything in the form.
|
|
12332
|
+
if (config.functionName || config.fn) {
|
|
12012
12333
|
deps.add('*');
|
|
12013
12334
|
}
|
|
12014
12335
|
}
|
|
@@ -12018,6 +12339,32 @@ function extractDependenciesFromConfig(config) {
|
|
|
12018
12339
|
return Array.from(deps);
|
|
12019
12340
|
}
|
|
12020
12341
|
|
|
12342
|
+
/**
|
|
12343
|
+
* Reads the optional inline `fn` property off a derivation config.
|
|
12344
|
+
*
|
|
12345
|
+
* `fn` only appears on the function-mode branch of the discriminated union, so
|
|
12346
|
+
* call sites that need to copy it through unconditionally must widen via cast.
|
|
12347
|
+
* This helper centralises the cast and keeps it read-only — no widening, no
|
|
12348
|
+
* mutation. Returns `undefined` for any non-function-mode variant.
|
|
12349
|
+
*
|
|
12350
|
+
* @internal
|
|
12351
|
+
*/
|
|
12352
|
+
function extractInlineFn(config) {
|
|
12353
|
+
return config.fn;
|
|
12354
|
+
}
|
|
12355
|
+
/**
|
|
12356
|
+
* Reads the optional inline `asyncFn` property off a derivation config.
|
|
12357
|
+
*
|
|
12358
|
+
* Same rationale as {@link extractInlineFn}: `asyncFn` only appears on the
|
|
12359
|
+
* `source: 'asyncFunction'` branch, so collectors that copy it through need a
|
|
12360
|
+
* cast. Read-only access, no widening.
|
|
12361
|
+
*
|
|
12362
|
+
* @internal
|
|
12363
|
+
*/
|
|
12364
|
+
function extractInlineAsyncFn(config) {
|
|
12365
|
+
return config.asyncFn;
|
|
12366
|
+
}
|
|
12367
|
+
|
|
12021
12368
|
/**
|
|
12022
12369
|
* Recursively walks a field-definition tree, invoking `visitor` for each field.
|
|
12023
12370
|
*
|
|
@@ -12360,9 +12707,11 @@ function createLogicEntry(fieldKey, config, context) {
|
|
|
12360
12707
|
value: config.value,
|
|
12361
12708
|
expression: config.expression,
|
|
12362
12709
|
functionName: config.functionName,
|
|
12710
|
+
fn: extractInlineFn(config),
|
|
12363
12711
|
http: config.http,
|
|
12364
12712
|
responseExpression: config.responseExpression,
|
|
12365
12713
|
asyncFunctionName: config.asyncFunctionName,
|
|
12714
|
+
asyncFn: extractInlineAsyncFn(config),
|
|
12366
12715
|
trigger,
|
|
12367
12716
|
debounceMs,
|
|
12368
12717
|
isShorthand: false,
|
|
@@ -12911,9 +13260,16 @@ function createAsyncDerivationStream(entry, formValue$, context) {
|
|
|
12911
13260
|
`Array async derivations will be supported in a future release.`);
|
|
12912
13261
|
return EMPTY;
|
|
12913
13262
|
}
|
|
12914
|
-
if (!entry.asyncFunctionName) {
|
|
13263
|
+
if (!entry.asyncFunctionName && !entry.asyncFn) {
|
|
13264
|
+
context.logger.warn(`${LOG_PREFIX} Derivation for '${entry.fieldKey}' has neither "asyncFunctionName" nor "asyncFn". ` +
|
|
13265
|
+
`One of the two is required for source: 'asyncFunction'.`);
|
|
12915
13266
|
return EMPTY;
|
|
12916
13267
|
}
|
|
13268
|
+
if (entry.asyncFn && entry.asyncFunctionName) {
|
|
13269
|
+
context.logger.warn(`${LOG_PREFIX} Both "asyncFn" and "asyncFunctionName" are set on derivation for '${entry.fieldKey}'. ` +
|
|
13270
|
+
`Inline "asyncFn" takes precedence; "asyncFunctionName" is ignored.`);
|
|
13271
|
+
}
|
|
13272
|
+
const inlineAsyncFn = entry.asyncFn;
|
|
12917
13273
|
const asyncFunctionName = entry.asyncFunctionName;
|
|
12918
13274
|
const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS;
|
|
12919
13275
|
// startWith(null) → pairwise() causes all fields to appear "changed" on the
|
|
@@ -12959,8 +13315,8 @@ function createAsyncDerivationStream(entry, formValue$, context) {
|
|
|
12959
13315
|
const customFunctions = context.customFunctions?.();
|
|
12960
13316
|
const asyncDerivationFunctions = context.asyncDerivationFunctions?.();
|
|
12961
13317
|
const externalData = context.externalData?.();
|
|
12962
|
-
//
|
|
12963
|
-
const asyncFn = asyncDerivationFunctions?.[asyncFunctionName];
|
|
13318
|
+
// Inline `asyncFn` wins; otherwise look up the registered function.
|
|
13319
|
+
const asyncFn = inlineAsyncFn ?? (asyncFunctionName ? asyncDerivationFunctions?.[asyncFunctionName] : undefined);
|
|
12964
13320
|
if (!asyncFn) {
|
|
12965
13321
|
context.logger.warn(`${LOG_PREFIX} Async derivation function '${asyncFunctionName}' not found for field '${entry.fieldKey}'. ` +
|
|
12966
13322
|
`Register it in customFnConfig.asyncDerivations.`);
|
|
@@ -13388,7 +13744,7 @@ class DerivationOrchestrator {
|
|
|
13388
13744
|
toObservable(this.derivationCollection, { injector: this.injector })
|
|
13389
13745
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
13390
13746
|
.subscribe((collection) => {
|
|
13391
|
-
const asyncEntries = collection?.entries.filter((entry) => entry.asyncFunctionName) ?? [];
|
|
13747
|
+
const asyncEntries = collection?.entries.filter((entry) => entry.asyncFunctionName || entry.asyncFn) ?? [];
|
|
13392
13748
|
if (asyncEntries.length === 0) {
|
|
13393
13749
|
this.teardownAsyncFunctionStreams();
|
|
13394
13750
|
this.lastAsyncEntryKeys = null;
|
|
@@ -13435,9 +13791,14 @@ class DerivationOrchestrator {
|
|
|
13435
13791
|
* Used for smart teardown comparison.
|
|
13436
13792
|
*/
|
|
13437
13793
|
computeAsyncEntryKeys(entries) {
|
|
13794
|
+
// For inline `asyncFn` entries we tag the key with the field path (the real
|
|
13795
|
+
// identity, since derivations are self-targeting) rather than the array
|
|
13796
|
+
// index. Index-based tagging churned identities whenever topologicalSort
|
|
13797
|
+
// reordered entries, causing unnecessary teardown + re-subscribe.
|
|
13438
13798
|
return new Set(entries.map((entry) => {
|
|
13439
13799
|
const config = {
|
|
13440
13800
|
asyncFunctionName: entry.asyncFunctionName,
|
|
13801
|
+
asyncFnId: entry.asyncFn ? `inline:${entry.fieldKey}` : undefined,
|
|
13441
13802
|
dependsOn: entry.dependsOn,
|
|
13442
13803
|
debounceMs: entry.debounceMs,
|
|
13443
13804
|
stopOnUserOverride: entry.stopOnUserOverride,
|
|
@@ -13486,10 +13847,11 @@ function warnAboutWildcardDependencies$1(logger, entries, fieldCount) {
|
|
|
13486
13847
|
// HTTP and async entries require explicit dependsOn (validated at collection time)
|
|
13487
13848
|
const implicitWildcards = wildcardEntries.filter((entry) => !entry.http &&
|
|
13488
13849
|
!entry.asyncFunctionName &&
|
|
13489
|
-
entry.
|
|
13850
|
+
!entry.asyncFn &&
|
|
13851
|
+
(entry.functionName || entry.fn) &&
|
|
13490
13852
|
(!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
|
|
13491
13853
|
if (implicitWildcards.length > 0) {
|
|
13492
|
-
const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName})`);
|
|
13854
|
+
const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName ?? '<inline fn>'})`);
|
|
13493
13855
|
logger.warn('Derivation: custom functions without explicit dependsOn detected. ' +
|
|
13494
13856
|
`These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
|
|
13495
13857
|
'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
|
|
@@ -13972,8 +14334,16 @@ function collectPropertyDerivations(fields, logger, tracker) {
|
|
|
13972
14334
|
const entries = [];
|
|
13973
14335
|
const context = { logger, tracker };
|
|
13974
14336
|
traverseFieldsWithContext(fields, context, (field, ctx) => collectFromField(field, entries, ctx), {
|
|
13975
|
-
|
|
13976
|
-
//
|
|
14337
|
+
// Array boundary: each item is its own model root. Reset groupPath so
|
|
14338
|
+
// ancestor groups OUTSIDE the array don't bleed into per-item keys.
|
|
14339
|
+
onArrayChild: (_, field) => ({ arrayPath: field.key, groupPath: undefined }),
|
|
14340
|
+
onGroupChild: (parent, field) => {
|
|
14341
|
+
// Keyless groups (rare) act like layout containers — pass through.
|
|
14342
|
+
if (!field.key)
|
|
14343
|
+
return {};
|
|
14344
|
+
return { groupPath: parent.groupPath ? `${parent.groupPath}.${field.key}` : field.key };
|
|
14345
|
+
},
|
|
14346
|
+
// Layout containers (page, row, container) leave context unchanged.
|
|
13977
14347
|
});
|
|
13978
14348
|
return { entries };
|
|
13979
14349
|
}
|
|
@@ -14005,7 +14375,7 @@ function collectFromField(field, entries, context) {
|
|
|
14005
14375
|
* @internal
|
|
14006
14376
|
*/
|
|
14007
14377
|
function createPropertyDerivationEntryFromDerivation(fieldKey, config, context) {
|
|
14008
|
-
const effectiveFieldKey = buildPropertyOverrideKey(context.arrayPath, context.arrayPath ? PLACEHOLDER_INDEX : undefined, fieldKey);
|
|
14378
|
+
const effectiveFieldKey = buildPropertyOverrideKey(context.arrayPath, context.arrayPath ? PLACEHOLDER_INDEX : undefined, fieldKey, context.groupPath);
|
|
14009
14379
|
const dependsOn = extractDependenciesFromConfig(config);
|
|
14010
14380
|
const condition = config.condition ?? true;
|
|
14011
14381
|
const trigger = config.trigger ?? 'onChange';
|
|
@@ -14018,6 +14388,7 @@ function createPropertyDerivationEntryFromDerivation(fieldKey, config, context)
|
|
|
14018
14388
|
value: config.value,
|
|
14019
14389
|
expression: config.expression,
|
|
14020
14390
|
functionName: config.functionName,
|
|
14391
|
+
fn: extractInlineFn(config),
|
|
14021
14392
|
trigger,
|
|
14022
14393
|
debounceMs,
|
|
14023
14394
|
debugName: config.debugName,
|
|
@@ -14172,10 +14543,10 @@ const PROPERTY_DERIVATION_ORCHESTRATOR = new InjectionToken('PROPERTY_DERIVATION
|
|
|
14172
14543
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
14173
14544
|
function warnAboutWildcardDependencies(logger, entries, fieldCount) {
|
|
14174
14545
|
const implicitWildcards = entries.filter((entry) => entry.dependsOn.includes('*') &&
|
|
14175
|
-
entry.functionName &&
|
|
14546
|
+
(entry.functionName || entry.fn) &&
|
|
14176
14547
|
(!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
|
|
14177
14548
|
if (implicitWildcards.length > 0) {
|
|
14178
|
-
const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName})`);
|
|
14549
|
+
const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName ?? '<inline fn>'})`);
|
|
14179
14550
|
logger.warn('PropertyDerivation: custom functions without explicit dependsOn detected. ' +
|
|
14180
14551
|
`These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
|
|
14181
14552
|
'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
|
|
@@ -14205,6 +14576,7 @@ function coreProviders() {
|
|
|
14205
14576
|
deps: [FormStateManager],
|
|
14206
14577
|
},
|
|
14207
14578
|
FieldContextRegistryService,
|
|
14579
|
+
ArrayItemRegistryService,
|
|
14208
14580
|
{
|
|
14209
14581
|
provide: DEFAULT_PROPS,
|
|
14210
14582
|
useFactory: (stateManager) => computed(() => stateManager.activeConfig()?.defaultProps),
|
|
@@ -14645,7 +15017,13 @@ class DynamicForm {
|
|
|
14645
15017
|
}
|
|
14646
15018
|
@case ('non-paged') {
|
|
14647
15019
|
@for (field of resolvedFields(); track field.key) {
|
|
14648
|
-
|
|
15020
|
+
<!-- @if + DfFieldOutlet's own renderReady-&-!hidden gate together silence NG01916:
|
|
15021
|
+
the template @if removes the host from the DOM (Angular's prescribed pattern), and
|
|
15022
|
+
the directive's gate stops a same-CD-pass mount before [formField] can warn.
|
|
15023
|
+
Don't dedupe one without the other — see DfFieldOutlet.renderReady. -->
|
|
15024
|
+
@if (!field.hidden()) {
|
|
15025
|
+
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
15026
|
+
}
|
|
14649
15027
|
}
|
|
14650
15028
|
}
|
|
14651
15029
|
@default {
|
|
@@ -14665,7 +15043,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
|
|
|
14665
15043
|
}
|
|
14666
15044
|
@case ('non-paged') {
|
|
14667
15045
|
@for (field of resolvedFields(); track field.key) {
|
|
14668
|
-
|
|
15046
|
+
<!-- @if + DfFieldOutlet's own renderReady-&-!hidden gate together silence NG01916:
|
|
15047
|
+
the template @if removes the host from the DOM (Angular's prescribed pattern), and
|
|
15048
|
+
the directive's gate stops a same-CD-pass mount before [formField] can warn.
|
|
15049
|
+
Don't dedupe one without the other — see DfFieldOutlet.renderReady. -->
|
|
15050
|
+
@if (!field.hidden()) {
|
|
15051
|
+
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
15052
|
+
}
|
|
14669
15053
|
}
|
|
14670
15054
|
}
|
|
14671
15055
|
@default {
|
|
@@ -15248,8 +15632,14 @@ function containerFieldMapper(fieldDef) {
|
|
|
15248
15632
|
* ```
|
|
15249
15633
|
*/
|
|
15250
15634
|
/**
|
|
15251
|
-
* Base
|
|
15252
|
-
*
|
|
15635
|
+
* Base for container field types that consume the mapper-emitted `field` input
|
|
15636
|
+
* (row, group, array, page, container).
|
|
15637
|
+
*/
|
|
15638
|
+
const CONTAINER_FIELD_TYPES_BASE = {
|
|
15639
|
+
renderReadyWhen: ['field'],
|
|
15640
|
+
};
|
|
15641
|
+
/**
|
|
15642
|
+
* Base for display-only fields (`text`) that render immediately without
|
|
15253
15643
|
* waiting for form value integration.
|
|
15254
15644
|
*/
|
|
15255
15645
|
const DISPLAY_FIELD_TYPES_BASE = {
|
|
@@ -15268,31 +15658,35 @@ const BUILT_IN_FIELDS = [
|
|
|
15268
15658
|
// `row` is a virtual field type: it maps to ContainerFieldComponent, and
|
|
15269
15659
|
// the rowFieldMapper injects a `{ type: 'row' }` wrapper so the container
|
|
15270
15660
|
// renders the flex/grid layout via RowWrapperComponent.
|
|
15271
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-
|
|
15661
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-VHaBk_1l.mjs'),
|
|
15272
15662
|
mapper: rowFieldMapper,
|
|
15273
15663
|
valueHandling: 'flatten',
|
|
15664
|
+
...CONTAINER_FIELD_TYPES_BASE,
|
|
15274
15665
|
},
|
|
15275
15666
|
{
|
|
15276
15667
|
name: 'group',
|
|
15277
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-group-field.component-
|
|
15668
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-group-field.component-BvTQwoLg.mjs'),
|
|
15278
15669
|
mapper: groupFieldMapper,
|
|
15279
15670
|
valueHandling: 'include',
|
|
15671
|
+
...CONTAINER_FIELD_TYPES_BASE,
|
|
15280
15672
|
},
|
|
15281
15673
|
{
|
|
15282
15674
|
name: 'array',
|
|
15283
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-array-field.component-
|
|
15675
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-array-field.component-BgCrTME1.mjs'),
|
|
15284
15676
|
mapper: arrayFieldMapper,
|
|
15285
15677
|
valueHandling: 'include',
|
|
15678
|
+
...CONTAINER_FIELD_TYPES_BASE,
|
|
15286
15679
|
},
|
|
15287
15680
|
{
|
|
15288
15681
|
name: 'page',
|
|
15289
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-
|
|
15682
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-DDzmhJNn.mjs'),
|
|
15290
15683
|
mapper: pageFieldMapper,
|
|
15291
15684
|
valueHandling: 'flatten',
|
|
15685
|
+
...CONTAINER_FIELD_TYPES_BASE,
|
|
15292
15686
|
},
|
|
15293
15687
|
{
|
|
15294
15688
|
name: 'text',
|
|
15295
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-
|
|
15689
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-Bo4zNJXY.mjs'),
|
|
15296
15690
|
mapper: textFieldMapper,
|
|
15297
15691
|
valueHandling: 'exclude',
|
|
15298
15692
|
...DISPLAY_FIELD_TYPES_BASE,
|
|
@@ -15304,15 +15698,16 @@ const BUILT_IN_FIELDS = [
|
|
|
15304
15698
|
},
|
|
15305
15699
|
{
|
|
15306
15700
|
name: 'container',
|
|
15307
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-
|
|
15701
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-VHaBk_1l.mjs'),
|
|
15308
15702
|
mapper: containerFieldMapper,
|
|
15309
15703
|
valueHandling: 'flatten',
|
|
15704
|
+
...CONTAINER_FIELD_TYPES_BASE,
|
|
15310
15705
|
},
|
|
15311
15706
|
];
|
|
15312
15707
|
const BUILT_IN_WRAPPERS = [
|
|
15313
15708
|
{
|
|
15314
15709
|
wrapperName: 'css',
|
|
15315
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-css-wrapper.component-
|
|
15710
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-css-wrapper.component-BOdw40ZF.mjs'),
|
|
15316
15711
|
},
|
|
15317
15712
|
{
|
|
15318
15713
|
wrapperName: 'row',
|
|
@@ -16386,5 +16781,5 @@ function extractErrorParams(error) {
|
|
|
16386
16781
|
* Generated bundle index. Do not edit.
|
|
16387
16782
|
*/
|
|
16388
16783
|
|
|
16389
|
-
export { SubmitEvent as $, ARRAY_CONTEXT as A, BUILT_IN_FIELDS as B, CONTAINER_FIELD_PROCESSORS as C, DynamicFormLogger as D, EventBus as E, FIELD_SIGNAL_CONTEXT as F,
|
|
16390
|
-
//# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-
|
|
16784
|
+
export { SubmitEvent as $, ARRAY_CONTEXT as A, BUILT_IN_FIELDS as B, CONTAINER_FIELD_PROCESSORS as C, DynamicFormLogger as D, EventBus as E, FIELD_SIGNAL_CONTEXT as F, GROUP_CONTEXT as G, DynamicForm as H, DynamicFormError as I, EventDispatcher as J, FIELD_REGISTRY as K, FORM_OPTIONS as L, FormClearEvent as M, FormResetEvent as N, INITIALIZATION_TIMEOUT_MS as O, InsertArrayItemEvent as P, MoveArrayItemEvent as Q, RootFormRegistryService as R, NextPageEvent as S, NoopLogger as T, PageChangeEvent as U, PopArrayItemEvent as V, WRAPPER_AUTO_ASSOCIATIONS as W, PrependArrayItemEvent as X, PreviousPageEvent as Y, RemoveAtIndexEvent as Z, ShiftArrayItemEvent as _, DEFAULT_WRAPPERS as a, WRAPPER_REGISTRY as a0, applyMetaToElement as a1, applyValidator as a2, applyValidators as a3, arrayEvent as a4, arrayFieldMapper as a5, baseFieldMapper as a6, buildBaseInputs as a7, containerFieldMapper as a8, createField as a9, resolveNextButtonDisabled as aA, resolveNonFieldDisabled as aB, resolveNonFieldHidden as aC, resolveSubmitButtonDisabled as aD, resolveTokens as aE, rowFieldMapper as aF, textFieldMapper as aG, toReadonlyFieldTree as aH, withEventFormValue as aI, withLoggerConfig as aJ, withPreviousValue as aK, withValidationExecutionDefaults as aL, withValueExclusionDefaults as aM, wrapperProps as aN, createWrappers as aa, evaluateNonFieldDisabled as ab, evaluateNonFieldHidden as ac, field as ad, formConfig as ae, getArrayLength as af, groupFieldMapper as ag, hasFormValue as ah, interpolateParams as ai, isArrayField as aj, isCheckedField as ak, isContainerField as al, isContainerTypedField as am, isDisplayOnlyField as an, isFormStateCondition as ao, isLeafField as ap, isPageField as aq, isRowField as ar, isSimplifiedArrayField as as, isValueBearingField as at, isValueField as au, isWrapperTypeDefinition as av, isWrappersBundle as aw, omit as ax, pageFieldMapper as ay, provideDynamicForm as az, isSameWrapperChain as b, createFieldResolutionPipe as c, derivedFromDeferred as d, createWrapperChainController as e, DfFieldOutlet as f, isEqual as g, createSchemaFromFields as h, injectFieldRegistry as i, createHiddenSignal as j, createRenderReadySignal as k, ArrayItemRegistryService as l, mapFieldToInputs as m, getNormalizedArrayMetadata as n, getFieldValueHandling as o, getFieldDefaultValue as p, isGroupField as q, resolveWrappers as r, ComponentInitializedEvent as s, DynamicTextPipe as t, dynamicTextToObservable as u, validatePageNesting as v, AppendArrayItemEvent as w, ConsoleLogger as x, DEFAULT_PROPS as y, DEFAULT_VALIDATION_MESSAGES as z };
|
|
16785
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-BAPGCjUs.mjs.map
|