@signaltree/ng-forms 3.0.1 → 4.0.0

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.
@@ -1,260 +1,773 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, isSignal, computed, EventEmitter, inject, ElementRef, Renderer2, effect, forwardRef, HostListener, Output, Input, Directive } from '@angular/core';
3
- import { NG_VALUE_ACCESSOR } from '@angular/forms';
4
- import { signalTree, parsePath } from '@signaltree/core';
5
- import { Observable } from 'rxjs';
2
+ import { signal, computed, isSignal, inject, DestroyRef, EventEmitter, ElementRef, Renderer2, effect, forwardRef, HostListener, Output, Input, Directive } from '@angular/core';
3
+ import { FormGroup, FormArray, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
4
+ import { signalTree } from '@signaltree/core';
5
+ import { deepClone, parsePath, mergeDeep, matchPath, snapshotsEqual } from '@signaltree/shared';
6
+ import { isObservable, firstValueFrom } from 'rxjs';
6
7
 
8
+ class FormValidationError extends Error {
9
+ errors;
10
+ asyncErrors;
11
+ constructor(errors, asyncErrors) {
12
+ super('Form validation failed');
13
+ this.errors = errors;
14
+ this.asyncErrors = asyncErrors;
15
+ this.name = 'FormValidationError';
16
+ }
17
+ }
18
+ const SYNC_ERROR_KEY = 'signaltree';
19
+ const ASYNC_ERROR_KEY = 'signaltreeAsync';
7
20
  function createFormTree(initialValues, config = {}) {
8
- const { validators = {}, asyncValidators = {}, ...treeConfig } = config;
9
- const valuesTree = signalTree(initialValues, treeConfig);
21
+ const { validators: baseValidators = {}, asyncValidators: baseAsyncValidators = {}, destroyRef: providedDestroyRef, fieldConfigs = {}, conditionals = [], persistKey, storage, persistDebounceMs = 100, validationBatchMs = 0, ...treeConfig } = config;
22
+ const syncValidators = normalizeSyncValidators(baseValidators, fieldConfigs);
23
+ const asyncValidators = normalizeAsyncValidators(baseAsyncValidators, fieldConfigs);
24
+ const { values: hydratedInitialValues } = hydrateInitialValues(initialValues, persistKey, storage);
25
+ const initialSnapshot = deepClone(hydratedInitialValues);
26
+ const valuesTree = signalTree(hydratedInitialValues, treeConfig);
27
+ assertTreeNode(valuesTree.state);
10
28
  const flattenedState = valuesTree.state;
11
- const formSignals = {
12
- errors: signal({}),
13
- asyncErrors: signal({}),
14
- touched: signal({}),
15
- asyncValidating: signal({}),
16
- dirty: signal(false),
17
- valid: signal(true),
18
- submitting: signal(false),
19
- };
20
- const markDirty = () => formSignals.dirty.set(true);
21
- const enhanceArray = (arraySignal) => {
22
- const enhanced = arraySignal;
23
- enhanced.push = (item) => {
24
- arraySignal.update((arr) => [...arr, item]);
25
- markDirty();
26
- };
27
- enhanced.removeAt = (index) => {
28
- arraySignal.update((arr) => arr.filter((_, i) => i !== index));
29
- markDirty();
30
- };
31
- enhanced.setAt = (index, value) => {
32
- arraySignal.update((arr) => arr.map((item, i) => (i === index ? value : item)));
33
- markDirty();
34
- };
35
- enhanced.insertAt = (index, item) => {
36
- arraySignal.update((arr) => [
37
- ...arr.slice(0, index),
38
- item,
39
- ...arr.slice(index),
40
- ]);
41
- markDirty();
42
- };
43
- enhanced.move = (from, to) => {
44
- arraySignal.update((arr) => {
45
- const newArr = [...arr];
46
- const [item] = newArr.splice(from, 1);
47
- if (item !== undefined) {
48
- newArr.splice(to, 0, item);
49
- }
50
- return newArr;
51
- });
52
- markDirty();
53
- };
54
- enhanced.clear = () => {
55
- arraySignal.set([]);
56
- markDirty();
57
- };
58
- return enhanced;
59
- };
60
- const enhanceArraysRecursively = (obj) => {
61
- for (const key in obj) {
62
- const value = obj[key];
63
- if (isSignal(value)) {
64
- const signalValue = value();
65
- if (Array.isArray(signalValue)) {
66
- obj[key] = enhanceArray(value);
67
- }
68
- }
69
- else if (typeof value === 'object' &&
70
- value !== null &&
71
- !Array.isArray(value)) {
72
- enhanceArraysRecursively(value);
29
+ enhanceArraysRecursively(flattenedState);
30
+ const formGroup = createAbstractControl(hydratedInitialValues, '', syncValidators, asyncValidators);
31
+ const destroyRef = providedDestroyRef ?? tryInjectDestroyRef();
32
+ const cleanupCallbacks = [];
33
+ const errors = signal({}, ...(ngDevMode ? [{ debugName: "errors" }] : []));
34
+ const asyncErrors = signal({}, ...(ngDevMode ? [{ debugName: "asyncErrors" }] : []));
35
+ const touched = signal({}, ...(ngDevMode ? [{ debugName: "touched" }] : []));
36
+ const asyncValidating = signal({}, ...(ngDevMode ? [{ debugName: "asyncValidating" }] : []));
37
+ const dirty = signal(formGroup.dirty, ...(ngDevMode ? [{ debugName: "dirty" }] : []));
38
+ const valid = signal(formGroup.valid && !formGroup.pending, ...(ngDevMode ? [{ debugName: "valid" }] : []));
39
+ const submitting = signal(false, ...(ngDevMode ? [{ debugName: "submitting" }] : []));
40
+ const refreshRunner = () => {
41
+ const snapshot = collectControlSnapshot(formGroup);
42
+ errors.set(snapshot.syncErrors);
43
+ asyncErrors.set(snapshot.asyncErrors);
44
+ touched.set(snapshot.touched);
45
+ asyncValidating.set(snapshot.pending);
46
+ dirty.set(formGroup.dirty);
47
+ valid.set(formGroup.valid && !formGroup.pending);
48
+ };
49
+ let refreshTimer = null;
50
+ const refreshAggregates = (immediate = false) => {
51
+ if (immediate || validationBatchMs <= 0) {
52
+ if (refreshTimer) {
53
+ clearTimeout(refreshTimer);
54
+ refreshTimer = null;
73
55
  }
56
+ refreshRunner();
57
+ return;
74
58
  }
59
+ if (refreshTimer) {
60
+ clearTimeout(refreshTimer);
61
+ }
62
+ refreshTimer = setTimeout(() => {
63
+ refreshTimer = null;
64
+ refreshRunner();
65
+ }, validationBatchMs);
75
66
  };
76
- enhanceArraysRecursively(flattenedState);
77
- const getNestedValue = (obj, path) => {
78
- const keys = parsePath(path);
79
- let current = obj;
80
- for (const key of keys) {
81
- if (current && typeof current === 'object') {
82
- current = current[key];
83
- if (isSignal(current)) {
84
- current = current();
85
- }
86
- }
87
- else {
88
- return undefined;
67
+ refreshAggregates(true);
68
+ const persistController = createPersistController(formGroup, persistKey, storage, persistDebounceMs, cleanupCallbacks);
69
+ persistController.persistImmediately();
70
+ const fieldErrorKeys = new Set([
71
+ ...Object.keys(syncValidators),
72
+ ...Object.keys(asyncValidators),
73
+ ]);
74
+ const fieldErrors = {};
75
+ const fieldAsyncErrors = {};
76
+ fieldErrorKeys.forEach((fieldPath) => {
77
+ fieldErrors[fieldPath] = computed(() => errors()[fieldPath]);
78
+ fieldAsyncErrors[fieldPath] = computed(() => asyncErrors()[fieldPath]);
79
+ });
80
+ const fieldConfigLookup = (path) => resolveFieldConfig(fieldConfigs, path);
81
+ const connectControlRecursive = (control, path) => {
82
+ if (control instanceof FormGroup) {
83
+ Object.entries(control.controls).forEach(([key, child]) => {
84
+ connectControlRecursive(child, joinPath(path, key));
85
+ });
86
+ return;
87
+ }
88
+ if (control instanceof FormArray) {
89
+ const arraySignal = getSignalAtPath(flattenedState, path);
90
+ if (arraySignal) {
91
+ connectFormArrayAndSignal(control, arraySignal, path, syncValidators, asyncValidators, cleanupCallbacks, connectControlRecursive);
89
92
  }
93
+ control.controls.forEach((child, index) => {
94
+ connectControlRecursive(child, joinPath(path, String(index)));
95
+ });
96
+ return;
97
+ }
98
+ const signalAtPath = getSignalAtPath(flattenedState, path);
99
+ if (signalAtPath) {
100
+ connectControlAndSignal(control, signalAtPath, cleanupCallbacks, fieldConfigLookup(path));
90
101
  }
91
- return current;
92
102
  };
93
- const setNestedValue = (path, value) => {
94
- const keys = parsePath(path);
95
- let current = flattenedState;
96
- for (let i = 0; i < keys.length - 1; i++) {
97
- current = current[keys[i]];
98
- if (!current)
99
- return;
103
+ connectControlRecursive(formGroup, '');
104
+ const conditionalState = new Map();
105
+ const applyConditionals = conditionals.length > 0
106
+ ? () => {
107
+ const values = formGroup.getRawValue();
108
+ conditionals.forEach(({ when, fields }) => {
109
+ let visible = true;
110
+ try {
111
+ visible = when(values);
112
+ }
113
+ catch {
114
+ visible = true;
115
+ }
116
+ fields.forEach((fieldPath) => {
117
+ const control = formGroup.get(fieldPath);
118
+ if (!control) {
119
+ return;
120
+ }
121
+ const previous = conditionalState.get(fieldPath);
122
+ if (previous === visible) {
123
+ return;
124
+ }
125
+ conditionalState.set(fieldPath, visible);
126
+ if (visible) {
127
+ control.enable({ emitEvent: false });
128
+ }
129
+ else {
130
+ control.disable({ emitEvent: false });
131
+ }
132
+ });
133
+ });
134
+ }
135
+ : () => undefined;
136
+ applyConditionals();
137
+ const aggregateSubscriptions = [];
138
+ aggregateSubscriptions.push(formGroup.valueChanges.subscribe(() => {
139
+ refreshAggregates();
140
+ persistController.schedulePersist();
141
+ applyConditionals();
142
+ }));
143
+ aggregateSubscriptions.push(formGroup.statusChanges.subscribe(() => refreshAggregates()));
144
+ if (formGroup.events) {
145
+ aggregateSubscriptions.push(formGroup.events.subscribe(() => refreshAggregates()));
146
+ }
147
+ cleanupCallbacks.push(() => {
148
+ aggregateSubscriptions.forEach((sub) => sub.unsubscribe());
149
+ if (refreshTimer) {
150
+ clearTimeout(refreshTimer);
151
+ }
152
+ });
153
+ if (destroyRef) {
154
+ destroyRef.onDestroy(() => {
155
+ cleanupCallbacks.splice(0).forEach((fn) => fn());
156
+ });
157
+ }
158
+ const setValue = (field, value) => {
159
+ const targetSignal = getSignalAtPath(flattenedState, field);
160
+ const control = formGroup.get(field);
161
+ if (targetSignal && 'set' in targetSignal) {
162
+ targetSignal.set(value);
100
163
  }
101
- const lastKey = keys[keys.length - 1];
102
- const target = current[lastKey];
103
- if (isSignal(target) && 'set' in target) {
104
- target.set(value);
164
+ else if (control) {
165
+ control.setValue(value, { emitEvent: true });
105
166
  }
167
+ if (control) {
168
+ const untypedControl = control;
169
+ untypedControl.markAsTouched();
170
+ untypedControl.markAsDirty();
171
+ untypedControl.updateValueAndValidity({ emitEvent: true });
172
+ }
173
+ refreshAggregates(true);
174
+ persistController.schedulePersist();
175
+ };
176
+ const setValues = (values) => {
177
+ Object.entries(values).forEach(([key, value]) => {
178
+ setValue(key, value);
179
+ });
180
+ };
181
+ const reset = () => {
182
+ formGroup.reset(initialSnapshot);
183
+ submitting.set(false);
184
+ refreshAggregates(true);
185
+ persistController.persistImmediately();
186
+ applyConditionals();
106
187
  };
107
188
  const validate = async (field) => {
108
- const errors = {};
109
- const asyncErrors = {};
110
- const fieldsToValidate = field ? [field] : Object.keys(validators);
111
- for (const fieldPath of fieldsToValidate) {
112
- const validator = validators[fieldPath];
113
- if (validator) {
114
- const value = getNestedValue(flattenedState, fieldPath);
115
- const error = validator(value);
116
- if (error) {
117
- errors[fieldPath] = error;
118
- }
189
+ if (field) {
190
+ const control = formGroup.get(field);
191
+ if (!control) {
192
+ return;
119
193
  }
194
+ control.markAsTouched();
195
+ control.updateValueAndValidity({ emitEvent: true });
196
+ refreshAggregates(true);
197
+ await waitForPending(control);
198
+ refreshAggregates(true);
199
+ return;
120
200
  }
121
- formSignals.errors.set(errors);
122
- const asyncFieldsToValidate = field
123
- ? [field]
124
- : Object.keys(asyncValidators);
125
- for (const fieldPath of asyncFieldsToValidate) {
126
- const asyncValidator = asyncValidators[fieldPath];
127
- if (asyncValidator && (!field || field === fieldPath)) {
128
- formSignals.asyncValidating.update((v) => ({
129
- ...v,
130
- [fieldPath]: true,
131
- }));
132
- try {
133
- const value = getNestedValue(flattenedState, fieldPath);
134
- const result = asyncValidator(value);
135
- let error;
136
- if (result instanceof Observable) {
137
- error = await new Promise((resolve) => {
138
- result.subscribe({
139
- next: (val) => resolve(val),
140
- error: () => resolve('Validation error'),
141
- });
142
- });
143
- }
144
- else {
145
- error = await result;
146
- }
147
- if (error) {
148
- asyncErrors[fieldPath] = error;
149
- }
150
- }
151
- catch {
152
- asyncErrors[fieldPath] = 'Validation error';
153
- }
154
- formSignals.asyncValidating.update((v) => ({
155
- ...v,
156
- [fieldPath]: false,
157
- }));
201
+ formGroup.markAllAsTouched();
202
+ formGroup.updateValueAndValidity({ emitEvent: true });
203
+ refreshAggregates(true);
204
+ await waitForPending(formGroup);
205
+ refreshAggregates(true);
206
+ };
207
+ const submit = async (submitFn) => {
208
+ submitting.set(true);
209
+ try {
210
+ await validate();
211
+ if (!valid()) {
212
+ throw new FormValidationError(errors(), asyncErrors());
158
213
  }
214
+ const currentValues = formGroup.getRawValue();
215
+ const result = await submitFn(currentValues);
216
+ return result;
217
+ }
218
+ finally {
219
+ submitting.set(false);
220
+ refreshAggregates(true);
159
221
  }
160
- formSignals.asyncErrors.set(asyncErrors);
161
- const hasErrors = Object.keys(errors).length > 0;
162
- const hasAsyncErrors = Object.keys(asyncErrors).length > 0;
163
- const isValidating = Object.values(formSignals.asyncValidating()).some((v) => v);
164
- formSignals.valid.set(!hasErrors && !hasAsyncErrors && !isValidating);
165
222
  };
166
- const fieldErrors = {};
167
- const fieldAsyncErrors = {};
168
- [...Object.keys(validators), ...Object.keys(asyncValidators)].forEach((fieldPath) => {
169
- fieldErrors[fieldPath] = computed(() => {
170
- const errors = formSignals.errors();
171
- return errors[fieldPath];
172
- });
173
- fieldAsyncErrors[fieldPath] = computed(() => {
174
- const errors = formSignals.asyncErrors();
175
- return errors[fieldPath];
176
- });
177
- });
223
+ const destroy = () => {
224
+ cleanupCallbacks.splice(0).forEach((fn) => fn());
225
+ };
178
226
  const formTree = {
179
227
  state: flattenedState,
180
228
  $: flattenedState,
181
- ...formSignals,
229
+ form: formGroup,
230
+ errors,
231
+ asyncErrors,
232
+ touched,
233
+ asyncValidating,
234
+ dirty,
235
+ valid,
236
+ submitting,
182
237
  unwrap: () => valuesTree(),
183
- setValue: (field, value) => {
184
- setNestedValue(field, value);
185
- formSignals.touched.update((t) => ({ ...t, [field]: true }));
186
- markDirty();
187
- void validate(field);
188
- },
189
- setValues: (values) => {
190
- valuesTree((v) => ({ ...v, ...values }));
191
- markDirty();
192
- void validate();
193
- },
194
- reset: () => {
195
- const resetSignals = (current, initial) => {
196
- for (const [key, initialValue] of Object.entries(initial)) {
197
- const currentValue = current[key];
198
- if (isSignal(currentValue) && 'set' in currentValue) {
199
- currentValue.set(initialValue);
200
- }
201
- else if (typeof initialValue === 'object' &&
202
- initialValue !== null &&
203
- !Array.isArray(initialValue) &&
204
- typeof currentValue === 'object' &&
205
- currentValue !== null &&
206
- !isSignal(currentValue)) {
207
- resetSignals(currentValue, initialValue);
208
- }
209
- }
210
- };
211
- resetSignals(flattenedState, initialValues);
212
- formSignals.errors.set({});
213
- formSignals.asyncErrors.set({});
214
- formSignals.touched.set({});
215
- formSignals.asyncValidating.set({});
216
- formSignals.dirty.set(false);
217
- formSignals.valid.set(true);
218
- formSignals.submitting.set(false);
219
- },
220
- submit: async (submitFn) => {
221
- formSignals.submitting.set(true);
222
- try {
223
- await validate();
224
- if (!formSignals.valid()) {
225
- throw new Error('Form is invalid');
226
- }
227
- const currentValues = valuesTree();
228
- const result = await submitFn(currentValues);
229
- return result;
230
- }
231
- finally {
232
- formSignals.submitting.set(false);
233
- }
234
- },
238
+ setValue,
239
+ setValues,
240
+ reset,
241
+ submit,
235
242
  validate,
236
243
  getFieldError: (field) => fieldErrors[field] || computed(() => undefined),
237
244
  getFieldAsyncError: (field) => fieldAsyncErrors[field] || computed(() => undefined),
238
- getFieldTouched: (field) => computed(() => {
239
- const touched = formSignals.touched();
240
- return touched[field];
241
- }),
245
+ getFieldTouched: (field) => computed(() => formGroup.get(field)?.touched ?? false),
242
246
  isFieldValid: (field) => computed(() => {
243
- const errors = formSignals.errors();
244
- const asyncErrors = formSignals.asyncErrors();
245
- const asyncValidating = formSignals.asyncValidating();
246
- return !errors[field] && !asyncErrors[field] && !asyncValidating[field];
247
- }),
248
- isFieldAsyncValidating: (field) => computed(() => {
249
- const asyncValidating = formSignals.asyncValidating();
250
- return asyncValidating[field];
247
+ const control = formGroup.get(field);
248
+ return !!control && control.valid && !control.pending;
251
249
  }),
250
+ isFieldAsyncValidating: (field) => computed(() => !!formGroup.get(field)?.pending),
252
251
  fieldErrors,
253
252
  fieldAsyncErrors,
254
253
  values: valuesTree,
254
+ destroy,
255
255
  };
256
256
  return formTree;
257
257
  }
258
+ function createVirtualFormArray(items, visibleRange, controlFactory = (value) => new FormControl(value)) {
259
+ const start = Math.max(0, visibleRange.start);
260
+ const end = Math.max(start, visibleRange.end);
261
+ const controls = items
262
+ .slice(start, end)
263
+ .map((item, offset) => controlFactory(item, start + offset));
264
+ return new FormArray(controls);
265
+ }
266
+ function enhanceArraysRecursively(obj, visited = new WeakSet()) {
267
+ if (visited.has(obj)) {
268
+ return;
269
+ }
270
+ visited.add(obj);
271
+ for (const key in obj) {
272
+ const value = obj[key];
273
+ if (isSignal(value)) {
274
+ const signalValue = value();
275
+ if (Array.isArray(signalValue)) {
276
+ obj[key] = enhanceArray(value);
277
+ }
278
+ }
279
+ else if (typeof value === 'object' &&
280
+ value !== null &&
281
+ !Array.isArray(value)) {
282
+ enhanceArraysRecursively(value, visited);
283
+ }
284
+ }
285
+ }
286
+ const enhanceArray = (arraySignal) => {
287
+ const enhanced = arraySignal;
288
+ enhanced.push = (item) => {
289
+ arraySignal.update((arr) => [...arr, item]);
290
+ };
291
+ enhanced.removeAt = (index) => {
292
+ arraySignal.update((arr) => arr.filter((_, i) => i !== index));
293
+ };
294
+ enhanced.setAt = (index, value) => {
295
+ arraySignal.update((arr) => arr.map((item, i) => (i === index ? value : item)));
296
+ };
297
+ enhanced.insertAt = (index, item) => {
298
+ arraySignal.update((arr) => [
299
+ ...arr.slice(0, index),
300
+ item,
301
+ ...arr.slice(index),
302
+ ]);
303
+ };
304
+ enhanced.move = (from, to) => {
305
+ arraySignal.update((arr) => {
306
+ const newArr = [...arr];
307
+ const [item] = newArr.splice(from, 1);
308
+ if (item !== undefined) {
309
+ newArr.splice(to, 0, item);
310
+ }
311
+ return newArr;
312
+ });
313
+ };
314
+ enhanced.clear = () => {
315
+ arraySignal.set([]);
316
+ };
317
+ return enhanced;
318
+ };
319
+ function getSignalAtPath(node, path) {
320
+ if (!path) {
321
+ return null;
322
+ }
323
+ const segments = parsePath(path);
324
+ let current = node;
325
+ for (const segment of segments) {
326
+ if (!current || typeof current !== 'object') {
327
+ return null;
328
+ }
329
+ current = current[segment];
330
+ }
331
+ if (isSignal(current)) {
332
+ return current;
333
+ }
334
+ return null;
335
+ }
336
+ function joinPath(parent, segment) {
337
+ return parent ? `${parent}.${segment}` : segment;
338
+ }
339
+ function wrapSyncValidator(validator) {
340
+ return (control) => {
341
+ const result = validator(control.value);
342
+ return result ? { [SYNC_ERROR_KEY]: result } : null;
343
+ };
344
+ }
345
+ function wrapAsyncValidator(validator) {
346
+ return async (control) => {
347
+ try {
348
+ const maybeAsync = validator(control.value);
349
+ const resolved = isObservable(maybeAsync)
350
+ ? await firstValueFrom(maybeAsync)
351
+ : await maybeAsync;
352
+ return resolved ? { [ASYNC_ERROR_KEY]: resolved } : null;
353
+ }
354
+ catch {
355
+ return { [ASYNC_ERROR_KEY]: 'Validation error' };
356
+ }
357
+ };
358
+ }
359
+ function createAbstractControl(value, path, validators, asyncValidators) {
360
+ const syncValidator = findValidator(validators, path);
361
+ const asyncValidator = findValidator(asyncValidators, path);
362
+ const syncFns = syncValidator
363
+ ? [wrapSyncValidator(syncValidator)]
364
+ : undefined;
365
+ const asyncFns = asyncValidator
366
+ ? [wrapAsyncValidator(asyncValidator)]
367
+ : undefined;
368
+ if (Array.isArray(value)) {
369
+ const controls = value.map((item, index) => createAbstractControl(item, joinPath(path, String(index)), validators, asyncValidators));
370
+ return new FormArray(controls, syncFns, asyncFns);
371
+ }
372
+ if (isPlainObject(value)) {
373
+ const controls = {};
374
+ for (const [key, child] of Object.entries(value)) {
375
+ controls[key] = createAbstractControl(child, joinPath(path, key), validators, asyncValidators);
376
+ }
377
+ return new FormGroup(controls, {
378
+ validators: syncFns,
379
+ asyncValidators: asyncFns,
380
+ });
381
+ }
382
+ return new FormControl(value, {
383
+ validators: syncFns,
384
+ asyncValidators: asyncFns,
385
+ nonNullable: false,
386
+ });
387
+ }
388
+ function connectControlAndSignal(control, valueSignal, cleanupCallbacks, fieldConfig) {
389
+ let updatingFromControl = false;
390
+ let updatingFromSignal = false;
391
+ let versionCounter = 0;
392
+ let lastControlVersion = 0;
393
+ let controlDebounceTimer = null;
394
+ const debounceMs = fieldConfig?.debounceMs ?? 0;
395
+ const originalSet = valueSignal.set.bind(valueSignal);
396
+ const originalUpdate = valueSignal.update.bind(valueSignal);
397
+ const applyControlValue = (value) => {
398
+ updatingFromSignal = true;
399
+ if (!Object.is(control.value, value)) {
400
+ const untypedControl = control;
401
+ untypedControl.setValue(value, { emitEvent: true });
402
+ untypedControl.markAsDirty();
403
+ }
404
+ updatingFromSignal = false;
405
+ };
406
+ valueSignal.set = (value) => {
407
+ const currentVersion = ++versionCounter;
408
+ originalSet(value);
409
+ if (updatingFromControl) {
410
+ return;
411
+ }
412
+ if (lastControlVersion > currentVersion) {
413
+ return;
414
+ }
415
+ applyControlValue(value);
416
+ };
417
+ valueSignal.update = (updater) => {
418
+ const next = updater(valueSignal());
419
+ valueSignal.set(next);
420
+ };
421
+ const pushUpdateFromControl = (value) => {
422
+ updatingFromControl = true;
423
+ lastControlVersion = ++versionCounter;
424
+ originalSet(value);
425
+ updatingFromControl = false;
426
+ };
427
+ const handleControlChange = (value) => {
428
+ if (updatingFromSignal) {
429
+ return;
430
+ }
431
+ if (debounceMs > 0) {
432
+ if (controlDebounceTimer) {
433
+ clearTimeout(controlDebounceTimer);
434
+ }
435
+ controlDebounceTimer = setTimeout(() => {
436
+ controlDebounceTimer = null;
437
+ pushUpdateFromControl(value);
438
+ }, debounceMs);
439
+ return;
440
+ }
441
+ pushUpdateFromControl(value);
442
+ };
443
+ const subscription = control.valueChanges.subscribe((value) => {
444
+ handleControlChange(value);
445
+ });
446
+ cleanupCallbacks.push(() => {
447
+ subscription.unsubscribe();
448
+ valueSignal.set = originalSet;
449
+ valueSignal.update = originalUpdate;
450
+ if (controlDebounceTimer) {
451
+ clearTimeout(controlDebounceTimer);
452
+ }
453
+ });
454
+ }
455
+ function connectFormArrayAndSignal(formArray, arraySignal, path, validators, asyncValidators, cleanupCallbacks, connectControlRecursive) {
456
+ let updatingFromControl = false;
457
+ let updatingFromSignal = false;
458
+ const originalSet = arraySignal.set.bind(arraySignal);
459
+ const originalUpdate = arraySignal.update.bind(arraySignal);
460
+ arraySignal.set = (value) => {
461
+ originalSet(value);
462
+ if (updatingFromControl) {
463
+ return;
464
+ }
465
+ updatingFromSignal = true;
466
+ syncFormArrayFromValue(formArray, value, path, validators, asyncValidators, connectControlRecursive);
467
+ formArray.markAsDirty();
468
+ updatingFromSignal = false;
469
+ };
470
+ arraySignal.update = (updater) => {
471
+ const next = updater(arraySignal());
472
+ arraySignal.set(next);
473
+ };
474
+ const subscription = formArray.valueChanges.subscribe((value) => {
475
+ if (updatingFromSignal) {
476
+ return;
477
+ }
478
+ updatingFromControl = true;
479
+ originalSet(value);
480
+ updatingFromControl = false;
481
+ });
482
+ cleanupCallbacks.push(() => {
483
+ subscription.unsubscribe();
484
+ arraySignal.set = originalSet;
485
+ arraySignal.update = originalUpdate;
486
+ });
487
+ syncFormArrayFromValue(formArray, arraySignal(), path, validators, asyncValidators, connectControlRecursive);
488
+ }
489
+ function syncFormArrayFromValue(formArray, nextValue, path, validators, asyncValidators, connectControlRecursive) {
490
+ if (!Array.isArray(nextValue)) {
491
+ nextValue = [];
492
+ }
493
+ while (formArray.length > nextValue.length) {
494
+ formArray.removeAt(formArray.length - 1);
495
+ }
496
+ nextValue.forEach((item, index) => {
497
+ const childPath = joinPath(path, String(index));
498
+ const existing = formArray.at(index);
499
+ if (!existing) {
500
+ const control = createAbstractControl(item, childPath, validators, asyncValidators);
501
+ formArray.insert(index, control);
502
+ connectControlRecursive(control, childPath);
503
+ return;
504
+ }
505
+ if (existing instanceof FormArray) {
506
+ syncFormArrayFromValue(existing, Array.isArray(item) ? item : [], childPath, validators, asyncValidators, connectControlRecursive);
507
+ return;
508
+ }
509
+ if (existing instanceof FormGroup) {
510
+ if (isPlainObject(item)) {
511
+ existing.setValue(item, {
512
+ emitEvent: false,
513
+ });
514
+ }
515
+ return;
516
+ }
517
+ if (!Object.is(existing.value, item)) {
518
+ const untypedExisting = existing;
519
+ untypedExisting.setValue(item, { emitEvent: false });
520
+ }
521
+ });
522
+ }
523
+ function collectControlSnapshot(control) {
524
+ const snapshot = {
525
+ syncErrors: {},
526
+ asyncErrors: {},
527
+ touched: {},
528
+ pending: {},
529
+ };
530
+ traverseControls(control, (currentPath, currentControl) => {
531
+ if (!currentPath) {
532
+ return;
533
+ }
534
+ if (currentControl.touched) {
535
+ snapshot.touched[currentPath] = true;
536
+ }
537
+ if (currentControl.pending) {
538
+ snapshot.pending[currentPath] = true;
539
+ }
540
+ const errors = currentControl.errors;
541
+ if (!errors) {
542
+ return;
543
+ }
544
+ const syncMessage = errors[SYNC_ERROR_KEY];
545
+ if (typeof syncMessage === 'string') {
546
+ snapshot.syncErrors[currentPath] = syncMessage;
547
+ }
548
+ const asyncMessage = errors[ASYNC_ERROR_KEY];
549
+ if (typeof asyncMessage === 'string') {
550
+ snapshot.asyncErrors[currentPath] = asyncMessage;
551
+ }
552
+ }, '');
553
+ return snapshot;
554
+ }
555
+ function traverseControls(control, visitor, path = '') {
556
+ visitor(path, control);
557
+ if (control instanceof FormGroup) {
558
+ Object.entries(control.controls).forEach(([key, child]) => {
559
+ traverseControls(child, visitor, joinPath(path, key));
560
+ });
561
+ return;
562
+ }
563
+ if (control instanceof FormArray) {
564
+ control.controls.forEach((child, index) => {
565
+ traverseControls(child, visitor, joinPath(path, String(index)));
566
+ });
567
+ }
568
+ }
569
+ function waitForPending(control) {
570
+ if (!control.pending) {
571
+ return Promise.resolve();
572
+ }
573
+ return new Promise((resolve) => {
574
+ const subscription = control.statusChanges.subscribe(() => {
575
+ if (!control.pending) {
576
+ subscription.unsubscribe();
577
+ resolve();
578
+ }
579
+ });
580
+ });
581
+ }
582
+ function isPlainObject(value) {
583
+ return (!!value &&
584
+ typeof value === 'object' &&
585
+ !Array.isArray(value) &&
586
+ Object.prototype.toString.call(value) === '[object Object]');
587
+ }
588
+ function tryInjectDestroyRef() {
589
+ try {
590
+ return inject(DestroyRef);
591
+ }
592
+ catch {
593
+ return null;
594
+ }
595
+ }
596
+ function normalizeSyncValidators(base, fieldConfigs) {
597
+ const buckets = new Map();
598
+ for (const [path, validator] of Object.entries(base)) {
599
+ const existing = buckets.get(path) ?? [];
600
+ existing.push(validator);
601
+ buckets.set(path, existing);
602
+ }
603
+ for (const [path, config] of Object.entries(fieldConfigs)) {
604
+ const validators = toValidatorArray(config.validators);
605
+ if (validators.length === 0) {
606
+ continue;
607
+ }
608
+ const existing = buckets.get(path) ?? [];
609
+ existing.push(...validators);
610
+ buckets.set(path, existing);
611
+ }
612
+ const normalized = {};
613
+ buckets.forEach((validators, path) => {
614
+ normalized[path] = (value) => {
615
+ for (const validator of validators) {
616
+ const result = validator(value);
617
+ if (result) {
618
+ return result;
619
+ }
620
+ }
621
+ return null;
622
+ };
623
+ });
624
+ return { ...base, ...normalized };
625
+ }
626
+ function normalizeAsyncValidators(base, fieldConfigs) {
627
+ const buckets = new Map();
628
+ for (const [path, validator] of Object.entries(base)) {
629
+ const existing = buckets.get(path) ?? [];
630
+ existing.push(validator);
631
+ buckets.set(path, existing);
632
+ }
633
+ for (const [path, config] of Object.entries(fieldConfigs)) {
634
+ const validators = toValidatorArray(config.asyncValidators);
635
+ if (validators.length === 0) {
636
+ continue;
637
+ }
638
+ const existing = buckets.get(path) ?? [];
639
+ existing.push(...validators);
640
+ buckets.set(path, existing);
641
+ }
642
+ const normalized = {};
643
+ buckets.forEach((validators, path) => {
644
+ normalized[path] = async (value) => {
645
+ for (const validator of validators) {
646
+ const maybeAsync = validator(value);
647
+ const result = isObservable(maybeAsync)
648
+ ? await firstValueFrom(maybeAsync)
649
+ : await maybeAsync;
650
+ if (result) {
651
+ return result;
652
+ }
653
+ }
654
+ return null;
655
+ };
656
+ });
657
+ return { ...base, ...normalized };
658
+ }
659
+ function toValidatorArray(input) {
660
+ if (!input) {
661
+ return [];
662
+ }
663
+ if (Array.isArray(input)) {
664
+ return input;
665
+ }
666
+ return Object.values(input);
667
+ }
668
+ function hydrateInitialValues(initialValues, persistKey, storage) {
669
+ const baseClone = deepClone(initialValues);
670
+ if (!persistKey || !storage) {
671
+ return { values: baseClone };
672
+ }
673
+ try {
674
+ const storedRaw = storage.getItem(persistKey);
675
+ if (!storedRaw) {
676
+ return { values: baseClone };
677
+ }
678
+ const parsed = JSON.parse(storedRaw);
679
+ const merged = mergeDeep(baseClone, parsed);
680
+ return { values: merged };
681
+ }
682
+ catch {
683
+ return { values: baseClone };
684
+ }
685
+ }
686
+ function assertTreeNode(state) {
687
+ if (!state || typeof state !== 'object') {
688
+ throw new Error('Invalid state structure for form tree');
689
+ }
690
+ }
691
+ function resolveFieldConfig(fieldConfigs, path) {
692
+ if (fieldConfigs[path]) {
693
+ return fieldConfigs[path];
694
+ }
695
+ const keys = Object.keys(fieldConfigs);
696
+ let match;
697
+ for (const key of keys) {
698
+ if (!key.includes('*')) {
699
+ continue;
700
+ }
701
+ if (matchPath(key, path)) {
702
+ if (!match || match.key.length < key.length) {
703
+ match = { key, config: fieldConfigs[key] };
704
+ }
705
+ }
706
+ }
707
+ if (match) {
708
+ return match.config;
709
+ }
710
+ return fieldConfigs['*'];
711
+ }
712
+ function findValidator(map, path) {
713
+ if (map[path]) {
714
+ return map[path];
715
+ }
716
+ let candidate;
717
+ for (const key of Object.keys(map)) {
718
+ if (!key.includes('*')) {
719
+ continue;
720
+ }
721
+ if (matchPath(key, path)) {
722
+ if (!candidate || candidate.key.length < key.length) {
723
+ candidate = { key, value: map[key] };
724
+ }
725
+ }
726
+ }
727
+ if (candidate) {
728
+ return candidate.value;
729
+ }
730
+ return map['*'];
731
+ }
732
+ function createPersistController(formGroup, persistKey, storage, debounceMs, cleanupCallbacks) {
733
+ if (!persistKey || !storage) {
734
+ return {
735
+ schedulePersist: () => undefined,
736
+ persistImmediately: () => undefined,
737
+ };
738
+ }
739
+ let timer = null;
740
+ const persist = () => {
741
+ try {
742
+ const payload = JSON.stringify(formGroup.getRawValue());
743
+ storage.setItem(persistKey, payload);
744
+ }
745
+ catch {
746
+ }
747
+ };
748
+ const schedulePersist = () => {
749
+ if (debounceMs <= 0) {
750
+ persist();
751
+ return;
752
+ }
753
+ if (timer) {
754
+ clearTimeout(timer);
755
+ }
756
+ timer = setTimeout(() => {
757
+ timer = null;
758
+ persist();
759
+ }, debounceMs);
760
+ };
761
+ cleanupCallbacks.push(() => {
762
+ if (timer) {
763
+ clearTimeout(timer);
764
+ }
765
+ });
766
+ return {
767
+ schedulePersist,
768
+ persistImmediately: persist,
769
+ };
770
+ }
258
771
  class SignalValueDirective {
259
772
  signalTreeSignalValue;
260
773
  signalTreeSignalValueChange = new EventEmitter();
@@ -296,8 +809,8 @@ class SignalValueDirective {
296
809
  setDisabledState(isDisabled) {
297
810
  this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', isDisabled);
298
811
  }
299
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: SignalValueDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
300
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.1.4", type: SignalValueDirective, isStandalone: true, selector: "[signalTreeSignalValue]", inputs: { signalTreeSignalValue: "signalTreeSignalValue" }, outputs: { signalTreeSignalValueChange: "signalTreeSignalValueChange" }, host: { listeners: { "input": "handleChange($event)", "change": "handleChange($event)", "blur": "handleBlur()" } }, providers: [
812
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: SignalValueDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
813
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.7", type: SignalValueDirective, isStandalone: true, selector: "[signalTreeSignalValue]", inputs: { signalTreeSignalValue: "signalTreeSignalValue" }, outputs: { signalTreeSignalValueChange: "signalTreeSignalValueChange" }, host: { listeners: { "input": "handleChange($event)", "change": "handleChange($event)", "blur": "handleBlur()" } }, providers: [
301
814
  {
302
815
  provide: NG_VALUE_ACCESSOR,
303
816
  useExisting: forwardRef(() => SignalValueDirective),
@@ -305,7 +818,7 @@ class SignalValueDirective {
305
818
  },
306
819
  ], ngImport: i0 });
307
820
  }
308
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: SignalValueDirective, decorators: [{
821
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: SignalValueDirective, decorators: [{
309
822
  type: Directive,
310
823
  args: [{
311
824
  selector: '[signalTreeSignalValue]',
@@ -332,69 +845,194 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImpor
332
845
  type: HostListener,
333
846
  args: ['blur']
334
847
  }] } });
335
- const validators = {
336
- required: (message = 'Required') => (value) => !value ? message : null,
337
- email: (message = 'Invalid email') => (value) => {
848
+ const SIGNAL_FORM_DIRECTIVES = [SignalValueDirective];
849
+
850
+ function required(message = 'Required') {
851
+ return (value) => (!value ? message : null);
852
+ }
853
+ function email(message = 'Invalid email') {
854
+ return (value) => {
338
855
  const strValue = value;
339
856
  return strValue && !strValue.includes('@') ? message : null;
340
- },
341
- minLength: (min) => (value) => {
857
+ };
858
+ }
859
+ function minLength(min, message) {
860
+ return (value) => {
342
861
  const strValue = value;
343
- return strValue && strValue.length < min ? `Min ${min} characters` : null;
344
- },
345
- pattern: (regex, message = 'Invalid format') => (value) => {
862
+ const errorMsg = message ?? `Min ${min} characters`;
863
+ return strValue && strValue.length < min ? errorMsg : null;
864
+ };
865
+ }
866
+ function maxLength(max, message) {
867
+ return (value) => {
868
+ const strValue = value;
869
+ const errorMsg = message ?? `Max ${max} characters`;
870
+ return strValue && strValue.length > max ? errorMsg : null;
871
+ };
872
+ }
873
+ function pattern(regex, message = 'Invalid format') {
874
+ return (value) => {
346
875
  const strValue = value;
347
876
  return strValue && !regex.test(strValue) ? message : null;
348
- },
349
- };
350
- const asyncValidators = {
351
- unique: (checkFn, message = 'Already exists') => async (value) => {
877
+ };
878
+ }
879
+ function min(min, message) {
880
+ return (value) => {
881
+ const numValue = value;
882
+ const errorMsg = message ?? `Must be at least ${min}`;
883
+ return numValue < min ? errorMsg : null;
884
+ };
885
+ }
886
+ function max(max, message) {
887
+ return (value) => {
888
+ const numValue = value;
889
+ const errorMsg = message ?? `Must be at most ${max}`;
890
+ return numValue > max ? errorMsg : null;
891
+ };
892
+ }
893
+ function compose(validators) {
894
+ return (value) => {
895
+ for (const validator of validators) {
896
+ const error = validator(value);
897
+ if (error) {
898
+ return error;
899
+ }
900
+ }
901
+ return null;
902
+ };
903
+ }
904
+
905
+ function unique(checkFn, message = 'Already exists') {
906
+ return async (value) => {
352
907
  if (!value)
353
908
  return null;
354
909
  const exists = await checkFn(value);
355
910
  return exists ? message : null;
356
- },
357
- };
358
- function toObservable(signal) {
359
- return new Observable((subscriber) => {
360
- try {
361
- const effectRef = effect(() => {
362
- subscriber.next(signal());
363
- }, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
364
- return () => effectRef.destroy();
911
+ };
912
+ }
913
+ function debounce(validator, delayMs) {
914
+ let timeoutId;
915
+ return async (value) => {
916
+ return new Promise((resolve) => {
917
+ clearTimeout(timeoutId);
918
+ timeoutId = setTimeout(async () => {
919
+ const maybeAsync = validator(value);
920
+ const result = isObservable(maybeAsync)
921
+ ? await firstValueFrom(maybeAsync)
922
+ : await maybeAsync;
923
+ resolve(result);
924
+ }, delayMs);
925
+ });
926
+ };
927
+ }
928
+
929
+ function withFormHistory(formTree, options = {}) {
930
+ const capacity = Math.max(1, options.capacity ?? 10);
931
+ const historySignal = signal({
932
+ past: [],
933
+ present: deepClone(formTree.form.getRawValue()),
934
+ future: [],
935
+ }, ...(ngDevMode ? [{ debugName: "historySignal" }] : []));
936
+ let recording = true;
937
+ let suppressUpdates = 0;
938
+ let internalHistory = {
939
+ past: [],
940
+ present: deepClone(formTree.form.getRawValue()),
941
+ future: [],
942
+ };
943
+ const subscription = formTree.form.valueChanges.subscribe(() => {
944
+ if (suppressUpdates > 0) {
945
+ suppressUpdates--;
946
+ return;
365
947
  }
366
- catch {
367
- subscriber.next(signal());
368
- return () => {
948
+ if (!recording) {
949
+ internalHistory = {
950
+ ...internalHistory,
951
+ present: deepClone(formTree.form.getRawValue()),
952
+ };
953
+ return;
954
+ }
955
+ const snapshot = deepClone(formTree.form.getRawValue());
956
+ if (snapshotsEqual(internalHistory.present, snapshot)) {
957
+ internalHistory = {
958
+ ...internalHistory,
959
+ present: snapshot,
369
960
  };
961
+ historySignal.set(cloneHistory(internalHistory));
962
+ return;
370
963
  }
964
+ const updatedPast = [...internalHistory.past, internalHistory.present];
965
+ if (updatedPast.length > capacity) {
966
+ updatedPast.shift();
967
+ }
968
+ internalHistory = {
969
+ past: updatedPast,
970
+ present: snapshot,
971
+ future: [],
972
+ };
973
+ historySignal.set(cloneHistory(internalHistory));
371
974
  });
372
- }
373
- function createAuditMiddleware(auditLog, getMetadata) {
374
- return {
375
- id: 'audit',
376
- after: (action, payload, oldState, newState) => {
377
- const changes = getChanges(oldState, newState);
378
- if (Object.keys(changes).length > 0) {
379
- auditLog.push({
380
- timestamp: Date.now(),
381
- changes,
382
- metadata: getMetadata?.(),
383
- });
384
- }
385
- },
975
+ const originalDestroy = formTree.destroy;
976
+ formTree.destroy = () => {
977
+ subscription.unsubscribe();
978
+ originalDestroy();
386
979
  };
387
- }
388
- function getChanges(oldState, newState) {
389
- const changes = {};
390
- for (const key in newState) {
391
- if (oldState[key] !== newState[key]) {
392
- changes[key] = newState[key];
980
+ const undo = () => {
981
+ const history = historySignal();
982
+ if (history.past.length === 0) {
983
+ return;
393
984
  }
985
+ const previous = deepClone(history.past[history.past.length - 1]);
986
+ recording = false;
987
+ suppressUpdates++;
988
+ formTree.setValues(previous);
989
+ recording = true;
990
+ internalHistory = {
991
+ past: history.past.slice(0, -1),
992
+ present: previous,
993
+ future: [history.present, ...history.future],
994
+ };
995
+ historySignal.set(cloneHistory(internalHistory));
996
+ };
997
+ const redo = () => {
998
+ const history = historySignal();
999
+ if (history.future.length === 0) {
1000
+ return;
1001
+ }
1002
+ const next = deepClone(history.future[0]);
1003
+ recording = false;
1004
+ suppressUpdates++;
1005
+ formTree.setValues(next);
1006
+ recording = true;
1007
+ internalHistory = {
1008
+ past: [...history.past, history.present],
1009
+ present: next,
1010
+ future: history.future.slice(1),
1011
+ };
1012
+ historySignal.set(cloneHistory(internalHistory));
1013
+ };
1014
+ const clearHistory = () => {
1015
+ internalHistory = {
1016
+ past: [],
1017
+ present: deepClone(formTree.form.getRawValue()),
1018
+ future: [],
1019
+ };
1020
+ historySignal.set(cloneHistory(internalHistory));
1021
+ };
1022
+ function cloneHistory(state) {
1023
+ return {
1024
+ past: state.past.map((entry) => deepClone(entry)),
1025
+ present: deepClone(state.present),
1026
+ future: state.future.map((entry) => deepClone(entry)),
1027
+ };
394
1028
  }
395
- return changes;
1029
+ return Object.assign(formTree, {
1030
+ undo,
1031
+ redo,
1032
+ clearHistory,
1033
+ history: historySignal.asReadonly(),
1034
+ });
396
1035
  }
397
- const SIGNAL_FORM_DIRECTIVES = [SignalValueDirective];
398
1036
 
399
- export { SIGNAL_FORM_DIRECTIVES, SignalValueDirective, asyncValidators, createAuditMiddleware, createFormTree, toObservable, validators };
1037
+ export { FormValidationError, SIGNAL_FORM_DIRECTIVES, SignalValueDirective, compose, createFormTree, createVirtualFormArray, debounce, email, max, maxLength, min, minLength, pattern, required, unique, withFormHistory };
400
1038
  //# sourceMappingURL=signaltree-ng-forms.mjs.map