@ng-forge/dynamic-forms 0.10.0-next.1 → 0.10.0-next.2
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-GiaVHO9N.mjs → ng-forge-dynamic-forms-array-field.component-PPh_uVVh.mjs} +99 -476
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-PPh_uVVh.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-container-field.component-C554hOxN.mjs → ng-forge-dynamic-forms-container-field.component-3dZDF3kh.mjs} +3 -14
- package/fesm2022/ng-forge-dynamic-forms-container-field.component-3dZDF3kh.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-container-utils-B7kCznbc.mjs +23 -0
- package/fesm2022/ng-forge-dynamic-forms-container-utils-B7kCznbc.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-css-wrapper.component-CnJgFWLB.mjs → ng-forge-dynamic-forms-css-wrapper.component-DtWBauY3.mjs} +2 -22
- package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-DtWBauY3.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-emit-initialization-CbY8WZZB.mjs +17 -0
- package/fesm2022/ng-forge-dynamic-forms-emit-initialization-CbY8WZZB.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-group-field.component-BJ8j7Sh0.mjs → ng-forge-dynamic-forms-group-field.component-Dr8Z628-.mjs} +15 -63
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-Dr8Z628-.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-integration.mjs +72 -844
- package/fesm2022/ng-forge-dynamic-forms-integration.mjs.map +1 -1
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-ww_BnAt_.mjs +10765 -0
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-ww_BnAt_.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-page-field.component-Bz2RpleR.mjs → ng-forge-dynamic-forms-page-field.component-DoGKs3LN.mjs} +10 -38
- package/fesm2022/ng-forge-dynamic-forms-page-field.component-DoGKs3LN.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-row-wrapper.component-CFtnatYj.mjs +1 -17
- package/fesm2022/ng-forge-dynamic-forms-row-wrapper.component-CFtnatYj.mjs.map +1 -1
- package/fesm2022/ng-forge-dynamic-forms-schema.mjs +0 -73
- package/fesm2022/ng-forge-dynamic-forms-schema.mjs.map +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-BGw6T-4h.mjs → ng-forge-dynamic-forms-text-field.component-C3TPVvj_.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-BGw6T-4h.mjs.map → ng-forge-dynamic-forms-text-field.component-C3TPVvj_.mjs.map} +1 -1
- package/fesm2022/ng-forge-dynamic-forms.mjs +1 -1
- package/package.json +1 -1
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-GiaVHO9N.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-container-field.component-C554hOxN.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-container-utils-BS4d0cCk.mjs +0 -48
- package/fesm2022/ng-forge-dynamic-forms-container-utils-BS4d0cCk.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-CnJgFWLB.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-emit-initialization-3PyXVwsn.mjs +0 -29
- package/fesm2022/ng-forge-dynamic-forms-emit-initialization-3PyXVwsn.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-BJ8j7Sh0.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms--PuL0AKc.mjs +0 -18773
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms--PuL0AKc.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-page-field.component-Bz2RpleR.mjs.map +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { signal, computed, runInInjectionContext, Injector, linkedSignal, inject, DestroyRef, EnvironmentInjector, input, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
-
import { O as RootFormRegistryService, ax as isEqual, aL as mapFieldToInputs, v as FIELD_SIGNAL_CONTEXT, e as ARRAY_CONTEXT, G as GROUP_CONTEXT, a5 as createHiddenSignal, a6 as createRenderReadySignal, s as DynamicFormLogger, ao as injectFieldRegistry, ap as injectFieldSignalContext, E as EventBus, g as ArrayItemRegistryService, ak as getNormalizedArrayMetadata, aj as getFieldValueHandling, ai as getFieldDefaultValue, aA as isGroupField, o as DfFieldOutlet } from './ng-forge-dynamic-forms-ng-forge-dynamic-forms
|
|
3
|
+
import { O as RootFormRegistryService, ax as isEqual, aL as mapFieldToInputs, v as FIELD_SIGNAL_CONTEXT, e as ARRAY_CONTEXT, G as GROUP_CONTEXT, a5 as createHiddenSignal, a6 as createRenderReadySignal, s as DynamicFormLogger, ao as injectFieldRegistry, ap as injectFieldSignalContext, E as EventBus, g as ArrayItemRegistryService, ak as getNormalizedArrayMetadata, aj as getFieldValueHandling, ai as getFieldDefaultValue, aA as isGroupField, o as DfFieldOutlet } from './ng-forge-dynamic-forms-ng-forge-dynamic-forms-ww_BnAt_.mjs';
|
|
4
4
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
5
5
|
import { of, from, map, catchError, forkJoin, filter, firstValueFrom, tap } from 'rxjs';
|
|
6
6
|
import { explicitEffect } from 'ngxtension/explicit-effect';
|
|
7
|
-
import { e as emitComponentInitialized } from './ng-forge-dynamic-forms-emit-initialization-
|
|
7
|
+
import { e as emitComponentInitialized } from './ng-forge-dynamic-forms-emit-initialization-CbY8WZZB.mjs';
|
|
8
8
|
|
|
9
9
|
class ArrayFieldStateMachine {
|
|
10
10
|
_state = signal('idle', ...(ngDevMode ? [{ debugName: "_state" }] : /* istanbul ignore next */ []));
|
|
@@ -13,8 +13,7 @@ class ArrayFieldStateMachine {
|
|
|
13
13
|
_hasEverEmitted = false;
|
|
14
14
|
state = this._state.asReadonly();
|
|
15
15
|
runId = this._runId.asReadonly();
|
|
16
|
-
|
|
17
|
-
get hasEverEmitted() {
|
|
16
|
+
get hasEverEmitted() {
|
|
18
17
|
return this._hasEverEmitted;
|
|
19
18
|
}
|
|
20
19
|
dispatch(op) {
|
|
@@ -27,9 +26,7 @@ class ArrayFieldStateMachine {
|
|
|
27
26
|
this._state.set('pending');
|
|
28
27
|
break;
|
|
29
28
|
case 'append':
|
|
30
|
-
|
|
31
|
-
// appendItems leaves the init-cycle tracking alone.
|
|
32
|
-
break;
|
|
29
|
+
break;
|
|
33
30
|
}
|
|
34
31
|
return {
|
|
35
32
|
id: nextId,
|
|
@@ -41,12 +38,7 @@ class ArrayFieldStateMachine {
|
|
|
41
38
|
},
|
|
42
39
|
};
|
|
43
40
|
}
|
|
44
|
-
|
|
45
|
-
* Edge-trigger for the emit-effect. Returns true exactly once per `settled` run
|
|
46
|
-
* when `allReady` is true. Side-effect: records the run as emitted and transitions
|
|
47
|
-
* the state back to `idle`. Subsequent calls without a fresh dispatch return false.
|
|
48
|
-
*/
|
|
49
|
-
shouldEmit(allReady) {
|
|
41
|
+
shouldEmit(allReady) {
|
|
50
42
|
if (this._state() !== 'settled' || !allReady)
|
|
51
43
|
return false;
|
|
52
44
|
if (this.lastEmittedRunId === this._runId())
|
|
@@ -58,29 +50,10 @@ class ArrayFieldStateMachine {
|
|
|
58
50
|
}
|
|
59
51
|
}
|
|
60
52
|
|
|
61
|
-
/**
|
|
62
|
-
* Safely extracts an array value from a model object.
|
|
63
|
-
* Returns empty array if the key doesn't exist or value isn't an array.
|
|
64
|
-
*/
|
|
65
53
|
function getArrayValue(value, key) {
|
|
66
54
|
const arrayValue = value?.[key];
|
|
67
55
|
return Array.isArray(arrayValue) ? arrayValue : [];
|
|
68
56
|
}
|
|
69
|
-
/**
|
|
70
|
-
* Determines the optimal differential update operation based on current and new state.
|
|
71
|
-
*
|
|
72
|
-
* Operations:
|
|
73
|
-
* - Clear all (empty array)
|
|
74
|
-
* - Initial render (no existing items)
|
|
75
|
-
* - Append only (items added at end)
|
|
76
|
-
* - Recreate (items removed - we can't know which items, so recreate all)
|
|
77
|
-
* - None (same length - items update via linkedSignal)
|
|
78
|
-
*
|
|
79
|
-
* Note: We always use 'recreate' for removals because we can't determine which
|
|
80
|
-
* specific items were removed. Operations like shift, removeAtIndex remove from
|
|
81
|
-
* the middle, not just the end. Each array item has its own local form that
|
|
82
|
-
* doesn't reactively track parent changes, so we must recreate to get correct values.
|
|
83
|
-
*/
|
|
84
57
|
function determineDifferentialOperation(currentItems, newLength) {
|
|
85
58
|
const currentLength = currentItems.length;
|
|
86
59
|
if (newLength === 0) {
|
|
@@ -89,53 +62,25 @@ function determineDifferentialOperation(currentItems, newLength) {
|
|
|
89
62
|
if (currentLength === 0) {
|
|
90
63
|
return { type: 'initial', fieldTreesLength: newLength };
|
|
91
64
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// Snapshot comparison was causing false "recreate" when existing items had been edited.
|
|
95
|
-
if (newLength > currentLength) {
|
|
96
|
-
// Items added - append new ones, existing items stay
|
|
97
|
-
return { type: 'append', startIndex: currentLength, endIndex: newLength };
|
|
65
|
+
if (newLength > currentLength) {
|
|
66
|
+
return { type: 'append', startIndex: currentLength, endIndex: newLength };
|
|
98
67
|
}
|
|
99
68
|
if (newLength < currentLength) {
|
|
100
|
-
|
|
101
|
-
// shift() removes from index 0, removeAtIndex() removes from any position.
|
|
102
|
-
// Each item's local form is initialized with its value at creation time and
|
|
103
|
-
// doesn't reactively track parent array changes.
|
|
104
|
-
return { type: 'recreate' };
|
|
69
|
+
return { type: 'recreate' };
|
|
105
70
|
}
|
|
106
|
-
|
|
107
|
-
return { type: 'none' };
|
|
71
|
+
return { type: 'none' };
|
|
108
72
|
}
|
|
109
73
|
|
|
110
|
-
/**
|
|
111
|
-
* Creates an injector and inputs for an array item.
|
|
112
|
-
*
|
|
113
|
-
* Uses direct root form binding - components bind directly to the root form's
|
|
114
|
-
* FieldTree for array items (rootForm['arrayKey'][index]). This architecture:
|
|
115
|
-
*
|
|
116
|
-
* - Eliminates the need for local forms and bidirectional sync
|
|
117
|
-
* - Allows Zod/StandardSchema validation errors to flow naturally to components
|
|
118
|
-
* - Reduces complexity by having a single source of truth (the root form)
|
|
119
|
-
*
|
|
120
|
-
* The FieldSignalContext.form property uses a getter that evaluates a computed
|
|
121
|
-
* signal, making form access reactive to index changes (when items reorder,
|
|
122
|
-
* the component points to the correct array position).
|
|
123
|
-
*/
|
|
124
74
|
function createArrayItemInjectorAndInputs(options) {
|
|
125
75
|
const { template, indexSignal, parentFieldSignalContext, parentInjector, registry, arrayField, primitiveFieldKey } = options;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const rootFormRegistry = parentInjector.get(RootFormRegistryService);
|
|
129
|
-
// Create a computed that derives the array item's FieldTree from root form.
|
|
130
|
-
// Uses isEqual to prevent unnecessary re-computation when index changes but value is same.
|
|
131
|
-
const itemFormAccessor = computed(() => {
|
|
76
|
+
const rootFormRegistry = parentInjector.get(RootFormRegistryService);
|
|
77
|
+
const itemFormAccessor = computed(() => {
|
|
132
78
|
const rootForm = rootFormRegistry.rootForm();
|
|
133
79
|
if (!rootForm) {
|
|
134
80
|
return undefined;
|
|
135
81
|
}
|
|
136
82
|
const index = indexSignal();
|
|
137
|
-
|
|
138
|
-
const arrayFieldTree = rootForm[arrayField.key];
|
|
83
|
+
const arrayFieldTree = rootForm[arrayField.key];
|
|
139
84
|
if (!arrayFieldTree) {
|
|
140
85
|
return undefined;
|
|
141
86
|
}
|
|
@@ -149,30 +94,14 @@ function createArrayItemInjectorAndInputs(options) {
|
|
|
149
94
|
arrayField,
|
|
150
95
|
primitiveFieldKey,
|
|
151
96
|
});
|
|
152
|
-
|
|
153
|
-
// and applies the index suffix to keys for unique DOM IDs
|
|
154
|
-
const inputs = runInInjectionContext(injector, () => {
|
|
97
|
+
const inputs = runInInjectionContext(injector, () => {
|
|
155
98
|
return mapFieldToInputs(template, registry);
|
|
156
99
|
});
|
|
157
100
|
return { injector, inputs };
|
|
158
101
|
}
|
|
159
|
-
/**
|
|
160
|
-
* Creates a scoped injector for an array item.
|
|
161
|
-
* Provides both FIELD_SIGNAL_CONTEXT (for form access) and ARRAY_CONTEXT (for position awareness).
|
|
162
|
-
*
|
|
163
|
-
* The FIELD_SIGNAL_CONTEXT.form uses a getter that evaluates itemFormAccessor() on access.
|
|
164
|
-
* This makes form access reactive - when mappers access context.form['fieldKey'], the getter runs,
|
|
165
|
-
* evaluating the computed which tracks indexSignal as a dependency. This ensures components
|
|
166
|
-
* automatically update when array items reorder.
|
|
167
|
-
*
|
|
168
|
-
* Uses `useFactory` with `deps: [Injector]` to provide FIELD_SIGNAL_CONTEXT. Angular resolves
|
|
169
|
-
* the `Injector` dep as the child injector being created, eliminating any temporal gap.
|
|
170
|
-
*/
|
|
171
102
|
function createItemInjector(options) {
|
|
172
103
|
const { itemFormAccessor, indexSignal, parentFieldSignalContext, parentInjector, arrayField, primitiveFieldKey } = options;
|
|
173
|
-
|
|
174
|
-
// Components accessing arrayContext.formValue will get the current value.
|
|
175
|
-
const arrayContext = {
|
|
104
|
+
const arrayContext = {
|
|
176
105
|
arrayKey: arrayField.key,
|
|
177
106
|
index: indexSignal,
|
|
178
107
|
get formValue() {
|
|
@@ -180,40 +109,24 @@ function createItemInjector(options) {
|
|
|
180
109
|
},
|
|
181
110
|
field: arrayField,
|
|
182
111
|
};
|
|
183
|
-
|
|
184
|
-
// on every form getter access, which would defeat reference-equality checks downstream.
|
|
185
|
-
let cachedPrimitiveRaw;
|
|
112
|
+
let cachedPrimitiveRaw;
|
|
186
113
|
let cachedPrimitiveWrapped;
|
|
187
114
|
return Injector.create({
|
|
188
115
|
parent: parentInjector,
|
|
189
116
|
providers: [
|
|
190
117
|
{
|
|
191
118
|
provide: FIELD_SIGNAL_CONTEXT,
|
|
192
|
-
|
|
193
|
-
// so no temporal gap exists — the injector reference is immediately available.
|
|
194
|
-
useFactory: (injector) => ({
|
|
119
|
+
useFactory: (injector) => ({
|
|
195
120
|
injector,
|
|
196
121
|
value: parentFieldSignalContext.value,
|
|
197
|
-
|
|
198
|
-
// are determined by its template fields at creation time (via getFieldDefaultValue).
|
|
199
|
-
// The parent's defaultValues contain top-level form defaults, not per-item defaults.
|
|
200
|
-
defaultValues: () => ({}),
|
|
122
|
+
defaultValues: () => ({}),
|
|
201
123
|
get form() {
|
|
202
124
|
const raw = itemFormAccessor();
|
|
203
125
|
if (!raw) {
|
|
204
|
-
|
|
205
|
-
// This occurs in the window between item creation and Angular's form tree construction.
|
|
206
|
-
// Return an empty object so that downstream getFieldTree(key) calls return undefined
|
|
207
|
-
// rather than causing a runtime error on a truly undefined value.
|
|
208
|
-
return {};
|
|
126
|
+
return {};
|
|
209
127
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// Without this, getFieldTree('value') would access FormControl['value'] (the WritableSignal),
|
|
213
|
-
// not a child FieldTree, causing NG0950 errors.
|
|
214
|
-
if (primitiveFieldKey) {
|
|
215
|
-
// Cache the wrapper to preserve reference identity when raw hasn't changed
|
|
216
|
-
if (raw !== cachedPrimitiveRaw) {
|
|
128
|
+
if (primitiveFieldKey) {
|
|
129
|
+
if (raw !== cachedPrimitiveRaw) {
|
|
217
130
|
cachedPrimitiveRaw = raw;
|
|
218
131
|
cachedPrimitiveWrapped = { [primitiveFieldKey]: raw };
|
|
219
132
|
}
|
|
@@ -225,51 +138,27 @@ function createItemInjector(options) {
|
|
|
225
138
|
deps: [Injector],
|
|
226
139
|
},
|
|
227
140
|
{ provide: ARRAY_CONTEXT, useValue: arrayContext },
|
|
228
|
-
|
|
229
|
-
// item start their path fresh, matching how value-derivation and
|
|
230
|
-
// property-derivation collectors key entries
|
|
231
|
-
// (`{arrayKey}.$.{innerGroupPath}.{fieldKey}`). Without this, an outer
|
|
232
|
-
// group around the array would leak into descendants' DOM IDs and
|
|
233
|
-
// override-store keys, drifting from the collector.
|
|
234
|
-
{ provide: GROUP_CONTEXT, useValue: EMPTY_GROUP_CONTEXT },
|
|
141
|
+
{ provide: GROUP_CONTEXT, useValue: EMPTY_GROUP_CONTEXT },
|
|
235
142
|
],
|
|
236
143
|
});
|
|
237
144
|
}
|
|
238
|
-
/**
|
|
239
|
-
* Sentinel `GroupContext` provided at array item boundaries. Static `signal('')`
|
|
240
|
-
* means `applyGroupPrefix` no-ops (empty path) and any inner group composes its
|
|
241
|
-
* own path from this empty base.
|
|
242
|
-
*/
|
|
243
145
|
const EMPTY_GROUP_CONTEXT = { groupPath: signal('') };
|
|
244
146
|
|
|
245
|
-
/**
|
|
246
|
-
* Resolves a single array item with all its fields for declarative rendering.
|
|
247
|
-
*
|
|
248
|
-
* Uses linkedSignal for the index, which automatically updates when itemOrderSignal changes.
|
|
249
|
-
* This enables position-aware updates without recreating components when items are reordered.
|
|
250
|
-
* Supports multiple sibling templates (e.g., name + email without a wrapper).
|
|
251
|
-
*/
|
|
252
147
|
function resolveArrayItem(options) {
|
|
253
148
|
const { index, templates, arrayField, itemPositionMap, parentFieldSignalContext, parentInjector, registry, destroyRef, loadTypeComponent, generateItemId, existingItemId, primitiveFieldKey, } = options;
|
|
254
149
|
if (templates.length === 0) {
|
|
255
150
|
return of(undefined);
|
|
256
151
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// form-scoped template registry continues to map cleanly.
|
|
260
|
-
const itemId = existingItemId ?? generateItemId();
|
|
261
|
-
// O(1) position lookup via Map instead of O(n) indexOf()
|
|
262
|
-
const indexSignal = linkedSignal(() => {
|
|
152
|
+
const itemId = existingItemId ?? generateItemId();
|
|
153
|
+
const indexSignal = linkedSignal(() => {
|
|
263
154
|
const positionMap = itemPositionMap();
|
|
264
155
|
return positionMap.get(itemId) ?? index;
|
|
265
156
|
}, ...(ngDevMode ? [{ debugName: "indexSignal" }] : /* istanbul ignore next */ []));
|
|
266
|
-
|
|
267
|
-
const fieldObservables = templates.map((template) => from(loadTypeComponent(template.type)).pipe(map((component) => {
|
|
157
|
+
const fieldObservables = templates.map((template) => from(loadTypeComponent(template.type)).pipe(map((component) => {
|
|
268
158
|
if (destroyRef.destroyed) {
|
|
269
159
|
return undefined;
|
|
270
160
|
}
|
|
271
|
-
|
|
272
|
-
if (!component) {
|
|
161
|
+
if (!component) {
|
|
273
162
|
return undefined;
|
|
274
163
|
}
|
|
275
164
|
const { injector, inputs } = createArrayItemInjectorAndInputs({
|
|
@@ -281,8 +170,7 @@ function resolveArrayItem(options) {
|
|
|
281
170
|
arrayField,
|
|
282
171
|
primitiveFieldKey,
|
|
283
172
|
});
|
|
284
|
-
|
|
285
|
-
if (!inputs) {
|
|
173
|
+
if (!inputs) {
|
|
286
174
|
return undefined;
|
|
287
175
|
}
|
|
288
176
|
return {
|
|
@@ -304,8 +192,7 @@ function resolveArrayItem(options) {
|
|
|
304
192
|
})));
|
|
305
193
|
return forkJoin(fieldObservables).pipe(map((fields) => {
|
|
306
194
|
const validFields = fields.filter((f) => f !== undefined);
|
|
307
|
-
|
|
308
|
-
if (validFields.length === 0) {
|
|
195
|
+
if (validFields.length === 0) {
|
|
309
196
|
return undefined;
|
|
310
197
|
}
|
|
311
198
|
return {
|
|
@@ -315,9 +202,6 @@ function resolveArrayItem(options) {
|
|
|
315
202
|
}));
|
|
316
203
|
}
|
|
317
204
|
|
|
318
|
-
/**
|
|
319
|
-
* All array event type discriminants.
|
|
320
|
-
*/
|
|
321
205
|
const ARRAY_EVENT_TYPES = [
|
|
322
206
|
'append-array-item',
|
|
323
207
|
'prepend-array-item',
|
|
@@ -327,10 +211,6 @@ const ARRAY_EVENT_TYPES = [
|
|
|
327
211
|
'shift-array-item',
|
|
328
212
|
'remove-at-index',
|
|
329
213
|
];
|
|
330
|
-
/**
|
|
331
|
-
* Converts an array event to a normalized action.
|
|
332
|
-
* Uses a switch for full type safety with discriminated union narrowing.
|
|
333
|
-
*/
|
|
334
214
|
function toArrayAction(event) {
|
|
335
215
|
switch (event.type) {
|
|
336
216
|
case 'append-array-item':
|
|
@@ -349,43 +229,12 @@ function toArrayAction(event) {
|
|
|
349
229
|
return { action: 'remove', index: event.index };
|
|
350
230
|
}
|
|
351
231
|
}
|
|
352
|
-
/**
|
|
353
|
-
* Creates an observable stream of normalized array actions for a specific array key.
|
|
354
|
-
*
|
|
355
|
-
* @param eventBus - The event bus to subscribe to
|
|
356
|
-
* @param arrayKey - Function returning the array key to filter events for
|
|
357
|
-
* @returns Observable of normalized ArrayAction objects
|
|
358
|
-
*
|
|
359
|
-
* @example
|
|
360
|
-
* ```typescript
|
|
361
|
-
* observeArrayActions(this.eventBus, () => this.key())
|
|
362
|
-
* .pipe(takeUntilDestroyed())
|
|
363
|
-
* .subscribe(action => {
|
|
364
|
-
* if (action.action === 'add') {
|
|
365
|
-
* this.addItem(action.template, action.index);
|
|
366
|
-
* } else {
|
|
367
|
-
* this.removeItem(action.index);
|
|
368
|
-
* }
|
|
369
|
-
* });
|
|
370
|
-
* ```
|
|
371
|
-
*/
|
|
372
232
|
function observeArrayActions(eventBus, arrayKey) {
|
|
373
233
|
return eventBus.on(ARRAY_EVENT_TYPES).pipe(filter((event) => event.arrayKey === arrayKey()), map(toArrayAction));
|
|
374
234
|
}
|
|
375
235
|
|
|
376
|
-
/**
|
|
377
|
-
* Container component for rendering dynamic arrays of fields.
|
|
378
|
-
*
|
|
379
|
-
* Supports add/remove/move operations via the arrayEvent() builder API.
|
|
380
|
-
* Uses differential updates to optimize rendering - only recreates items when necessary.
|
|
381
|
-
* Each item gets a scoped injector with ARRAY_CONTEXT for position-aware operations.
|
|
382
|
-
* Supports multiple sibling fields per array item (e.g., name + email without a wrapper).
|
|
383
|
-
*/
|
|
384
236
|
class ArrayFieldComponent {
|
|
385
|
-
|
|
386
|
-
// Dependencies
|
|
387
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
388
|
-
destroyRef = inject(DestroyRef);
|
|
237
|
+
destroyRef = inject(DestroyRef);
|
|
389
238
|
fieldRegistry = injectFieldRegistry();
|
|
390
239
|
parentFieldSignalContext = injectFieldSignalContext();
|
|
391
240
|
parentInjector = inject(Injector);
|
|
@@ -393,112 +242,47 @@ class ArrayFieldComponent {
|
|
|
393
242
|
eventBus = inject(EventBus);
|
|
394
243
|
logger = inject(DynamicFormLogger);
|
|
395
244
|
arrayItemRegistry = inject(ArrayItemRegistryService);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// would currently collide. Today's per-component-provider model has the same constraint
|
|
400
|
-
// (no nested-array hide/show tests exist). Path-aware keying is a follow-up.
|
|
401
|
-
// `field()` is `input.required`, so the slot reference is resolved lazily.
|
|
402
|
-
slot = computed(() => this.arrayItemRegistry.slotFor(this.field().key), ...(ngDevMode ? [{ debugName: "slot" }] : /* istanbul ignore next */ []));
|
|
403
|
-
/** Bound id-generator reference threaded to `resolveArrayItem` so callsites stay terse. */
|
|
404
|
-
generateItemId = () => this.slot().nextId();
|
|
405
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
406
|
-
// Inputs
|
|
407
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
408
|
-
field = input.required(...(ngDevMode ? [{ debugName: "field" }] : /* istanbul ignore next */ []));
|
|
245
|
+
slot = computed(() => this.arrayItemRegistry.slotFor(this.field().key), ...(ngDevMode ? [{ debugName: "slot" }] : /* istanbul ignore next */ []));
|
|
246
|
+
generateItemId = () => this.slot().nextId();
|
|
247
|
+
field = input.required(...(ngDevMode ? [{ debugName: "field" }] : /* istanbul ignore next */ []));
|
|
409
248
|
key = input.required(...(ngDevMode ? [{ debugName: "key" }] : /* istanbul ignore next */ []));
|
|
410
249
|
className = input(...(ngDevMode ? [undefined, { debugName: "className" }] : /* istanbul ignore next */ []));
|
|
411
250
|
hidden = input(false, ...(ngDevMode ? [{ debugName: "hidden" }] : /* istanbul ignore next */ []));
|
|
412
|
-
|
|
413
|
-
// Computed Signals
|
|
414
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
415
|
-
hostClasses = computed(() => {
|
|
251
|
+
hostClasses = computed(() => {
|
|
416
252
|
const base = 'df-field df-array';
|
|
417
253
|
const custom = this.className();
|
|
418
254
|
return custom ? `${base} ${custom}` : base;
|
|
419
255
|
}, ...(ngDevMode ? [{ debugName: "hostClasses" }] : /* istanbul ignore next */ []));
|
|
420
256
|
rawFieldRegistry = computed(() => this.fieldRegistry.raw, ...(ngDevMode ? [{ debugName: "rawFieldRegistry" }] : /* istanbul ignore next */ []));
|
|
421
|
-
|
|
422
|
-
* Gets the auto-remove button FieldDef from normalization metadata.
|
|
423
|
-
* Set by simplified array normalization for primitive arrays with remove buttons.
|
|
424
|
-
* The button is rendered alongside each item without wrapping in a row,
|
|
425
|
-
* preserving flat primitive form values.
|
|
426
|
-
*/
|
|
427
|
-
autoRemoveButton = computed(() => {
|
|
257
|
+
autoRemoveButton = computed(() => {
|
|
428
258
|
const metadata = getNormalizedArrayMetadata(this.field());
|
|
429
259
|
return metadata?.autoRemoveButton;
|
|
430
260
|
}, ...(ngDevMode ? [{ debugName: "autoRemoveButton" }] : /* istanbul ignore next */ []));
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
* Used to wrap the FormControl in the item context so getFieldTree(key) works.
|
|
434
|
-
* Returns undefined for object arrays (FormGroup items have natural child navigation).
|
|
435
|
-
*
|
|
436
|
-
* Detection order:
|
|
437
|
-
* 1. Normalization metadata (for simplified arrays — always available at normalization time)
|
|
438
|
-
* 2. Existing item definitions (non-empty full-API arrays)
|
|
439
|
-
* 3. Dynamically discovered key from handleAddFromEvent (empty full-API arrays)
|
|
440
|
-
*
|
|
441
|
-
* Uses linkedSignal: recomputes when field() changes, supports manual set()
|
|
442
|
-
* for the dynamic discovery case.
|
|
443
|
-
*/
|
|
444
|
-
primitiveFieldKey = linkedSignal(() => {
|
|
445
|
-
// Priority 1: Normalization metadata (simplified arrays always have this)
|
|
446
|
-
const metadata = getNormalizedArrayMetadata(this.field());
|
|
261
|
+
primitiveFieldKey = linkedSignal(() => {
|
|
262
|
+
const metadata = getNormalizedArrayMetadata(this.field());
|
|
447
263
|
if (metadata?.primitiveFieldKey) {
|
|
448
264
|
return metadata.primitiveFieldKey;
|
|
449
265
|
}
|
|
450
|
-
|
|
451
|
-
const definitions = this.field().fields || [];
|
|
266
|
+
const definitions = this.field().fields || [];
|
|
452
267
|
if (definitions.length > 0 && !Array.isArray(definitions[0])) {
|
|
453
268
|
return definitions[0].key;
|
|
454
269
|
}
|
|
455
270
|
return undefined;
|
|
456
271
|
}, ...(ngDevMode ? [{ debugName: "primitiveFieldKey" }] : /* istanbul ignore next */ []));
|
|
457
|
-
|
|
458
|
-
* Normalized item templates WITHOUT auto-remove button appended.
|
|
459
|
-
* Each element is normalized to an array: single FieldDef → [FieldDef], array stays as-is.
|
|
460
|
-
*
|
|
461
|
-
* Used by moveItem() to stash raw templates into the templateRegistry, preserving
|
|
462
|
-
* the invariant that registry entries are pre-synthesis (withAutoRemove() adds the
|
|
463
|
-
* button during resolution).
|
|
464
|
-
*/
|
|
465
|
-
rawItemTemplates = computed(() => {
|
|
272
|
+
rawItemTemplates = computed(() => {
|
|
466
273
|
const arrayField = this.field();
|
|
467
274
|
const definitions = arrayField.fields || [];
|
|
468
275
|
return definitions.map((def) => {
|
|
469
276
|
return (Array.isArray(def) ? def : [def]);
|
|
470
277
|
});
|
|
471
278
|
}, ...(ngDevMode ? [{ debugName: "rawItemTemplates" }] : /* istanbul ignore next */ []));
|
|
472
|
-
|
|
473
|
-
* Resolves the effective fallback template for this array — used when the form
|
|
474
|
-
* value contains an item with no registered template (i.e., items that were
|
|
475
|
-
* neither added via event handlers nor covered by a positional entry in `fields`).
|
|
476
|
-
*
|
|
477
|
-
* Populated from `SimplifiedArrayField.template` via normalization metadata, so every
|
|
478
|
-
* simplified array gets an automatic default. Returns undefined for full-API arrays;
|
|
479
|
-
* `createResolveItemObservable` then falls back to warn-and-drop.
|
|
480
|
-
*
|
|
481
|
-
* Homogeneous arrays only — all fallback items receive the same template.
|
|
482
|
-
*/
|
|
483
|
-
fallbackTemplate = computed(() => {
|
|
279
|
+
fallbackTemplate = computed(() => {
|
|
484
280
|
const raw = getNormalizedArrayMetadata(this.field())?.template;
|
|
485
281
|
if (!raw)
|
|
486
282
|
return undefined;
|
|
487
283
|
return (Array.isArray(raw) ? [...raw] : [raw]);
|
|
488
284
|
}, ...(ngDevMode ? [{ debugName: "fallbackTemplate" }] : /* istanbul ignore next */ []));
|
|
489
|
-
|
|
490
|
-
* Gets the item templates (field definitions) for the array.
|
|
491
|
-
* Each element can be either:
|
|
492
|
-
* - A single FieldDef (primitive item) - normalized to [FieldDef]
|
|
493
|
-
* - An array of FieldDefs (object item) - used as-is
|
|
494
|
-
*
|
|
495
|
-
* When auto-remove is configured, the remove button is appended to each item's
|
|
496
|
-
* template list for rendering. This is purely visual — the form schema uses the
|
|
497
|
-
* original primitive item definition (single FieldDef → FormControl → flat value).
|
|
498
|
-
*
|
|
499
|
-
* Returns normalized templates where all items are arrays for consistent handling.
|
|
500
|
-
*/
|
|
501
|
-
itemTemplates = computed(() => {
|
|
285
|
+
itemTemplates = computed(() => {
|
|
502
286
|
const raw = this.rawItemTemplates();
|
|
503
287
|
const removeButton = this.autoRemoveButton();
|
|
504
288
|
if (!removeButton)
|
|
@@ -516,38 +300,22 @@ class ArrayFieldComponent {
|
|
|
516
300
|
const arrayFieldTree = parentForm[arrayKey];
|
|
517
301
|
if (!arrayFieldTree)
|
|
518
302
|
return arrayValue.map(() => null);
|
|
519
|
-
|
|
520
|
-
const items = [];
|
|
303
|
+
const items = [];
|
|
521
304
|
for (let i = 0; i < arrayValue.length; i++) {
|
|
522
|
-
|
|
523
|
-
const itemFieldTree = arrayFieldTree[i];
|
|
305
|
+
const itemFieldTree = arrayFieldTree[i];
|
|
524
306
|
items.push(itemFieldTree ?? null);
|
|
525
307
|
}
|
|
526
308
|
return items;
|
|
527
309
|
}, ...(ngDevMode ? [{ debugName: "arrayFieldTrees" }] : /* istanbul ignore next */ []));
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
resolvedItemsSignal = signal([], ...(ngDevMode ? [{ debugName: "resolvedItemsSignal" }] : /* istanbul ignore next */ []));
|
|
532
|
-
// Explicit state machine for async resolution + componentInitialized emission.
|
|
533
|
-
// Replaces the prior updateVersion + pendingInitializationCycle + settledInitializationCycle trio.
|
|
534
|
-
fsm = new ArrayFieldStateMachine();
|
|
535
|
-
/**
|
|
536
|
-
* Map of item IDs to their current positions. O(1) lookup vs O(n) indexOf().
|
|
537
|
-
* Used by child linkedSignals to reactively track their position in the array.
|
|
538
|
-
*/
|
|
539
|
-
itemPositionMap = computed(() => {
|
|
310
|
+
resolvedItemsSignal = signal([], ...(ngDevMode ? [{ debugName: "resolvedItemsSignal" }] : /* istanbul ignore next */ []));
|
|
311
|
+
fsm = new ArrayFieldStateMachine();
|
|
312
|
+
itemPositionMap = computed(() => {
|
|
540
313
|
const items = this.resolvedItemsSignal();
|
|
541
314
|
return new Map(items.map((item, idx) => [item.id, idx]));
|
|
542
315
|
}, ...(ngDevMode ? [{ debugName: "itemPositionMap" }] : /* istanbul ignore next */ []));
|
|
543
|
-
|
|
544
|
-
resolvedItems = computed(() => this.resolvedItemsSignal(), ...(ngDevMode ? [{ debugName: "resolvedItems" }] : /* istanbul ignore next */ []));
|
|
316
|
+
resolvedItems = computed(() => this.resolvedItemsSignal(), ...(ngDevMode ? [{ debugName: "resolvedItems" }] : /* istanbul ignore next */ []));
|
|
545
317
|
allResolvedFieldsRenderReady = computed(() => this.resolvedItems().every((item) => item.fields.every((field) => field.renderReady())), ...(ngDevMode ? [{ debugName: "allResolvedFieldsRenderReady" }] : /* istanbul ignore next */ []));
|
|
546
|
-
|
|
547
|
-
* Whether the array has reached its configured maxLength.
|
|
548
|
-
* Exposed as a public signal so add-button components can bind [disabled]="atMaxLength()".
|
|
549
|
-
*/
|
|
550
|
-
atMaxLength = computed(() => {
|
|
318
|
+
atMaxLength = computed(() => {
|
|
551
319
|
const maxLength = this.field().maxLength;
|
|
552
320
|
if (maxLength === undefined)
|
|
553
321
|
return false;
|
|
@@ -555,30 +323,13 @@ class ArrayFieldComponent {
|
|
|
555
323
|
const currentArray = getArrayValue(this.parentFieldSignalContext.value(), arrayKey);
|
|
556
324
|
return currentArray.length >= maxLength;
|
|
557
325
|
}, ...(ngDevMode ? [{ debugName: "atMaxLength" }] : /* istanbul ignore next */ []));
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
561
|
-
/**
|
|
562
|
-
* Caches the result of appending the auto-remove button to template arrays.
|
|
563
|
-
* Keyed by template array reference — cache hits occur during recreate/resolution
|
|
564
|
-
* operations where stored template references are reused. Add operations via
|
|
565
|
-
* handleAddFromEvent always create a fresh `[...template]` copy (line ~277),
|
|
566
|
-
* so each add is a cache miss by design (the spread is needed for mutability).
|
|
567
|
-
*/
|
|
568
|
-
autoRemoveCache = new WeakMap();
|
|
569
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
570
|
-
// Constructor
|
|
571
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
572
|
-
constructor() {
|
|
326
|
+
autoRemoveCache = new WeakMap();
|
|
327
|
+
constructor() {
|
|
573
328
|
this.setupEffects();
|
|
574
329
|
this.setupEventHandlers();
|
|
575
330
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
579
|
-
setupEffects() {
|
|
580
|
-
// Sync field components when array data changes
|
|
581
|
-
explicitEffect([this.arrayFieldTrees], ([fieldTrees]) => {
|
|
331
|
+
setupEffects() {
|
|
332
|
+
explicitEffect([this.arrayFieldTrees], ([fieldTrees]) => {
|
|
582
333
|
this.performDifferentialUpdate(fieldTrees);
|
|
583
334
|
});
|
|
584
335
|
explicitEffect([this.fsm.state, this.allResolvedFieldsRenderReady], ([, allReady]) => {
|
|
@@ -592,8 +343,7 @@ class ArrayFieldComponent {
|
|
|
592
343
|
.pipe(takeUntilDestroyed())
|
|
593
344
|
.subscribe((action) => {
|
|
594
345
|
if (action.action === 'add') {
|
|
595
|
-
|
|
596
|
-
const templates = Array.isArray(action.template) ? action.template : action.template ? [action.template] : [];
|
|
346
|
+
const templates = Array.isArray(action.template) ? action.template : action.template ? [action.template] : [];
|
|
597
347
|
if (templates.length === 0) {
|
|
598
348
|
this.logger.error(`Cannot add item to array '${this.key()}': template is REQUIRED. ` +
|
|
599
349
|
'Buttons must specify an explicit template property. ' +
|
|
@@ -610,25 +360,11 @@ class ArrayFieldComponent {
|
|
|
610
360
|
}
|
|
611
361
|
});
|
|
612
362
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
* Creates resolved items FIRST, then updates form value.
|
|
616
|
-
* This ensures prepend/insert work correctly - differential update sees "none"
|
|
617
|
-
* because resolved items count already matches the new array length.
|
|
618
|
-
*
|
|
619
|
-
* Supports both primitive (single FieldDef) and object (FieldDef[]) templates.
|
|
620
|
-
*/
|
|
621
|
-
async handleAddFromEvent(template, index) {
|
|
622
|
-
// Normalize template to mutable array for consistent handling.
|
|
623
|
-
// A single FieldDef with valueHandling: 'flatten' (container, row) is an object item
|
|
624
|
-
// whose children should be flattened — NOT a primitive item. Only true leaf fields
|
|
625
|
-
// (input, checkbox, etc.) are primitive when passed as a single FieldDef.
|
|
626
|
-
const templates = Array.isArray(template) ? [...template] : [template];
|
|
363
|
+
async handleAddFromEvent(template, index) {
|
|
364
|
+
const templates = Array.isArray(template) ? [...template] : [template];
|
|
627
365
|
const isSingleField = !Array.isArray(template);
|
|
628
366
|
const isPrimitiveItem = isSingleField && getFieldValueHandling(templates[0].type, this.rawFieldRegistry()) !== 'flatten';
|
|
629
|
-
|
|
630
|
-
// Simplified arrays already have this info from normalization metadata.
|
|
631
|
-
if (isPrimitiveItem && templates[0].key && !this.primitiveFieldKey()) {
|
|
367
|
+
if (isPrimitiveItem && templates[0].key && !this.primitiveFieldKey()) {
|
|
632
368
|
this.primitiveFieldKey.set(templates[0].key);
|
|
633
369
|
}
|
|
634
370
|
if (templates.length === 0) {
|
|
@@ -641,27 +377,22 @@ class ArrayFieldComponent {
|
|
|
641
377
|
const currentValue = parentForm().value();
|
|
642
378
|
const currentArray = getArrayValue(currentValue, arrayKey);
|
|
643
379
|
const insertIndex = index !== undefined ? Math.min(index, currentArray.length) : currentArray.length;
|
|
644
|
-
|
|
645
|
-
let value;
|
|
380
|
+
let value;
|
|
646
381
|
if (isPrimitiveItem) {
|
|
647
|
-
|
|
648
|
-
value = getFieldDefaultValue(templates[0], this.rawFieldRegistry());
|
|
382
|
+
value = getFieldDefaultValue(templates[0], this.rawFieldRegistry());
|
|
649
383
|
}
|
|
650
384
|
else {
|
|
651
|
-
|
|
652
|
-
value = {};
|
|
385
|
+
value = {};
|
|
653
386
|
for (const templateField of templates) {
|
|
654
387
|
const rawValue = getFieldDefaultValue(templateField, this.rawFieldRegistry());
|
|
655
388
|
const valueHandling = getFieldValueHandling(templateField.type, this.rawFieldRegistry());
|
|
656
389
|
const isContainer = templateField.type === 'group' || templateField.type === 'row' || templateField.type === 'container';
|
|
657
390
|
if (isContainer) {
|
|
658
391
|
if (isGroupField(templateField)) {
|
|
659
|
-
|
|
660
|
-
value = { ...value, [templateField.key]: rawValue };
|
|
392
|
+
value = { ...value, [templateField.key]: rawValue };
|
|
661
393
|
}
|
|
662
394
|
else {
|
|
663
|
-
|
|
664
|
-
value = { ...value, ...rawValue };
|
|
395
|
+
value = { ...value, ...rawValue };
|
|
665
396
|
}
|
|
666
397
|
}
|
|
667
398
|
else if (valueHandling === 'include' && templateField.key) {
|
|
@@ -669,13 +400,9 @@ class ArrayFieldComponent {
|
|
|
669
400
|
}
|
|
670
401
|
}
|
|
671
402
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
// Append auto-remove button to resolution templates (for rendering only)
|
|
676
|
-
const resolveTemplates = this.withAutoRemove(templates);
|
|
677
|
-
// Resolve the new item
|
|
678
|
-
const resolvedItem = await firstValueFrom(resolveArrayItem({
|
|
403
|
+
const run = this.fsm.dispatch({ kind: 'append' });
|
|
404
|
+
const resolveTemplates = this.withAutoRemove(templates);
|
|
405
|
+
const resolvedItem = await firstValueFrom(resolveArrayItem({
|
|
679
406
|
index: insertIndex,
|
|
680
407
|
templates: resolveTemplates,
|
|
681
408
|
arrayField: this.field(),
|
|
@@ -694,52 +421,35 @@ class ArrayFieldComponent {
|
|
|
694
421
|
if (run.isStale() || !resolvedItem) {
|
|
695
422
|
return;
|
|
696
423
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
this.slot().itemOrder.splice(insertIndex, 0, resolvedItem.id);
|
|
701
|
-
// Insert resolved item at correct position
|
|
702
|
-
this.resolvedItemsSignal.update((current) => {
|
|
424
|
+
this.slot().templates.set(resolvedItem.id, templates);
|
|
425
|
+
this.slot().itemOrder.splice(insertIndex, 0, resolvedItem.id);
|
|
426
|
+
this.resolvedItemsSignal.update((current) => {
|
|
703
427
|
const newItems = [...current];
|
|
704
428
|
newItems.splice(insertIndex, 0, resolvedItem);
|
|
705
429
|
return newItems;
|
|
706
430
|
});
|
|
707
|
-
|
|
708
|
-
const newArray = [...currentArray];
|
|
431
|
+
const newArray = [...currentArray];
|
|
709
432
|
newArray.splice(insertIndex, 0, value);
|
|
710
|
-
|
|
711
|
-
// that cannot infer the correct type when setting a dynamically-keyed property
|
|
712
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
713
|
-
parentForm().value.set({ ...currentValue, [arrayKey]: newArray });
|
|
433
|
+
parentForm().value.set({ ...currentValue, [arrayKey]: newArray });
|
|
714
434
|
}
|
|
715
435
|
performDifferentialUpdate(fieldTrees) {
|
|
716
436
|
const resolvedItems = this.resolvedItemsSignal();
|
|
717
437
|
const operation = determineDifferentialOperation(resolvedItems, fieldTrees.length);
|
|
718
|
-
|
|
719
|
-
// interfere with in-flight async resolutions.
|
|
720
|
-
if (operation.type === 'none') {
|
|
438
|
+
if (operation.type === 'none') {
|
|
721
439
|
return;
|
|
722
440
|
}
|
|
723
441
|
switch (operation.type) {
|
|
724
442
|
case 'clear': {
|
|
725
|
-
|
|
726
|
-
this.slot().templates.clear();
|
|
443
|
+
this.slot().templates.clear();
|
|
727
444
|
this.resolvedItemsSignal.set([]);
|
|
728
445
|
const run = this.fsm.dispatch({ kind: 'clear' });
|
|
729
|
-
|
|
730
|
-
// the init pulse (matches the form-starts-empty case from the prior implementation).
|
|
731
|
-
if (resolvedItems.length === 0 && !this.fsm.hasEverEmitted) {
|
|
446
|
+
if (resolvedItems.length === 0 && !this.fsm.hasEverEmitted) {
|
|
732
447
|
run.resolve();
|
|
733
448
|
}
|
|
734
449
|
break;
|
|
735
450
|
}
|
|
736
451
|
case 'initial': {
|
|
737
|
-
|
|
738
|
-
// form, same array path, just a different render cycle — e.g. an `@if`-gated array
|
|
739
|
-
// that was hidden then re-shown), reuse the stored templates AND ids so dynamically-
|
|
740
|
-
// added items rebuild correctly. When the slot is empty this collapses to a normal
|
|
741
|
-
// initial resolve via Priority 2/3.
|
|
742
|
-
const slot = this.slot();
|
|
452
|
+
const slot = this.slot();
|
|
743
453
|
const useSurvival = slot.itemOrder.length === fieldTrees.length && fieldTrees.length > 0;
|
|
744
454
|
const positionalTemplates = useSurvival ? slot.itemOrder.map((id) => slot.templates.get(id)) : undefined;
|
|
745
455
|
const existingItemIds = useSurvival ? slot.itemOrder.slice() : undefined;
|
|
@@ -753,13 +463,8 @@ class ArrayFieldComponent {
|
|
|
753
463
|
break;
|
|
754
464
|
}
|
|
755
465
|
case 'recreate': {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
// or moves) take priority over positional itemTemplates lookup.
|
|
759
|
-
const positionalTemplates = resolvedItems.map((item) => {
|
|
760
|
-
// Registry entries (from dynamic adds or moves) take priority;
|
|
761
|
-
// unmoved initial items return undefined → falls through to itemTemplates[idx] during resolve.
|
|
762
|
-
return this.slot().templates.get(item.id);
|
|
466
|
+
const positionalTemplates = resolvedItems.map((item) => {
|
|
467
|
+
return this.slot().templates.get(item.id);
|
|
763
468
|
});
|
|
764
469
|
this.resolvedItemsSignal.set([]);
|
|
765
470
|
const run = this.fsm.dispatch({ kind: 'recreate' });
|
|
@@ -773,18 +478,15 @@ class ArrayFieldComponent {
|
|
|
773
478
|
this.resolvedItemsSignal.set([]);
|
|
774
479
|
return;
|
|
775
480
|
}
|
|
776
|
-
|
|
777
|
-
const safeItemObservables = fieldTrees.map((_, i) => this.createResolveItemObservable(i, positionalTemplates?.[i], existingItemIds?.[i]).pipe(catchError((error) => {
|
|
481
|
+
const safeItemObservables = fieldTrees.map((_, i) => this.createResolveItemObservable(i, positionalTemplates?.[i], existingItemIds?.[i]).pipe(catchError((error) => {
|
|
778
482
|
this.logger.error(`Failed to resolve array item at index ${i}:`, error);
|
|
779
483
|
return of(undefined);
|
|
780
484
|
})));
|
|
781
485
|
try {
|
|
782
486
|
const items = await firstValueFrom(forkJoin(safeItemObservables).pipe(map((items) => items.filter((item) => item !== undefined))));
|
|
783
487
|
if (!run.isStale()) {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
// Clean up old entries and add new ones
|
|
787
|
-
const newItemIds = new Set(items.map((item) => item.id));
|
|
488
|
+
if (positionalTemplates) {
|
|
489
|
+
const newItemIds = new Set(items.map((item) => item.id));
|
|
788
490
|
for (const existingId of this.slot().templates.keys()) {
|
|
789
491
|
if (!newItemIds.has(existingId)) {
|
|
790
492
|
this.slot().templates.delete(existingId);
|
|
@@ -797,15 +499,12 @@ class ArrayFieldComponent {
|
|
|
797
499
|
}
|
|
798
500
|
});
|
|
799
501
|
}
|
|
800
|
-
|
|
801
|
-
// recreate (e.g. after an @if hide cycle) can rehydrate via the survival path.
|
|
802
|
-
const slot = this.slot();
|
|
502
|
+
const slot = this.slot();
|
|
803
503
|
slot.itemOrder.length = 0;
|
|
804
504
|
for (const item of items)
|
|
805
505
|
slot.itemOrder.push(item.id);
|
|
806
506
|
this.resolvedItemsSignal.set(items);
|
|
807
|
-
|
|
808
|
-
run.resolve();
|
|
507
|
+
run.resolve();
|
|
809
508
|
}
|
|
810
509
|
}
|
|
811
510
|
catch (err) {
|
|
@@ -817,8 +516,7 @@ class ArrayFieldComponent {
|
|
|
817
516
|
const itemsToResolve = fieldTrees.slice(startIndex, endIndex);
|
|
818
517
|
if (itemsToResolve.length === 0)
|
|
819
518
|
return;
|
|
820
|
-
|
|
821
|
-
const safeItemObservables = itemsToResolve.map((_, i) => {
|
|
519
|
+
const safeItemObservables = itemsToResolve.map((_, i) => {
|
|
822
520
|
const index = startIndex + i;
|
|
823
521
|
return this.createResolveItemObservable(index).pipe(catchError((error) => {
|
|
824
522
|
this.logger.error(`Failed to resolve array item at index ${index}:`, error);
|
|
@@ -838,24 +536,10 @@ class ArrayFieldComponent {
|
|
|
838
536
|
this.logger.error('Failed to append array items:', err);
|
|
839
537
|
}
|
|
840
538
|
}
|
|
841
|
-
|
|
842
|
-
* Creates an observable that resolves a single array item.
|
|
843
|
-
*
|
|
844
|
-
* Template resolution order:
|
|
845
|
-
* 1. Use overrideTemplate if provided (from recreate with stored templates)
|
|
846
|
-
* 2. Use itemTemplates[index] if within defined templates range
|
|
847
|
-
* 3. Use the simplified-array fallback template (from normalization metadata) for
|
|
848
|
-
* untracked items present in the form value (e.g., external `value.set`, parent
|
|
849
|
-
* two-way binding, initial values beyond what was declared via simplified `value`).
|
|
850
|
-
* Registers the resolved item in templateRegistry so subsequent recreates use
|
|
851
|
-
* Priority 1.
|
|
852
|
-
* 4. Return undefined (item cannot be resolved without a template)
|
|
853
|
-
*/
|
|
854
|
-
createResolveItemObservable(index, overrideTemplate, existingItemId) {
|
|
539
|
+
createResolveItemObservable(index, overrideTemplate, existingItemId) {
|
|
855
540
|
const itemTemplates = this.itemTemplates();
|
|
856
541
|
const primitiveKey = this.primitiveFieldKey();
|
|
857
|
-
|
|
858
|
-
if (overrideTemplate && overrideTemplate.length > 0) {
|
|
542
|
+
if (overrideTemplate && overrideTemplate.length > 0) {
|
|
859
543
|
return resolveArrayItem({
|
|
860
544
|
index,
|
|
861
545
|
templates: this.withAutoRemove(overrideTemplate),
|
|
@@ -871,8 +555,7 @@ class ArrayFieldComponent {
|
|
|
871
555
|
primitiveFieldKey: primitiveKey,
|
|
872
556
|
});
|
|
873
557
|
}
|
|
874
|
-
|
|
875
|
-
const templates = itemTemplates[index];
|
|
558
|
+
const templates = itemTemplates[index];
|
|
876
559
|
if (templates && templates.length > 0) {
|
|
877
560
|
return resolveArrayItem({
|
|
878
561
|
index,
|
|
@@ -889,9 +572,7 @@ class ArrayFieldComponent {
|
|
|
889
572
|
primitiveFieldKey: primitiveKey,
|
|
890
573
|
});
|
|
891
574
|
}
|
|
892
|
-
|
|
893
|
-
// Homogeneous only — every fallback item receives the same template regardless of value shape.
|
|
894
|
-
const fallback = this.fallbackTemplate();
|
|
575
|
+
const fallback = this.fallbackTemplate();
|
|
895
576
|
if (fallback && fallback.length > 0) {
|
|
896
577
|
return resolveArrayItem({
|
|
897
578
|
index,
|
|
@@ -907,32 +588,14 @@ class ArrayFieldComponent {
|
|
|
907
588
|
existingItemId,
|
|
908
589
|
primitiveFieldKey: primitiveKey,
|
|
909
590
|
}).pipe(tap((item) => {
|
|
910
|
-
|
|
911
|
-
// recreates hit Priority 1 instead of re-resolving via this branch.
|
|
912
|
-
if (item)
|
|
591
|
+
if (item)
|
|
913
592
|
this.slot().templates.set(item.id, fallback);
|
|
914
593
|
}));
|
|
915
594
|
}
|
|
916
|
-
|
|
917
|
-
// `fields` declares one template per item, so values extending past `fields.length`
|
|
918
|
-
// (e.g., from external `value.set`, parent two-way binding, or initial values on a
|
|
919
|
-
// `fields: []` array) have no template to render. Use the simplified array API
|
|
920
|
-
// (`template` + `value`) when you need homogeneous arrays with value-driven items.
|
|
921
|
-
this.logger.warn(`No template found for array item at index ${index}. This likely occured for a Full-API array element that was created from the DynamicForm's value being set directly, which is currently not supported. Consider using the simplified array API.`);
|
|
595
|
+
this.logger.warn(`No template found for array item at index ${index}. This likely occured for a Full-API array element that was created from the DynamicForm's value being set directly, which is currently not supported. Consider using the simplified array API.`);
|
|
922
596
|
return of(undefined);
|
|
923
597
|
}
|
|
924
|
-
|
|
925
|
-
* Appends the auto-remove button to a templates array for rendering.
|
|
926
|
-
* The button is added for visual rendering only — it doesn't affect the form schema.
|
|
927
|
-
* Original templates are stored in templateRegistry WITHOUT the remove button,
|
|
928
|
-
* so this method is called during resolution to add it dynamically.
|
|
929
|
-
*
|
|
930
|
-
* Uses a WeakMap cache keyed by template array reference. Cache hits occur
|
|
931
|
-
* during recreate/resolution paths where stored templates are reused.
|
|
932
|
-
* Add operations always pass a fresh `[...template]` copy, so they miss
|
|
933
|
-
* the cache intentionally (the copy is needed for mutable item construction).
|
|
934
|
-
*/
|
|
935
|
-
withAutoRemove(templates) {
|
|
598
|
+
withAutoRemove(templates) {
|
|
936
599
|
const removeButton = this.autoRemoveButton();
|
|
937
600
|
if (!removeButton)
|
|
938
601
|
return templates;
|
|
@@ -943,15 +606,7 @@ class ArrayFieldComponent {
|
|
|
943
606
|
this.autoRemoveCache.set(templates, cached);
|
|
944
607
|
return cached;
|
|
945
608
|
}
|
|
946
|
-
|
|
947
|
-
* Handles move operations — reorders an existing item without destroying it.
|
|
948
|
-
* Updates resolvedItems and form value atomically. Since the array length
|
|
949
|
-
* doesn't change, `determineDifferentialOperation` returns 'none' and no
|
|
950
|
-
* recreate is triggered. The `@for` loop tracks by `item.id`, so Angular
|
|
951
|
-
* moves the DOM node instead of destroying/recreating. The `itemPositionMap`
|
|
952
|
-
* computed auto-recomputes, propagating new indices to child linkedSignals.
|
|
953
|
-
*/
|
|
954
|
-
moveItem(fromIndex, toIndex) {
|
|
609
|
+
moveItem(fromIndex, toIndex) {
|
|
955
610
|
const arrayKey = this.field().key;
|
|
956
611
|
const parentForm = this.parentFieldSignalContext.form;
|
|
957
612
|
const currentValue = parentForm().value();
|
|
@@ -967,14 +622,7 @@ class ArrayFieldComponent {
|
|
|
967
622
|
}
|
|
968
623
|
if (fromIndex === toIndex)
|
|
969
624
|
return;
|
|
970
|
-
|
|
971
|
-
// Initial items use itemTemplates[currentIndex] during recreate, but after
|
|
972
|
-
// a move their position no longer matches their original template. Stashing
|
|
973
|
-
// the template by item ID lets the recreate path resolve the correct one.
|
|
974
|
-
// We use rawItemTemplates (without auto-remove button) because the recreate path
|
|
975
|
-
// calls withAutoRemove() during resolution — storing the synthesized version would
|
|
976
|
-
// cause duplicate remove buttons.
|
|
977
|
-
const rawTemplates = this.rawItemTemplates();
|
|
625
|
+
const rawTemplates = this.rawItemTemplates();
|
|
978
626
|
const currentItems = this.resolvedItemsSignal();
|
|
979
627
|
const lo = Math.min(fromIndex, toIndex);
|
|
980
628
|
const hi = Math.max(fromIndex, toIndex);
|
|
@@ -984,37 +632,26 @@ class ArrayFieldComponent {
|
|
|
984
632
|
this.slot().templates.set(item.id, rawTemplates[i]);
|
|
985
633
|
}
|
|
986
634
|
}
|
|
987
|
-
|
|
988
|
-
this.resolvedItemsSignal.update((current) => {
|
|
635
|
+
this.resolvedItemsSignal.update((current) => {
|
|
989
636
|
const newItems = [...current];
|
|
990
637
|
const [moved] = newItems.splice(fromIndex, 1);
|
|
991
638
|
newItems.splice(toIndex, 0, moved);
|
|
992
639
|
return newItems;
|
|
993
640
|
});
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
// Reorder form value array the same way
|
|
997
|
-
const newArray = [...currentArray];
|
|
641
|
+
this.slot().moveItem(fromIndex, toIndex);
|
|
642
|
+
const newArray = [...currentArray];
|
|
998
643
|
const [movedValue] = newArray.splice(fromIndex, 1);
|
|
999
644
|
newArray.splice(toIndex, 0, movedValue);
|
|
1000
|
-
|
|
1001
|
-
parentForm().value.set({ ...currentValue, [arrayKey]: newArray });
|
|
645
|
+
parentForm().value.set({ ...currentValue, [arrayKey]: newArray });
|
|
1002
646
|
}
|
|
1003
|
-
|
|
1004
|
-
* Handles remove operations from events (pop, shift, removeAt).
|
|
1005
|
-
* Updates resolvedItems FIRST, then form value - this ensures differential
|
|
1006
|
-
* update sees "none" (lengths match) and avoids unnecessary recreates.
|
|
1007
|
-
* Remaining items' linkedSignal indices auto-update via itemPositionMap.
|
|
1008
|
-
*/
|
|
1009
|
-
removeItem(index) {
|
|
647
|
+
removeItem(index) {
|
|
1010
648
|
const arrayKey = this.field().key;
|
|
1011
649
|
const parentForm = this.parentFieldSignalContext.form;
|
|
1012
650
|
const currentValue = parentForm().value();
|
|
1013
651
|
const currentArray = getArrayValue(currentValue, arrayKey);
|
|
1014
652
|
if (currentArray.length === 0)
|
|
1015
653
|
return;
|
|
1016
|
-
|
|
1017
|
-
let removeIndex;
|
|
654
|
+
let removeIndex;
|
|
1018
655
|
if (index === undefined || index === -1) {
|
|
1019
656
|
removeIndex = currentArray.length - 1;
|
|
1020
657
|
}
|
|
@@ -1025,33 +662,19 @@ class ArrayFieldComponent {
|
|
|
1025
662
|
else {
|
|
1026
663
|
removeIndex = index;
|
|
1027
664
|
}
|
|
1028
|
-
|
|
1029
|
-
// This ensures differential update sees "none" (lengths already match).
|
|
1030
|
-
// Remaining items' linkedSignal indices auto-update via itemPositionMap.
|
|
1031
|
-
//
|
|
1032
|
-
// When the item is removed from resolvedItemsSignal, the @for loop removes the DOM element
|
|
1033
|
-
// and NgComponentOutlet destroys the component view, triggering DestroyRef callbacks.
|
|
1034
|
-
// Async validators use Angular's resource API tied to the form-level schema path — when the
|
|
1035
|
-
// array value is updated below, the resource's params re-evaluate. If the removed item's path
|
|
1036
|
-
// no longer exists, params returns undefined, cancelling the pending validation automatically.
|
|
1037
|
-
const removedItem = this.resolvedItemsSignal()[removeIndex];
|
|
665
|
+
const removedItem = this.resolvedItemsSignal()[removeIndex];
|
|
1038
666
|
if (removedItem) {
|
|
1039
667
|
this.slot().templates.delete(removedItem.id);
|
|
1040
668
|
}
|
|
1041
|
-
|
|
1042
|
-
// restore an id that's no longer in the form value.
|
|
1043
|
-
this.slot().removeAt(removeIndex);
|
|
669
|
+
this.slot().removeAt(removeIndex);
|
|
1044
670
|
this.resolvedItemsSignal.update((current) => {
|
|
1045
671
|
const newItems = [...current];
|
|
1046
672
|
newItems.splice(removeIndex, 1);
|
|
1047
673
|
return newItems;
|
|
1048
674
|
});
|
|
1049
|
-
|
|
1050
|
-
const newArray = [...currentArray];
|
|
675
|
+
const newArray = [...currentArray];
|
|
1051
676
|
newArray.splice(removeIndex, 1);
|
|
1052
|
-
|
|
1053
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1054
|
-
parentForm().value.set({ ...currentValue, [arrayKey]: newArray });
|
|
677
|
+
parentForm().value.set({ ...currentValue, [arrayKey]: newArray });
|
|
1055
678
|
}
|
|
1056
679
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ArrayFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1057
680
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: ArrayFieldComponent, isStandalone: true, selector: "array-field", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, key: { classPropertyName: "key", publicName: "key", isSignal: true, isRequired: true, transformFunction: null }, className: { classPropertyName: "className", publicName: "className", isSignal: true, isRequired: false, transformFunction: null }, hidden: { classPropertyName: "hidden", publicName: "hidden", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "hostClasses()", "class.df-container-hidden": "hidden()", "attr.aria-hidden": "hidden() || null", "id": "`${key()}`", "attr.data-testid": "key()" } }, ngImport: i0, template: `
|
|
@@ -1100,4 +723,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
|
|
|
1100
723
|
}], ctorParameters: () => [], propDecorators: { field: [{ type: i0.Input, args: [{ isSignal: true, alias: "field", required: true }] }], key: [{ type: i0.Input, args: [{ isSignal: true, alias: "key", required: true }] }], className: [{ type: i0.Input, args: [{ isSignal: true, alias: "className", required: false }] }], hidden: [{ type: i0.Input, args: [{ isSignal: true, alias: "hidden", required: false }] }] } });
|
|
1101
724
|
|
|
1102
725
|
export { ArrayFieldComponent, ArrayFieldComponent as default };
|
|
1103
|
-
//# sourceMappingURL=ng-forge-dynamic-forms-array-field.component-
|
|
726
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-array-field.component-PPh_uVVh.mjs.map
|