@ng-forge/dynamic-forms 0.9.0-next.1 → 0.9.0-next.11
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-CWxCWVdz.mjs → ng-forge-dynamic-forms-array-field.component-DJ-LiLm_.mjs} +17 -4
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-DJ-LiLm_.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-container-field.component-PozYj2JC.mjs → ng-forge-dynamic-forms-container-field.component-Pcxg5PQu.mjs} +3 -3
- package/fesm2022/{ng-forge-dynamic-forms-container-field.component-PozYj2JC.mjs.map → ng-forge-dynamic-forms-container-field.component-Pcxg5PQu.mjs.map} +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-container-utils-DW_P7w-b.mjs → ng-forge-dynamic-forms-container-utils-a4RstR2Z.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-container-utils-DW_P7w-b.mjs.map → ng-forge-dynamic-forms-container-utils-a4RstR2Z.mjs.map} +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-css-wrapper.component-DrtHSaVV.mjs → ng-forge-dynamic-forms-css-wrapper.component-CJACCIEK.mjs} +2 -2
- package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-CJACCIEK.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-emit-initialization-CSx0gwFc.mjs → ng-forge-dynamic-forms-emit-initialization-Bjf_I0D_.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-emit-initialization-CSx0gwFc.mjs.map → ng-forge-dynamic-forms-emit-initialization-Bjf_I0D_.mjs.map} +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-group-field.component-2yRmHAUp.mjs → ng-forge-dynamic-forms-group-field.component-fzKwPrQr.mjs} +24 -4
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-fzKwPrQr.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-integration.mjs +629 -54
- package/fesm2022/ng-forge-dynamic-forms-integration.mjs.map +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-ng-forge-dynamic-forms-Ll4W2Z_n.mjs → ng-forge-dynamic-forms-ng-forge-dynamic-forms-o3rJX4vG.mjs} +979 -180
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-o3rJX4vG.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-page-field.component-B_CxX0T-.mjs → ng-forge-dynamic-forms-page-field.component-BX4R9GGa.mjs} +3 -3
- package/fesm2022/{ng-forge-dynamic-forms-page-field.component-B_CxX0T-.mjs.map → ng-forge-dynamic-forms-page-field.component-BX4R9GGa.mjs.map} +1 -1
- package/fesm2022/ng-forge-dynamic-forms-row-wrapper.component-BIvEUoia.mjs.map +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-rJMtKX2X.mjs → ng-forge-dynamic-forms-text-field.component-B9SK8Vun.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-rJMtKX2X.mjs.map → ng-forge-dynamic-forms-text-field.component-B9SK8Vun.mjs.map} +1 -1
- package/fesm2022/ng-forge-dynamic-forms.mjs +1 -1
- package/package.json +1 -1
- package/types/ng-forge-dynamic-forms-integration.d.ts +380 -8
- package/types/ng-forge-dynamic-forms.d.ts +1905 -1588
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-CWxCWVdz.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-DrtHSaVV.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-2yRmHAUp.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-Ll4W2Z_n.mjs.map +0 -1
|
@@ -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
|
*
|
|
@@ -324,8 +350,7 @@ function getDeclaredInputs(componentType) {
|
|
|
324
350
|
* avoiding the reflection scan on each call is meaningful under heavy typing.
|
|
325
351
|
*/
|
|
326
352
|
function setInputIfDeclared(ref, inputName, value) {
|
|
327
|
-
|
|
328
|
-
if (declared.has(inputName)) {
|
|
353
|
+
if (getDeclaredInputs(ref.componentType).has(inputName)) {
|
|
329
354
|
ref.setInput(inputName, value);
|
|
330
355
|
}
|
|
331
356
|
}
|
|
@@ -545,6 +570,9 @@ function createWrapperChainController(opts) {
|
|
|
545
570
|
const state = createStateSignal(opts);
|
|
546
571
|
const mounted = { value: null };
|
|
547
572
|
let refs = [];
|
|
573
|
+
// INVARIANT: toObservable schedules emissions as microtasks, so `renderInnermost`
|
|
574
|
+
// runs outside any parent CD pass. Don't swap for sync signal wiring without
|
|
575
|
+
// restoring df-field-outlet's detectChanges — NG0950 would re-surface.
|
|
548
576
|
buildEmissionStream(state, deps)
|
|
549
577
|
.pipe(takeUntilDestroyed(deps.destroyRef))
|
|
550
578
|
.subscribe((emission) => {
|
|
@@ -841,6 +869,8 @@ class DfFieldOutlet {
|
|
|
841
869
|
injector: createWrapperAwareInjector(resolved.injector, slot.injector),
|
|
842
870
|
});
|
|
843
871
|
this.fieldSlot = slot;
|
|
872
|
+
// setInput dirties the signal synchronously; the next CD pass reads
|
|
873
|
+
// it. Safe outside CD — see wrapper-chain-controller scheduling.
|
|
844
874
|
this.pushRawInputs(this.fieldRef, this.rawInputs());
|
|
845
875
|
},
|
|
846
876
|
});
|
|
@@ -904,12 +934,12 @@ class DfFieldOutlet {
|
|
|
904
934
|
}
|
|
905
935
|
}
|
|
906
936
|
pushRawInputs(ref, rawInputs) {
|
|
907
|
-
//
|
|
937
|
+
// 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.
|
|
908
938
|
const last = this.lastPushedInputs;
|
|
909
939
|
for (const [key, value] of Object.entries(rawInputs)) {
|
|
910
940
|
if (last && last[key] === value)
|
|
911
941
|
continue;
|
|
912
|
-
|
|
942
|
+
ref.setInput(key, value);
|
|
913
943
|
}
|
|
914
944
|
this.lastPushedInputs = rawInputs;
|
|
915
945
|
}
|
|
@@ -2744,16 +2774,25 @@ function evaluateCondition(expression, context) {
|
|
|
2744
2774
|
case 'javascript':
|
|
2745
2775
|
return evaluateJavaScriptExpression(expression, context);
|
|
2746
2776
|
case 'custom': {
|
|
2747
|
-
|
|
2777
|
+
// Read both keys via cast — the XOR type narrows to `never` when both
|
|
2778
|
+
// would be truthy, but JSON-loaded configs can produce that state at
|
|
2779
|
+
// runtime. We accept it here, warn, and let inline win.
|
|
2780
|
+
const bothSet = expression;
|
|
2781
|
+
const inlineFn = bothSet.fn;
|
|
2782
|
+
const registeredName = bothSet.functionName;
|
|
2783
|
+
if (inlineFn && registeredName) {
|
|
2784
|
+
context.logger.warn('Both "fn" and "functionName" are set on custom condition. Inline "fn" takes precedence; "functionName" is ignored.');
|
|
2785
|
+
}
|
|
2786
|
+
const customFn = inlineFn ?? (registeredName ? context.customFunctions?.[registeredName] : undefined);
|
|
2748
2787
|
if (!customFn) {
|
|
2749
|
-
context.logger.error('Custom function not found:',
|
|
2788
|
+
context.logger.error('Custom function not found:', registeredName);
|
|
2750
2789
|
return false;
|
|
2751
2790
|
}
|
|
2752
2791
|
try {
|
|
2753
2792
|
return !!customFn(context);
|
|
2754
2793
|
}
|
|
2755
2794
|
catch (error) {
|
|
2756
|
-
context.logger.error('Error executing custom function:',
|
|
2795
|
+
context.logger.error('Error executing custom function:', registeredName ?? '<inline>', error);
|
|
2757
2796
|
return false;
|
|
2758
2797
|
}
|
|
2759
2798
|
}
|
|
@@ -2762,7 +2801,7 @@ function evaluateCondition(expression, context) {
|
|
|
2762
2801
|
case 'or':
|
|
2763
2802
|
return evaluateOrCondition(expression, context);
|
|
2764
2803
|
case 'http':
|
|
2765
|
-
context.logger.warn('
|
|
2804
|
+
context.logger.warn('HTTP conditions are resolved asynchronously via createHttpConditionLogicFunction(). ' +
|
|
2766
2805
|
'When used inside and/or composites, the HTTP result is not available synchronously. Returning false.');
|
|
2767
2806
|
return false;
|
|
2768
2807
|
case 'async':
|
|
@@ -4141,9 +4180,9 @@ class PageOrchestratorComponent {
|
|
|
4141
4180
|
}
|
|
4142
4181
|
}
|
|
4143
4182
|
}
|
|
4144
|
-
`, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-
|
|
4183
|
+
`, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-BX4R9GGa.mjs').then(m => m.default)], () => [import('./ng-forge-dynamic-forms-page-field.component-BX4R9GGa.mjs').then(m => m.default)]] });
|
|
4145
4184
|
}
|
|
4146
|
-
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.2.8", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-
|
|
4185
|
+
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.2.8", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-BX4R9GGa.mjs').then(m => m.default)], resolveMetadata: PageFieldComponent => ({ decorators: [{
|
|
4147
4186
|
type: Component,
|
|
4148
4187
|
args: [{ selector: 'div[page-orchestrator]', imports: [PageFieldComponent], template: `
|
|
4149
4188
|
@for (pageField of pageFields(); track pageField.key; let i = $index) {
|
|
@@ -4624,7 +4663,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
|
|
|
4624
4663
|
* Use this guard when you need type-safe access to `FunctionHttpValidatorConfig` properties.
|
|
4625
4664
|
*/
|
|
4626
4665
|
function isFunctionHttpValidator(config) {
|
|
4627
|
-
|
|
4666
|
+
const c = config;
|
|
4667
|
+
return typeof c.functionName === 'string' || (c.fn !== null && typeof c.fn === 'object');
|
|
4628
4668
|
}
|
|
4629
4669
|
|
|
4630
4670
|
/**
|
|
@@ -5043,11 +5083,18 @@ function createAsyncConditionLogicFunction(condition) {
|
|
|
5043
5083
|
const cacheService = inject(AsyncConditionFunctionCacheService);
|
|
5044
5084
|
const pendingValue = condition.pendingValue ?? false;
|
|
5045
5085
|
const debounceMs = condition.debounceMs ?? 300;
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5086
|
+
if (condition.asyncFn && condition.asyncFunctionName) {
|
|
5087
|
+
logger.warn('Both "asyncFn" and "asyncFunctionName" are set on async condition. Inline "asyncFn" takes precedence; "asyncFunctionName" is ignored.');
|
|
5088
|
+
}
|
|
5089
|
+
// Skip cross-condition caching when an inline function is used: stableStringify
|
|
5090
|
+
// cannot distinguish two different inline functions, which would cause cache hits
|
|
5091
|
+
// to wrongly reuse a LogicFn closing over a different function reference.
|
|
5092
|
+
const cacheKey = condition.asyncFn ? undefined : stableStringify(condition);
|
|
5093
|
+
if (cacheKey !== undefined) {
|
|
5094
|
+
const cached = cacheService.asyncConditionFunctionCache.get(cacheKey);
|
|
5095
|
+
if (cached) {
|
|
5096
|
+
return cached;
|
|
5097
|
+
}
|
|
5051
5098
|
}
|
|
5052
5099
|
// Each condition needs its own per-field signal store.
|
|
5053
5100
|
const perFunctionSignalStore = new Map();
|
|
@@ -5072,8 +5119,10 @@ function createAsyncConditionLogicFunction(condition) {
|
|
|
5072
5119
|
const asyncResource = rxResource({
|
|
5073
5120
|
params: () => debouncedTrigger() ?? undefined,
|
|
5074
5121
|
stream: ({ params: triggerKey }) => {
|
|
5075
|
-
//
|
|
5076
|
-
|
|
5122
|
+
// Inline `asyncFn` wins; otherwise look up the registered function at call time
|
|
5123
|
+
// (fresh reference, in case the registry was updated after LogicFn creation).
|
|
5124
|
+
const asyncFn = condition.asyncFn ??
|
|
5125
|
+
(condition.asyncFunctionName ? functionRegistry.getAsyncConditionFunction(condition.asyncFunctionName) : undefined);
|
|
5077
5126
|
if (!asyncFn) {
|
|
5078
5127
|
logger.warn(`Async Condition - function '${condition.asyncFunctionName}' not found. ` +
|
|
5079
5128
|
`Register it in customFnConfig.asyncConditions.`);
|
|
@@ -5125,7 +5174,9 @@ function createAsyncConditionLogicFunction(condition) {
|
|
|
5125
5174
|
});
|
|
5126
5175
|
return resultResource.value();
|
|
5127
5176
|
};
|
|
5128
|
-
|
|
5177
|
+
if (cacheKey !== undefined) {
|
|
5178
|
+
cacheService.asyncConditionFunctionCache.set(cacheKey, fn);
|
|
5179
|
+
}
|
|
5129
5180
|
return fn;
|
|
5130
5181
|
}
|
|
5131
5182
|
|
|
@@ -5760,12 +5811,12 @@ function applyCustomValidator(config, fieldPath) {
|
|
|
5760
5811
|
if (config.expression) {
|
|
5761
5812
|
validatorFn = createExpressionValidator(config);
|
|
5762
5813
|
}
|
|
5763
|
-
else if (config.functionName) {
|
|
5814
|
+
else if (config.fn || config.functionName) {
|
|
5764
5815
|
validatorFn = createFunctionValidator(config);
|
|
5765
5816
|
}
|
|
5766
5817
|
else {
|
|
5767
5818
|
const logger = inject(DynamicFormLogger);
|
|
5768
|
-
logger.warn('Custom validator must have
|
|
5819
|
+
logger.warn('Custom validator must have one of "expression", "functionName", or "fn"');
|
|
5769
5820
|
return;
|
|
5770
5821
|
}
|
|
5771
5822
|
const whenLogic = createConditionalLogic(config.when);
|
|
@@ -5777,6 +5828,13 @@ function applyCustomValidator(config, fieldPath) {
|
|
|
5777
5828
|
}
|
|
5778
5829
|
function createFunctionValidator(config) {
|
|
5779
5830
|
const logger = inject(DynamicFormLogger);
|
|
5831
|
+
if (config.fn && config.functionName) {
|
|
5832
|
+
logger.warn('Both "fn" and "functionName" are set on custom validator. Inline "fn" takes precedence; "functionName" is ignored.');
|
|
5833
|
+
}
|
|
5834
|
+
if (config.fn) {
|
|
5835
|
+
const inlineFn = config.fn;
|
|
5836
|
+
return (ctx) => inlineFn(ctx, config.params);
|
|
5837
|
+
}
|
|
5780
5838
|
const functionName = config.functionName;
|
|
5781
5839
|
if (!functionName) {
|
|
5782
5840
|
logger.warn('Custom validator missing functionName');
|
|
@@ -5837,10 +5895,24 @@ function createExpressionValidator(config) {
|
|
|
5837
5895
|
* - onError: Optional handler for resource errors
|
|
5838
5896
|
*/
|
|
5839
5897
|
function applyAsyncValidator(config, fieldPath) {
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5898
|
+
if (config.fn && config.functionName) {
|
|
5899
|
+
const logger = inject(DynamicFormLogger);
|
|
5900
|
+
logger.warn('Both "fn" and "functionName" are set on async validator. Inline "fn" takes precedence; "functionName" is ignored.');
|
|
5901
|
+
}
|
|
5902
|
+
let validatorConfig;
|
|
5903
|
+
if (config.fn) {
|
|
5904
|
+
validatorConfig = config.fn;
|
|
5905
|
+
}
|
|
5906
|
+
else if (config.functionName) {
|
|
5907
|
+
const registry = inject(FunctionRegistryService);
|
|
5908
|
+
validatorConfig = registry.getAsyncValidator(config.functionName);
|
|
5909
|
+
if (!validatorConfig) {
|
|
5910
|
+
throw new DynamicFormError(`Async validator "${config.functionName}" not found. Register it with customFnConfig.asyncValidators.`);
|
|
5911
|
+
}
|
|
5912
|
+
}
|
|
5913
|
+
else {
|
|
5914
|
+
// XOR type rejects this at compile time; JSON-loaded configs can still reach here at runtime.
|
|
5915
|
+
throw new DynamicFormError('Async validator requires one of "functionName" or "fn".');
|
|
5844
5916
|
}
|
|
5845
5917
|
const whenLogic = createConditionalLogic(config.when);
|
|
5846
5918
|
const asyncOptions = {
|
|
@@ -5862,10 +5934,16 @@ function applyAsyncValidator(config, fieldPath) {
|
|
|
5862
5934
|
function applyUnifiedHttpValidator(config, fieldPath) {
|
|
5863
5935
|
if (isFunctionHttpValidator(config)) {
|
|
5864
5936
|
applyFunctionHttpValidator(config, fieldPath);
|
|
5937
|
+
return;
|
|
5865
5938
|
}
|
|
5866
|
-
|
|
5867
|
-
|
|
5939
|
+
// Declarative path requires both `http` and `responseMapping` — reject early
|
|
5940
|
+
// for JSON-loaded configs that have neither side fully populated. The
|
|
5941
|
+
// discriminated union forbids this at compile time.
|
|
5942
|
+
const declarative = config;
|
|
5943
|
+
if (!declarative.http || !declarative.responseMapping) {
|
|
5944
|
+
throw new DynamicFormError('HTTP validator requires one of "functionName" / "fn" (function-based) or "http" + "responseMapping" (declarative).');
|
|
5868
5945
|
}
|
|
5946
|
+
applyDeclarativeHttpValidator(config, fieldPath);
|
|
5869
5947
|
}
|
|
5870
5948
|
/**
|
|
5871
5949
|
* Apply function-based HTTP validator to field path using Angular's public validateHttp() API.
|
|
@@ -5876,10 +5954,24 @@ function applyUnifiedHttpValidator(config, fieldPath) {
|
|
|
5876
5954
|
* - onError: Optional handler for HTTP errors
|
|
5877
5955
|
*/
|
|
5878
5956
|
function applyFunctionHttpValidator(config, fieldPath) {
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5957
|
+
if (config.fn && config.functionName) {
|
|
5958
|
+
const logger = inject(DynamicFormLogger);
|
|
5959
|
+
logger.warn('Both "fn" and "functionName" are set on HTTP validator. Inline "fn" takes precedence; "functionName" is ignored.');
|
|
5960
|
+
}
|
|
5961
|
+
let httpValidatorConfig;
|
|
5962
|
+
if (config.fn) {
|
|
5963
|
+
httpValidatorConfig = config.fn;
|
|
5964
|
+
}
|
|
5965
|
+
else if (config.functionName) {
|
|
5966
|
+
const registry = inject(FunctionRegistryService);
|
|
5967
|
+
httpValidatorConfig = registry.getHttpValidator(config.functionName);
|
|
5968
|
+
if (!httpValidatorConfig) {
|
|
5969
|
+
throw new DynamicFormError(`HTTP validator "${config.functionName}" not found. Register it with customFnConfig.httpValidators.`);
|
|
5970
|
+
}
|
|
5971
|
+
}
|
|
5972
|
+
else {
|
|
5973
|
+
// XOR type rejects this at compile time; JSON-loaded configs can still reach here at runtime.
|
|
5974
|
+
throw new DynamicFormError('HTTP validator requires one of "functionName" or "fn".');
|
|
5883
5975
|
}
|
|
5884
5976
|
const whenLogic = createConditionalLogic(config.when);
|
|
5885
5977
|
const httpOptions = {
|
|
@@ -6187,6 +6279,109 @@ function createSchemaFunction(schema) {
|
|
|
6187
6279
|
};
|
|
6188
6280
|
}
|
|
6189
6281
|
|
|
6282
|
+
/**
|
|
6283
|
+
* Combines the inherited "is any ancestor hidden" check into a single LogicFn.
|
|
6284
|
+
* Returns `undefined` when no ancestor has dynamic hidden logic.
|
|
6285
|
+
*
|
|
6286
|
+
* @internal
|
|
6287
|
+
*/
|
|
6288
|
+
function combineAncestorHiddenLogics(logics) {
|
|
6289
|
+
if (logics.length === 0)
|
|
6290
|
+
return undefined;
|
|
6291
|
+
if (logics.length === 1)
|
|
6292
|
+
return logics[0];
|
|
6293
|
+
return (ctx) => logics.some((fn) => fn(ctx));
|
|
6294
|
+
}
|
|
6295
|
+
/**
|
|
6296
|
+
* Returns the `FieldTreeMappingContext` to use **for the field itself** — i.e.
|
|
6297
|
+
* for gating its own validators. Only applies overrides that affect the field
|
|
6298
|
+
* (currently `validateWhenHidden`). The field's own hidden state is NOT folded
|
|
6299
|
+
* into `ancestorAlwaysHidden`/`ancestorHiddenLogics` here — Angular Signal
|
|
6300
|
+
* Forms already exposes that through `ctx.state.hidden()`, which the gate
|
|
6301
|
+
* checks separately.
|
|
6302
|
+
*
|
|
6303
|
+
* Returns the parent context unchanged when the field defines no overrides —
|
|
6304
|
+
* saves an allocation on every leaf in the common case.
|
|
6305
|
+
*
|
|
6306
|
+
* @internal
|
|
6307
|
+
*/
|
|
6308
|
+
function resolveFieldOwnContext(fieldDef, parentContext) {
|
|
6309
|
+
if (fieldDef.validateWhenHidden === undefined)
|
|
6310
|
+
return parentContext;
|
|
6311
|
+
if (fieldDef.validateWhenHidden === parentContext.validateWhenHidden)
|
|
6312
|
+
return parentContext;
|
|
6313
|
+
return {
|
|
6314
|
+
...parentContext,
|
|
6315
|
+
validateWhenHidden: fieldDef.validateWhenHidden,
|
|
6316
|
+
};
|
|
6317
|
+
}
|
|
6318
|
+
/**
|
|
6319
|
+
* Returns the `FieldTreeMappingContext` to use **for the field's descendants**.
|
|
6320
|
+
* Builds on top of `ownContext` by folding in the field's own hidden state so
|
|
6321
|
+
* children can correctly skip validation when this field hides them. Used for
|
|
6322
|
+
* groups, arrays, and layout containers that recurse into children.
|
|
6323
|
+
*
|
|
6324
|
+
* Behavior:
|
|
6325
|
+
* - `hidden: true` (static) → sets `ancestorAlwaysHidden`.
|
|
6326
|
+
* - `logic: [{type: 'hidden', condition: true}]` → sets `ancestorAlwaysHidden`.
|
|
6327
|
+
* - `logic: [{type: 'hidden', condition: false}]` → no effect.
|
|
6328
|
+
* - `logic: [{type: 'hidden', condition: <expr>}]` → appends a reactive
|
|
6329
|
+
* `LogicFn` to `ancestorHiddenLogics`.
|
|
6330
|
+
*
|
|
6331
|
+
* @internal
|
|
6332
|
+
*/
|
|
6333
|
+
function resolveDescendantContext(fieldDef, ownContext) {
|
|
6334
|
+
let ancestorAlwaysHidden = ownContext.ancestorAlwaysHidden;
|
|
6335
|
+
let ancestorHiddenLogics = ownContext.ancestorHiddenLogics;
|
|
6336
|
+
let mutated = false;
|
|
6337
|
+
if (fieldDef.hidden === true && !ancestorAlwaysHidden) {
|
|
6338
|
+
ancestorAlwaysHidden = true;
|
|
6339
|
+
mutated = true;
|
|
6340
|
+
}
|
|
6341
|
+
if (fieldDef.logic) {
|
|
6342
|
+
for (const logic of fieldDef.logic) {
|
|
6343
|
+
if (!isStateLogicConfig(logic) || logic.type !== 'hidden')
|
|
6344
|
+
continue;
|
|
6345
|
+
const condition = logic.condition;
|
|
6346
|
+
if (condition === true) {
|
|
6347
|
+
if (!ancestorAlwaysHidden) {
|
|
6348
|
+
ancestorAlwaysHidden = true;
|
|
6349
|
+
mutated = true;
|
|
6350
|
+
}
|
|
6351
|
+
continue;
|
|
6352
|
+
}
|
|
6353
|
+
if (condition === false)
|
|
6354
|
+
continue;
|
|
6355
|
+
// FormStateCondition strings ('formInvalid', etc.) are intended for buttons —
|
|
6356
|
+
// they don't make sense as a "field hidden" trigger, so we skip them here.
|
|
6357
|
+
if (typeof condition === 'string')
|
|
6358
|
+
continue;
|
|
6359
|
+
// Dynamic — we need a reactive logic function. createLogicFunction
|
|
6360
|
+
// requires an injection context, which the schema build provides.
|
|
6361
|
+
const fn = createLogicFunction(condition);
|
|
6362
|
+
ancestorHiddenLogics = ancestorHiddenLogics.length === 0 ? [fn] : [...ancestorHiddenLogics, fn];
|
|
6363
|
+
mutated = true;
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
if (!mutated)
|
|
6367
|
+
return ownContext;
|
|
6368
|
+
return {
|
|
6369
|
+
validateWhenHidden: ownContext.validateWhenHidden,
|
|
6370
|
+
ancestorAlwaysHidden,
|
|
6371
|
+
ancestorHiddenLogics,
|
|
6372
|
+
};
|
|
6373
|
+
}
|
|
6374
|
+
|
|
6375
|
+
/**
|
|
6376
|
+
* Default {@link FieldTreeMappingContext} used when no inherited context is provided.
|
|
6377
|
+
* Mirrors the global token defaults so callers that bypass `createSchemaFromFields`
|
|
6378
|
+
* (e.g. nested group forms) still get sensible behavior.
|
|
6379
|
+
*/
|
|
6380
|
+
const DEFAULT_FIELD_TREE_MAPPING_CONTEXT = {
|
|
6381
|
+
validateWhenHidden: false,
|
|
6382
|
+
ancestorAlwaysHidden: false,
|
|
6383
|
+
ancestorHiddenLogics: [],
|
|
6384
|
+
};
|
|
6190
6385
|
/**
|
|
6191
6386
|
* Applies string-specific validators to a schema path.
|
|
6192
6387
|
* This function is properly typed - no casts needed internally.
|
|
@@ -6267,25 +6462,42 @@ function getNumberValidationConfig(fieldDef) {
|
|
|
6267
6462
|
* which uses RootFormRegistryService.
|
|
6268
6463
|
*
|
|
6269
6464
|
* Cross-field validators are skipped at field level and applied at form level via validateTree.
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6465
|
+
*
|
|
6466
|
+
* @param fieldDef Field definition to map
|
|
6467
|
+
* @param fieldPath Schema path for this field
|
|
6468
|
+
* @param inheritedContext Cascading mapping context from the parent. Field-level
|
|
6469
|
+
* overrides on `fieldDef` are merged on top and passed down to descendants. Defaults
|
|
6470
|
+
* to {@link DEFAULT_FIELD_TREE_MAPPING_CONTEXT} when omitted.
|
|
6471
|
+
*/
|
|
6472
|
+
function mapFieldToForm(fieldDef, fieldPath, inheritedContext = DEFAULT_FIELD_TREE_MAPPING_CONTEXT) {
|
|
6473
|
+
const ownContext = resolveFieldOwnContext(fieldDef, inheritedContext);
|
|
6474
|
+
// Layout containers (page, row, container) - flatten children to current level.
|
|
6475
|
+
// Layout containers have no schema path of their own, so any hidden state on them
|
|
6476
|
+
// must be propagated to descendants through the cascade.
|
|
6273
6477
|
if (isPageField(fieldDef) || isRowField(fieldDef) || isContainerTypedField(fieldDef)) {
|
|
6274
|
-
|
|
6478
|
+
const descendantContext = resolveDescendantContext(fieldDef, ownContext);
|
|
6479
|
+
mapContainerChildren(fieldDef.fields, fieldPath, descendantContext);
|
|
6275
6480
|
return;
|
|
6276
6481
|
}
|
|
6277
|
-
// Group fields - map children under the group's path
|
|
6482
|
+
// Group fields - map children under the group's path. Groups have a schema path,
|
|
6483
|
+
// so Angular Signal Forms can propagate `hidden(path, ...)` calls to descendants —
|
|
6484
|
+
// but groups don't currently apply their own hidden state, so we still propagate
|
|
6485
|
+
// through the cascade as a belt-and-suspenders fallback.
|
|
6278
6486
|
if (isGroupField(fieldDef)) {
|
|
6279
|
-
|
|
6487
|
+
const descendantContext = resolveDescendantContext(fieldDef, ownContext);
|
|
6488
|
+
mapContainerChildren(fieldDef.fields, fieldPath, descendantContext);
|
|
6280
6489
|
return;
|
|
6281
6490
|
}
|
|
6282
|
-
// Array fields - use applyEach for item schema
|
|
6491
|
+
// Array fields - use applyEach for item schema.
|
|
6283
6492
|
if (isArrayField(fieldDef)) {
|
|
6284
|
-
|
|
6493
|
+
const descendantContext = resolveDescendantContext(fieldDef, ownContext);
|
|
6494
|
+
mapArrayFieldToForm(fieldDef, fieldPath, descendantContext);
|
|
6285
6495
|
return;
|
|
6286
6496
|
}
|
|
6287
|
-
// Leaf field - apply validation, logic, and configuration
|
|
6288
|
-
|
|
6497
|
+
// Leaf field - apply validation, logic, and configuration. Use the field's OWN
|
|
6498
|
+
// context (no descendant propagation) because the leaf's own hidden state is
|
|
6499
|
+
// already exposed via `ctx.state.hidden()`.
|
|
6500
|
+
mapLeafField(fieldDef, fieldPath, ownContext);
|
|
6289
6501
|
}
|
|
6290
6502
|
/**
|
|
6291
6503
|
* Maps children of a container field (page, row, group) to the form schema.
|
|
@@ -6296,50 +6508,100 @@ function mapFieldToForm(fieldDef, fieldPath) {
|
|
|
6296
6508
|
* leaf descendants. Group/array fields consume their key and look up their own
|
|
6297
6509
|
* child path on `parentPath`.
|
|
6298
6510
|
*/
|
|
6299
|
-
function mapContainerChildren(fields, parentPath) {
|
|
6511
|
+
function mapContainerChildren(fields, parentPath, inheritedContext) {
|
|
6300
6512
|
if (!fields)
|
|
6301
6513
|
return;
|
|
6302
6514
|
const pathRecord = parentPath;
|
|
6303
6515
|
for (const field of fields) {
|
|
6304
6516
|
if (isPageField(field) || isRowField(field) || isContainerTypedField(field)) {
|
|
6305
|
-
|
|
6517
|
+
// Layout containers can still carry their own cascading overrides that flow
|
|
6518
|
+
// down to their flattened descendants.
|
|
6519
|
+
const layoutOwn = resolveFieldOwnContext(field, inheritedContext);
|
|
6520
|
+
const layoutDescendant = resolveDescendantContext(field, layoutOwn);
|
|
6521
|
+
mapContainerChildren(field.fields, parentPath, layoutDescendant);
|
|
6306
6522
|
continue;
|
|
6307
6523
|
}
|
|
6308
6524
|
if (!field.key)
|
|
6309
6525
|
continue;
|
|
6310
6526
|
const childPath = pathRecord[field.key];
|
|
6311
6527
|
if (childPath) {
|
|
6312
|
-
mapFieldToForm(field, childPath);
|
|
6528
|
+
mapFieldToForm(field, childPath, inheritedContext);
|
|
6313
6529
|
}
|
|
6314
6530
|
}
|
|
6315
6531
|
}
|
|
6532
|
+
/**
|
|
6533
|
+
* Returns true for state logic that contributes validation (currently only `required`).
|
|
6534
|
+
* The other state logic types (`hidden`, `disabled`, `readonly`) must always apply,
|
|
6535
|
+
* even on hidden fields, so they're not gated by `validateWhenHidden`.
|
|
6536
|
+
*/
|
|
6537
|
+
function isValidationStateLogic(config) {
|
|
6538
|
+
return isStateLogicConfig(config) && config.type === 'required';
|
|
6539
|
+
}
|
|
6316
6540
|
/**
|
|
6317
6541
|
* Maps a leaf field (value-bearing field) to the form schema.
|
|
6542
|
+
*
|
|
6543
|
+
* @param context Resolved cascading context for this field. When
|
|
6544
|
+
* `context.validateWhenHidden` is `false`, the field's validation block is wrapped
|
|
6545
|
+
* in `applyWhen` keyed off `!ctx.state.hidden()`.
|
|
6318
6546
|
*/
|
|
6319
|
-
function mapLeafField(fieldDef, fieldPath) {
|
|
6547
|
+
function mapLeafField(fieldDef, fieldPath, context) {
|
|
6320
6548
|
const validationField = fieldDef;
|
|
6321
6549
|
const path = fieldPath;
|
|
6322
|
-
//
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
applyValidator(config, fieldPath);
|
|
6327
|
-
}
|
|
6328
|
-
}
|
|
6329
|
-
// Apply logic rules
|
|
6550
|
+
// Always-applied: state logic (hidden/disabled/readonly), schemas, and value derivations.
|
|
6551
|
+
// These must run regardless of hidden state — particularly the `hidden` logic itself,
|
|
6552
|
+
// which establishes the very signal `applyWhen` reads below.
|
|
6553
|
+
applyFieldState(fieldDef, path);
|
|
6330
6554
|
if (validationField.logic) {
|
|
6331
6555
|
for (const config of validationField.logic) {
|
|
6332
|
-
|
|
6556
|
+
if (!isValidationStateLogic(config)) {
|
|
6557
|
+
applyLogic(config, fieldPath);
|
|
6558
|
+
}
|
|
6333
6559
|
}
|
|
6334
6560
|
}
|
|
6335
|
-
// Apply schemas
|
|
6336
6561
|
if (validationField.schemas) {
|
|
6337
6562
|
for (const config of validationField.schemas) {
|
|
6338
6563
|
applySchema(config, fieldPath);
|
|
6339
6564
|
}
|
|
6340
6565
|
}
|
|
6341
|
-
//
|
|
6342
|
-
|
|
6566
|
+
// Validation block — gated by `validateWhenHidden` when hidden.
|
|
6567
|
+
const applyValidation = (validationPath) => {
|
|
6568
|
+
applySimpleValidationRules(validationField, validationPath);
|
|
6569
|
+
if (validationField.validators) {
|
|
6570
|
+
for (const config of validationField.validators) {
|
|
6571
|
+
applyValidator(config, validationPath);
|
|
6572
|
+
}
|
|
6573
|
+
}
|
|
6574
|
+
if (validationField.logic) {
|
|
6575
|
+
for (const config of validationField.logic) {
|
|
6576
|
+
if (isValidationStateLogic(config)) {
|
|
6577
|
+
applyLogic(config, validationPath);
|
|
6578
|
+
}
|
|
6579
|
+
}
|
|
6580
|
+
}
|
|
6581
|
+
};
|
|
6582
|
+
if (context.validateWhenHidden) {
|
|
6583
|
+
applyValidation(path);
|
|
6584
|
+
return;
|
|
6585
|
+
}
|
|
6586
|
+
// An ancestor is unconditionally hidden — there is no reactive case for the
|
|
6587
|
+
// validators to ever fire, so we simply do not apply them.
|
|
6588
|
+
if (context.ancestorAlwaysHidden) {
|
|
6589
|
+
return;
|
|
6590
|
+
}
|
|
6591
|
+
// applyWhen conditionally applies a sub-schema. The gate combines:
|
|
6592
|
+
// - the field's own `state.hidden()` (set by Angular Signal Forms whenever
|
|
6593
|
+
// `hidden(path, ...)` was called for this field or any ancestor with a
|
|
6594
|
+
// path); and
|
|
6595
|
+
// - any ancestor dynamic hidden logic that does NOT live on a schema path
|
|
6596
|
+
// (layout containers — see field-tree-mapping-context for details).
|
|
6597
|
+
const ancestorHiddenLogic = combineAncestorHiddenLogics(context.ancestorHiddenLogics);
|
|
6598
|
+
applyWhen(path, (ctx) => {
|
|
6599
|
+
if (ctx.state.hidden())
|
|
6600
|
+
return false;
|
|
6601
|
+
if (ancestorHiddenLogic && ancestorHiddenLogic(ctx))
|
|
6602
|
+
return false;
|
|
6603
|
+
return true;
|
|
6604
|
+
}, schema((subPath) => applyValidation(subPath)));
|
|
6343
6605
|
}
|
|
6344
6606
|
/**
|
|
6345
6607
|
* Applies simple validation rules from field properties.
|
|
@@ -6388,7 +6650,7 @@ function applyFieldState(fieldDef, path) {
|
|
|
6388
6650
|
* - Heterogeneous arrays - mixed primitives and objects
|
|
6389
6651
|
* - Container templates (row, group, page) that wrap children
|
|
6390
6652
|
*/
|
|
6391
|
-
function mapArrayFieldToForm(arrayField, fieldPath) {
|
|
6653
|
+
function mapArrayFieldToForm(arrayField, fieldPath, context) {
|
|
6392
6654
|
if (!isArrayField(arrayField)) {
|
|
6393
6655
|
return;
|
|
6394
6656
|
}
|
|
@@ -6459,20 +6721,24 @@ function mapArrayFieldToForm(arrayField, fieldPath) {
|
|
|
6459
6721
|
for (const templateField of allObjectFields) {
|
|
6460
6722
|
if (isRowField(templateField) || isPageField(templateField) || isContainerTypedField(templateField)) {
|
|
6461
6723
|
// Row/page/container templates flatten their children
|
|
6462
|
-
|
|
6724
|
+
const layoutOwn = resolveFieldOwnContext(templateField, context);
|
|
6725
|
+
const layoutDescendant = resolveDescendantContext(templateField, layoutOwn);
|
|
6726
|
+
mapContainerChildren(templateField.fields, itemPath, layoutDescendant);
|
|
6463
6727
|
}
|
|
6464
6728
|
else if (isGroupField(templateField)) {
|
|
6465
6729
|
// Group template - access group's path first
|
|
6466
6730
|
const groupKey = templateField.key;
|
|
6731
|
+
const groupOwn = resolveFieldOwnContext(templateField, context);
|
|
6732
|
+
const groupDescendant = resolveDescendantContext(templateField, groupOwn);
|
|
6467
6733
|
if (groupKey) {
|
|
6468
6734
|
const groupPath = pathRecord[groupKey];
|
|
6469
6735
|
if (groupPath) {
|
|
6470
|
-
mapContainerChildren(templateField.fields, groupPath);
|
|
6736
|
+
mapContainerChildren(templateField.fields, groupPath, groupDescendant);
|
|
6471
6737
|
}
|
|
6472
6738
|
}
|
|
6473
6739
|
else {
|
|
6474
6740
|
// No group key - apply children directly (edge case)
|
|
6475
|
-
mapContainerChildren(templateField.fields, itemPath);
|
|
6741
|
+
mapContainerChildren(templateField.fields, itemPath, groupDescendant);
|
|
6476
6742
|
}
|
|
6477
6743
|
}
|
|
6478
6744
|
else {
|
|
@@ -6481,7 +6747,7 @@ function mapArrayFieldToForm(arrayField, fieldPath) {
|
|
|
6481
6747
|
if (fieldKey) {
|
|
6482
6748
|
const fieldPathForKey = pathRecord[fieldKey];
|
|
6483
6749
|
if (fieldPathForKey) {
|
|
6484
|
-
mapFieldToForm(templateField, fieldPathForKey);
|
|
6750
|
+
mapFieldToForm(templateField, fieldPathForKey, context);
|
|
6485
6751
|
}
|
|
6486
6752
|
}
|
|
6487
6753
|
}
|
|
@@ -6899,6 +7165,103 @@ function mapValues(obj, fn) {
|
|
|
6899
7165
|
return acc;
|
|
6900
7166
|
}, {});
|
|
6901
7167
|
}
|
|
7168
|
+
/**
|
|
7169
|
+
* Deep-merges `value` into `defaults`, producing a new object that contains every
|
|
7170
|
+
* key from `defaults` plus any overrides from `value`. Plain-object values at
|
|
7171
|
+
* matching keys are merged recursively; primitives, dates, and class instances
|
|
7172
|
+
* in `value` replace the corresponding default wholesale.
|
|
7173
|
+
*
|
|
7174
|
+
* Arrays whose lengths match between `defaults` and `value` are merged
|
|
7175
|
+
* positionally: when both `defaults[i]` and `value[i]` are plain objects, they
|
|
7176
|
+
* are deep-merged so partial array-item values don't drop sibling sub-field
|
|
7177
|
+
* keys. When the lengths differ — the runtime add/insert/remove case — the
|
|
7178
|
+
* value array is returned by reference so Signal Forms doesn't see a fresh
|
|
7179
|
+
* array of fresh inner objects on every form-value write (which would rebuild
|
|
7180
|
+
* item bindings and desync the rendered rows from their FieldTrees).
|
|
7181
|
+
*
|
|
7182
|
+
* Used by `FormStateManager.entity` so a partial input value (e.g. a nested
|
|
7183
|
+
* group object missing one of its declared sub-fields, or an array item missing
|
|
7184
|
+
* one of its declared sub-fields) does not orphan the absent sub-field in
|
|
7185
|
+
* Angular Signal Forms' validation graph.
|
|
7186
|
+
*
|
|
7187
|
+
* @example
|
|
7188
|
+
* ```typescript
|
|
7189
|
+
* deepMergeDefaults(
|
|
7190
|
+
* { a: { line1: '', line2: '', city: '' }, b: 0 },
|
|
7191
|
+
* { a: { line1: 'X', city: 'Y' }, b: 1 },
|
|
7192
|
+
* );
|
|
7193
|
+
* // { a: { line1: 'X', line2: '', city: 'Y' }, b: 1 }
|
|
7194
|
+
*
|
|
7195
|
+
* deepMergeDefaults(
|
|
7196
|
+
* { items: [{ checkboxA: false, checkboxB: false }] },
|
|
7197
|
+
* { items: [{ checkboxA: true }] },
|
|
7198
|
+
* );
|
|
7199
|
+
* // { items: [{ checkboxA: true, checkboxB: false }] }
|
|
7200
|
+
* ```
|
|
7201
|
+
*/
|
|
7202
|
+
function deepMergeDefaults(defaults, value) {
|
|
7203
|
+
if (value == null)
|
|
7204
|
+
return { ...defaults };
|
|
7205
|
+
const result = { ...defaults };
|
|
7206
|
+
for (const key of Object.keys(value)) {
|
|
7207
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
|
|
7208
|
+
continue;
|
|
7209
|
+
const incoming = value[key];
|
|
7210
|
+
const existing = result[key];
|
|
7211
|
+
if (isPlainObject(existing) && isPlainObject(incoming)) {
|
|
7212
|
+
result[key] = deepMergeDefaults(existing, incoming);
|
|
7213
|
+
}
|
|
7214
|
+
else if (Array.isArray(existing) && Array.isArray(incoming)) {
|
|
7215
|
+
result[key] = mergeArrayDefaults(existing, incoming);
|
|
7216
|
+
}
|
|
7217
|
+
else {
|
|
7218
|
+
result[key] = incoming;
|
|
7219
|
+
}
|
|
7220
|
+
}
|
|
7221
|
+
return result;
|
|
7222
|
+
}
|
|
7223
|
+
/**
|
|
7224
|
+
* @internal
|
|
7225
|
+
* Merges a value array into a defaults array element-wise when their lengths
|
|
7226
|
+
* match. When lengths differ — the runtime case: prepend, insert, append,
|
|
7227
|
+
* remove — we return the value array reference as-is so we don't produce
|
|
7228
|
+
* fresh inner object references for items that already have every declared
|
|
7229
|
+
* sub-field key. Replacing references during the linkedSignal source
|
|
7230
|
+
* recompute that runs after Signal Forms wrote the array would otherwise
|
|
7231
|
+
* desync item FieldTrees from the rendered DOM rows.
|
|
7232
|
+
*
|
|
7233
|
+
* When lengths match (the declared/initial-value path the array fix targets)
|
|
7234
|
+
* each `defaults[i]`/`value[i]` pair is deep-merged when both are plain
|
|
7235
|
+
* objects so partial array-item values don't drop sibling sub-field keys.
|
|
7236
|
+
*/
|
|
7237
|
+
function mergeArrayDefaults(defaults, value) {
|
|
7238
|
+
if (defaults.length !== value.length)
|
|
7239
|
+
return value;
|
|
7240
|
+
// Short-circuit when no element pair would be deep-merged. Returning a fresh
|
|
7241
|
+
// .map() result for primitive arrays (e.g. tags: ['a', 'b']) hands Signal
|
|
7242
|
+
// Forms a new reference on every call and rebuilds item FieldTrees needlessly.
|
|
7243
|
+
if (!value.some((item, i) => isPlainObject(defaults[i]) && isPlainObject(item)))
|
|
7244
|
+
return value;
|
|
7245
|
+
return value.map((item, i) => {
|
|
7246
|
+
const defaultItem = defaults[i];
|
|
7247
|
+
if (isPlainObject(defaultItem) && isPlainObject(item)) {
|
|
7248
|
+
return deepMergeDefaults(defaultItem, item);
|
|
7249
|
+
}
|
|
7250
|
+
return item;
|
|
7251
|
+
});
|
|
7252
|
+
}
|
|
7253
|
+
/**
|
|
7254
|
+
* @internal
|
|
7255
|
+
* Returns true for objects that should be deep-merged (plain objects produced
|
|
7256
|
+
* by literals or `Object.create(null)`); false for arrays, Dates, RegExps,
|
|
7257
|
+
* Maps, Sets, class instances, and primitives.
|
|
7258
|
+
*/
|
|
7259
|
+
function isPlainObject(value) {
|
|
7260
|
+
if (typeof value !== 'object' || value === null)
|
|
7261
|
+
return false;
|
|
7262
|
+
const proto = Object.getPrototypeOf(value);
|
|
7263
|
+
return proto === null || proto === Object.prototype;
|
|
7264
|
+
}
|
|
6902
7265
|
/**
|
|
6903
7266
|
* Options for memoize function
|
|
6904
7267
|
*/
|
|
@@ -7088,6 +7451,27 @@ function createFormLevelSchema(formLevelSchema) {
|
|
|
7088
7451
|
});
|
|
7089
7452
|
}
|
|
7090
7453
|
|
|
7454
|
+
/**
|
|
7455
|
+
* Injection token for global validation execution defaults.
|
|
7456
|
+
*
|
|
7457
|
+
* Controls whether validators run on hidden fields. By default, validation is
|
|
7458
|
+
* skipped when a field is hidden (`validateWhenHidden: false`).
|
|
7459
|
+
*
|
|
7460
|
+
* Configured via `withValidationExecutionDefaults()`. Per-form overrides via
|
|
7461
|
+
* `FormOptions.validateWhenHidden`. Per-field overrides via `FieldDef.validateWhenHidden`,
|
|
7462
|
+
* which inherits down the field tree.
|
|
7463
|
+
*
|
|
7464
|
+
* **Resolution order:** Field (with parent inheritance) > Form > Global.
|
|
7465
|
+
*
|
|
7466
|
+
* @internal
|
|
7467
|
+
*/
|
|
7468
|
+
const VALIDATION_EXECUTION_DEFAULTS = new InjectionToken('VALIDATION_EXECUTION_DEFAULTS', {
|
|
7469
|
+
providedIn: 'root',
|
|
7470
|
+
factory: () => ({
|
|
7471
|
+
validateWhenHidden: false,
|
|
7472
|
+
}),
|
|
7473
|
+
});
|
|
7474
|
+
|
|
7091
7475
|
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
7092
7476
|
/**
|
|
7093
7477
|
* Gets a FieldTree by key, supporting nested paths with dot notation.
|
|
@@ -7132,11 +7516,17 @@ function createSchemaFromFields(fields, registry, optionsOrValidators) {
|
|
|
7132
7516
|
// These will be available because createSchemaFromFields is called within runInInjectionContext
|
|
7133
7517
|
const functionRegistry = inject(FunctionRegistryService);
|
|
7134
7518
|
const logger = inject(DynamicFormLogger);
|
|
7519
|
+
const validationExecutionDefaults = inject(VALIDATION_EXECUTION_DEFAULTS);
|
|
7135
7520
|
// Normalize options - support both old array signature and new options object
|
|
7136
7521
|
const options = Array.isArray(optionsOrValidators)
|
|
7137
7522
|
? { crossFieldValidators: optionsOrValidators }
|
|
7138
7523
|
: (optionsOrValidators ?? {});
|
|
7139
7524
|
const { crossFieldValidators, formLevelSchema } = options;
|
|
7525
|
+
const rootContext = {
|
|
7526
|
+
validateWhenHidden: options.validateWhenHidden ?? validationExecutionDefaults.validateWhenHidden,
|
|
7527
|
+
ancestorAlwaysHidden: false,
|
|
7528
|
+
ancestorHiddenLogics: [],
|
|
7529
|
+
};
|
|
7140
7530
|
return schema((path) => {
|
|
7141
7531
|
for (const fieldDef of fields) {
|
|
7142
7532
|
const valueHandling = getFieldValueHandling(fieldDef.type, registry);
|
|
@@ -7146,12 +7536,17 @@ function createSchemaFromFields(fields, registry, optionsOrValidators) {
|
|
|
7146
7536
|
continue;
|
|
7147
7537
|
}
|
|
7148
7538
|
if (valueHandling === 'flatten' && hasChildFields(fieldDef)) {
|
|
7539
|
+
// A flatten container can still set its own cascading overrides that flow
|
|
7540
|
+
// down to its children — including its own hidden state which has no
|
|
7541
|
+
// schema path of its own.
|
|
7542
|
+
const flattenedOwn = resolveFieldOwnContext(fieldDef, rootContext);
|
|
7543
|
+
const flattenedContext = resolveDescendantContext(fieldDef, flattenedOwn);
|
|
7149
7544
|
for (const childField of normalizeFieldsArray(fieldDef.fields)) {
|
|
7150
7545
|
if (!childField.key)
|
|
7151
7546
|
continue;
|
|
7152
7547
|
const childPath = path[childField.key];
|
|
7153
7548
|
if (childPath) {
|
|
7154
|
-
mapFieldToForm(childField, childPath);
|
|
7549
|
+
mapFieldToForm(childField, childPath, flattenedContext);
|
|
7155
7550
|
}
|
|
7156
7551
|
}
|
|
7157
7552
|
continue;
|
|
@@ -7164,7 +7559,7 @@ function createSchemaFromFields(fields, registry, optionsOrValidators) {
|
|
|
7164
7559
|
// Use the new modular form mapping function
|
|
7165
7560
|
// This will progressively apply validators, logic, and schemas
|
|
7166
7561
|
// Cross-field logic is handled automatically via RootFormRegistryService
|
|
7167
|
-
mapFieldToForm(fieldDef, fieldPath);
|
|
7562
|
+
mapFieldToForm(fieldDef, fieldPath, rootContext);
|
|
7168
7563
|
}
|
|
7169
7564
|
// Apply cross-field validators using validateTree
|
|
7170
7565
|
if (crossFieldValidators && crossFieldValidators.length > 0) {
|
|
@@ -7772,49 +8167,54 @@ function flattenFields(fields, registry, options = {}) {
|
|
|
7772
8167
|
* These functions are used by both `FormStateManager` and `GroupFieldComponent`
|
|
7773
8168
|
* to compute flattened fields, field lookups, and default values from field definitions.
|
|
7774
8169
|
*
|
|
8170
|
+
* Caches are keyed on the input array's reference identity (via WeakMap) rather than
|
|
8171
|
+
* a derived content key. Different array instances — even ones with the same field
|
|
8172
|
+
* keys/types — must produce fresh results so per-field content changes (e.g. updated
|
|
8173
|
+
* select options) propagate through the form pipeline.
|
|
8174
|
+
*
|
|
7775
8175
|
* @returns Object containing memoized processing functions
|
|
7776
8176
|
*/
|
|
7777
8177
|
function createContainerFieldProcessors() {
|
|
7778
|
-
const
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
8178
|
+
const flattenCache = new WeakMap();
|
|
8179
|
+
const memoizedFlattenFields = (fields, registry) => {
|
|
8180
|
+
const cached = flattenCache.get(fields);
|
|
8181
|
+
if (cached !== undefined) {
|
|
8182
|
+
return cached;
|
|
7782
8183
|
}
|
|
7783
|
-
|
|
8184
|
+
const result = flattenFields([...fields], registry);
|
|
8185
|
+
flattenCache.set(fields, result);
|
|
8186
|
+
return result;
|
|
8187
|
+
};
|
|
8188
|
+
const flattenForRenderingCache = new WeakMap();
|
|
8189
|
+
const memoizedFlattenFieldsForRendering = (fields, registry) => {
|
|
8190
|
+
const cached = flattenForRenderingCache.get(fields);
|
|
8191
|
+
if (cached !== undefined) {
|
|
8192
|
+
return cached;
|
|
8193
|
+
}
|
|
8194
|
+
const result = flattenFields([...fields], registry, { preserveRows: true });
|
|
8195
|
+
flattenForRenderingCache.set(fields, result);
|
|
8196
|
+
return result;
|
|
8197
|
+
};
|
|
8198
|
+
const keyByCache = new WeakMap();
|
|
8199
|
+
const memoizedKeyBy = (fields) => {
|
|
8200
|
+
const cached = keyByCache.get(fields);
|
|
8201
|
+
if (cached !== undefined) {
|
|
8202
|
+
return cached;
|
|
8203
|
+
}
|
|
8204
|
+
const result = keyBy(fields, 'key');
|
|
8205
|
+
keyByCache.set(fields, result);
|
|
8206
|
+
return result;
|
|
8207
|
+
};
|
|
8208
|
+
const defaultValuesCache = new WeakMap();
|
|
8209
|
+
const memoizedDefaultValues = (fieldsById, registry) => {
|
|
8210
|
+
const cached = defaultValuesCache.get(fieldsById);
|
|
8211
|
+
if (cached !== undefined) {
|
|
8212
|
+
return cached;
|
|
8213
|
+
}
|
|
8214
|
+
const result = mapValues(fieldsById, (field) => getFieldDefaultValue(field, registry));
|
|
8215
|
+
defaultValuesCache.set(fieldsById, result);
|
|
8216
|
+
return result;
|
|
7784
8217
|
};
|
|
7785
|
-
const memoizedFlattenFields = memoize((fields, registry) => flattenFields([...fields], registry), {
|
|
7786
|
-
// registry.size is a valid cache key proxy because the field registry is populated
|
|
7787
|
-
// once at bootstrap and never mutated at runtime. If the registry were mutable,
|
|
7788
|
-
// we would need a content-based hash instead.
|
|
7789
|
-
resolver: createFieldsResolver(),
|
|
7790
|
-
maxSize: 10,
|
|
7791
|
-
});
|
|
7792
|
-
const memoizedFlattenFieldsForRendering = memoize((fields, registry) => flattenFields([...fields], registry, { preserveRows: true }), {
|
|
7793
|
-
// registry.size is a valid cache key proxy because the field registry is populated
|
|
7794
|
-
// once at bootstrap and never mutated at runtime. If the registry were mutable,
|
|
7795
|
-
// we would need a content-based hash instead.
|
|
7796
|
-
resolver: createFieldsResolver(true),
|
|
7797
|
-
maxSize: 10,
|
|
7798
|
-
});
|
|
7799
|
-
const memoizedKeyBy = memoize((fields) => keyBy(fields, 'key'), {
|
|
7800
|
-
resolver: (fields) => {
|
|
7801
|
-
let key = '';
|
|
7802
|
-
for (const f of fields) {
|
|
7803
|
-
key += f.key + '|';
|
|
7804
|
-
}
|
|
7805
|
-
return key;
|
|
7806
|
-
},
|
|
7807
|
-
maxSize: 10,
|
|
7808
|
-
});
|
|
7809
|
-
const memoizedDefaultValues = memoize((fieldsById, registry) => mapValues(fieldsById, (field) => getFieldDefaultValue(field, registry)), {
|
|
7810
|
-
// registry.size is a valid cache key proxy because the field registry is populated
|
|
7811
|
-
// once at bootstrap and never mutated at runtime.
|
|
7812
|
-
resolver: (fieldsById, registry) => {
|
|
7813
|
-
const keys = Object.keys(fieldsById).sort();
|
|
7814
|
-
return keys.join('|') + '|' + registry.size;
|
|
7815
|
-
},
|
|
7816
|
-
maxSize: 10,
|
|
7817
|
-
});
|
|
7818
8218
|
return { memoizedFlattenFields, memoizedFlattenFieldsForRendering, memoizedKeyBy, memoizedDefaultValues };
|
|
7819
8219
|
}
|
|
7820
8220
|
/**
|
|
@@ -8155,22 +8555,31 @@ const PLACEHOLDER_INDEX = '$';
|
|
|
8155
8555
|
/**
|
|
8156
8556
|
* Builds a property override store key for a field.
|
|
8157
8557
|
*
|
|
8158
|
-
* For non-array fields, the key is
|
|
8159
|
-
*
|
|
8160
|
-
*
|
|
8558
|
+
* For non-array fields, the key is the dot-joined group ancestors plus the
|
|
8559
|
+
* field's leaf key (e.g., `address.street` for `street` inside `address`).
|
|
8560
|
+
* For array item fields, the key includes the array path, either a concrete
|
|
8561
|
+
* index or the `$` placeholder, and any group ancestors INSIDE the array
|
|
8562
|
+
* item (e.g., `contacts.0.email`, `contacts.$.email`, or
|
|
8563
|
+
* `contacts.0.profile.email` for a group nested under each contact).
|
|
8564
|
+
*
|
|
8565
|
+
* `groupPath` matches the form-value path of nested groups (collector resets
|
|
8566
|
+
* it at array boundaries — see `property-derivation-collector.ts`), so the
|
|
8567
|
+
* resulting key always mirrors the field's location in `formValue`.
|
|
8161
8568
|
*
|
|
8162
8569
|
* @param arrayKey - The array field key (e.g., 'contacts'), or undefined for non-array fields
|
|
8163
8570
|
* @param index - The array item index, `PLACEHOLDER_INDEX` for the wildcard format, or undefined for non-array fields
|
|
8164
8571
|
* @param fieldKey - The leaf field key (e.g., 'email')
|
|
8165
|
-
* @
|
|
8572
|
+
* @param groupPath - Dot-joined group ancestors scoped to the current array item (or top-level), or undefined when not inside any group
|
|
8573
|
+
* @returns The store key (e.g., 'email', 'address.street', 'contacts.0.email', 'contacts.$.profile.email')
|
|
8166
8574
|
*
|
|
8167
8575
|
* @public
|
|
8168
8576
|
*/
|
|
8169
|
-
function buildPropertyOverrideKey(arrayKey, index, fieldKey) {
|
|
8577
|
+
function buildPropertyOverrideKey(arrayKey, index, fieldKey, groupPath) {
|
|
8578
|
+
const leafScope = groupPath ? `${groupPath}.${fieldKey}` : fieldKey;
|
|
8170
8579
|
if (arrayKey != null && index != null) {
|
|
8171
|
-
return `${arrayKey}.${index}.${
|
|
8580
|
+
return `${arrayKey}.${index}.${leafScope}`;
|
|
8172
8581
|
}
|
|
8173
|
-
return
|
|
8582
|
+
return leafScope;
|
|
8174
8583
|
}
|
|
8175
8584
|
|
|
8176
8585
|
/**
|
|
@@ -8251,6 +8660,25 @@ function applyIndexSuffix(inputs, index) {
|
|
|
8251
8660
|
key: `${key}_${index}`,
|
|
8252
8661
|
};
|
|
8253
8662
|
}
|
|
8663
|
+
/**
|
|
8664
|
+
* Prefixes the key with the underscored group ancestor path so that the same
|
|
8665
|
+
* leaf key inside different groups produces distinct DOM IDs (issue #401).
|
|
8666
|
+
* Form-schema keys remain clean — only the rendered `key` input is rewritten.
|
|
8667
|
+
*
|
|
8668
|
+
* Dots in the group path become underscores, then the original leaf key is
|
|
8669
|
+
* appended: `(groupPath='user.address', inputs.key='street')` →
|
|
8670
|
+
* `inputs.key = 'user_address_street'`.
|
|
8671
|
+
*/
|
|
8672
|
+
function applyGroupPrefix(inputs, groupPath) {
|
|
8673
|
+
const key = inputs['key'];
|
|
8674
|
+
if (typeof key !== 'string' || groupPath.length === 0) {
|
|
8675
|
+
return inputs;
|
|
8676
|
+
}
|
|
8677
|
+
return {
|
|
8678
|
+
...inputs,
|
|
8679
|
+
key: `${groupPath.replace(/\./g, '_')}_${key}`,
|
|
8680
|
+
};
|
|
8681
|
+
}
|
|
8254
8682
|
/**
|
|
8255
8683
|
* Main field mapper function that uses the field registry to get the appropriate mapper
|
|
8256
8684
|
* based on the field's type property.
|
|
@@ -8287,6 +8715,10 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
|
|
|
8287
8715
|
// Check if we're inside an array item context - if so, we need to suffix keys
|
|
8288
8716
|
// Optional because ARRAY_CONTEXT is only provided inside array item injectors
|
|
8289
8717
|
const arrayContext = inject(ARRAY_CONTEXT, { optional: true });
|
|
8718
|
+
// Check if we're inside a group context - if so, prefix the key with the group path
|
|
8719
|
+
// so overlapping leaf keys across groups produce distinct DOM IDs (issue #401).
|
|
8720
|
+
// Optional because GROUP_CONTEXT is only provided by GroupFieldComponent.
|
|
8721
|
+
const groupContext = inject(GROUP_CONTEXT, { optional: true });
|
|
8290
8722
|
// Inject the property override store for property derivation overrides
|
|
8291
8723
|
// Always available — provided at the DynamicForm component level via provideDynamicFormDI
|
|
8292
8724
|
const store = inject(PROPERTY_OVERRIDE_STORE);
|
|
@@ -8297,6 +8729,7 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
|
|
|
8297
8729
|
// Check that arrayContext exists and has a valid index signal (guards against mock injectors)
|
|
8298
8730
|
const indexSignal = arrayContext?.index;
|
|
8299
8731
|
const hasArrayContext = indexSignal !== undefined && isSignal(indexSignal);
|
|
8732
|
+
const hasGroupContext = groupContext != null;
|
|
8300
8733
|
// Fast-path check for property overrides: only fields whose definition declares
|
|
8301
8734
|
// a property-derivation enter the computed() wrapper. We inspect the FieldDef
|
|
8302
8735
|
// directly rather than asking the store, because the store's registration
|
|
@@ -8305,7 +8738,7 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
|
|
|
8305
8738
|
// collector apply the same inclusion rule, so the answers always agree.
|
|
8306
8739
|
const hasOverrides = fieldHasPropertyDerivations(fieldDef);
|
|
8307
8740
|
// Fast path: no transformations needed
|
|
8308
|
-
if (!hasPropsForwarding && !hasArrayContext && !hasOverrides) {
|
|
8741
|
+
if (!hasPropsForwarding && !hasArrayContext && !hasGroupContext && !hasOverrides) {
|
|
8309
8742
|
return mapperResult;
|
|
8310
8743
|
}
|
|
8311
8744
|
// Wrap in computed to apply transformations
|
|
@@ -8315,18 +8748,34 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
|
|
|
8315
8748
|
if (hasPropsForwarding) {
|
|
8316
8749
|
inputs = mergeForwardedPropsToMeta(inputs, propsToMeta);
|
|
8317
8750
|
}
|
|
8751
|
+
// Apply group prefix BEFORE array suffix so the resulting key reads as
|
|
8752
|
+
// `{groupPath}_{key}_{index}` (e.g. `address_street_0`) — matching the
|
|
8753
|
+
// form-value path order: group → leaf → array index.
|
|
8754
|
+
if (hasGroupContext) {
|
|
8755
|
+
// Non-null after the hasGroupContext guard.
|
|
8756
|
+
inputs = applyGroupPrefix(inputs, groupContext.groupPath());
|
|
8757
|
+
}
|
|
8318
8758
|
// Apply index suffix for array items
|
|
8319
8759
|
if (hasArrayContext) {
|
|
8320
8760
|
const index = indexSignal();
|
|
8321
8761
|
inputs = applyIndexSuffix(inputs, index);
|
|
8322
8762
|
}
|
|
8323
|
-
// Apply property overrides from the store (AFTER all static transformations)
|
|
8763
|
+
// Apply property overrides from the store (AFTER all static transformations).
|
|
8764
|
+
//
|
|
8765
|
+
// INVARIANT: the store key MUST be built from `fieldDef.key` + the group/array
|
|
8766
|
+
// ancestry (via buildPropertyOverrideKey) — NEVER from the DOM-scoped
|
|
8767
|
+
// `inputs['key']` produced by applyGroupPrefix/applyIndexSuffix above. The
|
|
8768
|
+
// collector (property-derivation-collector.ts) registers using the same
|
|
8769
|
+
// shape; if these drift, overrides silently miss their target.
|
|
8324
8770
|
if (hasOverrides) {
|
|
8325
|
-
// Build
|
|
8326
|
-
//
|
|
8771
|
+
// Build inside computed() so the index/group signals establish reactive deps.
|
|
8772
|
+
// GROUP_CONTEXT resets at array boundaries (see create-array-item-injector.ts),
|
|
8773
|
+
// so groupPath() represents ancestors inside the current array item (or
|
|
8774
|
+
// top-level groups when not in an array) — matching the collector.
|
|
8775
|
+
const groupPath = hasGroupContext ? groupContext.groupPath() : undefined;
|
|
8327
8776
|
const key = hasArrayContext
|
|
8328
|
-
? buildPropertyOverrideKey(arrayContext.arrayKey, indexSignal(), fieldDef.key)
|
|
8329
|
-
: fieldDef.key;
|
|
8777
|
+
? buildPropertyOverrideKey(arrayContext.arrayKey, indexSignal(), fieldDef.key, groupPath || undefined)
|
|
8778
|
+
: buildPropertyOverrideKey(undefined, undefined, fieldDef.key, groupPath || undefined);
|
|
8330
8779
|
const overrides = store.getOverrides(key)();
|
|
8331
8780
|
inputs = applyPropertyOverrides(inputs, overrides);
|
|
8332
8781
|
}
|
|
@@ -8423,6 +8872,11 @@ function resolveFieldSync(fieldDef, context) {
|
|
|
8423
8872
|
* Reconciles previous and current resolved fields to preserve injector instances
|
|
8424
8873
|
* for fields that haven't changed type, preventing unnecessary component recreation.
|
|
8425
8874
|
*
|
|
8875
|
+
* When a field's key, component, and injector all match the previous render but the
|
|
8876
|
+
* underlying `fieldDef` has changed (e.g. updated options/props), the existing
|
|
8877
|
+
* component/injector are preserved while the new `inputs` and `renderReady` signals
|
|
8878
|
+
* are adopted so prop updates flow through to the rendered component.
|
|
8879
|
+
*
|
|
8426
8880
|
* @param prev - Previous resolved fields array
|
|
8427
8881
|
* @param curr - Current resolved fields array
|
|
8428
8882
|
* @returns Reconciled fields with preserved injectors where applicable
|
|
@@ -8432,8 +8886,20 @@ function reconcileFields(prev, curr) {
|
|
|
8432
8886
|
return curr.map((field) => {
|
|
8433
8887
|
const existing = prevMap.get(field.key);
|
|
8434
8888
|
if (existing && existing.component === field.component && existing.injector === field.injector) {
|
|
8435
|
-
|
|
8436
|
-
|
|
8889
|
+
if (existing.fieldDef === field.fieldDef) {
|
|
8890
|
+
// Truly unchanged - preserve object identity for signal stability
|
|
8891
|
+
return existing;
|
|
8892
|
+
}
|
|
8893
|
+
// Same component instance + injector, but the field definition changed.
|
|
8894
|
+
// Preserve component identity (so ngComponentOutlet keeps the same instance)
|
|
8895
|
+
// while swapping in the freshly-mapped inputs/renderReady signals so prop
|
|
8896
|
+
// updates (e.g. select options) reach the component.
|
|
8897
|
+
return {
|
|
8898
|
+
...existing,
|
|
8899
|
+
fieldDef: field.fieldDef,
|
|
8900
|
+
inputs: field.inputs,
|
|
8901
|
+
renderReady: field.renderReady,
|
|
8902
|
+
};
|
|
8437
8903
|
}
|
|
8438
8904
|
// New field, type changed, or context changed (new injector) - use new field
|
|
8439
8905
|
return field;
|
|
@@ -8750,15 +9216,32 @@ function injectFieldRegistry() {
|
|
|
8750
9216
|
* Collects all field keys, types, and validates regex patterns from a field tree
|
|
8751
9217
|
* by recursively traversing containers (page, row, group, array).
|
|
8752
9218
|
*
|
|
8753
|
-
*
|
|
8754
|
-
*
|
|
8755
|
-
*
|
|
8756
|
-
*
|
|
9219
|
+
* Keys are scoped by their group ancestors (e.g. `address.city` instead of `city`),
|
|
9220
|
+
* so the same leaf key can appear inside different groups without conflict. Page and
|
|
9221
|
+
* row containers do not contribute to the path because they flatten their children
|
|
9222
|
+
* into the parent scope. Array items are excluded entirely from key collection
|
|
9223
|
+
* because they share template keys across items by design.
|
|
9224
|
+
*
|
|
9225
|
+
* Fields whose registered `valueHandling` is anything other than `include` (e.g.
|
|
9226
|
+
* buttons with `exclude`, page/row with `flatten`) are not collected: they don't
|
|
9227
|
+
* bind to a form-value path, so duplicate-key uniqueness isn't meaningful for them.
|
|
9228
|
+
*
|
|
9229
|
+
* @param collectKeys - When false, the entire subtree skips key collection (used
|
|
9230
|
+
* when descending into an array container).
|
|
8757
9231
|
*/
|
|
8758
|
-
function collectFieldData(fields, data, collectKeys = true) {
|
|
9232
|
+
function collectFieldData(fields, data, registry, pathPrefix, collectKeys = true) {
|
|
8759
9233
|
for (const field of fields) {
|
|
8760
|
-
|
|
8761
|
-
|
|
9234
|
+
// Defensively skip only known non-include modes — unexpected/missing
|
|
9235
|
+
// valueHandling defaults back to participating in duplicate detection so a
|
|
9236
|
+
// mis-registered field type doesn't silently drop from collision checks.
|
|
9237
|
+
const valueHandling = field.type ? getFieldValueHandling(field.type, registry) : 'include';
|
|
9238
|
+
const participatesInValue = valueHandling !== 'exclude' && valueHandling !== 'flatten';
|
|
9239
|
+
if (collectKeys && field.key && participatesInValue) {
|
|
9240
|
+
const scopedKey = pathPrefix ? `${pathPrefix}.${field.key}` : field.key;
|
|
9241
|
+
data.keys.push(scopedKey);
|
|
9242
|
+
// Project to the DOM-ID format (dots → underscores) so we can detect
|
|
9243
|
+
// collisions like top-level `'foo_bar'` vs group `'foo'` + leaf `'bar'`.
|
|
9244
|
+
data.domIds.push(scopedKey.replace(/\./g, '_'));
|
|
8762
9245
|
}
|
|
8763
9246
|
if (field.type) {
|
|
8764
9247
|
data.types.add(field.type);
|
|
@@ -8778,13 +9261,16 @@ function collectFieldData(fields, data, collectKeys = true) {
|
|
|
8778
9261
|
// Array item fields share template keys across items by design — stop collecting keys
|
|
8779
9262
|
// once inside an array. Types and regex patterns are still validated.
|
|
8780
9263
|
const childCollectKeys = collectKeys && field.type !== 'array';
|
|
9264
|
+
// Only group containers extend the scoping path — page/row flatten into the parent
|
|
9265
|
+
// scope, and array items use dynamic indices that aren't statically knowable.
|
|
9266
|
+
const childPrefix = isGroupField(field) && field.key ? (pathPrefix ? `${pathPrefix}.${field.key}` : field.key) : pathPrefix;
|
|
8781
9267
|
const children = normalizeFieldsArray(field.fields);
|
|
8782
9268
|
for (const child of children) {
|
|
8783
9269
|
if (Array.isArray(child)) {
|
|
8784
|
-
collectFieldData(child, data, childCollectKeys);
|
|
9270
|
+
collectFieldData(child, data, registry, childPrefix, childCollectKeys);
|
|
8785
9271
|
}
|
|
8786
9272
|
else {
|
|
8787
|
-
collectFieldData([child], data, childCollectKeys);
|
|
9273
|
+
collectFieldData([child], data, registry, childPrefix, childCollectKeys);
|
|
8788
9274
|
}
|
|
8789
9275
|
}
|
|
8790
9276
|
}
|
|
@@ -8820,7 +9306,38 @@ function validateNoDuplicateKeys(allKeys) {
|
|
|
8820
9306
|
const duplicateList = Array.from(duplicates)
|
|
8821
9307
|
.map((k) => `'${k}'`)
|
|
8822
9308
|
.join(', ');
|
|
8823
|
-
throw new DynamicFormError(`Duplicate field keys detected: ${duplicateList}.
|
|
9309
|
+
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).`);
|
|
9310
|
+
}
|
|
9311
|
+
}
|
|
9312
|
+
/**
|
|
9313
|
+
* Validates that the underscore-projected DOM-ID namespace has no collisions.
|
|
9314
|
+
*
|
|
9315
|
+
* DOM IDs are built by underscore-joining the group ancestor path with the
|
|
9316
|
+
* leaf key (e.g. `address_street`). Two scoped paths can land on the same
|
|
9317
|
+
* DOM ID even when their form-value paths differ — e.g. a top-level key
|
|
9318
|
+
* `'foo_bar'` and a leaf `'bar'` inside group `'foo'` both render as
|
|
9319
|
+
* `id="foo_bar"`. The form-value-path check accepts this (paths are
|
|
9320
|
+
* `'foo_bar'` vs `'foo.bar'` — distinct) but the rendered DOM would have
|
|
9321
|
+
* duplicate IDs, breaking aria associations and CSS selectors.
|
|
9322
|
+
*
|
|
9323
|
+
* @throws {DynamicFormError} When two scoped paths project to the same DOM ID
|
|
9324
|
+
*/
|
|
9325
|
+
function validateNoDomIdCollisions(domIds) {
|
|
9326
|
+
const seen = new Set();
|
|
9327
|
+
const duplicates = new Set();
|
|
9328
|
+
for (const id of domIds) {
|
|
9329
|
+
if (seen.has(id)) {
|
|
9330
|
+
duplicates.add(id);
|
|
9331
|
+
}
|
|
9332
|
+
seen.add(id);
|
|
9333
|
+
}
|
|
9334
|
+
if (duplicates.size > 0) {
|
|
9335
|
+
const duplicateList = Array.from(duplicates)
|
|
9336
|
+
.map((k) => `'${k}'`)
|
|
9337
|
+
.join(', ');
|
|
9338
|
+
throw new DynamicFormError(`DOM id collision detected for: ${duplicateList}. Group-nested keys are joined with '_' to form DOM ids ` +
|
|
9339
|
+
`(e.g. group 'foo' + leaf 'bar' renders as id='foo_bar'). A separate top-level key like 'foo_bar' or a different ` +
|
|
9340
|
+
`group/leaf pair that underscores to the same string will produce duplicate ids. Rename one of the colliding keys.`);
|
|
8824
9341
|
}
|
|
8825
9342
|
}
|
|
8826
9343
|
/**
|
|
@@ -8858,11 +9375,13 @@ function validateFieldTypesRegistered(allTypes, registry, logger) {
|
|
|
8858
9375
|
function validateFormConfig(fields, registry, logger) {
|
|
8859
9376
|
const data = {
|
|
8860
9377
|
keys: [],
|
|
9378
|
+
domIds: [],
|
|
8861
9379
|
types: new Set(),
|
|
8862
9380
|
regexErrors: [],
|
|
8863
9381
|
};
|
|
8864
|
-
collectFieldData(fields, data);
|
|
9382
|
+
collectFieldData(fields, data, registry, '');
|
|
8865
9383
|
validateNoDuplicateKeys(data.keys);
|
|
9384
|
+
validateNoDomIdCollisions(data.domIds);
|
|
8866
9385
|
validateFieldTypesRegistered(data.types, registry, logger);
|
|
8867
9386
|
if (data.regexErrors.length > 0) {
|
|
8868
9387
|
throw new DynamicFormError(data.regexErrors.length === 1
|
|
@@ -8916,18 +9435,22 @@ function resolveExclusionConfig(global, form, field) {
|
|
|
8916
9435
|
};
|
|
8917
9436
|
}
|
|
8918
9437
|
/**
|
|
8919
|
-
* Determines whether a field's value should be excluded based on its reactive state
|
|
9438
|
+
* Determines whether a field's value should be excluded based on its reactive state,
|
|
9439
|
+
* the field def's static state (for fields whose `state.hidden()` isn't wired — e.g. groups),
|
|
8920
9440
|
* and the resolved exclusion config.
|
|
8921
9441
|
*/
|
|
8922
|
-
function shouldExcludeField(fieldState, config) {
|
|
9442
|
+
function shouldExcludeField(field, fieldState, config) {
|
|
8923
9443
|
const state = fieldState();
|
|
8924
|
-
|
|
9444
|
+
const isHidden = state.hidden() || field.hidden === true;
|
|
9445
|
+
if (config.excludeValueIfHidden && isHidden) {
|
|
8925
9446
|
return true;
|
|
8926
9447
|
}
|
|
8927
|
-
|
|
9448
|
+
const isDisabled = state.disabled() || field.disabled === true;
|
|
9449
|
+
if (config.excludeValueIfDisabled && isDisabled) {
|
|
8928
9450
|
return true;
|
|
8929
9451
|
}
|
|
8930
|
-
|
|
9452
|
+
const isReadonly = state.readonly() || field.readonly === true;
|
|
9453
|
+
if (config.excludeValueIfReadonly && isReadonly) {
|
|
8931
9454
|
return true;
|
|
8932
9455
|
}
|
|
8933
9456
|
return false;
|
|
@@ -8959,14 +9482,6 @@ function filterFormValue(rawValue, schemaFields, formTree, registry, globalDefau
|
|
|
8959
9482
|
// If the key doesn't exist in the raw value, skip
|
|
8960
9483
|
if (!(key in rawValue))
|
|
8961
9484
|
continue;
|
|
8962
|
-
// Try to access the field state from the form tree
|
|
8963
|
-
const fieldState = formTree[key];
|
|
8964
|
-
// If no field state is available (e.g., componentless hidden fields),
|
|
8965
|
-
// include the value as-is since we can't determine reactive state
|
|
8966
|
-
if (!fieldState || typeof fieldState !== 'function') {
|
|
8967
|
-
result[key] = rawValue[key];
|
|
8968
|
-
continue;
|
|
8969
|
-
}
|
|
8970
9485
|
// Resolve per-field exclusion config
|
|
8971
9486
|
const fieldExclusion = {
|
|
8972
9487
|
excludeValueIfHidden: field.excludeValueIfHidden,
|
|
@@ -8974,8 +9489,19 @@ function filterFormValue(rawValue, schemaFields, formTree, registry, globalDefau
|
|
|
8974
9489
|
excludeValueIfReadonly: field.excludeValueIfReadonly,
|
|
8975
9490
|
};
|
|
8976
9491
|
const resolvedConfig = resolveExclusionConfig(globalDefaults, formOptions, fieldExclusion);
|
|
9492
|
+
// Componentless field (no FieldTree) — decide from the static field def alone, then include if not excluded.
|
|
9493
|
+
const fieldState = formTree[key];
|
|
9494
|
+
if (!fieldState || typeof fieldState !== 'function') {
|
|
9495
|
+
const staticallyExcluded = (resolvedConfig.excludeValueIfHidden && field.hidden === true) ||
|
|
9496
|
+
(resolvedConfig.excludeValueIfDisabled && field.disabled === true) ||
|
|
9497
|
+
(resolvedConfig.excludeValueIfReadonly && field.readonly === true);
|
|
9498
|
+
if (!staticallyExcluded) {
|
|
9499
|
+
result[key] = rawValue[key];
|
|
9500
|
+
}
|
|
9501
|
+
continue;
|
|
9502
|
+
}
|
|
8977
9503
|
// Check if this field should be excluded based on its state
|
|
8978
|
-
if (shouldExcludeField(fieldState, resolvedConfig)) {
|
|
9504
|
+
if (shouldExcludeField(field, fieldState, resolvedConfig)) {
|
|
8979
9505
|
continue;
|
|
8980
9506
|
}
|
|
8981
9507
|
// Handle group fields: recurse into nested structure
|
|
@@ -9470,6 +9996,77 @@ const FORM_STATE_DEPS = new InjectionToken('FORM_STATE_DEPS');
|
|
|
9470
9996
|
function asFieldTreeRecord(tree) {
|
|
9471
9997
|
return tree;
|
|
9472
9998
|
}
|
|
9999
|
+
/** All-false baseline for the outward two-way binding filter — see `boundFormValue`. */
|
|
10000
|
+
const BOUND_VALUE_EXCLUSION_BASELINE = {
|
|
10001
|
+
excludeValueIfHidden: false,
|
|
10002
|
+
excludeValueIfDisabled: false,
|
|
10003
|
+
excludeValueIfReadonly: false,
|
|
10004
|
+
};
|
|
10005
|
+
/** True when any of the three exclusion axes is asserted (i.e. the field could be filtered out of the bound model). */
|
|
10006
|
+
function isFieldExcluded(axes) {
|
|
10007
|
+
return !!axes && (axes.hidden || axes.disabled || axes.readonly);
|
|
10008
|
+
}
|
|
10009
|
+
/**
|
|
10010
|
+
* Recursively populates `out` with `dottedPath → exclusion axes` for every keyed field reachable
|
|
10011
|
+
* from `fields`, walking into groups so nested-leaf transitions can be detected. Arrays are
|
|
10012
|
+
* treated as leaves (whole-array filtering only — see value-filter v1 limitation).
|
|
10013
|
+
*/
|
|
10014
|
+
function collectFieldStateSnapshot(fields, treeRecord, pathParts, out) {
|
|
10015
|
+
for (const field of fields) {
|
|
10016
|
+
const key = field.key;
|
|
10017
|
+
if (!key)
|
|
10018
|
+
continue;
|
|
10019
|
+
const subtree = treeRecord[key];
|
|
10020
|
+
if (!subtree || typeof subtree !== 'function')
|
|
10021
|
+
continue;
|
|
10022
|
+
const state = subtree();
|
|
10023
|
+
const path = [...pathParts, key];
|
|
10024
|
+
out[path.join('.')] = { hidden: state.hidden(), disabled: state.disabled(), readonly: state.readonly() };
|
|
10025
|
+
if (isGroupField(field) && field.fields) {
|
|
10026
|
+
collectFieldStateSnapshot(field.fields, asFieldTreeRecord(subtree), path, out);
|
|
10027
|
+
}
|
|
10028
|
+
}
|
|
10029
|
+
}
|
|
10030
|
+
/**
|
|
10031
|
+
* Reads a value from a nested object by dotted-path segments. Returns undefined
|
|
10032
|
+
* if any intermediate segment is missing or non-object.
|
|
10033
|
+
*/
|
|
10034
|
+
function getValueAtPath(source, segments) {
|
|
10035
|
+
let current = source;
|
|
10036
|
+
for (const segment of segments) {
|
|
10037
|
+
if (current === null || typeof current !== 'object')
|
|
10038
|
+
return undefined;
|
|
10039
|
+
current = current[segment];
|
|
10040
|
+
}
|
|
10041
|
+
return current;
|
|
10042
|
+
}
|
|
10043
|
+
/**
|
|
10044
|
+
* Whether a value is worth capturing into the saved-hidden-value store. `NaN` (the number-input
|
|
10045
|
+
* default) is excluded — restoring it would re-leak the default into the bound model when the
|
|
10046
|
+
* field becomes visible again, defeating the bug fix.
|
|
10047
|
+
*/
|
|
10048
|
+
function isRestoreRelevant(value) {
|
|
10049
|
+
return !(typeof value === 'number' && Number.isNaN(value));
|
|
10050
|
+
}
|
|
10051
|
+
/**
|
|
10052
|
+
* Returns a new object with `value` set at the path described by `segments`.
|
|
10053
|
+
* Intermediate objects are cloned shallowly to preserve immutability of `target`.
|
|
10054
|
+
*/
|
|
10055
|
+
function setValueAtPath(target, segments, value) {
|
|
10056
|
+
if (segments.length === 0)
|
|
10057
|
+
return target;
|
|
10058
|
+
const [head, ...rest] = segments;
|
|
10059
|
+
const next = { ...target };
|
|
10060
|
+
if (rest.length === 0) {
|
|
10061
|
+
next[head] = value;
|
|
10062
|
+
}
|
|
10063
|
+
else {
|
|
10064
|
+
const child = target[head];
|
|
10065
|
+
const childObj = child !== null && typeof child === 'object' ? child : {};
|
|
10066
|
+
next[head] = setValueAtPath(childObj, rest, value);
|
|
10067
|
+
}
|
|
10068
|
+
return next;
|
|
10069
|
+
}
|
|
9473
10070
|
/**
|
|
9474
10071
|
* Central service that manages all form state and coordinates the form lifecycle.
|
|
9475
10072
|
* Single source of truth for lifecycle state, field resolution, form signals, and events.
|
|
@@ -9641,6 +10238,29 @@ class FormStateManager {
|
|
|
9641
10238
|
return undefined;
|
|
9642
10239
|
return new Set(schemaFields.map((f) => f.key).filter((key) => key !== undefined));
|
|
9643
10240
|
}, ...(ngDevMode ? [{ debugName: "validKeys" }] : /* istanbul ignore next */ []));
|
|
10241
|
+
/**
|
|
10242
|
+
* Per-field exclusion-axis state (hidden/disabled/readonly) keyed by dotted path. Drives the
|
|
10243
|
+
* save-on-exclude effect's transition detection. Walks recursively into groups so nested-leaf
|
|
10244
|
+
* transitions are captured. Arrays are treated as leaves (whole-array preservation only).
|
|
10245
|
+
*/
|
|
10246
|
+
fieldStateSnapshot = computed(() => {
|
|
10247
|
+
const setup = this.formSetup();
|
|
10248
|
+
if (!setup.schemaFields || setup.schemaFields.length === 0)
|
|
10249
|
+
return {};
|
|
10250
|
+
const snapshot = {};
|
|
10251
|
+
collectFieldStateSnapshot(setup.schemaFields, asFieldTreeRecord(this.form()), [], snapshot);
|
|
10252
|
+
return snapshot;
|
|
10253
|
+
}, ...(ngDevMode ? [{ debugName: "fieldStateSnapshot" }] : /* istanbul ignore next */ []));
|
|
10254
|
+
/**
|
|
10255
|
+
* Captured values for fields actively excluded from the bound model — either via
|
|
10256
|
+
* `excludeValueIfHidden`, `excludeValueIfDisabled`, or `excludeValueIfReadonly`. Restored when
|
|
10257
|
+
* the field becomes re-included (visible/enabled/editable). The save effect only writes here
|
|
10258
|
+
* when the field is being filtered out by `boundFormValue` — fields without explicit opt-in
|
|
10259
|
+
* never enter the store.
|
|
10260
|
+
*/
|
|
10261
|
+
excludedValueStore = signal({}, ...(ngDevMode ? [{ debugName: "excludedValueStore" }] : /* istanbul ignore next */ []));
|
|
10262
|
+
/** Tracks per-path exclusion state across save-effect runs to detect newly-excluded fields. */
|
|
10263
|
+
prevFieldStateSnapshot = signal({}, ...(ngDevMode ? [{ debugName: "prevFieldStateSnapshot" }] : /* istanbul ignore next */ []));
|
|
9644
10264
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
9645
10265
|
// Computed Signals - Entity & Form
|
|
9646
10266
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -9664,7 +10284,13 @@ class FormStateManager {
|
|
|
9664
10284
|
const inputValue = this.deps.value();
|
|
9665
10285
|
const defaults = this.defaultValues();
|
|
9666
10286
|
const keys = this.validKeys();
|
|
9667
|
-
const
|
|
10287
|
+
const saved = this.excludedValueStore();
|
|
10288
|
+
// Deep-merge so a partial nested object in `inputValue` (e.g. a group
|
|
10289
|
+
// value missing one of its declared sub-field keys) does not orphan
|
|
10290
|
+
// the absent sub-field in the Signal Forms validation graph.
|
|
10291
|
+
// Layer order: defaults < saved (excluded-field restorations) < inputValue.
|
|
10292
|
+
const withSaved = deepMergeDefaults(defaults, saved);
|
|
10293
|
+
const combined = deepMergeDefaults(withSaved, inputValue);
|
|
9668
10294
|
if (keys) {
|
|
9669
10295
|
const filtered = {};
|
|
9670
10296
|
for (const key of Object.keys(combined)) {
|
|
@@ -9694,6 +10320,7 @@ class FormStateManager {
|
|
|
9694
10320
|
return createSchemaFromFields(setup.schemaFields, setup.registry, {
|
|
9695
10321
|
crossFieldValidators: crossFieldCollection.validators,
|
|
9696
10322
|
formLevelSchema: config.schema,
|
|
10323
|
+
validateWhenHidden: this.effectiveFormOptions().validateWhenHidden,
|
|
9697
10324
|
});
|
|
9698
10325
|
}
|
|
9699
10326
|
if (config.schema) {
|
|
@@ -9773,6 +10400,35 @@ class FormStateManager {
|
|
|
9773
10400
|
const fieldTreeRecord = asFieldTreeRecord(formTree);
|
|
9774
10401
|
return filterFormValue(rawValue, setup.schemaFields, fieldTreeRecord, setup.registry, this.valueExclusionDefaults, formOptions);
|
|
9775
10402
|
}, ...(ngDevMode ? [{ debugName: "filteredFormValue" }] : /* istanbul ignore next */ []));
|
|
10403
|
+
/**
|
|
10404
|
+
* Form value used for the outward two-way binding sync.
|
|
10405
|
+
*
|
|
10406
|
+
* Only honors *explicit* form-level and field-level `excludeValueIf*` settings — global
|
|
10407
|
+
* defaults (`VALUE_EXCLUSION_DEFAULTS`) are intentionally ignored here. Rationale:
|
|
10408
|
+
* - Original V1 behavior: the bound model carries all values regardless of visibility.
|
|
10409
|
+
* - Bug fix: when a user explicitly sets `excludeValueIfHidden: true` on a field/form,
|
|
10410
|
+
* they expect that value to disappear from the bound model when hidden (e.g. NaN for
|
|
10411
|
+
* number inputs, see issue #394).
|
|
10412
|
+
* - Without explicit opt-in, the bound model keeps the V1 contract.
|
|
10413
|
+
* `filteredFormValue` (above) still applies the full Field > Form > Global hierarchy
|
|
10414
|
+
* for the submission output.
|
|
10415
|
+
*/
|
|
10416
|
+
boundFormValue = computed(() => {
|
|
10417
|
+
const rawValue = this.formValue();
|
|
10418
|
+
const setup = this.formSetup();
|
|
10419
|
+
const options = this.effectiveFormOptions();
|
|
10420
|
+
if (!setup.schemaFields || setup.schemaFields.length === 0) {
|
|
10421
|
+
return rawValue;
|
|
10422
|
+
}
|
|
10423
|
+
const formTree = this.form();
|
|
10424
|
+
const formOptions = {
|
|
10425
|
+
excludeValueIfHidden: options.excludeValueIfHidden,
|
|
10426
|
+
excludeValueIfDisabled: options.excludeValueIfDisabled,
|
|
10427
|
+
excludeValueIfReadonly: options.excludeValueIfReadonly,
|
|
10428
|
+
};
|
|
10429
|
+
const fieldTreeRecord = asFieldTreeRecord(formTree);
|
|
10430
|
+
return filterFormValue(rawValue, setup.schemaFields, fieldTreeRecord, setup.registry, BOUND_VALUE_EXCLUSION_BASELINE, formOptions);
|
|
10431
|
+
}, ...(ngDevMode ? [{ debugName: "boundFormValue" }] : /* istanbul ignore next */ []));
|
|
9776
10432
|
/** Whether the form is currently valid. */
|
|
9777
10433
|
valid = computed(() => this.formInstance().valid(), ...(ngDevMode ? [{ debugName: "valid" }] : /* istanbul ignore next */ []));
|
|
9778
10434
|
/** Whether the form is currently invalid. */
|
|
@@ -9797,7 +10453,12 @@ class FormStateManager {
|
|
|
9797
10453
|
/**
|
|
9798
10454
|
* Phase-aware field source that coordinates component lifecycle with form updates.
|
|
9799
10455
|
* Teardown/Applying → intersection of old/new fields; Restoring → all new fields.
|
|
9800
|
-
*
|
|
10456
|
+
*
|
|
10457
|
+
* Uses per-field reference equality to suppress spurious emissions when the same
|
|
10458
|
+
* field definitions are returned (e.g. memoized intersections during transitions)
|
|
10459
|
+
* while still triggering re-resolution when the underlying field definition
|
|
10460
|
+
* references change (e.g. a config swap that updates options/props for a
|
|
10461
|
+
* same-keyed field).
|
|
9801
10462
|
*/
|
|
9802
10463
|
fieldsSource = computed(() => {
|
|
9803
10464
|
const state = this.machine.state();
|
|
@@ -9818,7 +10479,7 @@ class FormStateManager {
|
|
|
9818
10479
|
return true;
|
|
9819
10480
|
if (a.length !== b.length)
|
|
9820
10481
|
return false;
|
|
9821
|
-
return a.every((field, i) => field
|
|
10482
|
+
return a.every((field, i) => field === b[i]);
|
|
9822
10483
|
} });
|
|
9823
10484
|
/**
|
|
9824
10485
|
* Injector for field components with FIELD_SIGNAL_CONTEXT.
|
|
@@ -9984,6 +10645,7 @@ class FormStateManager {
|
|
|
9984
10645
|
* Resets the form to default values.
|
|
9985
10646
|
*/
|
|
9986
10647
|
reset() {
|
|
10648
|
+
this.excludedValueStore.set({});
|
|
9987
10649
|
const defaults = this.defaultValues();
|
|
9988
10650
|
this.form()().value.set(defaults);
|
|
9989
10651
|
this.deps.value.set(defaults);
|
|
@@ -9992,6 +10654,7 @@ class FormStateManager {
|
|
|
9992
10654
|
* Clears the form to empty state.
|
|
9993
10655
|
*/
|
|
9994
10656
|
clear() {
|
|
10657
|
+
this.excludedValueStore.set({});
|
|
9995
10658
|
const emptyValue = {};
|
|
9996
10659
|
this.form()().value.set(emptyValue);
|
|
9997
10660
|
this.deps.value.set(emptyValue);
|
|
@@ -10016,12 +10679,49 @@ class FormStateManager {
|
|
|
10016
10679
|
this.machine.dispatch({ type: Action.ConfigChange, config });
|
|
10017
10680
|
}
|
|
10018
10681
|
});
|
|
10019
|
-
//
|
|
10020
|
-
//
|
|
10021
|
-
|
|
10682
|
+
// Save-on-exclude: capture entity values for fields that boundFormValue is actively
|
|
10683
|
+
// filtering out via any of the three axes (hidden / disabled / readonly), so they can be
|
|
10684
|
+
// restored when the field becomes re-included. The transition fires once per "no axis
|
|
10685
|
+
// asserted → at least one axis asserted" edge; subsequent axis changes while still excluded
|
|
10686
|
+
// are no-ops. Comparing entity vs bound at the path limits the store to fields that are
|
|
10687
|
+
// actually being filtered — fields without explicit opt-in never enter the store and never
|
|
10688
|
+
// participate in the entity merge. `NaN` values are skipped: they're the number-input default
|
|
10689
|
+
// and would re-leak via the merge once the field becomes re-included.
|
|
10690
|
+
explicitEffect([this.fieldStateSnapshot], ([snapshot]) => {
|
|
10691
|
+
const currentSaved = this.excludedValueStore();
|
|
10692
|
+
const currentEntity = this.entity();
|
|
10693
|
+
const boundValue = this.boundFormValue();
|
|
10694
|
+
const prev = this.prevFieldStateSnapshot();
|
|
10695
|
+
let newSaved = currentSaved;
|
|
10696
|
+
for (const [path, axes] of Object.entries(snapshot)) {
|
|
10697
|
+
if (isFieldExcluded(axes) && !isFieldExcluded(prev[path])) {
|
|
10698
|
+
const segments = path.split('.');
|
|
10699
|
+
const inEntity = getValueAtPath(currentEntity, segments);
|
|
10700
|
+
const inBound = getValueAtPath(boundValue, segments);
|
|
10701
|
+
if (inEntity !== undefined && inBound === undefined && isRestoreRelevant(inEntity)) {
|
|
10702
|
+
newSaved = setValueAtPath(newSaved, segments, inEntity);
|
|
10703
|
+
}
|
|
10704
|
+
}
|
|
10705
|
+
}
|
|
10706
|
+
this.prevFieldStateSnapshot.set({ ...snapshot });
|
|
10707
|
+
if (newSaved !== currentSaved && !isEqual(newSaved, currentSaved)) {
|
|
10708
|
+
this.excludedValueStore.set(newSaved);
|
|
10709
|
+
}
|
|
10710
|
+
});
|
|
10711
|
+
// Schema change resets the saved store + transition tracker so values from a stale schema
|
|
10712
|
+
// can't leak into the new one.
|
|
10713
|
+
explicitEffect([this.formSetup], () => {
|
|
10714
|
+
this.prevFieldStateSnapshot.set({});
|
|
10715
|
+
if (Object.keys(this.excludedValueStore()).length > 0) {
|
|
10716
|
+
this.excludedValueStore.set({});
|
|
10717
|
+
}
|
|
10718
|
+
});
|
|
10719
|
+
// Outward sync uses boundFormValue (honors only explicit excludeValueIf* opt-ins) so
|
|
10720
|
+
// global defaults that target submission output don't reshape the host's bound model.
|
|
10721
|
+
explicitEffect([this.boundFormValue], ([currentBound]) => {
|
|
10022
10722
|
const currentValue = this.deps.value();
|
|
10023
|
-
if (!isEqual(
|
|
10024
|
-
this.deps.value.set(
|
|
10723
|
+
if (!isEqual(currentBound, currentValue)) {
|
|
10724
|
+
this.deps.value.set(currentBound);
|
|
10025
10725
|
}
|
|
10026
10726
|
});
|
|
10027
10727
|
explicitEffect([this.formConfigValidation], ([validation]) => {
|
|
@@ -10526,15 +11226,18 @@ function computeValueFromEntry(entry, evalContext, options) {
|
|
|
10526
11226
|
if (entry.expression) {
|
|
10527
11227
|
return ExpressionParser.evaluate(entry.expression, evalContext);
|
|
10528
11228
|
}
|
|
10529
|
-
if (entry.functionName) {
|
|
10530
|
-
|
|
11229
|
+
if (entry.fn || entry.functionName) {
|
|
11230
|
+
if (entry.fn && entry.functionName) {
|
|
11231
|
+
evalContext.logger.warn(`Both "fn" and "functionName" are set on derivation for '${options.subject}'. Inline "fn" takes precedence; "functionName" is ignored.`);
|
|
11232
|
+
}
|
|
11233
|
+
const fn = entry.fn ?? (entry.functionName ? options.derivationFunctions?.[entry.functionName] : undefined);
|
|
10531
11234
|
if (!fn) {
|
|
10532
11235
|
const kind = options.functionKind ?? 'Derivation function';
|
|
10533
11236
|
throw new DynamicFormError(`${kind} '${entry.functionName}' not found in customFnConfig.derivations`);
|
|
10534
11237
|
}
|
|
10535
11238
|
return fn(evalContext);
|
|
10536
11239
|
}
|
|
10537
|
-
throw new DynamicFormError(`Derivation for ${options.subject} has no value source. Specify 'value', 'expression', or '
|
|
11240
|
+
throw new DynamicFormError(`Derivation for ${options.subject} has no value source. Specify 'value', 'expression', 'functionName', or 'fn'.`);
|
|
10538
11241
|
}
|
|
10539
11242
|
|
|
10540
11243
|
/**
|
|
@@ -10937,7 +11640,7 @@ function getEntriesForChangedFields$1(entries, changedFields) {
|
|
|
10937
11640
|
function tryApplyDerivation(entry, context, chainContext) {
|
|
10938
11641
|
const { derivationLogger } = context;
|
|
10939
11642
|
// HTTP and async entries are processed asynchronously in their own streams — skip in sync loop
|
|
10940
|
-
if (entry.http || entry.asyncFunctionName) {
|
|
11643
|
+
if (entry.http || entry.asyncFunctionName || entry.asyncFn) {
|
|
10941
11644
|
return { applied: false, fieldKey: entry.fieldKey };
|
|
10942
11645
|
}
|
|
10943
11646
|
// Check if this is an array field derivation (has '$' placeholder)
|
|
@@ -11244,9 +11947,11 @@ function formatDerivationError(entry, phase, message) {
|
|
|
11244
11947
|
? `expression: "${entry.expression.substring(0, 50)}${entry.expression.length > 50 ? '...' : ''}"`
|
|
11245
11948
|
: entry.functionName
|
|
11246
11949
|
? `function: "${entry.functionName}"`
|
|
11247
|
-
: entry.
|
|
11248
|
-
? '
|
|
11249
|
-
:
|
|
11950
|
+
: entry.fn
|
|
11951
|
+
? 'inline fn'
|
|
11952
|
+
: entry.value !== undefined
|
|
11953
|
+
? 'static value'
|
|
11954
|
+
: 'unknown source';
|
|
11250
11955
|
return `${ERROR_PREFIX$1} Failed to ${phase} derivation (field: ${entry.fieldKey}, ${source}): ${message}`;
|
|
11251
11956
|
}
|
|
11252
11957
|
/**
|
|
@@ -11315,7 +12020,7 @@ function shouldSkipForUserOverride(entry, resolvedFieldKey, context, chainContex
|
|
|
11315
12020
|
function applyDerivationsForTrigger(collection, trigger, context, changedFields) {
|
|
11316
12021
|
// Filter entries by trigger type, excluding HTTP and async entries (processed in async streams)
|
|
11317
12022
|
const filteredEntries = collection.entries.filter((entry) => {
|
|
11318
|
-
if (entry.http || entry.asyncFunctionName)
|
|
12023
|
+
if (entry.http || entry.asyncFunctionName || entry.asyncFn)
|
|
11319
12024
|
return false;
|
|
11320
12025
|
if (trigger === 'onChange') {
|
|
11321
12026
|
return !entry.trigger || entry.trigger === 'onChange';
|
|
@@ -11484,7 +12189,7 @@ function topologicalSort(entries) {
|
|
|
11484
12189
|
* Combines:
|
|
11485
12190
|
* - Explicit `dependsOn` (when provided, it takes precedence over auto-detection)
|
|
11486
12191
|
* - Expression-detected dependencies (only used when `dependsOn` is empty)
|
|
11487
|
-
* - `'*'` wildcard for `functionName` (only when `dependsOn` is empty)
|
|
12192
|
+
* - `'*'` wildcard for `functionName` / inline `fn` (only when `dependsOn` is empty)
|
|
11488
12193
|
* - Condition expression dependencies (always included)
|
|
11489
12194
|
*
|
|
11490
12195
|
* @internal
|
|
@@ -11498,7 +12203,9 @@ function extractDependenciesFromConfig(config) {
|
|
|
11498
12203
|
if (config.expression) {
|
|
11499
12204
|
extractStringDependencies(config.expression).forEach((dep) => deps.add(dep));
|
|
11500
12205
|
}
|
|
11501
|
-
|
|
12206
|
+
// Inline `fn` mirrors `functionName` semantics: without explicit dependsOn,
|
|
12207
|
+
// assume the function may depend on anything in the form.
|
|
12208
|
+
if (config.functionName || config.fn) {
|
|
11502
12209
|
deps.add('*');
|
|
11503
12210
|
}
|
|
11504
12211
|
}
|
|
@@ -11508,6 +12215,32 @@ function extractDependenciesFromConfig(config) {
|
|
|
11508
12215
|
return Array.from(deps);
|
|
11509
12216
|
}
|
|
11510
12217
|
|
|
12218
|
+
/**
|
|
12219
|
+
* Reads the optional inline `fn` property off a derivation config.
|
|
12220
|
+
*
|
|
12221
|
+
* `fn` only appears on the function-mode branch of the discriminated union, so
|
|
12222
|
+
* call sites that need to copy it through unconditionally must widen via cast.
|
|
12223
|
+
* This helper centralises the cast and keeps it read-only — no widening, no
|
|
12224
|
+
* mutation. Returns `undefined` for any non-function-mode variant.
|
|
12225
|
+
*
|
|
12226
|
+
* @internal
|
|
12227
|
+
*/
|
|
12228
|
+
function extractInlineFn(config) {
|
|
12229
|
+
return config.fn;
|
|
12230
|
+
}
|
|
12231
|
+
/**
|
|
12232
|
+
* Reads the optional inline `asyncFn` property off a derivation config.
|
|
12233
|
+
*
|
|
12234
|
+
* Same rationale as {@link extractInlineFn}: `asyncFn` only appears on the
|
|
12235
|
+
* `source: 'asyncFunction'` branch, so collectors that copy it through need a
|
|
12236
|
+
* cast. Read-only access, no widening.
|
|
12237
|
+
*
|
|
12238
|
+
* @internal
|
|
12239
|
+
*/
|
|
12240
|
+
function extractInlineAsyncFn(config) {
|
|
12241
|
+
return config.asyncFn;
|
|
12242
|
+
}
|
|
12243
|
+
|
|
11511
12244
|
/**
|
|
11512
12245
|
* Recursively walks a field-definition tree, invoking `visitor` for each field.
|
|
11513
12246
|
*
|
|
@@ -11850,9 +12583,11 @@ function createLogicEntry(fieldKey, config, context) {
|
|
|
11850
12583
|
value: config.value,
|
|
11851
12584
|
expression: config.expression,
|
|
11852
12585
|
functionName: config.functionName,
|
|
12586
|
+
fn: extractInlineFn(config),
|
|
11853
12587
|
http: config.http,
|
|
11854
12588
|
responseExpression: config.responseExpression,
|
|
11855
12589
|
asyncFunctionName: config.asyncFunctionName,
|
|
12590
|
+
asyncFn: extractInlineAsyncFn(config),
|
|
11856
12591
|
trigger,
|
|
11857
12592
|
debounceMs,
|
|
11858
12593
|
isShorthand: false,
|
|
@@ -12401,9 +13136,16 @@ function createAsyncDerivationStream(entry, formValue$, context) {
|
|
|
12401
13136
|
`Array async derivations will be supported in a future release.`);
|
|
12402
13137
|
return EMPTY;
|
|
12403
13138
|
}
|
|
12404
|
-
if (!entry.asyncFunctionName) {
|
|
13139
|
+
if (!entry.asyncFunctionName && !entry.asyncFn) {
|
|
13140
|
+
context.logger.warn(`${LOG_PREFIX} Derivation for '${entry.fieldKey}' has neither "asyncFunctionName" nor "asyncFn". ` +
|
|
13141
|
+
`One of the two is required for source: 'asyncFunction'.`);
|
|
12405
13142
|
return EMPTY;
|
|
12406
13143
|
}
|
|
13144
|
+
if (entry.asyncFn && entry.asyncFunctionName) {
|
|
13145
|
+
context.logger.warn(`${LOG_PREFIX} Both "asyncFn" and "asyncFunctionName" are set on derivation for '${entry.fieldKey}'. ` +
|
|
13146
|
+
`Inline "asyncFn" takes precedence; "asyncFunctionName" is ignored.`);
|
|
13147
|
+
}
|
|
13148
|
+
const inlineAsyncFn = entry.asyncFn;
|
|
12407
13149
|
const asyncFunctionName = entry.asyncFunctionName;
|
|
12408
13150
|
const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS;
|
|
12409
13151
|
// startWith(null) → pairwise() causes all fields to appear "changed" on the
|
|
@@ -12449,8 +13191,8 @@ function createAsyncDerivationStream(entry, formValue$, context) {
|
|
|
12449
13191
|
const customFunctions = context.customFunctions?.();
|
|
12450
13192
|
const asyncDerivationFunctions = context.asyncDerivationFunctions?.();
|
|
12451
13193
|
const externalData = context.externalData?.();
|
|
12452
|
-
//
|
|
12453
|
-
const asyncFn = asyncDerivationFunctions?.[asyncFunctionName];
|
|
13194
|
+
// Inline `asyncFn` wins; otherwise look up the registered function.
|
|
13195
|
+
const asyncFn = inlineAsyncFn ?? (asyncFunctionName ? asyncDerivationFunctions?.[asyncFunctionName] : undefined);
|
|
12454
13196
|
if (!asyncFn) {
|
|
12455
13197
|
context.logger.warn(`${LOG_PREFIX} Async derivation function '${asyncFunctionName}' not found for field '${entry.fieldKey}'. ` +
|
|
12456
13198
|
`Register it in customFnConfig.asyncDerivations.`);
|
|
@@ -12878,7 +13620,7 @@ class DerivationOrchestrator {
|
|
|
12878
13620
|
toObservable(this.derivationCollection, { injector: this.injector })
|
|
12879
13621
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
12880
13622
|
.subscribe((collection) => {
|
|
12881
|
-
const asyncEntries = collection?.entries.filter((entry) => entry.asyncFunctionName) ?? [];
|
|
13623
|
+
const asyncEntries = collection?.entries.filter((entry) => entry.asyncFunctionName || entry.asyncFn) ?? [];
|
|
12882
13624
|
if (asyncEntries.length === 0) {
|
|
12883
13625
|
this.teardownAsyncFunctionStreams();
|
|
12884
13626
|
this.lastAsyncEntryKeys = null;
|
|
@@ -12925,9 +13667,14 @@ class DerivationOrchestrator {
|
|
|
12925
13667
|
* Used for smart teardown comparison.
|
|
12926
13668
|
*/
|
|
12927
13669
|
computeAsyncEntryKeys(entries) {
|
|
13670
|
+
// For inline `asyncFn` entries we tag the key with the field path (the real
|
|
13671
|
+
// identity, since derivations are self-targeting) rather than the array
|
|
13672
|
+
// index. Index-based tagging churned identities whenever topologicalSort
|
|
13673
|
+
// reordered entries, causing unnecessary teardown + re-subscribe.
|
|
12928
13674
|
return new Set(entries.map((entry) => {
|
|
12929
13675
|
const config = {
|
|
12930
13676
|
asyncFunctionName: entry.asyncFunctionName,
|
|
13677
|
+
asyncFnId: entry.asyncFn ? `inline:${entry.fieldKey}` : undefined,
|
|
12931
13678
|
dependsOn: entry.dependsOn,
|
|
12932
13679
|
debounceMs: entry.debounceMs,
|
|
12933
13680
|
stopOnUserOverride: entry.stopOnUserOverride,
|
|
@@ -12976,10 +13723,11 @@ function warnAboutWildcardDependencies$1(logger, entries, fieldCount) {
|
|
|
12976
13723
|
// HTTP and async entries require explicit dependsOn (validated at collection time)
|
|
12977
13724
|
const implicitWildcards = wildcardEntries.filter((entry) => !entry.http &&
|
|
12978
13725
|
!entry.asyncFunctionName &&
|
|
12979
|
-
entry.
|
|
13726
|
+
!entry.asyncFn &&
|
|
13727
|
+
(entry.functionName || entry.fn) &&
|
|
12980
13728
|
(!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
|
|
12981
13729
|
if (implicitWildcards.length > 0) {
|
|
12982
|
-
const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName})`);
|
|
13730
|
+
const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName ?? '<inline fn>'})`);
|
|
12983
13731
|
logger.warn('Derivation: custom functions without explicit dependsOn detected. ' +
|
|
12984
13732
|
`These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
|
|
12985
13733
|
'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
|
|
@@ -13462,8 +14210,16 @@ function collectPropertyDerivations(fields, logger, tracker) {
|
|
|
13462
14210
|
const entries = [];
|
|
13463
14211
|
const context = { logger, tracker };
|
|
13464
14212
|
traverseFieldsWithContext(fields, context, (field, ctx) => collectFromField(field, entries, ctx), {
|
|
13465
|
-
|
|
13466
|
-
//
|
|
14213
|
+
// Array boundary: each item is its own model root. Reset groupPath so
|
|
14214
|
+
// ancestor groups OUTSIDE the array don't bleed into per-item keys.
|
|
14215
|
+
onArrayChild: (_, field) => ({ arrayPath: field.key, groupPath: undefined }),
|
|
14216
|
+
onGroupChild: (parent, field) => {
|
|
14217
|
+
// Keyless groups (rare) act like layout containers — pass through.
|
|
14218
|
+
if (!field.key)
|
|
14219
|
+
return {};
|
|
14220
|
+
return { groupPath: parent.groupPath ? `${parent.groupPath}.${field.key}` : field.key };
|
|
14221
|
+
},
|
|
14222
|
+
// Layout containers (page, row, container) leave context unchanged.
|
|
13467
14223
|
});
|
|
13468
14224
|
return { entries };
|
|
13469
14225
|
}
|
|
@@ -13495,7 +14251,7 @@ function collectFromField(field, entries, context) {
|
|
|
13495
14251
|
* @internal
|
|
13496
14252
|
*/
|
|
13497
14253
|
function createPropertyDerivationEntryFromDerivation(fieldKey, config, context) {
|
|
13498
|
-
const effectiveFieldKey = buildPropertyOverrideKey(context.arrayPath, context.arrayPath ? PLACEHOLDER_INDEX : undefined, fieldKey);
|
|
14254
|
+
const effectiveFieldKey = buildPropertyOverrideKey(context.arrayPath, context.arrayPath ? PLACEHOLDER_INDEX : undefined, fieldKey, context.groupPath);
|
|
13499
14255
|
const dependsOn = extractDependenciesFromConfig(config);
|
|
13500
14256
|
const condition = config.condition ?? true;
|
|
13501
14257
|
const trigger = config.trigger ?? 'onChange';
|
|
@@ -13508,6 +14264,7 @@ function createPropertyDerivationEntryFromDerivation(fieldKey, config, context)
|
|
|
13508
14264
|
value: config.value,
|
|
13509
14265
|
expression: config.expression,
|
|
13510
14266
|
functionName: config.functionName,
|
|
14267
|
+
fn: extractInlineFn(config),
|
|
13511
14268
|
trigger,
|
|
13512
14269
|
debounceMs,
|
|
13513
14270
|
debugName: config.debugName,
|
|
@@ -13662,10 +14419,10 @@ const PROPERTY_DERIVATION_ORCHESTRATOR = new InjectionToken('PROPERTY_DERIVATION
|
|
|
13662
14419
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
13663
14420
|
function warnAboutWildcardDependencies(logger, entries, fieldCount) {
|
|
13664
14421
|
const implicitWildcards = entries.filter((entry) => entry.dependsOn.includes('*') &&
|
|
13665
|
-
entry.functionName &&
|
|
14422
|
+
(entry.functionName || entry.fn) &&
|
|
13666
14423
|
(!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
|
|
13667
14424
|
if (implicitWildcards.length > 0) {
|
|
13668
|
-
const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName})`);
|
|
14425
|
+
const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName ?? '<inline fn>'})`);
|
|
13669
14426
|
logger.warn('PropertyDerivation: custom functions without explicit dependsOn detected. ' +
|
|
13670
14427
|
`These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
|
|
13671
14428
|
'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
|
|
@@ -14758,31 +15515,31 @@ const BUILT_IN_FIELDS = [
|
|
|
14758
15515
|
// `row` is a virtual field type: it maps to ContainerFieldComponent, and
|
|
14759
15516
|
// the rowFieldMapper injects a `{ type: 'row' }` wrapper so the container
|
|
14760
15517
|
// renders the flex/grid layout via RowWrapperComponent.
|
|
14761
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-
|
|
15518
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-Pcxg5PQu.mjs'),
|
|
14762
15519
|
mapper: rowFieldMapper,
|
|
14763
15520
|
valueHandling: 'flatten',
|
|
14764
15521
|
},
|
|
14765
15522
|
{
|
|
14766
15523
|
name: 'group',
|
|
14767
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-group-field.component-
|
|
15524
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-group-field.component-fzKwPrQr.mjs'),
|
|
14768
15525
|
mapper: groupFieldMapper,
|
|
14769
15526
|
valueHandling: 'include',
|
|
14770
15527
|
},
|
|
14771
15528
|
{
|
|
14772
15529
|
name: 'array',
|
|
14773
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-array-field.component-
|
|
15530
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-array-field.component-DJ-LiLm_.mjs'),
|
|
14774
15531
|
mapper: arrayFieldMapper,
|
|
14775
15532
|
valueHandling: 'include',
|
|
14776
15533
|
},
|
|
14777
15534
|
{
|
|
14778
15535
|
name: 'page',
|
|
14779
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-
|
|
15536
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-BX4R9GGa.mjs'),
|
|
14780
15537
|
mapper: pageFieldMapper,
|
|
14781
15538
|
valueHandling: 'flatten',
|
|
14782
15539
|
},
|
|
14783
15540
|
{
|
|
14784
15541
|
name: 'text',
|
|
14785
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-
|
|
15542
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-B9SK8Vun.mjs'),
|
|
14786
15543
|
mapper: textFieldMapper,
|
|
14787
15544
|
valueHandling: 'exclude',
|
|
14788
15545
|
...DISPLAY_FIELD_TYPES_BASE,
|
|
@@ -14794,7 +15551,7 @@ const BUILT_IN_FIELDS = [
|
|
|
14794
15551
|
},
|
|
14795
15552
|
{
|
|
14796
15553
|
name: 'container',
|
|
14797
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-
|
|
15554
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-Pcxg5PQu.mjs'),
|
|
14798
15555
|
mapper: containerFieldMapper,
|
|
14799
15556
|
valueHandling: 'flatten',
|
|
14800
15557
|
},
|
|
@@ -14802,7 +15559,7 @@ const BUILT_IN_FIELDS = [
|
|
|
14802
15559
|
const BUILT_IN_WRAPPERS = [
|
|
14803
15560
|
{
|
|
14804
15561
|
wrapperName: 'css',
|
|
14805
|
-
loadComponent: () => import('./ng-forge-dynamic-forms-css-wrapper.component-
|
|
15562
|
+
loadComponent: () => import('./ng-forge-dynamic-forms-css-wrapper.component-CJACCIEK.mjs'),
|
|
14806
15563
|
},
|
|
14807
15564
|
{
|
|
14808
15565
|
wrapperName: 'row',
|
|
@@ -15112,6 +15869,48 @@ function withValueExclusionDefaults(config) {
|
|
|
15112
15869
|
return createFeature('value-exclusion', [{ provide: VALUE_EXCLUSION_DEFAULTS, useValue: resolved }]);
|
|
15113
15870
|
}
|
|
15114
15871
|
|
|
15872
|
+
/**
|
|
15873
|
+
* Configures global validation execution defaults — when validators should run
|
|
15874
|
+
* relative to a field's reactive state.
|
|
15875
|
+
*
|
|
15876
|
+
* By default, validators are **skipped** for hidden fields (`validateWhenHidden: false`),
|
|
15877
|
+
* matching what most users expect: a hidden field is inert. Use this feature to
|
|
15878
|
+
* restore the legacy behavior where hidden fields still validate.
|
|
15879
|
+
*
|
|
15880
|
+
* @example Default behavior — no feature needed
|
|
15881
|
+
* ```typescript
|
|
15882
|
+
* provideDynamicForm(...withMaterialFields())
|
|
15883
|
+
* // hidden fields skip validation
|
|
15884
|
+
* ```
|
|
15885
|
+
*
|
|
15886
|
+
* @example Restore legacy validation-on-hidden behavior
|
|
15887
|
+
* ```typescript
|
|
15888
|
+
* provideDynamicForm(
|
|
15889
|
+
* ...withMaterialFields(),
|
|
15890
|
+
* withValidationExecutionDefaults({ validateWhenHidden: true })
|
|
15891
|
+
* )
|
|
15892
|
+
* ```
|
|
15893
|
+
*
|
|
15894
|
+
* @remarks
|
|
15895
|
+
* **Precedence rules:**
|
|
15896
|
+
* 1. Per-field `FieldDef.validateWhenHidden` — wins for that field; inherits down to descendants
|
|
15897
|
+
* that don't override.
|
|
15898
|
+
* 2. Per-form `FormOptions.validateWhenHidden` — root inherited value for the form.
|
|
15899
|
+
* 3. Global `withValidationExecutionDefaults()` — fallback when no form/field setting.
|
|
15900
|
+
* 4. No feature configured — uses token default (`validateWhenHidden: false`).
|
|
15901
|
+
*
|
|
15902
|
+
* @param config - Partial override. Unspecified properties keep their defaults.
|
|
15903
|
+
* @returns A `DynamicFormFeature` that configures validation execution defaults.
|
|
15904
|
+
*
|
|
15905
|
+
* @public
|
|
15906
|
+
*/
|
|
15907
|
+
function withValidationExecutionDefaults(config) {
|
|
15908
|
+
const resolved = {
|
|
15909
|
+
validateWhenHidden: config?.validateWhenHidden ?? false,
|
|
15910
|
+
};
|
|
15911
|
+
return createFeature('validation-execution', [{ provide: VALIDATION_EXECUTION_DEFAULTS, useValue: resolved }]);
|
|
15912
|
+
}
|
|
15913
|
+
|
|
15115
15914
|
/**
|
|
15116
15915
|
* Zero-cost type carrier used in wrapper registrations.
|
|
15117
15916
|
*
|
|
@@ -15834,5 +16633,5 @@ function extractErrorParams(error) {
|
|
|
15834
16633
|
* Generated bundle index. Do not edit.
|
|
15835
16634
|
*/
|
|
15836
16635
|
|
|
15837
|
-
export {
|
|
15838
|
-
//# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-
|
|
16636
|
+
export { ShiftArrayItemEvent 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, DEFAULT_VALIDATION_MESSAGES as H, DynamicForm as I, DynamicFormError as J, EventDispatcher as K, FIELD_REGISTRY as L, FORM_OPTIONS as M, FormClearEvent as N, FormResetEvent as O, INITIALIZATION_TIMEOUT_MS as P, InsertArrayItemEvent as Q, RootFormRegistryService as R, MoveArrayItemEvent as S, NextPageEvent as T, NoopLogger as U, PageChangeEvent as V, WRAPPER_AUTO_ASSOCIATIONS as W, PopArrayItemEvent as X, PrependArrayItemEvent as Y, PreviousPageEvent as Z, RemoveAtIndexEvent as _, DEFAULT_WRAPPERS as a, SubmitEvent as a0, WRAPPER_REGISTRY as a1, applyMetaToElement as a2, applyValidator as a3, applyValidators as a4, arrayEvent as a5, arrayFieldMapper as a6, baseFieldMapper as a7, buildBaseInputs as a8, containerFieldMapper as a9, provideDynamicForm as aA, resolveNextButtonDisabled as aB, resolveNonFieldDisabled as aC, resolveNonFieldHidden as aD, resolveSubmitButtonDisabled as aE, resolveTokens as aF, rowFieldMapper as aG, textFieldMapper as aH, toReadonlyFieldTree as aI, withEventFormValue as aJ, withLoggerConfig as aK, withPreviousValue as aL, withValidationExecutionDefaults as aM, withValueExclusionDefaults as aN, wrapperProps as aO, createField as aa, createWrappers as ab, evaluateNonFieldDisabled as ac, evaluateNonFieldHidden as ad, field as ae, formConfig as af, getArrayLength as ag, groupFieldMapper as ah, hasFormValue as ai, interpolateParams as aj, isArrayField as ak, isCheckedField as al, isContainerField as am, isContainerTypedField as an, isDisplayOnlyField as ao, isFormStateCondition as ap, isLeafField as aq, isPageField as ar, isRowField as as, isSimplifiedArrayField as at, isValueBearingField as au, isValueField as av, isWrapperTypeDefinition as aw, isWrappersBundle as ax, omit as ay, pageFieldMapper 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, createRenderReadySignal as j, ARRAY_TEMPLATE_REGISTRY as k, ARRAY_ITEM_ID_GENERATOR as l, mapFieldToInputs as m, getNormalizedArrayMetadata as n, getFieldValueHandling as o, getFieldDefaultValue as p, isGroupField as q, resolveWrappers as r, createArrayItemIdGenerator as s, ComponentInitializedEvent as t, DynamicTextPipe as u, validatePageNesting as v, dynamicTextToObservable as w, AppendArrayItemEvent as x, ConsoleLogger as y, DEFAULT_PROPS as z };
|
|
16637
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-o3rJX4vG.mjs.map
|