@signaltree/ng-forms 7.1.3 β 7.3.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.
- package/README.md +182 -68
- package/dist/enhancer/form-bridge.js +194 -0
- package/dist/enhancer/index.js +2 -0
- package/dist/index.js +1 -0
- package/package.json +2 -2
- package/src/enhancer/form-bridge.d.ts +30 -0
- package/src/enhancer/index.d.ts +1 -0
- package/src/index.d.ts +1 -0
- package/dist/audit/audit.js +0 -74
- package/dist/audit/index.js +0 -1
- package/dist/get-changes.js +0 -11
package/README.md
CHANGED
|
@@ -1,81 +1,129 @@
|
|
|
1
1
|
# @signaltree/ng-forms
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Angular FormGroup bridge for SignalTree's `form()` marker**. Adds reactive forms integration, conditional fields, and undo/redo to tree-integrated forms.
|
|
4
4
|
|
|
5
5
|
**Bundle size: 3.38KB gzipped**
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Architecture: form() + formBridge()
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
SignalTree v7 introduces a layered forms architecture:
|
|
10
10
|
|
|
11
|
-
### **π² Tree-Structured State**
|
|
12
|
-
```typescript
|
|
13
|
-
// Angular 21: Flat fields, no relationships
|
|
14
|
-
const name = formField('');
|
|
15
|
-
const email = formField('');
|
|
16
|
-
|
|
17
|
-
// ng-forms: Hierarchical structure that mirrors your data model
|
|
18
|
-
const form = createFormTree({
|
|
19
|
-
user: { name: '', email: '' },
|
|
20
|
-
address: { street: '', city: '', zip: '' }
|
|
21
|
-
});
|
|
22
|
-
// Access nested: form.$.user.name()
|
|
23
|
-
// Validate paths: 'address.zip'
|
|
24
11
|
```
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
12
|
+
@signaltree/core @signaltree/ng-forms
|
|
13
|
+
βββββββββββββββββββββββββββ βββββββββββββββββββββββββββ
|
|
14
|
+
β form() marker β β formBridge() β
|
|
15
|
+
β βββββββββββββββββββββββ β ββββΊ β enhancer that: β
|
|
16
|
+
β β’ Signal-based fields β β β’ Creates FormGroup β
|
|
17
|
+
β β’ Sync/async validators β β β’ Bidirectional sync β
|
|
18
|
+
β β’ Persistence β β β’ Conditional fields β
|
|
19
|
+
β β’ Wizard navigation β β β’ Angular validators β
|
|
20
|
+
β β’ dirty/valid/submittingβ β β
|
|
21
|
+
βββββββββββββββββββββββββββ β withFormHistory() β
|
|
22
|
+
Works standalone! β β’ Undo/redo β
|
|
23
|
+
βββββββββββββββββββββββββββ
|
|
36
24
|
```
|
|
37
25
|
|
|
38
|
-
|
|
26
|
+
**Key insight**: `form()` is self-sufficient. `formBridge()` adds Angular-specific capabilities.
|
|
27
|
+
|
|
28
|
+
## Quick Start (Recommended Pattern)
|
|
29
|
+
|
|
39
30
|
```typescript
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
import { signalTree, form } from '@signaltree/core';
|
|
32
|
+
import { formBridge } from '@signaltree/ng-forms';
|
|
33
|
+
|
|
34
|
+
// Define forms in your tree
|
|
35
|
+
const tree = signalTree({
|
|
36
|
+
checkout: {
|
|
37
|
+
shipping: form({
|
|
38
|
+
initial: { name: '', address: '', zip: '' },
|
|
39
|
+
validators: {
|
|
40
|
+
zip: (v) => (/^\d{5}$/.test(String(v)) ? null : 'Invalid ZIP'),
|
|
41
|
+
},
|
|
42
|
+
persist: 'checkout-shipping',
|
|
43
|
+
}),
|
|
44
|
+
payment: form({
|
|
45
|
+
initial: { card: '', cvv: '' },
|
|
46
|
+
wizard: { steps: ['card', 'review'] },
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
}).with(
|
|
50
|
+
formBridge({
|
|
51
|
+
conditionals: [{ when: (v) => v.checkout.sameAsBilling, fields: ['checkout.shipping.*'] }],
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Use in components
|
|
56
|
+
@Component({
|
|
57
|
+
template: `
|
|
58
|
+
<!-- Option 1: Use form() signals directly -->
|
|
59
|
+
<input [value]="tree.$.checkout.shipping.$.name()" (input)="tree.$.checkout.shipping.$.name.set($event.target.value)" />
|
|
60
|
+
|
|
61
|
+
<!-- Option 2: Use Angular FormGroup -->
|
|
62
|
+
<form [formGroup]="shippingForm">
|
|
63
|
+
<input formControlName="name" />
|
|
64
|
+
</form>
|
|
65
|
+
`,
|
|
66
|
+
})
|
|
67
|
+
class CheckoutComponent {
|
|
68
|
+
tree = inject(CHECKOUT_TREE);
|
|
69
|
+
|
|
70
|
+
// Get the FormGroup bridge
|
|
71
|
+
shippingForm = this.tree.getAngularForm('checkout.shipping')?.formGroup;
|
|
72
|
+
}
|
|
47
73
|
```
|
|
48
74
|
|
|
49
|
-
|
|
75
|
+
## When to Use Each Layer
|
|
76
|
+
|
|
77
|
+
### form() alone (no ng-forms needed)
|
|
78
|
+
|
|
50
79
|
```typescript
|
|
51
|
-
// Angular
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
80
|
+
// Pure signal forms - works without Angular forms module
|
|
81
|
+
const tree = signalTree({
|
|
82
|
+
login: form({
|
|
83
|
+
initial: { email: '', password: '' },
|
|
84
|
+
validators: { email: validators.email() },
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Full functionality without Angular FormGroup
|
|
89
|
+
tree.$.login.$.email.set('user@test.com');
|
|
90
|
+
tree.$.login.valid(); // Reactive validation
|
|
91
|
+
tree.$.login.validate(); // Trigger validation
|
|
92
|
+
tree.$.login.submit(fn); // Submit handling
|
|
93
|
+
tree.$.login.wizard?.next(); // Wizard navigation (if configured)
|
|
56
94
|
```
|
|
57
95
|
|
|
58
|
-
|
|
96
|
+
**Use when**: SSR, unit tests, simple forms, non-Angular environments
|
|
97
|
+
|
|
98
|
+
### form() + formBridge()
|
|
99
|
+
|
|
59
100
|
```typescript
|
|
60
|
-
// Angular
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
101
|
+
// Add Angular FormGroup bridge
|
|
102
|
+
const tree = signalTree({
|
|
103
|
+
profile: form({ initial: { name: '' } }),
|
|
104
|
+
}).with(formBridge());
|
|
105
|
+
|
|
106
|
+
// Now you get FormGroup access
|
|
107
|
+
const formGroup = tree.getAngularForm('profile')?.formGroup;
|
|
108
|
+
// Or attached directly: (tree.$.profile as any).formGroup
|
|
66
109
|
```
|
|
67
110
|
|
|
68
|
-
|
|
111
|
+
**Use when**: Need `[formGroup]` directives, Angular validators, conditional field disabling
|
|
112
|
+
|
|
113
|
+
### form() + formBridge() + withFormHistory()
|
|
114
|
+
|
|
69
115
|
```typescript
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
116
|
+
const tree = signalTree({
|
|
117
|
+
editor: form({ initial: { content: '' } }),
|
|
118
|
+
})
|
|
119
|
+
.with(formBridge())
|
|
120
|
+
.with(withFormHistory({ capacity: 50 }));
|
|
121
|
+
|
|
122
|
+
tree.undo();
|
|
123
|
+
tree.redo();
|
|
76
124
|
```
|
|
77
125
|
|
|
78
|
-
**Use
|
|
126
|
+
**Use when**: Complex editors, need undo/redo
|
|
79
127
|
|
|
80
128
|
## Installation
|
|
81
129
|
|
|
@@ -175,11 +223,13 @@ The returned `FormTree` exposes:
|
|
|
175
223
|
**ng-forms complements Angular 21's native signal forms**βuse both in the same app:
|
|
176
224
|
|
|
177
225
|
### Use Angular 21 `FormField<T>` for:
|
|
226
|
+
|
|
178
227
|
- β
Simple, flat forms (login, search)
|
|
179
228
|
- β
Single-field validation
|
|
180
229
|
- β
Maximum type safety
|
|
181
230
|
|
|
182
231
|
### Use ng-forms `createFormTree()` for:
|
|
232
|
+
|
|
183
233
|
- β
Nested object structures (user + address + payment)
|
|
184
234
|
- β
Forms with persistence/auto-save
|
|
185
235
|
- β
Wizard/multi-step flows
|
|
@@ -188,6 +238,7 @@ The returned `FormTree` exposes:
|
|
|
188
238
|
- β
Migration from reactive forms
|
|
189
239
|
|
|
190
240
|
### Hybrid Example: Simple Fields + Complex Tree
|
|
241
|
+
|
|
191
242
|
```typescript
|
|
192
243
|
import { formField } from '@angular/forms';
|
|
193
244
|
import { createFormTree } from '@signaltree/ng-forms';
|
|
@@ -215,6 +266,7 @@ class CheckoutComponent {
|
|
|
215
266
|
```
|
|
216
267
|
|
|
217
268
|
### Connecting to Reactive Forms
|
|
269
|
+
|
|
218
270
|
```ts
|
|
219
271
|
import { toWritableSignal } from '@signaltree/core';
|
|
220
272
|
|
|
@@ -325,21 +377,83 @@ Use `SignalValueDirective` to keep standalone signals and `ngModel` fields align
|
|
|
325
377
|
|
|
326
378
|
## When to use ng-forms vs Angular 21 signal forms
|
|
327
379
|
|
|
328
|
-
| Scenario
|
|
329
|
-
|
|
330
|
-
| Login form (2-3 fields)
|
|
331
|
-
| Search bar with filters
|
|
332
|
-
| User profile with nested address
|
|
333
|
-
| Checkout flow (shipping + payment + items) | β
**ng-forms** (persistence + wizard)
|
|
334
|
-
| Multi-step onboarding (5+ steps)
|
|
335
|
-
| Form with auto-save drafts
|
|
336
|
-
| Complex editor with undo/redo
|
|
337
|
-
| Migrating from reactive forms
|
|
338
|
-
| Dynamic form with conditional fields
|
|
339
|
-
| Form synced with global app state
|
|
380
|
+
| Scenario | Recommendation |
|
|
381
|
+
| ------------------------------------------ | ---------------------------------------- |
|
|
382
|
+
| Login form (2-3 fields) | β
Angular 21 `FormField` |
|
|
383
|
+
| Search bar with filters | β
Angular 21 `FormField` |
|
|
384
|
+
| User profile with nested address | β
**ng-forms** (tree structure) |
|
|
385
|
+
| Checkout flow (shipping + payment + items) | β
**ng-forms** (persistence + wizard) |
|
|
386
|
+
| Multi-step onboarding (5+ steps) | β
**ng-forms** (wizard API) |
|
|
387
|
+
| Form with auto-save drafts | β
**ng-forms** (built-in persistence) |
|
|
388
|
+
| Complex editor with undo/redo | β
**ng-forms** (history tracking) |
|
|
389
|
+
| Migrating from reactive forms | β
**ng-forms** (FormGroup bridge) |
|
|
390
|
+
| Dynamic form with conditional fields | β
**ng-forms** (conditionals config) |
|
|
391
|
+
| Form synced with global app state | β
**ng-forms** (SignalTree integration) |
|
|
340
392
|
|
|
341
393
|
**Rule of thumb**: If your form data is a nested object or needs workflow features (persistence/wizards/history), use ng-forms. For simple flat forms, Angular 21's native signal forms are perfect.
|
|
342
394
|
|
|
395
|
+
## Migration from createFormTree()
|
|
396
|
+
|
|
397
|
+
`createFormTree()` is deprecated in favor of the composable `form()` + `formBridge()` pattern.
|
|
398
|
+
|
|
399
|
+
### Before (deprecated)
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
import { createFormTree } from '@signaltree/ng-forms';
|
|
403
|
+
|
|
404
|
+
const form = createFormTree(
|
|
405
|
+
{
|
|
406
|
+
name: '',
|
|
407
|
+
email: '',
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
validators: { email: validators.email() },
|
|
411
|
+
persistKey: 'profile-form',
|
|
412
|
+
}
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
// Access
|
|
416
|
+
form.$.name.set('John');
|
|
417
|
+
form.form; // FormGroup
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### After (recommended)
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
import { signalTree, form } from '@signaltree/core';
|
|
424
|
+
import { formBridge } from '@signaltree/ng-forms';
|
|
425
|
+
|
|
426
|
+
const tree = signalTree({
|
|
427
|
+
profile: form({
|
|
428
|
+
initial: { name: '', email: '' },
|
|
429
|
+
validators: { email: validators.email() },
|
|
430
|
+
persist: 'profile-form',
|
|
431
|
+
}),
|
|
432
|
+
}).with(formBridge());
|
|
433
|
+
|
|
434
|
+
// Access
|
|
435
|
+
tree.$.profile.$.name.set('John');
|
|
436
|
+
tree.getAngularForm('profile')?.formGroup; // FormGroup
|
|
437
|
+
// Or: (tree.$.profile as any).formGroup
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Key differences
|
|
441
|
+
|
|
442
|
+
| Aspect | createFormTree() | form() + formBridge() |
|
|
443
|
+
| -------------------- | ----------------------- | ---------------------------- |
|
|
444
|
+
| **Standalone** | Always needs Angular | form() works without Angular |
|
|
445
|
+
| **Tree integration** | Separate from app state | Lives in your main tree |
|
|
446
|
+
| **DevTools** | Separate | Inherits tree DevTools |
|
|
447
|
+
| **Composability** | Limited | Add enhancers freely |
|
|
448
|
+
| **Tree-shaking** | All-or-nothing | Only what you use |
|
|
449
|
+
|
|
450
|
+
### Migration steps
|
|
451
|
+
|
|
452
|
+
1. Move form state into your SignalTree using `form()` marker
|
|
453
|
+
2. Add `.with(formBridge())` to your tree
|
|
454
|
+
3. Update access patterns: `form.$.field` β `tree.$.formName.$.field`
|
|
455
|
+
4. Update FormGroup access: `form.form` β `tree.getAngularForm('path')?.formGroup`
|
|
456
|
+
|
|
343
457
|
## Links
|
|
344
458
|
|
|
345
459
|
- [SignalTree Documentation](https://signaltree.io)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { inject, DestroyRef, computed } from '@angular/core';
|
|
2
|
+
import { FormControl, FormArray, FormGroup } from '@angular/forms';
|
|
3
|
+
|
|
4
|
+
function findFormSignals(node, path, results) {
|
|
5
|
+
if (node === null || node === undefined) return;
|
|
6
|
+
const hasForm = (typeof node === 'function' || typeof node === 'object') && node !== null && '$' in node && 'valid' in node && 'dirty' in node && 'validate' in node;
|
|
7
|
+
if (hasForm && typeof node.validate === 'function') {
|
|
8
|
+
results.push({
|
|
9
|
+
path,
|
|
10
|
+
formSignal: node
|
|
11
|
+
});
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (typeof node === 'function' || typeof node === 'object') {
|
|
15
|
+
const entries = Object.entries(node);
|
|
16
|
+
for (const [key, value] of entries) {
|
|
17
|
+
if (key.startsWith('_') || key === 'set' || key === 'update') continue;
|
|
18
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
19
|
+
findFormSignals(value, childPath, results);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function createFormGroupBridge(formSignal, config, cleanupCallbacks) {
|
|
24
|
+
const values = formSignal();
|
|
25
|
+
const formGroup = createFormGroupFromValues(values, config.fieldConfigs || {});
|
|
26
|
+
const angularErrors = computed(() => {
|
|
27
|
+
const result = {};
|
|
28
|
+
collectControlErrors(formGroup, '', result);
|
|
29
|
+
return result;
|
|
30
|
+
});
|
|
31
|
+
const asyncPending = computed(() => formGroup.pending);
|
|
32
|
+
const syncSignalToControl = () => {
|
|
33
|
+
const currentValues = formSignal();
|
|
34
|
+
patchFormGroupValues(formGroup, currentValues);
|
|
35
|
+
};
|
|
36
|
+
const syncControlToSignal = () => {
|
|
37
|
+
const formValues = formGroup.getRawValue();
|
|
38
|
+
formSignal.set(formValues);
|
|
39
|
+
};
|
|
40
|
+
const subscription = formGroup.valueChanges.subscribe(() => {
|
|
41
|
+
syncControlToSignal();
|
|
42
|
+
});
|
|
43
|
+
cleanupCallbacks.push(() => subscription.unsubscribe());
|
|
44
|
+
syncSignalToControl();
|
|
45
|
+
if (config.conditionals && config.conditionals.length > 0) {
|
|
46
|
+
const conditionalState = new Map();
|
|
47
|
+
const applyConditionals = () => {
|
|
48
|
+
const values = formGroup.getRawValue();
|
|
49
|
+
for (const {
|
|
50
|
+
when,
|
|
51
|
+
fields
|
|
52
|
+
} of config.conditionals) {
|
|
53
|
+
let visible = true;
|
|
54
|
+
try {
|
|
55
|
+
visible = when(values);
|
|
56
|
+
} catch {
|
|
57
|
+
visible = true;
|
|
58
|
+
}
|
|
59
|
+
for (const fieldPath of fields) {
|
|
60
|
+
const control = formGroup.get(fieldPath);
|
|
61
|
+
if (!control) continue;
|
|
62
|
+
const previous = conditionalState.get(fieldPath);
|
|
63
|
+
if (previous === visible) continue;
|
|
64
|
+
conditionalState.set(fieldPath, visible);
|
|
65
|
+
if (visible) {
|
|
66
|
+
control.enable({
|
|
67
|
+
emitEvent: true
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
control.disable({
|
|
71
|
+
emitEvent: false
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
applyConditionals();
|
|
78
|
+
const condSub = formGroup.valueChanges.subscribe(() => applyConditionals());
|
|
79
|
+
cleanupCallbacks.push(() => condSub.unsubscribe());
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
formGroup,
|
|
83
|
+
formControl: path => {
|
|
84
|
+
const control = formGroup.get(path);
|
|
85
|
+
return control instanceof FormControl ? control : null;
|
|
86
|
+
},
|
|
87
|
+
angularErrors,
|
|
88
|
+
asyncPending
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function createFormGroupFromValues(values, fieldConfigs, basePath = '') {
|
|
92
|
+
const controls = {};
|
|
93
|
+
for (const [key, value] of Object.entries(values)) {
|
|
94
|
+
const path = basePath ? `${basePath}.${key}` : key;
|
|
95
|
+
const fieldConfig = fieldConfigs[path] || {};
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
const arrayControls = value.map((item, index) => {
|
|
98
|
+
if (typeof item === 'object' && item !== null) {
|
|
99
|
+
return createFormGroupFromValues(item, fieldConfigs, `${path}.${index}`);
|
|
100
|
+
}
|
|
101
|
+
return new FormControl(item);
|
|
102
|
+
});
|
|
103
|
+
controls[key] = new FormArray(arrayControls);
|
|
104
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
105
|
+
controls[key] = createFormGroupFromValues(value, fieldConfigs, path);
|
|
106
|
+
} else {
|
|
107
|
+
const validators = fieldConfig.validators ? Array.isArray(fieldConfig.validators) ? fieldConfig.validators : [fieldConfig.validators] : [];
|
|
108
|
+
const asyncValidators = fieldConfig.asyncValidators ? Array.isArray(fieldConfig.asyncValidators) ? fieldConfig.asyncValidators : [fieldConfig.asyncValidators] : [];
|
|
109
|
+
controls[key] = new FormControl(value, {
|
|
110
|
+
validators,
|
|
111
|
+
asyncValidators
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return new FormGroup(controls);
|
|
116
|
+
}
|
|
117
|
+
function patchFormGroupValues(group, values, basePath) {
|
|
118
|
+
for (const [key, value] of Object.entries(values)) {
|
|
119
|
+
const control = group.get(key);
|
|
120
|
+
if (!control) continue;
|
|
121
|
+
if (control instanceof FormGroup && typeof value === 'object' && value !== null) {
|
|
122
|
+
patchFormGroupValues(control, value);
|
|
123
|
+
} else if (control instanceof FormArray && Array.isArray(value)) {
|
|
124
|
+
control.clear({
|
|
125
|
+
emitEvent: false
|
|
126
|
+
});
|
|
127
|
+
for (const item of value) {
|
|
128
|
+
if (typeof item === 'object' && item !== null) {
|
|
129
|
+
control.push(createFormGroupFromValues(item, {}));
|
|
130
|
+
} else {
|
|
131
|
+
control.push(new FormControl(item));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
control.setValue(value, {
|
|
136
|
+
emitEvent: false
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function collectControlErrors(control, path, result) {
|
|
142
|
+
if (control.errors) {
|
|
143
|
+
result[path || 'root'] = control.errors;
|
|
144
|
+
}
|
|
145
|
+
if (control instanceof FormGroup) {
|
|
146
|
+
for (const [key, child] of Object.entries(control.controls)) {
|
|
147
|
+
collectControlErrors(child, path ? `${path}.${key}` : key, result);
|
|
148
|
+
}
|
|
149
|
+
} else if (control instanceof FormArray) {
|
|
150
|
+
control.controls.forEach((child, index) => {
|
|
151
|
+
collectControlErrors(child, `${path}.${index}`, result);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function formBridge(config = {}) {
|
|
156
|
+
return tree => {
|
|
157
|
+
const cleanupCallbacks = [];
|
|
158
|
+
const formBridgeMap = new Map();
|
|
159
|
+
const formLocations = [];
|
|
160
|
+
findFormSignals(tree.$, '', formLocations);
|
|
161
|
+
for (const {
|
|
162
|
+
path,
|
|
163
|
+
formSignal
|
|
164
|
+
} of formLocations) {
|
|
165
|
+
const bridge = createFormGroupBridge(formSignal, config, cleanupCallbacks);
|
|
166
|
+
formBridgeMap.set(path, bridge);
|
|
167
|
+
formSignal['formGroup'] = bridge.formGroup;
|
|
168
|
+
formSignal['formControl'] = bridge.formControl;
|
|
169
|
+
formSignal['angularErrors'] = bridge.angularErrors;
|
|
170
|
+
formSignal['asyncPending'] = bridge.asyncPending;
|
|
171
|
+
}
|
|
172
|
+
const destroyRef = config.destroyRef ?? tryInjectDestroyRef();
|
|
173
|
+
if (destroyRef) {
|
|
174
|
+
destroyRef.onDestroy(() => {
|
|
175
|
+
cleanupCallbacks.forEach(fn => fn());
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const enhanced = tree;
|
|
179
|
+
enhanced.formBridge = formBridgeMap;
|
|
180
|
+
enhanced.getAngularForm = function (path) {
|
|
181
|
+
return formBridgeMap.get(path) ?? null;
|
|
182
|
+
};
|
|
183
|
+
return enhanced;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function tryInjectDestroyRef() {
|
|
187
|
+
try {
|
|
188
|
+
return inject(DestroyRef);
|
|
189
|
+
} catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export { formBridge };
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signaltree/ng-forms",
|
|
3
|
-
"version": "7.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "7.3.0",
|
|
4
|
+
"description": "Angular forms as reactive JSON. Seamless SignalTree integration with FormTree creation, validators, and form state tracking.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { DestroyRef, Signal } from '@angular/core';
|
|
2
|
+
import { AsyncValidatorFn, FormControl, FormGroup, ValidationErrors, ValidatorFn } from '@angular/forms';
|
|
3
|
+
import { ISignalTree } from '@signaltree/core';
|
|
4
|
+
export interface AngularConditionalField<T = unknown> {
|
|
5
|
+
when: (values: T) => boolean;
|
|
6
|
+
fields: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface AngularFieldConfig {
|
|
9
|
+
debounceMs?: number;
|
|
10
|
+
validators?: ValidatorFn | ValidatorFn[];
|
|
11
|
+
asyncValidators?: AsyncValidatorFn | AsyncValidatorFn[];
|
|
12
|
+
}
|
|
13
|
+
export interface AngularFormsConfig<T = unknown> {
|
|
14
|
+
conditionals?: AngularConditionalField<T>[];
|
|
15
|
+
fieldConfigs?: Record<string, AngularFieldConfig>;
|
|
16
|
+
destroyRef?: DestroyRef;
|
|
17
|
+
validationBatchMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface AngularFormBridge<T extends Record<string, unknown>> {
|
|
20
|
+
formGroup: FormGroup;
|
|
21
|
+
formControl(path: string): FormControl | null;
|
|
22
|
+
angularErrors: Signal<Record<string, ValidationErrors | null>>;
|
|
23
|
+
asyncPending: Signal<boolean>;
|
|
24
|
+
}
|
|
25
|
+
export interface AngularFormsMethods {
|
|
26
|
+
getAngularForm<T extends Record<string, unknown>>(path: string): AngularFormBridge<T> | null;
|
|
27
|
+
formBridge: Map<string, AngularFormBridge<Record<string, unknown>>>;
|
|
28
|
+
}
|
|
29
|
+
export declare function formBridge<TConfig = unknown>(config?: AngularFormsConfig<TConfig>): <T>(tree: ISignalTree<T>) => ISignalTree<T> & AngularFormsMethods;
|
|
30
|
+
export { FORM_MARKER, isFormMarker } from '@signaltree/core';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './form-bridge';
|
package/src/index.d.ts
CHANGED
package/dist/audit/audit.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { getChanges } from '../get-changes.js';
|
|
2
|
-
|
|
3
|
-
function createAuditTracker(tree, auditLog, config = {}) {
|
|
4
|
-
const {
|
|
5
|
-
getMetadata,
|
|
6
|
-
includePreviousValues = false,
|
|
7
|
-
filter,
|
|
8
|
-
maxEntries = 0
|
|
9
|
-
} = config;
|
|
10
|
-
let previousState = structuredClone(tree());
|
|
11
|
-
let isTracking = true;
|
|
12
|
-
const handleChange = () => {
|
|
13
|
-
if (!isTracking) return;
|
|
14
|
-
const currentState = tree();
|
|
15
|
-
const changes = getChanges(previousState, currentState);
|
|
16
|
-
if (Object.keys(changes).length > 0) {
|
|
17
|
-
if (filter && !filter(changes)) {
|
|
18
|
-
previousState = structuredClone(currentState);
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
const entry = {
|
|
22
|
-
timestamp: Date.now(),
|
|
23
|
-
changes,
|
|
24
|
-
metadata: getMetadata?.()
|
|
25
|
-
};
|
|
26
|
-
if (includePreviousValues) {
|
|
27
|
-
const prevValues = {};
|
|
28
|
-
for (const key of Object.keys(changes)) {
|
|
29
|
-
prevValues[key] = previousState[key];
|
|
30
|
-
}
|
|
31
|
-
entry.previousValues = prevValues;
|
|
32
|
-
}
|
|
33
|
-
auditLog.push(entry);
|
|
34
|
-
if (maxEntries > 0 && auditLog.length > maxEntries) {
|
|
35
|
-
auditLog.splice(0, auditLog.length - maxEntries);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
previousState = structuredClone(currentState);
|
|
39
|
-
};
|
|
40
|
-
let unsubscribe;
|
|
41
|
-
let pollingId;
|
|
42
|
-
if ('subscribe' in tree && typeof tree.subscribe === 'function') {
|
|
43
|
-
try {
|
|
44
|
-
unsubscribe = tree.subscribe(handleChange);
|
|
45
|
-
} catch {
|
|
46
|
-
pollingId = setInterval(handleChange, 100);
|
|
47
|
-
}
|
|
48
|
-
} else {
|
|
49
|
-
pollingId = setInterval(handleChange, 100);
|
|
50
|
-
}
|
|
51
|
-
return () => {
|
|
52
|
-
isTracking = false;
|
|
53
|
-
if (unsubscribe) {
|
|
54
|
-
unsubscribe();
|
|
55
|
-
}
|
|
56
|
-
if (pollingId) {
|
|
57
|
-
clearInterval(pollingId);
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
function createAuditCallback(auditLog, getMetadata) {
|
|
62
|
-
return (previousState, currentState) => {
|
|
63
|
-
const changes = getChanges(previousState, currentState);
|
|
64
|
-
if (Object.keys(changes).length > 0) {
|
|
65
|
-
auditLog.push({
|
|
66
|
-
timestamp: Date.now(),
|
|
67
|
-
changes,
|
|
68
|
-
metadata: getMetadata?.()
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export { createAuditCallback, createAuditTracker };
|
package/dist/audit/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { createAuditCallback, createAuditTracker } from './audit.js';
|
package/dist/get-changes.js
DELETED