@signaltree/ng-forms 4.0.7 → 4.0.9
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/LICENSE +54 -0
- package/eslint.config.mjs +48 -0
- package/jest.config.ts +21 -0
- package/ng-package.json +8 -0
- package/package.json +6 -27
- package/project.json +48 -0
- package/src/audit/audit.ts +75 -0
- package/src/audit/index.ts +1 -0
- package/src/audit/ng-package.json +5 -0
- package/src/core/async-validators.ts +80 -0
- package/src/core/ng-forms.spec.ts +204 -0
- package/src/core/ng-forms.ts +1316 -0
- package/src/core/validators.ts +209 -0
- package/src/history/history.ts +169 -0
- package/src/history/index.ts +1 -0
- package/src/history/ng-package.json +5 -0
- package/src/index.ts +5 -0
- package/src/rxjs/index.ts +1 -0
- package/src/rxjs/ng-package.json +5 -0
- package/src/rxjs/public-api.ts +5 -0
- package/src/rxjs/rxjs-bridge.ts +75 -0
- package/src/test-setup.ts +6 -0
- package/src/types/signaltree-core.d.ts +11 -0
- package/src/wizard/index.ts +1 -0
- package/src/wizard/wizard.ts +145 -0
- package/tsconfig.json +33 -0
- package/tsconfig.lib.json +25 -0
- package/tsconfig.lib.prod.json +13 -0
- package/tsconfig.spec.json +17 -0
- package/fesm2022/signaltree-ng-forms-src-audit.mjs +0 -20
- package/fesm2022/signaltree-ng-forms-src-audit.mjs.map +0 -1
- package/fesm2022/signaltree-ng-forms-src-history.mjs +0 -113
- package/fesm2022/signaltree-ng-forms-src-history.mjs.map +0 -1
- package/fesm2022/signaltree-ng-forms-src-rxjs.mjs +0 -21
- package/fesm2022/signaltree-ng-forms-src-rxjs.mjs.map +0 -1
- package/fesm2022/signaltree-ng-forms.mjs +0 -1038
- package/fesm2022/signaltree-ng-forms.mjs.map +0 -1
- package/index.d.ts +0 -128
- package/src/audit/index.d.ts +0 -15
- package/src/history/index.d.ts +0 -24
- package/src/rxjs/index.d.ts +0 -6
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tree-shakeable validator functions for form fields
|
|
3
|
+
*
|
|
4
|
+
* These validators are exported as individual functions to enable
|
|
5
|
+
* tree-shaking. Only the validators you actually use will be included
|
|
6
|
+
* in your bundle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FieldValidator } from './ng-forms';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a required field validator
|
|
13
|
+
*
|
|
14
|
+
* @param message - Custom error message (default: "Required")
|
|
15
|
+
* @returns Validator function that returns error message if value is falsy
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* createFormTree(data, {
|
|
20
|
+
* validators: {
|
|
21
|
+
* name: required(),
|
|
22
|
+
* email: required('Email is required')
|
|
23
|
+
* }
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function required(message = 'Required'): FieldValidator {
|
|
28
|
+
return (value: unknown) => (!value ? message : null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates an email format validator
|
|
33
|
+
*
|
|
34
|
+
* @param message - Custom error message (default: "Invalid email")
|
|
35
|
+
* @returns Validator function that checks for @ symbol
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* createFormTree(data, {
|
|
40
|
+
* validators: {
|
|
41
|
+
* email: email('Please enter a valid email address')
|
|
42
|
+
* }
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function email(message = 'Invalid email'): FieldValidator {
|
|
47
|
+
return (value: unknown) => {
|
|
48
|
+
const strValue = value as string;
|
|
49
|
+
return strValue && !strValue.includes('@') ? message : null;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a minimum length validator for strings
|
|
55
|
+
*
|
|
56
|
+
* @param min - Minimum required length
|
|
57
|
+
* @param message - Optional custom error message
|
|
58
|
+
* @returns Validator function that checks string length
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* createFormTree(data, {
|
|
63
|
+
* validators: {
|
|
64
|
+
* password: minLength(8),
|
|
65
|
+
* description: minLength(10, 'Description must be at least 10 characters')
|
|
66
|
+
* }
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function minLength(min: number, message?: string): FieldValidator {
|
|
71
|
+
return (value: unknown) => {
|
|
72
|
+
const strValue = value as string;
|
|
73
|
+
const errorMsg = message ?? `Min ${min} characters`;
|
|
74
|
+
return strValue && strValue.length < min ? errorMsg : null;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Creates a maximum length validator for strings
|
|
80
|
+
*
|
|
81
|
+
* @param max - Maximum allowed length
|
|
82
|
+
* @param message - Optional custom error message
|
|
83
|
+
* @returns Validator function that checks string length
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* createFormTree(data, {
|
|
88
|
+
* validators: {
|
|
89
|
+
* username: maxLength(20),
|
|
90
|
+
* bio: maxLength(500, 'Bio must be under 500 characters')
|
|
91
|
+
* }
|
|
92
|
+
* });
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function maxLength(max: number, message?: string): FieldValidator {
|
|
96
|
+
return (value: unknown) => {
|
|
97
|
+
const strValue = value as string;
|
|
98
|
+
const errorMsg = message ?? `Max ${max} characters`;
|
|
99
|
+
return strValue && strValue.length > max ? errorMsg : null;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Creates a regex pattern validator
|
|
105
|
+
*
|
|
106
|
+
* @param regex - Regular expression to test against
|
|
107
|
+
* @param message - Custom error message (default: "Invalid format")
|
|
108
|
+
* @returns Validator function that tests value against regex
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```typescript
|
|
112
|
+
* createFormTree(data, {
|
|
113
|
+
* validators: {
|
|
114
|
+
* phone: pattern(/^\d{3}-\d{3}-\d{4}$/, 'Phone must be in format: 123-456-7890'),
|
|
115
|
+
* zipCode: pattern(/^\d{5}$/, 'Zip code must be 5 digits')
|
|
116
|
+
* }
|
|
117
|
+
* });
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export function pattern(
|
|
121
|
+
regex: RegExp,
|
|
122
|
+
message = 'Invalid format'
|
|
123
|
+
): FieldValidator {
|
|
124
|
+
return (value: unknown) => {
|
|
125
|
+
const strValue = value as string;
|
|
126
|
+
return strValue && !regex.test(strValue) ? message : null;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Creates a minimum value validator for numbers
|
|
132
|
+
*
|
|
133
|
+
* @param min - Minimum allowed value
|
|
134
|
+
* @param message - Optional custom error message
|
|
135
|
+
* @returns Validator function that checks numeric minimum
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```typescript
|
|
139
|
+
* createFormTree(data, {
|
|
140
|
+
* validators: {
|
|
141
|
+
* age: min(18, 'Must be at least 18 years old'),
|
|
142
|
+
* quantity: min(1)
|
|
143
|
+
* }
|
|
144
|
+
* });
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export function min(min: number, message?: string): FieldValidator {
|
|
148
|
+
return (value: unknown) => {
|
|
149
|
+
const numValue = value as number;
|
|
150
|
+
const errorMsg = message ?? `Must be at least ${min}`;
|
|
151
|
+
return numValue < min ? errorMsg : null;
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Creates a maximum value validator for numbers
|
|
157
|
+
*
|
|
158
|
+
* @param max - Maximum allowed value
|
|
159
|
+
* @param message - Optional custom error message
|
|
160
|
+
* @returns Validator function that checks numeric maximum
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```typescript
|
|
164
|
+
* createFormTree(data, {
|
|
165
|
+
* validators: {
|
|
166
|
+
* age: max(120, 'Must be under 120 years old'),
|
|
167
|
+
* quantity: max(100)
|
|
168
|
+
* }
|
|
169
|
+
* });
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
export function max(max: number, message?: string): FieldValidator {
|
|
173
|
+
return (value: unknown) => {
|
|
174
|
+
const numValue = value as number;
|
|
175
|
+
const errorMsg = message ?? `Must be at most ${max}`;
|
|
176
|
+
return numValue > max ? errorMsg : null;
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Combines multiple validators into a single validator function
|
|
182
|
+
*
|
|
183
|
+
* @param validators - Array of validator functions to combine
|
|
184
|
+
* @returns Combined validator that returns the first error encountered
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* ```typescript
|
|
188
|
+
* createFormTree(data, {
|
|
189
|
+
* validators: {
|
|
190
|
+
* email: compose([
|
|
191
|
+
* required('Email is required'),
|
|
192
|
+
* email('Invalid email format'),
|
|
193
|
+
* pattern(/@company\.com$/, 'Must be a company email')
|
|
194
|
+
* ])
|
|
195
|
+
* }
|
|
196
|
+
* });
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export function compose(validators: FieldValidator[]): FieldValidator {
|
|
200
|
+
return (value: unknown) => {
|
|
201
|
+
for (const validator of validators) {
|
|
202
|
+
const error = validator(value);
|
|
203
|
+
if (error) {
|
|
204
|
+
return error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Signal, signal } from '@angular/core';
|
|
2
|
+
import { deepClone, snapshotsEqual } from '@signaltree/shared';
|
|
3
|
+
|
|
4
|
+
// Local type definition to avoid circular imports in secondary entry points
|
|
5
|
+
interface FormTree<T extends Record<string, unknown>> {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
form: any; // FormGroup
|
|
8
|
+
unwrap(): T;
|
|
9
|
+
destroy(): void;
|
|
10
|
+
setValues(values: Partial<T>): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Form history state structure
|
|
15
|
+
*/
|
|
16
|
+
export interface FormHistory<T> {
|
|
17
|
+
past: T[];
|
|
18
|
+
present: T;
|
|
19
|
+
future: T[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Enhances a FormTree with undo/redo capabilities.
|
|
24
|
+
* Provides form-specific history management with capacity limits and change tracking.
|
|
25
|
+
*
|
|
26
|
+
* @param formTree - The form tree to enhance
|
|
27
|
+
* @param options - Configuration options
|
|
28
|
+
* @param options.capacity - Maximum number of history entries (default: 10)
|
|
29
|
+
* @returns Extended FormTree with undo, redo, and history access
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* const form = createFormTree({ name: '', email: '' });
|
|
34
|
+
* const formWithHistory = withFormHistory(form, { capacity: 20 });
|
|
35
|
+
*
|
|
36
|
+
* form.$.name.set('John');
|
|
37
|
+
* form.$.email.set('john@example.com');
|
|
38
|
+
*
|
|
39
|
+
* formWithHistory.undo(); // Reverts email change
|
|
40
|
+
* formWithHistory.undo(); // Reverts name change
|
|
41
|
+
* formWithHistory.redo(); // Reapplies name change
|
|
42
|
+
*
|
|
43
|
+
* console.log(formWithHistory.history().past.length); // 1
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function withFormHistory<T extends Record<string, unknown>>(
|
|
47
|
+
formTree: FormTree<T>,
|
|
48
|
+
options: { capacity?: number } = {}
|
|
49
|
+
): FormTree<T> & {
|
|
50
|
+
undo: () => void;
|
|
51
|
+
redo: () => void;
|
|
52
|
+
clearHistory: () => void;
|
|
53
|
+
history: Signal<FormHistory<T>>;
|
|
54
|
+
} {
|
|
55
|
+
const capacity = Math.max(1, options.capacity ?? 10);
|
|
56
|
+
const historySignal = signal<FormHistory<T>>({
|
|
57
|
+
past: [],
|
|
58
|
+
present: deepClone(formTree.form.getRawValue() as T),
|
|
59
|
+
future: [],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let recording = true;
|
|
63
|
+
let suppressUpdates = 0;
|
|
64
|
+
let internalHistory: FormHistory<T> = {
|
|
65
|
+
past: [],
|
|
66
|
+
present: deepClone(formTree.form.getRawValue() as T),
|
|
67
|
+
future: [],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const subscription = formTree.form.valueChanges.subscribe(() => {
|
|
71
|
+
if (suppressUpdates > 0) {
|
|
72
|
+
suppressUpdates--;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!recording) {
|
|
77
|
+
internalHistory = {
|
|
78
|
+
...internalHistory,
|
|
79
|
+
present: deepClone(formTree.form.getRawValue() as T),
|
|
80
|
+
};
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const snapshot = deepClone(formTree.form.getRawValue() as T);
|
|
84
|
+
if (snapshotsEqual(internalHistory.present, snapshot)) {
|
|
85
|
+
internalHistory = {
|
|
86
|
+
...internalHistory,
|
|
87
|
+
present: snapshot,
|
|
88
|
+
};
|
|
89
|
+
historySignal.set(cloneHistory(internalHistory));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const updatedPast = [...internalHistory.past, internalHistory.present];
|
|
93
|
+
if (updatedPast.length > capacity) {
|
|
94
|
+
updatedPast.shift();
|
|
95
|
+
}
|
|
96
|
+
internalHistory = {
|
|
97
|
+
past: updatedPast,
|
|
98
|
+
present: snapshot,
|
|
99
|
+
future: [],
|
|
100
|
+
};
|
|
101
|
+
historySignal.set(cloneHistory(internalHistory));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const originalDestroy = formTree.destroy;
|
|
105
|
+
formTree.destroy = () => {
|
|
106
|
+
subscription.unsubscribe();
|
|
107
|
+
originalDestroy();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const undo = () => {
|
|
111
|
+
const history = historySignal();
|
|
112
|
+
if (history.past.length === 0) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const previous = deepClone(history.past[history.past.length - 1]);
|
|
116
|
+
recording = false;
|
|
117
|
+
suppressUpdates++;
|
|
118
|
+
formTree.setValues(previous);
|
|
119
|
+
recording = true;
|
|
120
|
+
internalHistory = {
|
|
121
|
+
past: history.past.slice(0, -1),
|
|
122
|
+
present: previous,
|
|
123
|
+
future: [history.present, ...history.future],
|
|
124
|
+
};
|
|
125
|
+
historySignal.set(cloneHistory(internalHistory));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const redo = () => {
|
|
129
|
+
const history = historySignal();
|
|
130
|
+
if (history.future.length === 0) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const next = deepClone(history.future[0]);
|
|
134
|
+
recording = false;
|
|
135
|
+
suppressUpdates++;
|
|
136
|
+
formTree.setValues(next);
|
|
137
|
+
recording = true;
|
|
138
|
+
internalHistory = {
|
|
139
|
+
past: [...history.past, history.present],
|
|
140
|
+
present: next,
|
|
141
|
+
future: history.future.slice(1),
|
|
142
|
+
};
|
|
143
|
+
historySignal.set(cloneHistory(internalHistory));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const clearHistory = () => {
|
|
147
|
+
internalHistory = {
|
|
148
|
+
past: [],
|
|
149
|
+
present: deepClone(formTree.form.getRawValue() as T),
|
|
150
|
+
future: [],
|
|
151
|
+
};
|
|
152
|
+
historySignal.set(cloneHistory(internalHistory));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function cloneHistory(state: FormHistory<T>): FormHistory<T> {
|
|
156
|
+
return {
|
|
157
|
+
past: state.past.map((entry) => deepClone(entry)),
|
|
158
|
+
present: deepClone(state.present),
|
|
159
|
+
future: state.future.map((entry) => deepClone(entry)),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return Object.assign(formTree, {
|
|
164
|
+
undo,
|
|
165
|
+
redo,
|
|
166
|
+
clearHistory,
|
|
167
|
+
history: historySignal.asReadonly(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { withFormHistory, type FormHistory } from './history';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './public-api';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { effect, Signal } from '@angular/core';
|
|
2
|
+
import { Observable } from 'rxjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @fileoverview RxJS bridge for converting Angular signals to observables
|
|
6
|
+
*
|
|
7
|
+
* This module provides utilities for integrating SignalTree forms with
|
|
8
|
+
* RxJS-based reactive patterns.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Converts an Angular signal to an RxJS Observable.
|
|
13
|
+
*
|
|
14
|
+
* Creates an Observable that emits the signal's value whenever it changes.
|
|
15
|
+
* The effect is automatically destroyed when the Observable is unsubscribed.
|
|
16
|
+
*
|
|
17
|
+
* @template T - The type of value emitted by the signal
|
|
18
|
+
* @param signal - The Angular signal to convert
|
|
19
|
+
* @returns Observable that mirrors the signal's values
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* import { toObservable } from '@signaltree/ng-forms/rxjs';
|
|
24
|
+
*
|
|
25
|
+
* const form = createFormTree({ name: '' });
|
|
26
|
+
*
|
|
27
|
+
* // Convert form signals to observables
|
|
28
|
+
* const name$ = toObservable(form.$.name);
|
|
29
|
+
* const errors$ = toObservable(form.errors);
|
|
30
|
+
*
|
|
31
|
+
* // Use with RxJS operators
|
|
32
|
+
* name$.pipe(
|
|
33
|
+
* debounceTime(300),
|
|
34
|
+
* distinctUntilChanged()
|
|
35
|
+
* ).subscribe(value => {
|
|
36
|
+
* console.log('Name changed:', value);
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* // Combine multiple form signals
|
|
43
|
+
* import { combineLatest } from 'rxjs';
|
|
44
|
+
*
|
|
45
|
+
* const form = createFormTree({
|
|
46
|
+
* firstName: '',
|
|
47
|
+
* lastName: ''
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* combineLatest([
|
|
51
|
+
* toObservable(form.$.firstName),
|
|
52
|
+
* toObservable(form.$.lastName)
|
|
53
|
+
* ]).pipe(
|
|
54
|
+
* map(([first, last]) => `${first} ${last}`)
|
|
55
|
+
* ).subscribe(fullName => {
|
|
56
|
+
* console.log('Full name:', fullName);
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function toObservable<T>(signal: Signal<T>): Observable<T> {
|
|
61
|
+
return new Observable((subscriber) => {
|
|
62
|
+
try {
|
|
63
|
+
const effectRef = effect(() => {
|
|
64
|
+
subscriber.next(signal());
|
|
65
|
+
});
|
|
66
|
+
return () => effectRef.destroy();
|
|
67
|
+
} catch {
|
|
68
|
+
// Fallback for test environment without injection context
|
|
69
|
+
subscriber.next(signal());
|
|
70
|
+
return () => {
|
|
71
|
+
// No cleanup needed for single emission
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
declare module '@signaltree/core' {
|
|
2
|
+
export { signalTree } from '../../../core/src/lib/signal-tree';
|
|
3
|
+
export type {
|
|
4
|
+
SignalTree,
|
|
5
|
+
TreeConfig,
|
|
6
|
+
TreeNode,
|
|
7
|
+
Enhancer,
|
|
8
|
+
Middleware
|
|
9
|
+
} from '../../../core/src/lib/types';
|
|
10
|
+
export * from '../../../core/src/index';
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createWizardForm, type FormStep } from './wizard';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { computed, Signal, signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
import { createFormTree } from '..';
|
|
4
|
+
|
|
5
|
+
// Local type definitions to avoid circular imports in secondary entry points
|
|
6
|
+
type FormTreeOptions = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
interface FormTree<T extends Record<string, unknown>> {
|
|
9
|
+
unwrap(): T;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Defines a step in a wizard form
|
|
13
|
+
*/
|
|
14
|
+
export interface FormStep<T extends Record<string, unknown>> {
|
|
15
|
+
/** Fields visible in this step */
|
|
16
|
+
fields: Array<keyof T | string>;
|
|
17
|
+
/** Optional validation function to run before proceeding to next step */
|
|
18
|
+
validate?: (form: FormTree<T>) => Promise<boolean> | boolean;
|
|
19
|
+
/** Optional function to determine if this step can be skipped */
|
|
20
|
+
canSkip?: (values: T) => boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a wizard form with multi-step navigation.
|
|
25
|
+
* Manages step visibility, navigation, and per-step validation.
|
|
26
|
+
*
|
|
27
|
+
* @param steps - Array of form steps defining fields and validation
|
|
28
|
+
* @param initialValues - Initial form values
|
|
29
|
+
* @param config - Optional form tree configuration
|
|
30
|
+
* @returns Extended FormTree with wizard navigation methods
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const wizard = createWizardForm(
|
|
35
|
+
* [
|
|
36
|
+
* { fields: ['email', 'password'] },
|
|
37
|
+
* { fields: ['firstName', 'lastName'], validate: async (form) => form.valid() },
|
|
38
|
+
* { fields: ['address', 'city', 'zip'] }
|
|
39
|
+
* ],
|
|
40
|
+
* { email: '', password: '', firstName: '', lastName: '', address: '', city: '', zip: '' }
|
|
41
|
+
* );
|
|
42
|
+
*
|
|
43
|
+
* await wizard.nextStep(); // Move to step 2
|
|
44
|
+
* wizard.previousStep(); // Back to step 1
|
|
45
|
+
* await wizard.goToStep(2); // Jump to step 3
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function createWizardForm<T extends Record<string, unknown>>(
|
|
49
|
+
steps: FormStep<T>[],
|
|
50
|
+
initialValues: T,
|
|
51
|
+
config: FormTreeOptions = {}
|
|
52
|
+
): FormTree<T> & {
|
|
53
|
+
currentStep: Signal<number>;
|
|
54
|
+
nextStep: () => Promise<boolean>;
|
|
55
|
+
previousStep: () => void;
|
|
56
|
+
goToStep: (index: number) => Promise<boolean>;
|
|
57
|
+
canGoToStep: (index: number) => boolean;
|
|
58
|
+
isFieldVisible: (field: keyof T | string) => Signal<boolean>;
|
|
59
|
+
} {
|
|
60
|
+
const formTree = createFormTree(initialValues, config);
|
|
61
|
+
const currentStepSignal = signal(0);
|
|
62
|
+
|
|
63
|
+
const visibleFields = computed(() => {
|
|
64
|
+
const step = steps[currentStepSignal()];
|
|
65
|
+
if (!step) {
|
|
66
|
+
return new Set<string>();
|
|
67
|
+
}
|
|
68
|
+
return new Set(step.fields.map((field) => String(field)));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const canGoToStep = (index: number) => index >= 0 && index < steps.length;
|
|
72
|
+
|
|
73
|
+
const goToStep = async (index: number) => {
|
|
74
|
+
if (!canGoToStep(index)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (index === currentStepSignal()) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
currentStepSignal.set(index);
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const findNextStep = (startIndex: number) => {
|
|
85
|
+
let candidate = startIndex;
|
|
86
|
+
while (candidate < steps.length) {
|
|
87
|
+
const candidateStep = steps[candidate];
|
|
88
|
+
if (
|
|
89
|
+
!candidateStep?.canSkip ||
|
|
90
|
+
!candidateStep.canSkip(formTree.unwrap())
|
|
91
|
+
) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
candidate++;
|
|
95
|
+
}
|
|
96
|
+
return candidate;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const nextStep = async () => {
|
|
100
|
+
const currentIndex = currentStepSignal();
|
|
101
|
+
const currentStep = steps[currentIndex];
|
|
102
|
+
if (!currentStep) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (currentStep.validate) {
|
|
107
|
+
const result = await currentStep.validate(formTree);
|
|
108
|
+
if (!result) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const nextIndex = findNextStep(currentIndex + 1);
|
|
114
|
+
if (!canGoToStep(nextIndex) || nextIndex <= currentIndex) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
currentStepSignal.set(nextIndex);
|
|
119
|
+
return true;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const previousStep = () => {
|
|
123
|
+
const currentIndex = currentStepSignal();
|
|
124
|
+
currentStepSignal.set(Math.max(0, currentIndex - 1));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const isFieldVisible = (field: keyof T | string) =>
|
|
128
|
+
computed(() => {
|
|
129
|
+
const fields = visibleFields();
|
|
130
|
+
if (fields.size === 0) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return fields.has(String(field));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
...formTree,
|
|
138
|
+
currentStep: currentStepSignal.asReadonly(),
|
|
139
|
+
nextStep,
|
|
140
|
+
previousStep,
|
|
141
|
+
goToStep,
|
|
142
|
+
canGoToStep,
|
|
143
|
+
isFieldVisible,
|
|
144
|
+
};
|
|
145
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "es2022",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noImplicitOverride": true,
|
|
8
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
9
|
+
"noImplicitReturns": true,
|
|
10
|
+
"noFallthroughCasesInSwitch": true,
|
|
11
|
+
"module": "preserve"
|
|
12
|
+
},
|
|
13
|
+
"angularCompilerOptions": {
|
|
14
|
+
"enableI18nLegacyMessageIdFormat": false,
|
|
15
|
+
"strictInjectionParameters": true,
|
|
16
|
+
"strictInputAccessModifiers": true,
|
|
17
|
+
"typeCheckHostBindings": true,
|
|
18
|
+
"strictTemplates": true
|
|
19
|
+
},
|
|
20
|
+
"files": [],
|
|
21
|
+
"include": [],
|
|
22
|
+
"references": [
|
|
23
|
+
{
|
|
24
|
+
"path": "../core"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "./tsconfig.lib.json"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"path": "./tsconfig.spec.json"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|