@signaltree/ng-forms 1.0.0 → 1.0.1
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 +882 -4
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,7 +1,885 @@
|
|
|
1
|
-
# ng-forms
|
|
1
|
+
# @signaltree/ng-forms
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Angular Forms integration for SignalTree featuring reactive forms binding, validation, form state management, and seamless Angular integration.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## ✨ What is @signaltree/ng-forms?
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The ng-forms package provides deep Angular Forms integration:
|
|
8
|
+
|
|
9
|
+
- **Reactive Forms binding** with automatic synchronization
|
|
10
|
+
- **Template-driven forms** support with signals
|
|
11
|
+
- **Advanced validation** with real-time feedback
|
|
12
|
+
- **Form state management** (dirty, touched, valid states)
|
|
13
|
+
- **Dynamic form generation** from SignalTree state
|
|
14
|
+
- **Cross-field validation** and complex form logic
|
|
15
|
+
|
|
16
|
+
## 🚀 Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @signaltree/core @signaltree/ng-forms
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 📖 Basic Usage
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { signalTree } from '@signaltree/core';
|
|
26
|
+
import { withForms } from '@signaltree/ng-forms';
|
|
27
|
+
|
|
28
|
+
const tree = signalTree({
|
|
29
|
+
user: {
|
|
30
|
+
name: '',
|
|
31
|
+
email: '',
|
|
32
|
+
age: 0,
|
|
33
|
+
},
|
|
34
|
+
preferences: {
|
|
35
|
+
newsletter: false,
|
|
36
|
+
theme: 'light',
|
|
37
|
+
},
|
|
38
|
+
}).pipe(withForms());
|
|
39
|
+
|
|
40
|
+
// Automatic form generation
|
|
41
|
+
const userForm = tree.createForm('user');
|
|
42
|
+
const preferencesForm = tree.createForm('preferences');
|
|
43
|
+
|
|
44
|
+
// Forms automatically sync with SignalTree state
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 🎯 Core Features
|
|
48
|
+
|
|
49
|
+
### Reactive Forms Integration
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { Component } from '@angular/core';
|
|
53
|
+
import { FormBuilder, Validators } from '@angular/forms';
|
|
54
|
+
|
|
55
|
+
@Component({
|
|
56
|
+
template: `
|
|
57
|
+
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
|
|
58
|
+
<!-- Form automatically synced with SignalTree -->
|
|
59
|
+
<input formControlName="name" placeholder="Name" />
|
|
60
|
+
<input formControlName="email" placeholder="Email" />
|
|
61
|
+
<input formControlName="age" type="number" placeholder="Age" />
|
|
62
|
+
|
|
63
|
+
<button type="submit" [disabled]="userForm.invalid">Submit</button>
|
|
64
|
+
</form>
|
|
65
|
+
|
|
66
|
+
<!-- Real-time state display -->
|
|
67
|
+
<div>
|
|
68
|
+
<p>Current State: {{ tree.$.user() | json }}</p>
|
|
69
|
+
<p>Form Valid: {{ userForm.valid }}</p>
|
|
70
|
+
<p>Form Dirty: {{ userForm.dirty }}</p>
|
|
71
|
+
</div>
|
|
72
|
+
`,
|
|
73
|
+
})
|
|
74
|
+
class UserFormComponent {
|
|
75
|
+
tree = signalTree({
|
|
76
|
+
user: {
|
|
77
|
+
name: '',
|
|
78
|
+
email: '',
|
|
79
|
+
age: 0,
|
|
80
|
+
},
|
|
81
|
+
}).pipe(withForms());
|
|
82
|
+
|
|
83
|
+
// Create form with automatic binding
|
|
84
|
+
userForm = this.tree.createForm('user', {
|
|
85
|
+
name: ['', [Validators.required, Validators.minLength(2)]],
|
|
86
|
+
email: ['', [Validators.required, Validators.email]],
|
|
87
|
+
age: [0, [Validators.required, Validators.min(18)]],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
onSubmit() {
|
|
91
|
+
if (this.userForm.valid) {
|
|
92
|
+
console.log('Form Data:', this.tree.$.user());
|
|
93
|
+
console.log('Form Value:', this.userForm.value);
|
|
94
|
+
// Both are automatically synchronized!
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Template-Driven Forms
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
@Component({
|
|
104
|
+
template: `
|
|
105
|
+
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
|
|
106
|
+
<!-- Bind directly to SignalTree signals -->
|
|
107
|
+
<input name="name" ngModel [ngModel]="tree.$.user.name()" (ngModelChange)="tree.$.user.name.set($event)" #name="ngModel" required minlength="2" />
|
|
108
|
+
<div *ngIf="name.invalid && name.touched">Name is required (min 2 characters)</div>
|
|
109
|
+
|
|
110
|
+
<input name="email" type="email" ngModel [ngModel]="tree.$.user.email()" (ngModelChange)="tree.$.user.email.set($event)" #email="ngModel" required email />
|
|
111
|
+
<div *ngIf="email.invalid && email.touched">Valid email is required</div>
|
|
112
|
+
|
|
113
|
+
<button type="submit" [disabled]="userForm.invalid">Submit</button>
|
|
114
|
+
</form>
|
|
115
|
+
`,
|
|
116
|
+
})
|
|
117
|
+
class TemplateFormComponent {
|
|
118
|
+
tree = signalTree({
|
|
119
|
+
user: {
|
|
120
|
+
name: '',
|
|
121
|
+
email: '',
|
|
122
|
+
},
|
|
123
|
+
}).pipe(withForms());
|
|
124
|
+
|
|
125
|
+
onSubmit(form: NgForm) {
|
|
126
|
+
if (form.valid) {
|
|
127
|
+
console.log('User Data:', this.tree.$.user());
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Advanced Validation
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
const tree = signalTree({
|
|
137
|
+
registration: {
|
|
138
|
+
username: '',
|
|
139
|
+
email: '',
|
|
140
|
+
password: '',
|
|
141
|
+
confirmPassword: '',
|
|
142
|
+
agreeToTerms: false,
|
|
143
|
+
},
|
|
144
|
+
}).pipe(withForms());
|
|
145
|
+
|
|
146
|
+
// Custom validators that can access SignalTree state
|
|
147
|
+
const usernameAsyncValidator = (control: AbstractControl) => {
|
|
148
|
+
return of(control.value).pipe(
|
|
149
|
+
delay(300), // Debounce
|
|
150
|
+
switchMap((username) =>
|
|
151
|
+
// Check if username exists (can access tree state)
|
|
152
|
+
this.userService.checkUsernameAvailability(username)
|
|
153
|
+
),
|
|
154
|
+
map((isAvailable) => (isAvailable ? null : { usernameTaken: true }))
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const passwordMatchValidator = (group: AbstractControl) => {
|
|
159
|
+
const password = group.get('password')?.value;
|
|
160
|
+
const confirmPassword = group.get('confirmPassword')?.value;
|
|
161
|
+
return password === confirmPassword ? null : { passwordMismatch: true };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const registrationForm = tree.createForm(
|
|
165
|
+
'registration',
|
|
166
|
+
{
|
|
167
|
+
username: [
|
|
168
|
+
'',
|
|
169
|
+
{
|
|
170
|
+
validators: [Validators.required, Validators.minLength(3)],
|
|
171
|
+
asyncValidators: [usernameAsyncValidator],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
email: ['', [Validators.required, Validators.email]],
|
|
175
|
+
password: ['', [Validators.required, Validators.minLength(8)]],
|
|
176
|
+
confirmPassword: ['', Validators.required],
|
|
177
|
+
agreeToTerms: [false, Validators.requiredTrue],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
validators: [passwordMatchValidator], // Form-level validator
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Real-time validation feedback
|
|
185
|
+
@Component({
|
|
186
|
+
template: `
|
|
187
|
+
<form [formGroup]="registrationForm" (ngSubmit)="onSubmit()">
|
|
188
|
+
<div class="field">
|
|
189
|
+
<input formControlName="username" placeholder="Username" />
|
|
190
|
+
<div class="validation-messages">
|
|
191
|
+
@if (registrationForm.get('username')?.hasError('required')) {
|
|
192
|
+
<span class="error">Username is required</span>
|
|
193
|
+
} @if (registrationForm.get('username')?.hasError('minlength')) {
|
|
194
|
+
<span class="error">Username must be at least 3 characters</span>
|
|
195
|
+
} @if (registrationForm.get('username')?.hasError('usernameTaken')) {
|
|
196
|
+
<span class="error">Username is already taken</span>
|
|
197
|
+
} @if (registrationForm.get('username')?.pending) {
|
|
198
|
+
<span class="checking">Checking availability...</span>
|
|
199
|
+
}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div class="field">
|
|
204
|
+
<input formControlName="password" type="password" placeholder="Password" />
|
|
205
|
+
<div class="validation-messages">
|
|
206
|
+
@if (registrationForm.get('password')?.hasError('required')) {
|
|
207
|
+
<span class="error">Password is required</span>
|
|
208
|
+
} @if (registrationForm.get('password')?.hasError('minlength')) {
|
|
209
|
+
<span class="error">Password must be at least 8 characters</span>
|
|
210
|
+
}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div class="field">
|
|
215
|
+
<input formControlName="confirmPassword" type="password" placeholder="Confirm Password" />
|
|
216
|
+
<div class="validation-messages">
|
|
217
|
+
@if (registrationForm.hasError('passwordMismatch')) {
|
|
218
|
+
<span class="error">Passwords don't match</span>
|
|
219
|
+
}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<label>
|
|
224
|
+
<input formControlName="agreeToTerms" type="checkbox" />
|
|
225
|
+
I agree to the terms and conditions
|
|
226
|
+
</label>
|
|
227
|
+
|
|
228
|
+
<button type="submit" [disabled]="registrationForm.invalid">Register</button>
|
|
229
|
+
</form>
|
|
230
|
+
`,
|
|
231
|
+
})
|
|
232
|
+
class RegistrationComponent {
|
|
233
|
+
registrationForm = registrationForm;
|
|
234
|
+
|
|
235
|
+
onSubmit() {
|
|
236
|
+
if (this.registrationForm.valid) {
|
|
237
|
+
console.log('Registration Data:', tree.$.registration());
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Dynamic Form Generation
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
interface FormField {
|
|
247
|
+
name: string;
|
|
248
|
+
type: 'text' | 'email' | 'number' | 'checkbox' | 'select';
|
|
249
|
+
label: string;
|
|
250
|
+
required?: boolean;
|
|
251
|
+
options?: Array<{ value: any; label: string }>;
|
|
252
|
+
validators?: any[];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const tree = signalTree({
|
|
256
|
+
dynamicData: {} as Record<string, any>,
|
|
257
|
+
formConfig: {
|
|
258
|
+
fields: [] as FormField[],
|
|
259
|
+
},
|
|
260
|
+
}).pipe(withForms());
|
|
261
|
+
|
|
262
|
+
@Injectable()
|
|
263
|
+
class DynamicFormService {
|
|
264
|
+
generateForm(fields: FormField[]) {
|
|
265
|
+
const formConfig: Record<string, any> = {};
|
|
266
|
+
|
|
267
|
+
fields.forEach((field) => {
|
|
268
|
+
const validators = [];
|
|
269
|
+
if (field.required) validators.push(Validators.required);
|
|
270
|
+
if (field.type === 'email') validators.push(Validators.email);
|
|
271
|
+
|
|
272
|
+
formConfig[field.name] = ['', validators];
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return tree.createForm('dynamicData', formConfig);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
@Component({
|
|
280
|
+
template: `
|
|
281
|
+
<form [formGroup]="dynamicForm" (ngSubmit)="onSubmit()">
|
|
282
|
+
@for (field of formFields(); track field.name) {
|
|
283
|
+
<div class="field">
|
|
284
|
+
<label>{{ field.label }}</label>
|
|
285
|
+
|
|
286
|
+
@switch (field.type) { @case ('text') {
|
|
287
|
+
<input [formControlName]="field.name" type="text" />
|
|
288
|
+
} @case ('email') {
|
|
289
|
+
<input [formControlName]="field.name" type="email" />
|
|
290
|
+
} @case ('number') {
|
|
291
|
+
<input [formControlName]="field.name" type="number" />
|
|
292
|
+
} @case ('checkbox') {
|
|
293
|
+
<input [formControlName]="field.name" type="checkbox" />
|
|
294
|
+
} @case ('select') {
|
|
295
|
+
<select [formControlName]="field.name">
|
|
296
|
+
@for (option of field.options; track option.value) {
|
|
297
|
+
<option [value]="option.value">{{ option.label }}</option>
|
|
298
|
+
}
|
|
299
|
+
</select>
|
|
300
|
+
} }
|
|
301
|
+
|
|
302
|
+
<div class="validation-messages">
|
|
303
|
+
@if (dynamicForm.get(field.name)?.invalid && dynamicForm.get(field.name)?.touched) {
|
|
304
|
+
<span class="error">{{ field.label }} is invalid</span>
|
|
305
|
+
}
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
<button type="submit" [disabled]="dynamicForm.invalid">Submit</button>
|
|
311
|
+
</form>
|
|
312
|
+
`,
|
|
313
|
+
})
|
|
314
|
+
class DynamicFormComponent {
|
|
315
|
+
formFields = computed(() => tree.$.formConfig.fields());
|
|
316
|
+
dynamicForm = this.dynamicFormService.generateForm(this.formFields());
|
|
317
|
+
|
|
318
|
+
constructor(private dynamicFormService: DynamicFormService) {}
|
|
319
|
+
|
|
320
|
+
onSubmit() {
|
|
321
|
+
if (this.dynamicForm.valid) {
|
|
322
|
+
console.log('Dynamic Form Data:', tree.$.dynamicData());
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## 🔧 Advanced Configuration
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
const tree = signalTree(state).pipe(
|
|
332
|
+
withForms({
|
|
333
|
+
// Automatic synchronization settings
|
|
334
|
+
autoSync: true,
|
|
335
|
+
syncDirection: 'bidirectional', // 'toForm' | 'toState' | 'bidirectional'
|
|
336
|
+
|
|
337
|
+
// Debounce settings for performance
|
|
338
|
+
debounceTime: 300,
|
|
339
|
+
|
|
340
|
+
// Validation settings
|
|
341
|
+
validateOnChange: true,
|
|
342
|
+
validateOnBlur: true,
|
|
343
|
+
showErrorsOnTouched: true,
|
|
344
|
+
|
|
345
|
+
// Form state management
|
|
346
|
+
trackFormState: true, // Track dirty, touched, valid states
|
|
347
|
+
persistFormState: true, // Persist across navigation
|
|
348
|
+
|
|
349
|
+
// Custom error messages
|
|
350
|
+
errorMessages: {
|
|
351
|
+
required: 'This field is required',
|
|
352
|
+
email: 'Please enter a valid email',
|
|
353
|
+
minlength: 'Minimum length not met',
|
|
354
|
+
maxlength: 'Maximum length exceeded',
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
// Custom validators
|
|
358
|
+
validators: {
|
|
359
|
+
strongPassword: (control: AbstractControl) => {
|
|
360
|
+
const value = control.value;
|
|
361
|
+
const hasNumber = /[0-9]/.test(value);
|
|
362
|
+
const hasUpper = /[A-Z]/.test(value);
|
|
363
|
+
const hasLower = /[a-z]/.test(value);
|
|
364
|
+
const hasSpecial = /[#?!@$%^&*-]/.test(value);
|
|
365
|
+
|
|
366
|
+
const valid = hasNumber && hasUpper && hasLower && hasSpecial;
|
|
367
|
+
return valid ? null : { strongPassword: true };
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
);
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## 📊 Real-World Examples
|
|
375
|
+
|
|
376
|
+
### Multi-Step Form Wizard
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
interface WizardState {
|
|
380
|
+
currentStep: number;
|
|
381
|
+
steps: {
|
|
382
|
+
personal: {
|
|
383
|
+
firstName: string;
|
|
384
|
+
lastName: string;
|
|
385
|
+
email: string;
|
|
386
|
+
phone: string;
|
|
387
|
+
};
|
|
388
|
+
address: {
|
|
389
|
+
street: string;
|
|
390
|
+
city: string;
|
|
391
|
+
state: string;
|
|
392
|
+
zipCode: string;
|
|
393
|
+
};
|
|
394
|
+
preferences: {
|
|
395
|
+
newsletter: boolean;
|
|
396
|
+
notifications: boolean;
|
|
397
|
+
theme: 'light' | 'dark';
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
validation: {
|
|
401
|
+
personalValid: boolean;
|
|
402
|
+
addressValid: boolean;
|
|
403
|
+
preferencesValid: boolean;
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const wizardTree = signalTree<WizardState>({
|
|
408
|
+
currentStep: 1,
|
|
409
|
+
steps: {
|
|
410
|
+
personal: {
|
|
411
|
+
firstName: '',
|
|
412
|
+
lastName: '',
|
|
413
|
+
email: '',
|
|
414
|
+
phone: '',
|
|
415
|
+
},
|
|
416
|
+
address: {
|
|
417
|
+
street: '',
|
|
418
|
+
city: '',
|
|
419
|
+
state: '',
|
|
420
|
+
zipCode: '',
|
|
421
|
+
},
|
|
422
|
+
preferences: {
|
|
423
|
+
newsletter: false,
|
|
424
|
+
notifications: true,
|
|
425
|
+
theme: 'light',
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
validation: {
|
|
429
|
+
personalValid: false,
|
|
430
|
+
addressValid: false,
|
|
431
|
+
preferencesValid: false,
|
|
432
|
+
},
|
|
433
|
+
}).pipe(withForms());
|
|
434
|
+
|
|
435
|
+
@Component({
|
|
436
|
+
template: `
|
|
437
|
+
<div class="wizard">
|
|
438
|
+
<div class="wizard-steps">
|
|
439
|
+
@for (step of steps; track step.number) {
|
|
440
|
+
<div class="step" [class.active]="currentStep() === step.number" [class.completed]="isStepCompleted(step.number)">
|
|
441
|
+
{{ step.title }}
|
|
442
|
+
</div>
|
|
443
|
+
}
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<div class="wizard-content">
|
|
447
|
+
@switch (currentStep()) { @case (1) {
|
|
448
|
+
<form [formGroup]="personalForm">
|
|
449
|
+
<h2>Personal Information</h2>
|
|
450
|
+
<input formControlName="firstName" placeholder="First Name" />
|
|
451
|
+
<input formControlName="lastName" placeholder="Last Name" />
|
|
452
|
+
<input formControlName="email" placeholder="Email" />
|
|
453
|
+
<input formControlName="phone" placeholder="Phone" />
|
|
454
|
+
</form>
|
|
455
|
+
} @case (2) {
|
|
456
|
+
<form [formGroup]="addressForm">
|
|
457
|
+
<h2>Address Information</h2>
|
|
458
|
+
<input formControlName="street" placeholder="Street Address" />
|
|
459
|
+
<input formControlName="city" placeholder="City" />
|
|
460
|
+
<input formControlName="state" placeholder="State" />
|
|
461
|
+
<input formControlName="zipCode" placeholder="ZIP Code" />
|
|
462
|
+
</form>
|
|
463
|
+
} @case (3) {
|
|
464
|
+
<form [formGroup]="preferencesForm">
|
|
465
|
+
<h2>Preferences</h2>
|
|
466
|
+
<label>
|
|
467
|
+
<input formControlName="newsletter" type="checkbox" />
|
|
468
|
+
Subscribe to newsletter
|
|
469
|
+
</label>
|
|
470
|
+
<label>
|
|
471
|
+
<input formControlName="notifications" type="checkbox" />
|
|
472
|
+
Enable notifications
|
|
473
|
+
</label>
|
|
474
|
+
<select formControlName="theme">
|
|
475
|
+
<option value="light">Light Theme</option>
|
|
476
|
+
<option value="dark">Dark Theme</option>
|
|
477
|
+
</select>
|
|
478
|
+
</form>
|
|
479
|
+
} }
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<div class="wizard-navigation">
|
|
483
|
+
<button (click)="previousStep()" [disabled]="currentStep() === 1">Previous</button>
|
|
484
|
+
|
|
485
|
+
@if (currentStep() < 3) {
|
|
486
|
+
<button (click)="nextStep()" [disabled]="!canProceed()">Next</button>
|
|
487
|
+
} @else {
|
|
488
|
+
<button (click)="submitWizard()" [disabled]="!allStepsValid()">Submit</button>
|
|
489
|
+
}
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
`,
|
|
493
|
+
})
|
|
494
|
+
class WizardComponent {
|
|
495
|
+
wizardTree = wizardTree;
|
|
496
|
+
currentStep = computed(() => this.wizardTree.$.currentStep());
|
|
497
|
+
|
|
498
|
+
steps = [
|
|
499
|
+
{ number: 1, title: 'Personal' },
|
|
500
|
+
{ number: 2, title: 'Address' },
|
|
501
|
+
{ number: 3, title: 'Preferences' },
|
|
502
|
+
];
|
|
503
|
+
|
|
504
|
+
// Create forms for each step
|
|
505
|
+
personalForm = this.wizardTree.createForm('steps.personal', {
|
|
506
|
+
firstName: ['', Validators.required],
|
|
507
|
+
lastName: ['', Validators.required],
|
|
508
|
+
email: ['', [Validators.required, Validators.email]],
|
|
509
|
+
phone: ['', Validators.required],
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
addressForm = this.wizardTree.createForm('steps.address', {
|
|
513
|
+
street: ['', Validators.required],
|
|
514
|
+
city: ['', Validators.required],
|
|
515
|
+
state: ['', Validators.required],
|
|
516
|
+
zipCode: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
preferencesForm = this.wizardTree.createForm('steps.preferences');
|
|
520
|
+
|
|
521
|
+
// Track form validity
|
|
522
|
+
constructor() {
|
|
523
|
+
// Update validation state when forms change
|
|
524
|
+
effect(() => {
|
|
525
|
+
this.wizardTree.$.validation.personalValid.set(this.personalForm.valid);
|
|
526
|
+
this.wizardTree.$.validation.addressValid.set(this.addressForm.valid);
|
|
527
|
+
this.wizardTree.$.validation.preferencesValid.set(this.preferencesForm.valid);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
nextStep() {
|
|
532
|
+
if (this.canProceed()) {
|
|
533
|
+
this.wizardTree.$.currentStep.update((step) => step + 1);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
previousStep() {
|
|
538
|
+
this.wizardTree.$.currentStep.update((step) => Math.max(1, step - 1));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
canProceed(): boolean {
|
|
542
|
+
const step = this.currentStep();
|
|
543
|
+
const validation = this.wizardTree.$.validation();
|
|
544
|
+
|
|
545
|
+
switch (step) {
|
|
546
|
+
case 1:
|
|
547
|
+
return validation.personalValid;
|
|
548
|
+
case 2:
|
|
549
|
+
return validation.addressValid;
|
|
550
|
+
case 3:
|
|
551
|
+
return validation.preferencesValid;
|
|
552
|
+
default:
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
isStepCompleted(stepNumber: number): boolean {
|
|
558
|
+
const validation = this.wizardTree.$.validation();
|
|
559
|
+
switch (stepNumber) {
|
|
560
|
+
case 1:
|
|
561
|
+
return validation.personalValid;
|
|
562
|
+
case 2:
|
|
563
|
+
return validation.addressValid;
|
|
564
|
+
case 3:
|
|
565
|
+
return validation.preferencesValid;
|
|
566
|
+
default:
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
allStepsValid(): boolean {
|
|
572
|
+
const validation = this.wizardTree.$.validation();
|
|
573
|
+
return validation.personalValid && validation.addressValid && validation.preferencesValid;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
submitWizard() {
|
|
577
|
+
if (this.allStepsValid()) {
|
|
578
|
+
const wizardData = this.wizardTree.$.steps();
|
|
579
|
+
console.log('Wizard completed:', wizardData);
|
|
580
|
+
// Submit to API
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
### Dynamic Survey Builder
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
interface Question {
|
|
590
|
+
id: string;
|
|
591
|
+
type: 'text' | 'number' | 'select' | 'radio' | 'checkbox';
|
|
592
|
+
label: string;
|
|
593
|
+
required: boolean;
|
|
594
|
+
options?: string[];
|
|
595
|
+
validation?: {
|
|
596
|
+
min?: number;
|
|
597
|
+
max?: number;
|
|
598
|
+
pattern?: string;
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
interface Survey {
|
|
603
|
+
id: string;
|
|
604
|
+
title: string;
|
|
605
|
+
questions: Question[];
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
interface SurveyResponse {
|
|
609
|
+
surveyId: string;
|
|
610
|
+
responses: Record<string, any>;
|
|
611
|
+
submittedAt?: Date;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const surveyTree = signalTree({
|
|
615
|
+
survey: null as Survey | null,
|
|
616
|
+
responses: {} as Record<string, any>,
|
|
617
|
+
currentQuestion: 0,
|
|
618
|
+
isSubmitting: false,
|
|
619
|
+
submitError: null as string | null,
|
|
620
|
+
}).pipe(withForms());
|
|
621
|
+
|
|
622
|
+
@Component({
|
|
623
|
+
template: `
|
|
624
|
+
<div class="survey" *ngIf="survey()">
|
|
625
|
+
<h1>{{ survey()!.title }}</h1>
|
|
626
|
+
|
|
627
|
+
<div class="progress">
|
|
628
|
+
<div class="progress-bar" [style.width.%]="progressPercent()"></div>
|
|
629
|
+
</div>
|
|
630
|
+
|
|
631
|
+
<form [formGroup]="surveyForm" (ngSubmit)="submitSurvey()">
|
|
632
|
+
@for (question of survey()!.questions; track question.id; let i = $index) {
|
|
633
|
+
<div class="question" [class.active]="currentQuestion() === i">
|
|
634
|
+
<h3>{{ question.label }}</h3>
|
|
635
|
+
|
|
636
|
+
@switch (question.type) { @case ('text') {
|
|
637
|
+
<input [formControlName]="question.id" type="text" [placeholder]="question.label" />
|
|
638
|
+
} @case ('number') {
|
|
639
|
+
<input [formControlName]="question.id" type="number" [min]="question.validation?.min" [max]="question.validation?.max" />
|
|
640
|
+
} @case ('select') {
|
|
641
|
+
<select [formControlName]="question.id">
|
|
642
|
+
<option value="">Select an option</option>
|
|
643
|
+
@for (option of question.options; track option) {
|
|
644
|
+
<option [value]="option">{{ option }}</option>
|
|
645
|
+
}
|
|
646
|
+
</select>
|
|
647
|
+
} @case ('radio') { @for (option of question.options; track option) {
|
|
648
|
+
<label>
|
|
649
|
+
<input [formControlName]="question.id" type="radio" [value]="option" />
|
|
650
|
+
{{ option }}
|
|
651
|
+
</label>
|
|
652
|
+
} } @case ('checkbox') { @for (option of question.options; track option) {
|
|
653
|
+
<label>
|
|
654
|
+
<input type="checkbox" [value]="option" (change)="onCheckboxChange(question.id, option, $event)" />
|
|
655
|
+
{{ option }}
|
|
656
|
+
</label>
|
|
657
|
+
} } }
|
|
658
|
+
|
|
659
|
+
<div class="validation-error" *ngIf="getQuestionError(question.id)">
|
|
660
|
+
{{ getQuestionError(question.id) }}
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
<div class="survey-navigation">
|
|
666
|
+
<button type="button" (click)="previousQuestion()" [disabled]="currentQuestion() === 0">Previous</button>
|
|
667
|
+
|
|
668
|
+
@if (currentQuestion() < survey()!.questions.length - 1) {
|
|
669
|
+
<button type="button" (click)="nextQuestion()" [disabled]="!isCurrentQuestionValid()">Next</button>
|
|
670
|
+
} @else {
|
|
671
|
+
<button type="submit" [disabled]="surveyForm.invalid || isSubmitting()">
|
|
672
|
+
{{ isSubmitting() ? 'Submitting...' : 'Submit Survey' }}
|
|
673
|
+
</button>
|
|
674
|
+
}
|
|
675
|
+
</div>
|
|
676
|
+
</form>
|
|
677
|
+
|
|
678
|
+
<div class="error" *ngIf="submitError()">
|
|
679
|
+
{{ submitError() }}
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
`,
|
|
683
|
+
})
|
|
684
|
+
class SurveyComponent implements OnInit {
|
|
685
|
+
surveyTree = surveyTree;
|
|
686
|
+
surveyForm!: FormGroup;
|
|
687
|
+
|
|
688
|
+
survey = computed(() => this.surveyTree.$.survey());
|
|
689
|
+
currentQuestion = computed(() => this.surveyTree.$.currentQuestion());
|
|
690
|
+
isSubmitting = computed(() => this.surveyTree.$.isSubmitting());
|
|
691
|
+
submitError = computed(() => this.surveyTree.$.submitError());
|
|
692
|
+
|
|
693
|
+
progressPercent = computed(() => {
|
|
694
|
+
const survey = this.survey();
|
|
695
|
+
const current = this.currentQuestion();
|
|
696
|
+
if (!survey) return 0;
|
|
697
|
+
return ((current + 1) / survey.questions.length) * 100;
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
ngOnInit() {
|
|
701
|
+
// Load survey and create form
|
|
702
|
+
this.loadSurvey().then((survey) => {
|
|
703
|
+
this.surveyTree.$.survey.set(survey);
|
|
704
|
+
this.createSurveyForm(survey);
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private createSurveyForm(survey: Survey) {
|
|
709
|
+
const formConfig: Record<string, any> = {};
|
|
710
|
+
|
|
711
|
+
survey.questions.forEach((question) => {
|
|
712
|
+
const validators = [];
|
|
713
|
+
|
|
714
|
+
if (question.required) {
|
|
715
|
+
validators.push(Validators.required);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (question.validation) {
|
|
719
|
+
if (question.validation.min !== undefined) {
|
|
720
|
+
validators.push(Validators.min(question.validation.min));
|
|
721
|
+
}
|
|
722
|
+
if (question.validation.max !== undefined) {
|
|
723
|
+
validators.push(Validators.max(question.validation.max));
|
|
724
|
+
}
|
|
725
|
+
if (question.validation.pattern) {
|
|
726
|
+
validators.push(Validators.pattern(question.validation.pattern));
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const defaultValue = question.type === 'checkbox' ? [] : '';
|
|
731
|
+
formConfig[question.id] = [defaultValue, validators];
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
this.surveyForm = this.surveyTree.createForm('responses', formConfig);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
nextQuestion() {
|
|
738
|
+
if (this.isCurrentQuestionValid()) {
|
|
739
|
+
this.surveyTree.$.currentQuestion.update((q) => q + 1);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
previousQuestion() {
|
|
744
|
+
this.surveyTree.$.currentQuestion.update((q) => Math.max(0, q - 1));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
isCurrentQuestionValid(): boolean {
|
|
748
|
+
const survey = this.survey();
|
|
749
|
+
const current = this.currentQuestion();
|
|
750
|
+
if (!survey) return false;
|
|
751
|
+
|
|
752
|
+
const question = survey.questions[current];
|
|
753
|
+
const control = this.surveyForm.get(question.id);
|
|
754
|
+
return control ? control.valid : false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
getQuestionError(questionId: string): string | null {
|
|
758
|
+
const control = this.surveyForm.get(questionId);
|
|
759
|
+
if (control?.errors && control.touched) {
|
|
760
|
+
if (control.errors['required']) return 'This question is required';
|
|
761
|
+
if (control.errors['min']) return `Minimum value is ${control.errors['min'].min}`;
|
|
762
|
+
if (control.errors['max']) return `Maximum value is ${control.errors['max'].max}`;
|
|
763
|
+
if (control.errors['pattern']) return 'Invalid format';
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
onCheckboxChange(questionId: string, option: string, event: any) {
|
|
769
|
+
const control = this.surveyForm.get(questionId);
|
|
770
|
+
const currentValue = control?.value || [];
|
|
771
|
+
|
|
772
|
+
if (event.target.checked) {
|
|
773
|
+
control?.setValue([...currentValue, option]);
|
|
774
|
+
} else {
|
|
775
|
+
control?.setValue(currentValue.filter((v: string) => v !== option));
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async submitSurvey() {
|
|
780
|
+
if (this.surveyForm.valid) {
|
|
781
|
+
this.surveyTree.$.isSubmitting.set(true);
|
|
782
|
+
this.surveyTree.$.submitError.set(null);
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
const response: SurveyResponse = {
|
|
786
|
+
surveyId: this.survey()!.id,
|
|
787
|
+
responses: this.surveyTree.$.responses(),
|
|
788
|
+
submittedAt: new Date(),
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
await this.submitSurveyResponse(response);
|
|
792
|
+
console.log('Survey submitted successfully');
|
|
793
|
+
} catch (error) {
|
|
794
|
+
this.surveyTree.$.submitError.set('Failed to submit survey. Please try again.');
|
|
795
|
+
} finally {
|
|
796
|
+
this.surveyTree.$.isSubmitting.set(false);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private async loadSurvey(): Promise<Survey> {
|
|
802
|
+
// Load survey from API
|
|
803
|
+
return {
|
|
804
|
+
id: '1',
|
|
805
|
+
title: 'Customer Satisfaction Survey',
|
|
806
|
+
questions: [
|
|
807
|
+
{
|
|
808
|
+
id: 'q1',
|
|
809
|
+
type: 'radio',
|
|
810
|
+
label: 'How satisfied are you with our service?',
|
|
811
|
+
required: true,
|
|
812
|
+
options: ['Very Satisfied', 'Satisfied', 'Neutral', 'Dissatisfied', 'Very Dissatisfied'],
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
id: 'q2',
|
|
816
|
+
type: 'text',
|
|
817
|
+
label: 'What can we improve?',
|
|
818
|
+
required: false,
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
id: 'q3',
|
|
822
|
+
type: 'number',
|
|
823
|
+
label: 'Rate us from 1-10',
|
|
824
|
+
required: true,
|
|
825
|
+
validation: { min: 1, max: 10 },
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private async submitSurveyResponse(response: SurveyResponse): Promise<void> {
|
|
832
|
+
// Submit to API
|
|
833
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
## 🎯 When to Use ng-forms
|
|
839
|
+
|
|
840
|
+
Perfect for:
|
|
841
|
+
|
|
842
|
+
- ✅ Angular applications with complex forms
|
|
843
|
+
- ✅ Real-time form validation and feedback
|
|
844
|
+
- ✅ Multi-step forms and wizards
|
|
845
|
+
- ✅ Dynamic form generation
|
|
846
|
+
- ✅ Forms with cross-field validation
|
|
847
|
+
- ✅ Applications requiring form state persistence
|
|
848
|
+
|
|
849
|
+
## 🔗 Composition with Other Packages
|
|
850
|
+
|
|
851
|
+
```typescript
|
|
852
|
+
import { signalTree } from '@signaltree/core';
|
|
853
|
+
import { withForms } from '@signaltree/ng-forms';
|
|
854
|
+
import { withValidation } from '@signaltree/validation';
|
|
855
|
+
import { withDevtools } from '@signaltree/devtools';
|
|
856
|
+
|
|
857
|
+
const tree = signalTree(state).pipe(
|
|
858
|
+
withValidation(), // Enhanced validation rules
|
|
859
|
+
withForms(), // Angular forms integration
|
|
860
|
+
withDevtools() // Debug form state changes
|
|
861
|
+
);
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
## 📈 Performance Benefits
|
|
865
|
+
|
|
866
|
+
- **Automatic synchronization** between forms and state
|
|
867
|
+
- **Debounced updates** prevent excessive state changes
|
|
868
|
+
- **Efficient validation** with minimal re-computation
|
|
869
|
+
- **Tree-shakeable** - only includes what you use
|
|
870
|
+
- **Memory efficient** form state management
|
|
871
|
+
|
|
872
|
+
## 🔗 Links
|
|
873
|
+
|
|
874
|
+
- [SignalTree Documentation](https://signaltree.io)
|
|
875
|
+
- [Core Package](https://www.npmjs.com/package/@signaltree/core)
|
|
876
|
+
- [GitHub Repository](https://github.com/JBorgia/signaltree)
|
|
877
|
+
- [ng-forms Examples](https://signaltree.io/examples/ng-forms)
|
|
878
|
+
|
|
879
|
+
## 📄 License
|
|
880
|
+
|
|
881
|
+
MIT License with AI Training Restriction - see the [LICENSE](../../LICENSE) file for details.
|
|
882
|
+
|
|
883
|
+
---
|
|
884
|
+
|
|
885
|
+
**Seamless Angular Forms** with SignalTree power! 🅰️
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signaltree/ng-forms",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Complete Angular forms integration for SignalTree. FormTree creation, custom directives, validators, form state tracking, and RxJS bridge.",
|
|
4
5
|
"peerDependencies": {
|
|
5
6
|
"@angular/core": "^20.1.0",
|
|
6
7
|
"@angular/forms": "^20.1.0",
|