@signaltree/ng-forms 4.0.9 → 4.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/signaltree-ng-forms-src-audit.mjs +20 -0
- package/fesm2022/signaltree-ng-forms-src-audit.mjs.map +1 -0
- package/fesm2022/signaltree-ng-forms-src-history.mjs +113 -0
- package/fesm2022/signaltree-ng-forms-src-history.mjs.map +1 -0
- package/fesm2022/signaltree-ng-forms-src-rxjs.mjs +21 -0
- package/fesm2022/signaltree-ng-forms-src-rxjs.mjs.map +1 -0
- package/fesm2022/signaltree-ng-forms.mjs +1038 -0
- package/fesm2022/signaltree-ng-forms.mjs.map +1 -0
- package/index.d.ts +128 -0
- package/package.json +27 -6
- package/src/audit/index.d.ts +15 -0
- package/src/history/index.d.ts +24 -0
- package/src/rxjs/index.d.ts +6 -0
- package/LICENSE +0 -54
- package/eslint.config.mjs +0 -48
- package/jest.config.ts +0 -21
- package/ng-package.json +0 -8
- package/project.json +0 -48
- package/src/audit/audit.ts +0 -75
- package/src/audit/index.ts +0 -1
- package/src/audit/ng-package.json +0 -5
- package/src/core/async-validators.ts +0 -80
- package/src/core/ng-forms.spec.ts +0 -204
- package/src/core/ng-forms.ts +0 -1316
- package/src/core/validators.ts +0 -209
- package/src/history/history.ts +0 -169
- package/src/history/index.ts +0 -1
- package/src/history/ng-package.json +0 -5
- package/src/index.ts +0 -5
- package/src/rxjs/index.ts +0 -1
- package/src/rxjs/ng-package.json +0 -5
- package/src/rxjs/public-api.ts +0 -5
- package/src/rxjs/rxjs-bridge.ts +0 -75
- package/src/test-setup.ts +0 -6
- package/src/types/signaltree-core.d.ts +0 -11
- package/src/wizard/index.ts +0 -1
- package/src/wizard/wizard.ts +0 -145
- package/tsconfig.json +0 -33
- package/tsconfig.lib.json +0 -25
- package/tsconfig.lib.prod.json +0 -13
- package/tsconfig.spec.json +0 -17
package/src/core/ng-forms.ts
DELETED
|
@@ -1,1316 +0,0 @@
|
|
|
1
|
-
/// <reference path="../types/signaltree-core.d.ts" />
|
|
2
|
-
import {
|
|
3
|
-
computed,
|
|
4
|
-
effect,
|
|
5
|
-
inject,
|
|
6
|
-
signal,
|
|
7
|
-
Signal,
|
|
8
|
-
WritableSignal,
|
|
9
|
-
DestroyRef,
|
|
10
|
-
Directive,
|
|
11
|
-
ElementRef,
|
|
12
|
-
Renderer2,
|
|
13
|
-
HostListener,
|
|
14
|
-
Input,
|
|
15
|
-
Output,
|
|
16
|
-
EventEmitter,
|
|
17
|
-
Injectable,
|
|
18
|
-
isSignal,
|
|
19
|
-
forwardRef,
|
|
20
|
-
OnInit,
|
|
21
|
-
} from '@angular/core';
|
|
22
|
-
import {
|
|
23
|
-
AbstractControl,
|
|
24
|
-
AsyncValidatorFn as AngularAsyncValidatorFn,
|
|
25
|
-
ControlValueAccessor,
|
|
26
|
-
FormArray,
|
|
27
|
-
FormControl,
|
|
28
|
-
FormGroup,
|
|
29
|
-
NG_VALUE_ACCESSOR,
|
|
30
|
-
ValidationErrors,
|
|
31
|
-
ValidatorFn as AngularValidatorFn,
|
|
32
|
-
} from '@angular/forms';
|
|
33
|
-
import { signalTree } from '@signaltree/core';
|
|
34
|
-
import { deepClone, matchPath, mergeDeep, parsePath } from '@signaltree/shared';
|
|
35
|
-
import { firstValueFrom, isObservable, Observable, Subscription } from 'rxjs';
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* @fileoverview Angular Forms Integration for SignalTree
|
|
39
|
-
*
|
|
40
|
-
* Provides comprehensive Angular form integration including FormTree,
|
|
41
|
-
* directives, validators, enhanced array operations, and RxJS bridge.
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
// Re-export core types needed for forms
|
|
45
|
-
import type { SignalTree, TreeConfig, TreeNode } from '@signaltree/core';
|
|
46
|
-
// ============================================
|
|
47
|
-
// FORM TREE TYPES
|
|
48
|
-
// ============================================
|
|
49
|
-
|
|
50
|
-
export type FormTreeAsyncValidatorFn<T> = (
|
|
51
|
-
value: T
|
|
52
|
-
) => Observable<string | null> | Promise<string | null>;
|
|
53
|
-
|
|
54
|
-
export type EnhancedArraySignal<T> = WritableSignal<T[]> & {
|
|
55
|
-
push: (item: T) => void;
|
|
56
|
-
removeAt: (index: number) => void;
|
|
57
|
-
setAt: (index: number, value: T) => void;
|
|
58
|
-
insertAt: (index: number, item: T) => void;
|
|
59
|
-
move: (from: number, to: number) => void;
|
|
60
|
-
clear: () => void;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export type FieldValidator = (value: unknown) => string | null;
|
|
64
|
-
|
|
65
|
-
export interface FieldConfig {
|
|
66
|
-
debounceMs?: number;
|
|
67
|
-
validators?: Record<string, FieldValidator> | FieldValidator[];
|
|
68
|
-
asyncValidators?:
|
|
69
|
-
| Record<string, FormTreeAsyncValidatorFn<unknown>>
|
|
70
|
-
| Array<FormTreeAsyncValidatorFn<unknown>>;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface ConditionalField<T> {
|
|
74
|
-
when: (values: T) => boolean;
|
|
75
|
-
fields: string[];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface FormTreeOptions<T extends Record<string, unknown>>
|
|
79
|
-
extends TreeConfig {
|
|
80
|
-
validators?: SyncValidatorMap;
|
|
81
|
-
asyncValidators?: AsyncValidatorMap;
|
|
82
|
-
destroyRef?: DestroyRef;
|
|
83
|
-
fieldConfigs?: Record<string, FieldConfig>;
|
|
84
|
-
conditionals?: Array<ConditionalField<T>>;
|
|
85
|
-
persistKey?: string;
|
|
86
|
-
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
87
|
-
persistDebounceMs?: number;
|
|
88
|
-
validationBatchMs?: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Form tree type that flattens the state access while maintaining form-specific properties
|
|
93
|
-
*/
|
|
94
|
-
export type FormTree<T extends Record<string, unknown>> = {
|
|
95
|
-
// Flattened state access - direct access to form values as signals
|
|
96
|
-
state: TreeNode<T>;
|
|
97
|
-
$: TreeNode<T>; // Alias for state
|
|
98
|
-
|
|
99
|
-
// Underlying Angular form structure
|
|
100
|
-
form: FormGroup;
|
|
101
|
-
|
|
102
|
-
// Form-specific signals
|
|
103
|
-
errors: WritableSignal<Record<string, string>>;
|
|
104
|
-
asyncErrors: WritableSignal<Record<string, string>>;
|
|
105
|
-
touched: WritableSignal<Record<string, boolean>>;
|
|
106
|
-
asyncValidating: WritableSignal<Record<string, boolean>>;
|
|
107
|
-
dirty: WritableSignal<boolean>;
|
|
108
|
-
valid: WritableSignal<boolean>;
|
|
109
|
-
submitting: WritableSignal<boolean>;
|
|
110
|
-
|
|
111
|
-
// Form methods
|
|
112
|
-
unwrap(): T;
|
|
113
|
-
setValue(field: string, value: unknown): void;
|
|
114
|
-
setValues(values: Partial<T>): void;
|
|
115
|
-
reset(): void;
|
|
116
|
-
submit<TResult>(submitFn: (values: T) => Promise<TResult>): Promise<TResult>;
|
|
117
|
-
validate(field?: string): Promise<void>;
|
|
118
|
-
|
|
119
|
-
// Field-level helpers
|
|
120
|
-
getFieldError(field: string): Signal<string | undefined>;
|
|
121
|
-
getFieldAsyncError(field: string): Signal<string | undefined>;
|
|
122
|
-
getFieldTouched(field: string): Signal<boolean | undefined>;
|
|
123
|
-
isFieldValid(field: string): Signal<boolean>;
|
|
124
|
-
isFieldAsyncValidating(field: string): Signal<boolean | undefined>;
|
|
125
|
-
|
|
126
|
-
// Direct access to field errors
|
|
127
|
-
fieldErrors: Record<string, Signal<string | undefined>>;
|
|
128
|
-
fieldAsyncErrors: Record<string, Signal<string | undefined>>;
|
|
129
|
-
|
|
130
|
-
// Keep values tree for backward compatibility
|
|
131
|
-
values: SignalTree<T>;
|
|
132
|
-
|
|
133
|
-
// Cleanup helpers to tear down subscriptions created for bridge layer
|
|
134
|
-
destroy(): void;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
export class FormValidationError extends Error {
|
|
138
|
-
constructor(
|
|
139
|
-
public readonly errors: Record<string, string>,
|
|
140
|
-
public readonly asyncErrors: Record<string, string>
|
|
141
|
-
) {
|
|
142
|
-
super('Form validation failed');
|
|
143
|
-
this.name = 'FormValidationError';
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
type SyncValidatorMap = Record<string, (value: unknown) => string | null>;
|
|
148
|
-
type AsyncValidatorMap = Record<string, FormTreeAsyncValidatorFn<unknown>>;
|
|
149
|
-
|
|
150
|
-
const SYNC_ERROR_KEY = 'signaltree';
|
|
151
|
-
const ASYNC_ERROR_KEY = 'signaltreeAsync';
|
|
152
|
-
|
|
153
|
-
// ============================================
|
|
154
|
-
// UTILITY FUNCTIONS
|
|
155
|
-
// ============================================
|
|
156
|
-
|
|
157
|
-
// ============================================
|
|
158
|
-
// FORM TREE IMPLEMENTATION
|
|
159
|
-
// ============================================
|
|
160
|
-
|
|
161
|
-
export function createFormTree<T extends Record<string, unknown>>(
|
|
162
|
-
initialValues: T,
|
|
163
|
-
config: FormTreeOptions<T> = {}
|
|
164
|
-
): FormTree<T> {
|
|
165
|
-
const {
|
|
166
|
-
validators: baseValidators = {},
|
|
167
|
-
asyncValidators: baseAsyncValidators = {},
|
|
168
|
-
destroyRef: providedDestroyRef,
|
|
169
|
-
fieldConfigs = {},
|
|
170
|
-
conditionals = [],
|
|
171
|
-
persistKey,
|
|
172
|
-
storage,
|
|
173
|
-
persistDebounceMs = 100,
|
|
174
|
-
validationBatchMs = 0,
|
|
175
|
-
...treeConfig
|
|
176
|
-
} = config;
|
|
177
|
-
|
|
178
|
-
const syncValidators = normalizeSyncValidators(baseValidators, fieldConfigs);
|
|
179
|
-
const asyncValidators = normalizeAsyncValidators(
|
|
180
|
-
baseAsyncValidators,
|
|
181
|
-
fieldConfigs
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
const { values: hydratedInitialValues } = hydrateInitialValues(
|
|
185
|
-
initialValues,
|
|
186
|
-
persistKey,
|
|
187
|
-
storage
|
|
188
|
-
);
|
|
189
|
-
const initialSnapshot = deepClone(hydratedInitialValues);
|
|
190
|
-
|
|
191
|
-
const valuesTree = signalTree(hydratedInitialValues, treeConfig);
|
|
192
|
-
assertTreeNode<T>(valuesTree.state);
|
|
193
|
-
const flattenedState = valuesTree.state;
|
|
194
|
-
|
|
195
|
-
enhanceArraysRecursively(
|
|
196
|
-
flattenedState as unknown as Record<string, unknown>
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
const formGroup = createAbstractControl(
|
|
200
|
-
hydratedInitialValues,
|
|
201
|
-
'',
|
|
202
|
-
syncValidators,
|
|
203
|
-
asyncValidators
|
|
204
|
-
) as FormGroup;
|
|
205
|
-
|
|
206
|
-
const destroyRef = providedDestroyRef ?? tryInjectDestroyRef();
|
|
207
|
-
const cleanupCallbacks: Array<() => void> = [];
|
|
208
|
-
|
|
209
|
-
const errors = signal<Record<string, string>>({});
|
|
210
|
-
const asyncErrors = signal<Record<string, string>>({});
|
|
211
|
-
const touched = signal<Record<string, boolean>>({});
|
|
212
|
-
const asyncValidating = signal<Record<string, boolean>>({});
|
|
213
|
-
const dirty = signal(formGroup.dirty);
|
|
214
|
-
const valid = signal(formGroup.valid && !formGroup.pending);
|
|
215
|
-
const submitting = signal(false);
|
|
216
|
-
|
|
217
|
-
const refreshRunner = () => {
|
|
218
|
-
const snapshot = collectControlSnapshot(formGroup);
|
|
219
|
-
errors.set(snapshot.syncErrors);
|
|
220
|
-
asyncErrors.set(snapshot.asyncErrors);
|
|
221
|
-
touched.set(snapshot.touched);
|
|
222
|
-
asyncValidating.set(snapshot.pending);
|
|
223
|
-
dirty.set(formGroup.dirty);
|
|
224
|
-
valid.set(formGroup.valid && !formGroup.pending);
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
228
|
-
const refreshAggregates = (immediate = false) => {
|
|
229
|
-
if (immediate || validationBatchMs <= 0) {
|
|
230
|
-
if (refreshTimer) {
|
|
231
|
-
clearTimeout(refreshTimer);
|
|
232
|
-
refreshTimer = null;
|
|
233
|
-
}
|
|
234
|
-
refreshRunner();
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (refreshTimer) {
|
|
239
|
-
clearTimeout(refreshTimer);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
refreshTimer = setTimeout(() => {
|
|
243
|
-
refreshTimer = null;
|
|
244
|
-
refreshRunner();
|
|
245
|
-
}, validationBatchMs);
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
refreshAggregates(true);
|
|
249
|
-
|
|
250
|
-
const persistController = createPersistController(
|
|
251
|
-
formGroup,
|
|
252
|
-
persistKey,
|
|
253
|
-
storage,
|
|
254
|
-
persistDebounceMs,
|
|
255
|
-
cleanupCallbacks
|
|
256
|
-
);
|
|
257
|
-
persistController.persistImmediately();
|
|
258
|
-
|
|
259
|
-
const fieldErrorKeys = new Set([
|
|
260
|
-
...Object.keys(syncValidators),
|
|
261
|
-
...Object.keys(asyncValidators),
|
|
262
|
-
]);
|
|
263
|
-
|
|
264
|
-
const fieldErrors: Record<string, Signal<string | undefined>> = {};
|
|
265
|
-
const fieldAsyncErrors: Record<string, Signal<string | undefined>> = {};
|
|
266
|
-
|
|
267
|
-
fieldErrorKeys.forEach((fieldPath) => {
|
|
268
|
-
fieldErrors[fieldPath] = computed(() => errors()[fieldPath]);
|
|
269
|
-
fieldAsyncErrors[fieldPath] = computed(() => asyncErrors()[fieldPath]);
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
const fieldConfigLookup = (path: string) =>
|
|
273
|
-
resolveFieldConfig(fieldConfigs, path);
|
|
274
|
-
|
|
275
|
-
const connectControlRecursive = (control: AbstractControl, path: string) => {
|
|
276
|
-
if (control instanceof FormGroup) {
|
|
277
|
-
Object.entries(control.controls).forEach(([key, child]) => {
|
|
278
|
-
connectControlRecursive(child, joinPath(path, key));
|
|
279
|
-
});
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (control instanceof FormArray) {
|
|
284
|
-
const arraySignal = getSignalAtPath(flattenedState, path);
|
|
285
|
-
if (arraySignal) {
|
|
286
|
-
connectFormArrayAndSignal(
|
|
287
|
-
control,
|
|
288
|
-
arraySignal as EnhancedArraySignal<unknown>,
|
|
289
|
-
path,
|
|
290
|
-
syncValidators,
|
|
291
|
-
asyncValidators,
|
|
292
|
-
cleanupCallbacks,
|
|
293
|
-
connectControlRecursive
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
control.controls.forEach((child, index) => {
|
|
298
|
-
connectControlRecursive(child, joinPath(path, String(index)));
|
|
299
|
-
});
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const signalAtPath = getSignalAtPath(flattenedState, path);
|
|
304
|
-
if (signalAtPath) {
|
|
305
|
-
connectControlAndSignal(
|
|
306
|
-
control as FormControl,
|
|
307
|
-
signalAtPath as WritableSignal<unknown>,
|
|
308
|
-
cleanupCallbacks,
|
|
309
|
-
fieldConfigLookup(path)
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
connectControlRecursive(formGroup, '');
|
|
315
|
-
|
|
316
|
-
const conditionalState = new Map<string, boolean>();
|
|
317
|
-
const applyConditionals =
|
|
318
|
-
conditionals.length > 0
|
|
319
|
-
? () => {
|
|
320
|
-
const values = formGroup.getRawValue() as T;
|
|
321
|
-
conditionals.forEach(({ when, fields }) => {
|
|
322
|
-
let visible = true;
|
|
323
|
-
try {
|
|
324
|
-
visible = when(values);
|
|
325
|
-
} catch {
|
|
326
|
-
visible = true;
|
|
327
|
-
}
|
|
328
|
-
fields.forEach((fieldPath) => {
|
|
329
|
-
const control = formGroup.get(fieldPath);
|
|
330
|
-
if (!control) {
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
const previous = conditionalState.get(fieldPath);
|
|
334
|
-
if (previous === visible) {
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
conditionalState.set(fieldPath, visible);
|
|
338
|
-
if (visible) {
|
|
339
|
-
control.enable({ emitEvent: false });
|
|
340
|
-
} else {
|
|
341
|
-
control.disable({ emitEvent: false });
|
|
342
|
-
}
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
: () => undefined;
|
|
347
|
-
|
|
348
|
-
applyConditionals();
|
|
349
|
-
|
|
350
|
-
const aggregateSubscriptions: Subscription[] = [];
|
|
351
|
-
aggregateSubscriptions.push(
|
|
352
|
-
formGroup.valueChanges.subscribe(() => {
|
|
353
|
-
refreshAggregates();
|
|
354
|
-
persistController.schedulePersist();
|
|
355
|
-
applyConditionals();
|
|
356
|
-
})
|
|
357
|
-
);
|
|
358
|
-
aggregateSubscriptions.push(
|
|
359
|
-
formGroup.statusChanges.subscribe(() => refreshAggregates())
|
|
360
|
-
);
|
|
361
|
-
if (formGroup.events) {
|
|
362
|
-
aggregateSubscriptions.push(
|
|
363
|
-
formGroup.events.subscribe(() => refreshAggregates())
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
cleanupCallbacks.push(() => {
|
|
368
|
-
aggregateSubscriptions.forEach((sub) => sub.unsubscribe());
|
|
369
|
-
if (refreshTimer) {
|
|
370
|
-
clearTimeout(refreshTimer);
|
|
371
|
-
}
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
if (destroyRef) {
|
|
375
|
-
destroyRef.onDestroy(() => {
|
|
376
|
-
cleanupCallbacks.splice(0).forEach((fn) => fn());
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const setValue = (field: string, value: unknown) => {
|
|
381
|
-
const targetSignal = getSignalAtPath(flattenedState, field);
|
|
382
|
-
const control = formGroup.get(field);
|
|
383
|
-
|
|
384
|
-
if (targetSignal && 'set' in targetSignal) {
|
|
385
|
-
(targetSignal as WritableSignal<unknown>).set(value);
|
|
386
|
-
} else if (control) {
|
|
387
|
-
(control as AbstractControl).setValue(value, { emitEvent: true });
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (control) {
|
|
391
|
-
const untypedControl = control as AbstractControl;
|
|
392
|
-
untypedControl.markAsTouched();
|
|
393
|
-
untypedControl.markAsDirty();
|
|
394
|
-
untypedControl.updateValueAndValidity({ emitEvent: true });
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
refreshAggregates(true);
|
|
398
|
-
persistController.schedulePersist();
|
|
399
|
-
};
|
|
400
|
-
|
|
401
|
-
const setValues = (values: Partial<T>) => {
|
|
402
|
-
Object.entries(values).forEach(([key, value]) => {
|
|
403
|
-
setValue(key, value);
|
|
404
|
-
});
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
const reset = () => {
|
|
408
|
-
formGroup.reset(initialSnapshot);
|
|
409
|
-
submitting.set(false);
|
|
410
|
-
refreshAggregates(true);
|
|
411
|
-
persistController.persistImmediately();
|
|
412
|
-
applyConditionals();
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
const validate = async (field?: string): Promise<void> => {
|
|
416
|
-
if (field) {
|
|
417
|
-
const control = formGroup.get(field);
|
|
418
|
-
if (!control) {
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
control.markAsTouched();
|
|
423
|
-
control.updateValueAndValidity({ emitEvent: true });
|
|
424
|
-
refreshAggregates(true);
|
|
425
|
-
await waitForPending(control);
|
|
426
|
-
refreshAggregates(true);
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
formGroup.markAllAsTouched();
|
|
431
|
-
formGroup.updateValueAndValidity({ emitEvent: true });
|
|
432
|
-
refreshAggregates(true);
|
|
433
|
-
await waitForPending(formGroup);
|
|
434
|
-
refreshAggregates(true);
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
const submit = async <TResult>(
|
|
438
|
-
submitFn: (values: T) => Promise<TResult>
|
|
439
|
-
): Promise<TResult> => {
|
|
440
|
-
submitting.set(true);
|
|
441
|
-
try {
|
|
442
|
-
await validate();
|
|
443
|
-
|
|
444
|
-
if (!valid()) {
|
|
445
|
-
throw new FormValidationError(errors(), asyncErrors());
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const currentValues = formGroup.getRawValue() as T;
|
|
449
|
-
const result = await submitFn(currentValues);
|
|
450
|
-
return result;
|
|
451
|
-
} finally {
|
|
452
|
-
submitting.set(false);
|
|
453
|
-
refreshAggregates(true);
|
|
454
|
-
}
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
const destroy = () => {
|
|
458
|
-
cleanupCallbacks.splice(0).forEach((fn) => fn());
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
const formTree: FormTree<T> = {
|
|
462
|
-
state: flattenedState,
|
|
463
|
-
$: flattenedState,
|
|
464
|
-
form: formGroup,
|
|
465
|
-
errors,
|
|
466
|
-
asyncErrors,
|
|
467
|
-
touched,
|
|
468
|
-
asyncValidating,
|
|
469
|
-
dirty,
|
|
470
|
-
valid,
|
|
471
|
-
submitting,
|
|
472
|
-
unwrap: () => valuesTree(),
|
|
473
|
-
setValue,
|
|
474
|
-
setValues,
|
|
475
|
-
reset,
|
|
476
|
-
submit,
|
|
477
|
-
validate,
|
|
478
|
-
getFieldError: (field: string) =>
|
|
479
|
-
fieldErrors[field] || computed(() => undefined),
|
|
480
|
-
getFieldAsyncError: (field: string) =>
|
|
481
|
-
fieldAsyncErrors[field] || computed(() => undefined),
|
|
482
|
-
getFieldTouched: (field: string) =>
|
|
483
|
-
computed(() => formGroup.get(field)?.touched ?? false),
|
|
484
|
-
isFieldValid: (field: string) =>
|
|
485
|
-
computed(() => {
|
|
486
|
-
const control = formGroup.get(field);
|
|
487
|
-
return !!control && control.valid && !control.pending;
|
|
488
|
-
}),
|
|
489
|
-
isFieldAsyncValidating: (field: string) =>
|
|
490
|
-
computed(() => !!formGroup.get(field)?.pending),
|
|
491
|
-
fieldErrors,
|
|
492
|
-
fieldAsyncErrors,
|
|
493
|
-
values: valuesTree,
|
|
494
|
-
destroy,
|
|
495
|
-
};
|
|
496
|
-
|
|
497
|
-
return formTree;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
export function createVirtualFormArray<T>(
|
|
501
|
-
items: T[],
|
|
502
|
-
visibleRange: { start: number; end: number },
|
|
503
|
-
controlFactory: (value: T, index: number) => AbstractControl = (value) =>
|
|
504
|
-
new FormControl(value)
|
|
505
|
-
): FormArray {
|
|
506
|
-
const start = Math.max(0, visibleRange.start);
|
|
507
|
-
const end = Math.max(start, visibleRange.end);
|
|
508
|
-
|
|
509
|
-
const controls = items
|
|
510
|
-
.slice(start, end)
|
|
511
|
-
.map((item, offset) => controlFactory(item, start + offset));
|
|
512
|
-
|
|
513
|
-
return new FormArray(controls);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
function enhanceArraysRecursively(
|
|
517
|
-
obj: Record<string, unknown>,
|
|
518
|
-
visited = new WeakSet<object>()
|
|
519
|
-
): void {
|
|
520
|
-
if (visited.has(obj)) {
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
visited.add(obj);
|
|
524
|
-
|
|
525
|
-
for (const key in obj) {
|
|
526
|
-
const value = obj[key];
|
|
527
|
-
if (isSignal(value)) {
|
|
528
|
-
const signalValue = (value as Signal<unknown>)();
|
|
529
|
-
if (Array.isArray(signalValue)) {
|
|
530
|
-
obj[key] = enhanceArray(value as WritableSignal<unknown[]>);
|
|
531
|
-
}
|
|
532
|
-
} else if (
|
|
533
|
-
typeof value === 'object' &&
|
|
534
|
-
value !== null &&
|
|
535
|
-
!Array.isArray(value)
|
|
536
|
-
) {
|
|
537
|
-
enhanceArraysRecursively(value as Record<string, unknown>, visited);
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const enhanceArray = <U>(
|
|
543
|
-
arraySignal: WritableSignal<U[]>
|
|
544
|
-
): EnhancedArraySignal<U> => {
|
|
545
|
-
const enhanced = arraySignal as EnhancedArraySignal<U>;
|
|
546
|
-
|
|
547
|
-
enhanced.push = (item: U) => {
|
|
548
|
-
arraySignal.update((arr) => [...arr, item]);
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
enhanced.removeAt = (index: number) => {
|
|
552
|
-
arraySignal.update((arr) => arr.filter((_, i) => i !== index));
|
|
553
|
-
};
|
|
554
|
-
|
|
555
|
-
enhanced.setAt = (index: number, value: U) => {
|
|
556
|
-
arraySignal.update((arr) =>
|
|
557
|
-
arr.map((item, i) => (i === index ? value : item))
|
|
558
|
-
);
|
|
559
|
-
};
|
|
560
|
-
|
|
561
|
-
enhanced.insertAt = (index: number, item: U) => {
|
|
562
|
-
arraySignal.update((arr) => [
|
|
563
|
-
...arr.slice(0, index),
|
|
564
|
-
item,
|
|
565
|
-
...arr.slice(index),
|
|
566
|
-
]);
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
enhanced.move = (from: number, to: number) => {
|
|
570
|
-
arraySignal.update((arr) => {
|
|
571
|
-
const newArr = [...arr];
|
|
572
|
-
const [item] = newArr.splice(from, 1);
|
|
573
|
-
if (item !== undefined) {
|
|
574
|
-
newArr.splice(to, 0, item);
|
|
575
|
-
}
|
|
576
|
-
return newArr;
|
|
577
|
-
});
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
enhanced.clear = () => {
|
|
581
|
-
arraySignal.set([]);
|
|
582
|
-
};
|
|
583
|
-
|
|
584
|
-
return enhanced;
|
|
585
|
-
};
|
|
586
|
-
|
|
587
|
-
function getSignalAtPath<T>(
|
|
588
|
-
node: TreeNode<T>,
|
|
589
|
-
path: string
|
|
590
|
-
): WritableSignal<unknown> | null {
|
|
591
|
-
if (!path) {
|
|
592
|
-
return null;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const segments = parsePath(path);
|
|
596
|
-
let current: unknown = node;
|
|
597
|
-
|
|
598
|
-
for (const segment of segments) {
|
|
599
|
-
if (!current || typeof current !== 'object') {
|
|
600
|
-
return null;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
current = (current as Record<string, unknown>)[segment];
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
if (isSignal(current)) {
|
|
607
|
-
return current as WritableSignal<unknown>;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
return null;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function joinPath(parent: string, segment: string): string {
|
|
614
|
-
return parent ? `${parent}.${segment}` : segment;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function wrapSyncValidator(
|
|
618
|
-
validator: (value: unknown) => string | null
|
|
619
|
-
): AngularValidatorFn {
|
|
620
|
-
return (control) => {
|
|
621
|
-
const result = validator(control.value);
|
|
622
|
-
return result ? { [SYNC_ERROR_KEY]: result } : null;
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function wrapAsyncValidator(
|
|
627
|
-
validator: FormTreeAsyncValidatorFn<unknown>
|
|
628
|
-
): AngularAsyncValidatorFn {
|
|
629
|
-
return async (control) => {
|
|
630
|
-
try {
|
|
631
|
-
const maybeAsync = validator(control.value);
|
|
632
|
-
const resolved = isObservable(maybeAsync)
|
|
633
|
-
? await firstValueFrom(maybeAsync)
|
|
634
|
-
: await maybeAsync;
|
|
635
|
-
return resolved ? { [ASYNC_ERROR_KEY]: resolved } : null;
|
|
636
|
-
} catch {
|
|
637
|
-
return { [ASYNC_ERROR_KEY]: 'Validation error' };
|
|
638
|
-
}
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
function createAbstractControl(
|
|
643
|
-
value: unknown,
|
|
644
|
-
path: string,
|
|
645
|
-
validators: SyncValidatorMap,
|
|
646
|
-
asyncValidators: AsyncValidatorMap
|
|
647
|
-
): AbstractControl {
|
|
648
|
-
const syncValidator = findValidator(validators, path);
|
|
649
|
-
const asyncValidator = findValidator(asyncValidators, path);
|
|
650
|
-
|
|
651
|
-
const syncFns = syncValidator
|
|
652
|
-
? [wrapSyncValidator(syncValidator)]
|
|
653
|
-
: undefined;
|
|
654
|
-
const asyncFns = asyncValidator
|
|
655
|
-
? [wrapAsyncValidator(asyncValidator)]
|
|
656
|
-
: undefined;
|
|
657
|
-
|
|
658
|
-
if (Array.isArray(value)) {
|
|
659
|
-
const controls = value.map((item, index) =>
|
|
660
|
-
createAbstractControl(
|
|
661
|
-
item,
|
|
662
|
-
joinPath(path, String(index)),
|
|
663
|
-
validators,
|
|
664
|
-
asyncValidators
|
|
665
|
-
)
|
|
666
|
-
);
|
|
667
|
-
return new FormArray(controls, syncFns, asyncFns);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
if (isPlainObject(value)) {
|
|
671
|
-
const controls: Record<string, AbstractControl> = {};
|
|
672
|
-
for (const [key, child] of Object.entries(value)) {
|
|
673
|
-
controls[key] = createAbstractControl(
|
|
674
|
-
child,
|
|
675
|
-
joinPath(path, key),
|
|
676
|
-
validators,
|
|
677
|
-
asyncValidators
|
|
678
|
-
);
|
|
679
|
-
}
|
|
680
|
-
return new FormGroup(controls, {
|
|
681
|
-
validators: syncFns,
|
|
682
|
-
asyncValidators: asyncFns,
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
return new FormControl(value, {
|
|
687
|
-
validators: syncFns,
|
|
688
|
-
asyncValidators: asyncFns,
|
|
689
|
-
nonNullable: false,
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
function connectControlAndSignal(
|
|
694
|
-
control: FormControl,
|
|
695
|
-
valueSignal: WritableSignal<unknown>,
|
|
696
|
-
cleanupCallbacks: Array<() => void>,
|
|
697
|
-
fieldConfig?: FieldConfig
|
|
698
|
-
): void {
|
|
699
|
-
let updatingFromControl = false;
|
|
700
|
-
let updatingFromSignal = false;
|
|
701
|
-
let versionCounter = 0;
|
|
702
|
-
let lastControlVersion = 0;
|
|
703
|
-
let controlDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
704
|
-
const debounceMs = fieldConfig?.debounceMs ?? 0;
|
|
705
|
-
|
|
706
|
-
const originalSet = valueSignal.set.bind(valueSignal);
|
|
707
|
-
const originalUpdate = valueSignal.update.bind(valueSignal);
|
|
708
|
-
|
|
709
|
-
const applyControlValue = (value: unknown) => {
|
|
710
|
-
updatingFromSignal = true;
|
|
711
|
-
if (!Object.is(control.value, value)) {
|
|
712
|
-
const untypedControl = control as AbstractControl;
|
|
713
|
-
untypedControl.setValue(value, { emitEvent: true });
|
|
714
|
-
untypedControl.markAsDirty();
|
|
715
|
-
}
|
|
716
|
-
updatingFromSignal = false;
|
|
717
|
-
};
|
|
718
|
-
|
|
719
|
-
valueSignal.set = (value: unknown) => {
|
|
720
|
-
const currentVersion = ++versionCounter;
|
|
721
|
-
originalSet(value);
|
|
722
|
-
if (updatingFromControl) {
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
if (lastControlVersion > currentVersion) {
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
|
-
applyControlValue(value);
|
|
729
|
-
};
|
|
730
|
-
|
|
731
|
-
valueSignal.update = (updater) => {
|
|
732
|
-
const next = updater(valueSignal());
|
|
733
|
-
valueSignal.set(next);
|
|
734
|
-
};
|
|
735
|
-
|
|
736
|
-
const pushUpdateFromControl = (value: unknown) => {
|
|
737
|
-
updatingFromControl = true;
|
|
738
|
-
lastControlVersion = ++versionCounter;
|
|
739
|
-
originalSet(value);
|
|
740
|
-
updatingFromControl = false;
|
|
741
|
-
};
|
|
742
|
-
|
|
743
|
-
const handleControlChange = (value: unknown) => {
|
|
744
|
-
if (updatingFromSignal) {
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
if (debounceMs > 0) {
|
|
749
|
-
if (controlDebounceTimer) {
|
|
750
|
-
clearTimeout(controlDebounceTimer);
|
|
751
|
-
}
|
|
752
|
-
controlDebounceTimer = setTimeout(() => {
|
|
753
|
-
controlDebounceTimer = null;
|
|
754
|
-
pushUpdateFromControl(value);
|
|
755
|
-
}, debounceMs);
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
pushUpdateFromControl(value);
|
|
760
|
-
};
|
|
761
|
-
|
|
762
|
-
const subscription = control.valueChanges.subscribe((value) => {
|
|
763
|
-
handleControlChange(value);
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
cleanupCallbacks.push(() => {
|
|
767
|
-
subscription.unsubscribe();
|
|
768
|
-
valueSignal.set = originalSet;
|
|
769
|
-
valueSignal.update = originalUpdate;
|
|
770
|
-
if (controlDebounceTimer) {
|
|
771
|
-
clearTimeout(controlDebounceTimer);
|
|
772
|
-
}
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function connectFormArrayAndSignal(
|
|
777
|
-
formArray: FormArray,
|
|
778
|
-
arraySignal: EnhancedArraySignal<unknown>,
|
|
779
|
-
path: string,
|
|
780
|
-
validators: SyncValidatorMap,
|
|
781
|
-
asyncValidators: AsyncValidatorMap,
|
|
782
|
-
cleanupCallbacks: Array<() => void>,
|
|
783
|
-
connectControlRecursive: (control: AbstractControl, path: string) => void
|
|
784
|
-
): void {
|
|
785
|
-
let updatingFromControl = false;
|
|
786
|
-
let updatingFromSignal = false;
|
|
787
|
-
|
|
788
|
-
const originalSet = arraySignal.set.bind(arraySignal);
|
|
789
|
-
const originalUpdate = arraySignal.update.bind(arraySignal);
|
|
790
|
-
|
|
791
|
-
arraySignal.set = (value: unknown[]) => {
|
|
792
|
-
originalSet(value);
|
|
793
|
-
if (updatingFromControl) {
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
updatingFromSignal = true;
|
|
797
|
-
syncFormArrayFromValue(
|
|
798
|
-
formArray,
|
|
799
|
-
value,
|
|
800
|
-
path,
|
|
801
|
-
validators,
|
|
802
|
-
asyncValidators,
|
|
803
|
-
connectControlRecursive
|
|
804
|
-
);
|
|
805
|
-
formArray.markAsDirty();
|
|
806
|
-
updatingFromSignal = false;
|
|
807
|
-
};
|
|
808
|
-
|
|
809
|
-
arraySignal.update = (updater) => {
|
|
810
|
-
const next = updater(arraySignal());
|
|
811
|
-
arraySignal.set(next);
|
|
812
|
-
};
|
|
813
|
-
|
|
814
|
-
const subscription = formArray.valueChanges.subscribe((value) => {
|
|
815
|
-
if (updatingFromSignal) {
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
updatingFromControl = true;
|
|
819
|
-
originalSet(value as unknown[]);
|
|
820
|
-
updatingFromControl = false;
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
cleanupCallbacks.push(() => {
|
|
824
|
-
subscription.unsubscribe();
|
|
825
|
-
arraySignal.set = originalSet;
|
|
826
|
-
arraySignal.update = originalUpdate;
|
|
827
|
-
});
|
|
828
|
-
|
|
829
|
-
syncFormArrayFromValue(
|
|
830
|
-
formArray,
|
|
831
|
-
arraySignal(),
|
|
832
|
-
path,
|
|
833
|
-
validators,
|
|
834
|
-
asyncValidators,
|
|
835
|
-
connectControlRecursive
|
|
836
|
-
);
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function syncFormArrayFromValue(
|
|
840
|
-
formArray: FormArray,
|
|
841
|
-
nextValue: unknown[],
|
|
842
|
-
path: string,
|
|
843
|
-
validators: SyncValidatorMap,
|
|
844
|
-
asyncValidators: AsyncValidatorMap,
|
|
845
|
-
connectControlRecursive: (control: AbstractControl, path: string) => void
|
|
846
|
-
): void {
|
|
847
|
-
if (!Array.isArray(nextValue)) {
|
|
848
|
-
nextValue = [];
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
while (formArray.length > nextValue.length) {
|
|
852
|
-
formArray.removeAt(formArray.length - 1);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
nextValue.forEach((item, index) => {
|
|
856
|
-
const childPath = joinPath(path, String(index));
|
|
857
|
-
const existing = formArray.at(index);
|
|
858
|
-
|
|
859
|
-
if (!existing) {
|
|
860
|
-
const control = createAbstractControl(
|
|
861
|
-
item,
|
|
862
|
-
childPath,
|
|
863
|
-
validators,
|
|
864
|
-
asyncValidators
|
|
865
|
-
);
|
|
866
|
-
formArray.insert(index, control);
|
|
867
|
-
connectControlRecursive(control, childPath);
|
|
868
|
-
return;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
if (existing instanceof FormArray) {
|
|
872
|
-
syncFormArrayFromValue(
|
|
873
|
-
existing,
|
|
874
|
-
Array.isArray(item) ? item : [],
|
|
875
|
-
childPath,
|
|
876
|
-
validators,
|
|
877
|
-
asyncValidators,
|
|
878
|
-
connectControlRecursive
|
|
879
|
-
);
|
|
880
|
-
return;
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
if (existing instanceof FormGroup) {
|
|
884
|
-
if (isPlainObject(item)) {
|
|
885
|
-
existing.setValue(item as Record<string, unknown>, {
|
|
886
|
-
emitEvent: false,
|
|
887
|
-
});
|
|
888
|
-
}
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
if (!Object.is(existing.value, item)) {
|
|
893
|
-
const untypedExisting = existing as AbstractControl;
|
|
894
|
-
untypedExisting.setValue(item, { emitEvent: false });
|
|
895
|
-
}
|
|
896
|
-
});
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
interface ControlSnapshot {
|
|
900
|
-
syncErrors: Record<string, string>;
|
|
901
|
-
asyncErrors: Record<string, string>;
|
|
902
|
-
touched: Record<string, boolean>;
|
|
903
|
-
pending: Record<string, boolean>;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
function collectControlSnapshot(control: AbstractControl): ControlSnapshot {
|
|
907
|
-
const snapshot: ControlSnapshot = {
|
|
908
|
-
syncErrors: {},
|
|
909
|
-
asyncErrors: {},
|
|
910
|
-
touched: {},
|
|
911
|
-
pending: {},
|
|
912
|
-
};
|
|
913
|
-
|
|
914
|
-
traverseControls(
|
|
915
|
-
control,
|
|
916
|
-
(currentPath, currentControl) => {
|
|
917
|
-
if (!currentPath) {
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
if (currentControl.touched) {
|
|
922
|
-
snapshot.touched[currentPath] = true;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
if (currentControl.pending) {
|
|
926
|
-
snapshot.pending[currentPath] = true;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
const errors = currentControl.errors as ValidationErrors | null;
|
|
930
|
-
if (!errors) {
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
const syncMessage = errors[SYNC_ERROR_KEY];
|
|
935
|
-
if (typeof syncMessage === 'string') {
|
|
936
|
-
snapshot.syncErrors[currentPath] = syncMessage;
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
const asyncMessage = errors[ASYNC_ERROR_KEY];
|
|
940
|
-
if (typeof asyncMessage === 'string') {
|
|
941
|
-
snapshot.asyncErrors[currentPath] = asyncMessage;
|
|
942
|
-
}
|
|
943
|
-
},
|
|
944
|
-
''
|
|
945
|
-
);
|
|
946
|
-
|
|
947
|
-
return snapshot;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
function traverseControls(
|
|
951
|
-
control: AbstractControl,
|
|
952
|
-
visitor: (path: string, control: AbstractControl) => void,
|
|
953
|
-
path = ''
|
|
954
|
-
): void {
|
|
955
|
-
visitor(path, control);
|
|
956
|
-
|
|
957
|
-
if (control instanceof FormGroup) {
|
|
958
|
-
Object.entries(control.controls).forEach(([key, child]) => {
|
|
959
|
-
traverseControls(child, visitor, joinPath(path, key));
|
|
960
|
-
});
|
|
961
|
-
return;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
if (control instanceof FormArray) {
|
|
965
|
-
control.controls.forEach((child, index) => {
|
|
966
|
-
traverseControls(child, visitor, joinPath(path, String(index)));
|
|
967
|
-
});
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
function waitForPending(control: AbstractControl): Promise<void> {
|
|
972
|
-
if (!control.pending) {
|
|
973
|
-
return Promise.resolve();
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
return new Promise((resolve) => {
|
|
977
|
-
const subscription = control.statusChanges.subscribe(() => {
|
|
978
|
-
if (!control.pending) {
|
|
979
|
-
subscription.unsubscribe();
|
|
980
|
-
resolve();
|
|
981
|
-
}
|
|
982
|
-
});
|
|
983
|
-
});
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
987
|
-
return (
|
|
988
|
-
!!value &&
|
|
989
|
-
typeof value === 'object' &&
|
|
990
|
-
!Array.isArray(value) &&
|
|
991
|
-
Object.prototype.toString.call(value) === '[object Object]'
|
|
992
|
-
);
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
function tryInjectDestroyRef(): DestroyRef | null {
|
|
996
|
-
try {
|
|
997
|
-
return inject(DestroyRef);
|
|
998
|
-
} catch {
|
|
999
|
-
return null;
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
function normalizeSyncValidators(
|
|
1004
|
-
base: SyncValidatorMap,
|
|
1005
|
-
fieldConfigs: Record<string, FieldConfig>
|
|
1006
|
-
): SyncValidatorMap {
|
|
1007
|
-
const buckets = new Map<string, FieldValidator[]>();
|
|
1008
|
-
|
|
1009
|
-
for (const [path, validator] of Object.entries(base)) {
|
|
1010
|
-
const existing = buckets.get(path) ?? [];
|
|
1011
|
-
existing.push(validator);
|
|
1012
|
-
buckets.set(path, existing);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
for (const [path, config] of Object.entries(fieldConfigs)) {
|
|
1016
|
-
const validators = toValidatorArray(config.validators);
|
|
1017
|
-
if (validators.length === 0) {
|
|
1018
|
-
continue;
|
|
1019
|
-
}
|
|
1020
|
-
const existing = buckets.get(path) ?? [];
|
|
1021
|
-
existing.push(...validators);
|
|
1022
|
-
buckets.set(path, existing);
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
const normalized: SyncValidatorMap = {};
|
|
1026
|
-
buckets.forEach((validators, path) => {
|
|
1027
|
-
normalized[path] = (value: unknown) => {
|
|
1028
|
-
for (const validator of validators) {
|
|
1029
|
-
const result = validator(value);
|
|
1030
|
-
if (result) {
|
|
1031
|
-
return result;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
return null;
|
|
1035
|
-
};
|
|
1036
|
-
});
|
|
1037
|
-
|
|
1038
|
-
return { ...base, ...normalized };
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
function normalizeAsyncValidators(
|
|
1042
|
-
base: AsyncValidatorMap,
|
|
1043
|
-
fieldConfigs: Record<string, FieldConfig>
|
|
1044
|
-
): AsyncValidatorMap {
|
|
1045
|
-
const buckets = new Map<string, Array<FormTreeAsyncValidatorFn<unknown>>>();
|
|
1046
|
-
|
|
1047
|
-
for (const [path, validator] of Object.entries(base)) {
|
|
1048
|
-
const existing = buckets.get(path) ?? [];
|
|
1049
|
-
existing.push(validator);
|
|
1050
|
-
buckets.set(path, existing);
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
for (const [path, config] of Object.entries(fieldConfigs)) {
|
|
1054
|
-
const validators = toValidatorArray(config.asyncValidators);
|
|
1055
|
-
if (validators.length === 0) {
|
|
1056
|
-
continue;
|
|
1057
|
-
}
|
|
1058
|
-
const existing = buckets.get(path) ?? [];
|
|
1059
|
-
existing.push(...validators);
|
|
1060
|
-
buckets.set(path, existing);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
const normalized: AsyncValidatorMap = {};
|
|
1064
|
-
buckets.forEach((validators, path) => {
|
|
1065
|
-
normalized[path] = async (value: unknown) => {
|
|
1066
|
-
for (const validator of validators) {
|
|
1067
|
-
const maybeAsync = validator(value);
|
|
1068
|
-
const result = isObservable(maybeAsync)
|
|
1069
|
-
? await firstValueFrom(maybeAsync)
|
|
1070
|
-
: await maybeAsync;
|
|
1071
|
-
if (result) {
|
|
1072
|
-
return result;
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
return null;
|
|
1076
|
-
};
|
|
1077
|
-
});
|
|
1078
|
-
|
|
1079
|
-
return { ...base, ...normalized };
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
function toValidatorArray<T>(input?: Record<string, T> | T[]): T[] {
|
|
1083
|
-
if (!input) {
|
|
1084
|
-
return [];
|
|
1085
|
-
}
|
|
1086
|
-
if (Array.isArray(input)) {
|
|
1087
|
-
return input;
|
|
1088
|
-
}
|
|
1089
|
-
return Object.values(input);
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
function hydrateInitialValues<T extends Record<string, unknown>>(
|
|
1093
|
-
initialValues: T,
|
|
1094
|
-
persistKey?: string,
|
|
1095
|
-
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
|
1096
|
-
): { values: T } {
|
|
1097
|
-
const baseClone = deepClone(initialValues);
|
|
1098
|
-
|
|
1099
|
-
if (!persistKey || !storage) {
|
|
1100
|
-
return { values: baseClone };
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
try {
|
|
1104
|
-
const storedRaw = storage.getItem(persistKey);
|
|
1105
|
-
if (!storedRaw) {
|
|
1106
|
-
return { values: baseClone };
|
|
1107
|
-
}
|
|
1108
|
-
const parsed = JSON.parse(storedRaw) as Partial<T>;
|
|
1109
|
-
const merged = mergeDeep(baseClone, parsed);
|
|
1110
|
-
return { values: merged };
|
|
1111
|
-
} catch {
|
|
1112
|
-
return { values: baseClone };
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
function assertTreeNode<T>(state: unknown): asserts state is TreeNode<T> {
|
|
1117
|
-
if (!state || typeof state !== 'object') {
|
|
1118
|
-
throw new Error('Invalid state structure for form tree');
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
function resolveFieldConfig(
|
|
1123
|
-
fieldConfigs: Record<string, FieldConfig>,
|
|
1124
|
-
path: string
|
|
1125
|
-
): FieldConfig | undefined {
|
|
1126
|
-
if (fieldConfigs[path]) {
|
|
1127
|
-
return fieldConfigs[path];
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
const keys = Object.keys(fieldConfigs);
|
|
1131
|
-
let match: { key: string; config: FieldConfig } | undefined;
|
|
1132
|
-
|
|
1133
|
-
for (const key of keys) {
|
|
1134
|
-
if (!key.includes('*')) {
|
|
1135
|
-
continue;
|
|
1136
|
-
}
|
|
1137
|
-
if (matchPath(key, path)) {
|
|
1138
|
-
if (!match || match.key.length < key.length) {
|
|
1139
|
-
match = { key, config: fieldConfigs[key] };
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
if (match) {
|
|
1145
|
-
return match.config;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
return fieldConfigs['*'];
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
function findValidator<T>(map: Record<string, T>, path: string): T | undefined {
|
|
1152
|
-
if (map[path]) {
|
|
1153
|
-
return map[path];
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
let candidate: { key: string; value: T } | undefined;
|
|
1157
|
-
for (const key of Object.keys(map)) {
|
|
1158
|
-
if (!key.includes('*')) {
|
|
1159
|
-
continue;
|
|
1160
|
-
}
|
|
1161
|
-
if (matchPath(key, path)) {
|
|
1162
|
-
if (!candidate || candidate.key.length < key.length) {
|
|
1163
|
-
candidate = { key, value: map[key] };
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
if (candidate) {
|
|
1169
|
-
return candidate.value;
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
return map['*'];
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
interface PersistController {
|
|
1176
|
-
schedulePersist: () => void;
|
|
1177
|
-
persistImmediately: () => void;
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
function createPersistController(
|
|
1181
|
-
formGroup: FormGroup,
|
|
1182
|
-
persistKey: string | undefined,
|
|
1183
|
-
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | undefined,
|
|
1184
|
-
debounceMs: number,
|
|
1185
|
-
cleanupCallbacks: Array<() => void>
|
|
1186
|
-
): PersistController {
|
|
1187
|
-
if (!persistKey || !storage) {
|
|
1188
|
-
return {
|
|
1189
|
-
schedulePersist: () => undefined,
|
|
1190
|
-
persistImmediately: () => undefined,
|
|
1191
|
-
};
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
1195
|
-
|
|
1196
|
-
const persist = () => {
|
|
1197
|
-
try {
|
|
1198
|
-
const payload = JSON.stringify(formGroup.getRawValue());
|
|
1199
|
-
storage.setItem(persistKey, payload);
|
|
1200
|
-
} catch {
|
|
1201
|
-
// Swallow persistence errors to avoid breaking form updates
|
|
1202
|
-
}
|
|
1203
|
-
};
|
|
1204
|
-
|
|
1205
|
-
const schedulePersist = () => {
|
|
1206
|
-
if (debounceMs <= 0) {
|
|
1207
|
-
persist();
|
|
1208
|
-
return;
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
if (timer) {
|
|
1212
|
-
clearTimeout(timer);
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
timer = setTimeout(() => {
|
|
1216
|
-
timer = null;
|
|
1217
|
-
persist();
|
|
1218
|
-
}, debounceMs);
|
|
1219
|
-
};
|
|
1220
|
-
|
|
1221
|
-
cleanupCallbacks.push(() => {
|
|
1222
|
-
if (timer) {
|
|
1223
|
-
clearTimeout(timer);
|
|
1224
|
-
}
|
|
1225
|
-
});
|
|
1226
|
-
|
|
1227
|
-
return {
|
|
1228
|
-
schedulePersist,
|
|
1229
|
-
persistImmediately: persist,
|
|
1230
|
-
};
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
// ============================================
|
|
1234
|
-
// ANGULAR DIRECTIVE
|
|
1235
|
-
// ============================================
|
|
1236
|
-
|
|
1237
|
-
/**
|
|
1238
|
-
* Simple directive for two-way binding with signals
|
|
1239
|
-
*/
|
|
1240
|
-
@Directive({
|
|
1241
|
-
selector: '[signalTreeSignalValue]',
|
|
1242
|
-
providers: [
|
|
1243
|
-
{
|
|
1244
|
-
provide: NG_VALUE_ACCESSOR,
|
|
1245
|
-
useExisting: forwardRef(() => SignalValueDirective),
|
|
1246
|
-
multi: true,
|
|
1247
|
-
},
|
|
1248
|
-
],
|
|
1249
|
-
standalone: true,
|
|
1250
|
-
})
|
|
1251
|
-
export class SignalValueDirective implements ControlValueAccessor, OnInit {
|
|
1252
|
-
@Input() signalTreeSignalValue!: WritableSignal<unknown>;
|
|
1253
|
-
@Output() signalTreeSignalValueChange = new EventEmitter<unknown>();
|
|
1254
|
-
|
|
1255
|
-
private elementRef = inject(ElementRef);
|
|
1256
|
-
private renderer = inject(Renderer2);
|
|
1257
|
-
|
|
1258
|
-
private onChange: (value: unknown) => void = () => {
|
|
1259
|
-
// Empty implementation for ControlValueAccessor
|
|
1260
|
-
};
|
|
1261
|
-
private onTouched: () => void = () => {
|
|
1262
|
-
// Empty implementation for ControlValueAccessor
|
|
1263
|
-
};
|
|
1264
|
-
|
|
1265
|
-
ngOnInit() {
|
|
1266
|
-
effect(() => {
|
|
1267
|
-
const value = this.signalTreeSignalValue();
|
|
1268
|
-
this.renderer.setProperty(this.elementRef.nativeElement, 'value', value);
|
|
1269
|
-
});
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
@HostListener('input', ['$event'])
|
|
1273
|
-
@HostListener('change', ['$event'])
|
|
1274
|
-
handleChange(event: Event) {
|
|
1275
|
-
const target = event.target as HTMLInputElement;
|
|
1276
|
-
const value = target?.value;
|
|
1277
|
-
if (value !== undefined) {
|
|
1278
|
-
this.signalTreeSignalValue.set(value);
|
|
1279
|
-
this.signalTreeSignalValueChange.emit(value);
|
|
1280
|
-
this.onChange(value);
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
@HostListener('blur')
|
|
1285
|
-
handleBlur() {
|
|
1286
|
-
this.onTouched();
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
writeValue(value: unknown): void {
|
|
1290
|
-
if (value !== undefined) {
|
|
1291
|
-
this.signalTreeSignalValue.set(value);
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
registerOnChange(fn: (value: unknown) => void): void {
|
|
1296
|
-
this.onChange = fn;
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
registerOnTouched(fn: () => void): void {
|
|
1300
|
-
this.onTouched = fn;
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
setDisabledState?(isDisabled: boolean): void {
|
|
1304
|
-
this.renderer.setProperty(
|
|
1305
|
-
this.elementRef.nativeElement,
|
|
1306
|
-
'disabled',
|
|
1307
|
-
isDisabled
|
|
1308
|
-
);
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
// ============================================
|
|
1313
|
-
// EXPORTS
|
|
1314
|
-
// ============================================
|
|
1315
|
-
|
|
1316
|
-
export const SIGNAL_FORM_DIRECTIVES = [SignalValueDirective];
|