ngx-vest-forms 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 +586 -0
- package/esm2022/lib/components/control-wrapper/control-wrapper.component.mjs +56 -0
- package/esm2022/lib/constants.mjs +2 -0
- package/esm2022/lib/directives/form-model-group.directive.mjs +34 -0
- package/esm2022/lib/directives/form-model.directive.mjs +34 -0
- package/esm2022/lib/directives/form.directive.mjs +167 -0
- package/esm2022/lib/directives/validate-root-form.directive.mjs +70 -0
- package/esm2022/lib/exports.mjs +63 -0
- package/esm2022/lib/utils/array-to-object.mjs +4 -0
- package/esm2022/lib/utils/deep-partial.mjs +2 -0
- package/esm2022/lib/utils/deep-required.mjs +2 -0
- package/esm2022/lib/utils/form-utils.mjs +163 -0
- package/esm2022/lib/utils/shape-validation.mjs +59 -0
- package/esm2022/ngx-vest-forms.mjs +5 -0
- package/esm2022/public-api.mjs +14 -0
- package/fesm2022/ngx-vest-forms.mjs +629 -0
- package/fesm2022/ngx-vest-forms.mjs.map +1 -0
- package/index.d.ts +5 -0
- package/lib/components/control-wrapper/control-wrapper.component.d.ts +17 -0
- package/lib/constants.d.ts +1 -0
- package/lib/directives/form-model-group.directive.d.ts +13 -0
- package/lib/directives/form-model.directive.d.ts +13 -0
- package/lib/directives/form.directive.d.ts +81 -0
- package/lib/directives/validate-root-form.directive.d.ts +22 -0
- package/lib/exports.d.ts +17 -0
- package/lib/utils/array-to-object.d.ts +3 -0
- package/lib/utils/deep-partial.d.ts +8 -0
- package/lib/utils/deep-required.d.ts +7 -0
- package/lib/utils/form-utils.d.ts +32 -0
- package/lib/utils/shape-validation.d.ts +15 -0
- package/package.json +36 -0
- package/public-api.d.ts +12 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { input, inject, DestroyRef, Directive, isDevMode, Output, ChangeDetectorRef, Component, ChangeDetectionStrategy, ContentChild, HostBinding, Optional } from '@angular/core';
|
|
3
|
+
import { FormGroup, FormArray, NG_ASYNC_VALIDATORS, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, NgModelGroup, NgModel, ControlContainer, FormsModule } from '@angular/forms';
|
|
4
|
+
import { of, ReplaySubject, debounceTime, take, switchMap, Observable, filter, map, distinctUntilChanged, tap, zip, mergeWith } from 'rxjs';
|
|
5
|
+
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
6
|
+
|
|
7
|
+
const ROOT_FORM = 'rootForm';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Recursively calculates the path of a form control
|
|
11
|
+
* @param formGroup
|
|
12
|
+
* @param control
|
|
13
|
+
*/
|
|
14
|
+
function getControlPath(formGroup, control) {
|
|
15
|
+
for (const key in formGroup.controls) {
|
|
16
|
+
if (formGroup.controls.hasOwnProperty(key)) {
|
|
17
|
+
const ctrl = formGroup.get(key);
|
|
18
|
+
if (ctrl instanceof FormGroup) {
|
|
19
|
+
const path = getControlPath(ctrl, control);
|
|
20
|
+
if (path) {
|
|
21
|
+
return key + '.' + path;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
else if (ctrl === control) {
|
|
25
|
+
return key;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Recursively calculates the path of a form group
|
|
33
|
+
* @param formGroup
|
|
34
|
+
* @param control
|
|
35
|
+
*/
|
|
36
|
+
function getGroupPath(formGroup, control) {
|
|
37
|
+
for (const key in formGroup.controls) {
|
|
38
|
+
if (formGroup.controls.hasOwnProperty(key)) {
|
|
39
|
+
const ctrl = formGroup.get(key);
|
|
40
|
+
if (ctrl === control) {
|
|
41
|
+
return key;
|
|
42
|
+
}
|
|
43
|
+
if (ctrl instanceof FormGroup) {
|
|
44
|
+
const path = getGroupPath(ctrl, control);
|
|
45
|
+
if (path) {
|
|
46
|
+
return key + '.' + path;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Calculates the field name of a form control: Eg: addresses.shippingAddress.street
|
|
55
|
+
* @param rootForm
|
|
56
|
+
* @param control
|
|
57
|
+
*/
|
|
58
|
+
function getFormControlField(rootForm, control) {
|
|
59
|
+
return getControlPath(rootForm, control);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Calcuates the field name of a form group Eg: addresses.shippingAddress
|
|
63
|
+
* @param rootForm
|
|
64
|
+
* @param control
|
|
65
|
+
*/
|
|
66
|
+
function getFormGroupField(rootForm, control) {
|
|
67
|
+
return getGroupPath(rootForm, control);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* This RxJS operator merges the value of the form with the raw value.
|
|
71
|
+
* By doing this we can assure that we don't lose values of disabled form fields
|
|
72
|
+
* @param form
|
|
73
|
+
*/
|
|
74
|
+
function mergeValuesAndRawValues(form) {
|
|
75
|
+
// Retrieve the standard values (respecting references)
|
|
76
|
+
const value = { ...form.value };
|
|
77
|
+
// Retrieve the raw values (including disabled values)
|
|
78
|
+
const rawValue = form.getRawValue();
|
|
79
|
+
// Recursive function to merge rawValue into value
|
|
80
|
+
function mergeRecursive(target, source) {
|
|
81
|
+
Object.keys(source).forEach(key => {
|
|
82
|
+
if (target[key] === undefined) {
|
|
83
|
+
// If the key is not in the target, add it directly (for disabled fields)
|
|
84
|
+
target[key] = source[key];
|
|
85
|
+
}
|
|
86
|
+
else if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
|
|
87
|
+
// If the value is an object, merge it recursively
|
|
88
|
+
mergeRecursive(target[key], source[key]);
|
|
89
|
+
}
|
|
90
|
+
// If the target already has the key with a primitive value, it's left as is to maintain references
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
mergeRecursive(value, rawValue);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function isPrimitive(value) {
|
|
97
|
+
return value === null || (typeof value !== "object" && typeof value !== "function");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Performs a deep-clone of an object
|
|
101
|
+
* @param obj
|
|
102
|
+
*/
|
|
103
|
+
function cloneDeep(obj) {
|
|
104
|
+
// Handle primitives (null, undefined, boolean, string, number, function)
|
|
105
|
+
if (isPrimitive(obj)) {
|
|
106
|
+
return obj;
|
|
107
|
+
}
|
|
108
|
+
// Handle Date
|
|
109
|
+
if (obj instanceof Date) {
|
|
110
|
+
return new Date(obj.getTime());
|
|
111
|
+
}
|
|
112
|
+
// Handle Array
|
|
113
|
+
if (Array.isArray(obj)) {
|
|
114
|
+
return obj.map(item => cloneDeep(item));
|
|
115
|
+
}
|
|
116
|
+
// Handle Object
|
|
117
|
+
if (obj instanceof Object) {
|
|
118
|
+
const clonedObj = {};
|
|
119
|
+
for (const key in obj) {
|
|
120
|
+
if (obj.hasOwnProperty(key)) {
|
|
121
|
+
clonedObj[key] = cloneDeep(obj[key]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return clonedObj;
|
|
125
|
+
}
|
|
126
|
+
throw new Error("Unable to copy object! Its type isn't supported.");
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Sets a value in an object in the correct path
|
|
130
|
+
* @param obj
|
|
131
|
+
* @param path
|
|
132
|
+
* @param value
|
|
133
|
+
*/
|
|
134
|
+
function set(obj, path, value) {
|
|
135
|
+
const keys = path.split('.');
|
|
136
|
+
let current = obj;
|
|
137
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
138
|
+
const key = keys[i];
|
|
139
|
+
if (!current[key]) {
|
|
140
|
+
current[key] = {};
|
|
141
|
+
}
|
|
142
|
+
current = current[key];
|
|
143
|
+
}
|
|
144
|
+
current[keys[keys.length - 1]] = value;
|
|
145
|
+
}
|
|
146
|
+
function getAllFormErrors(form) {
|
|
147
|
+
const errors = {};
|
|
148
|
+
if (!form) {
|
|
149
|
+
return errors;
|
|
150
|
+
}
|
|
151
|
+
function collect(control, path) {
|
|
152
|
+
if (control instanceof FormGroup || control instanceof FormArray) {
|
|
153
|
+
Object.keys(control.controls).forEach((key) => {
|
|
154
|
+
const childControl = control.get(key);
|
|
155
|
+
const controlPath = path ? `${path}.${key}` : key;
|
|
156
|
+
if (childControl) {
|
|
157
|
+
collect(childControl, controlPath);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (control.errors && control.enabled) {
|
|
162
|
+
errors[path] = control.errors['errors'];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
collect(form, '');
|
|
166
|
+
errors[ROOT_FORM] = form.errors && form.errors['errors'];
|
|
167
|
+
return errors;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class ValidateRootFormDirective {
|
|
171
|
+
constructor() {
|
|
172
|
+
this.formValue = input(null);
|
|
173
|
+
this.suite = input(null);
|
|
174
|
+
this.destroyRef = inject(DestroyRef);
|
|
175
|
+
/**
|
|
176
|
+
* Whether the root form should be validated or not
|
|
177
|
+
* This will use the field rootForm
|
|
178
|
+
*/
|
|
179
|
+
this.validateRootForm = input(false);
|
|
180
|
+
/**
|
|
181
|
+
* Used to debounce formValues to make sure vest isn't triggered all the time
|
|
182
|
+
*/
|
|
183
|
+
this.formValueCache = {};
|
|
184
|
+
}
|
|
185
|
+
validate(control) {
|
|
186
|
+
if (!this.suite() || !this.formValue()) {
|
|
187
|
+
return of(null);
|
|
188
|
+
}
|
|
189
|
+
return this.createAsyncValidator('rootForm')(control.getRawValue());
|
|
190
|
+
}
|
|
191
|
+
createAsyncValidator(field) {
|
|
192
|
+
if (!this.suite()) {
|
|
193
|
+
return () => of(null);
|
|
194
|
+
}
|
|
195
|
+
return (value) => {
|
|
196
|
+
if (!this.formValue()) {
|
|
197
|
+
return of(null);
|
|
198
|
+
}
|
|
199
|
+
const mod = cloneDeep(value);
|
|
200
|
+
set(mod, field, value); // Update the property with path
|
|
201
|
+
if (!this.formValueCache[field]) {
|
|
202
|
+
this.formValueCache[field] = {
|
|
203
|
+
sub$$: new ReplaySubject(1), // Keep track of the last model
|
|
204
|
+
};
|
|
205
|
+
this.formValueCache[field].debounced = this.formValueCache[field].sub$$.pipe(debounceTime(0));
|
|
206
|
+
}
|
|
207
|
+
// Next the latest model in the cache for a certain field
|
|
208
|
+
this.formValueCache[field].sub$$.next(mod);
|
|
209
|
+
return this.formValueCache[field].debounced.pipe(
|
|
210
|
+
// When debounced, take the latest value and perform the asynchronous vest validation
|
|
211
|
+
take(1), switchMap(() => {
|
|
212
|
+
return new Observable((observer) => {
|
|
213
|
+
this.suite()(mod, field).done((result) => {
|
|
214
|
+
const errors = result.getErrors()[field];
|
|
215
|
+
observer.next((errors ? { error: errors[0], errors } : null));
|
|
216
|
+
observer.complete();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}), takeUntilDestroyed(this.destroyRef));
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
223
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "18.0.1", type: ValidateRootFormDirective, isStandalone: true, selector: "form[validateRootForm][formValue][suite]", inputs: { formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, validateRootForm: { classPropertyName: "validateRootForm", publicName: "validateRootForm", isSignal: true, isRequired: false, transformFunction: null } }, providers: [{ provide: NG_ASYNC_VALIDATORS, useExisting: ValidateRootFormDirective, multi: true }], ngImport: i0 }); }
|
|
224
|
+
}
|
|
225
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: ValidateRootFormDirective, decorators: [{
|
|
226
|
+
type: Directive,
|
|
227
|
+
args: [{
|
|
228
|
+
selector: 'form[validateRootForm][formValue][suite]',
|
|
229
|
+
standalone: true,
|
|
230
|
+
providers: [{ provide: NG_ASYNC_VALIDATORS, useExisting: ValidateRootFormDirective, multi: true }]
|
|
231
|
+
}]
|
|
232
|
+
}] });
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Clean error that improves the DX when making typo's in the `name` or `ngModelGroup` attributes
|
|
236
|
+
*/
|
|
237
|
+
class ShapeMismatchError extends Error {
|
|
238
|
+
constructor(errorList) {
|
|
239
|
+
super(`Shape mismatch:\n\n${errorList.join('\n')}\n\n`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Validates a form value against a shape
|
|
244
|
+
* When there is something in the form value that is not in the shape, throw an error
|
|
245
|
+
* This is how we throw runtime errors in develop when the developer has made a typo in the `name` or `ngModelGroup`
|
|
246
|
+
* attributes.
|
|
247
|
+
* @param formVal
|
|
248
|
+
* @param shape
|
|
249
|
+
*/
|
|
250
|
+
function validateShape(formVal, shape) {
|
|
251
|
+
// Only execute in dev mode
|
|
252
|
+
if (isDevMode()) {
|
|
253
|
+
const errors = validateFormValue(formVal, shape);
|
|
254
|
+
if (errors.length) {
|
|
255
|
+
throw new ShapeMismatchError(errors);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Validates a form value against a shape value to see if it matches
|
|
261
|
+
* Returns clean errors that have a good DX
|
|
262
|
+
* @param formValue
|
|
263
|
+
* @param shape
|
|
264
|
+
* @param path
|
|
265
|
+
*/
|
|
266
|
+
function validateFormValue(formValue, shape, path = '') {
|
|
267
|
+
const errors = [];
|
|
268
|
+
for (const key in formValue) {
|
|
269
|
+
if (Object.keys(formValue).includes(key)) {
|
|
270
|
+
// In form arrays we don't know how many items there are
|
|
271
|
+
// This means that we always need to provide one record in the shape of our form array
|
|
272
|
+
// so every time reset the key to '0' when the key is a number and is bigger than 0
|
|
273
|
+
let keyToCompareWith = key;
|
|
274
|
+
if (parseFloat(key) > 0) {
|
|
275
|
+
keyToCompareWith = '0';
|
|
276
|
+
}
|
|
277
|
+
const newPath = path ? `${path}.${key}` : key;
|
|
278
|
+
if (typeof formValue[key] === 'object' && formValue[key] !== null) {
|
|
279
|
+
if ((typeof shape[keyToCompareWith] !== 'object' || shape[keyToCompareWith] === null) && isNaN(parseFloat(key))) {
|
|
280
|
+
errors.push(`[ngModelGroup] Mismatch: '${newPath}'`);
|
|
281
|
+
}
|
|
282
|
+
errors.push(...validateFormValue(formValue[key], shape[keyToCompareWith], newPath));
|
|
283
|
+
}
|
|
284
|
+
else if ((shape ? !(key in shape) : true) && isNaN(parseFloat(key))) {
|
|
285
|
+
errors.push(`[ngModel] Mismatch '${newPath}'`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return errors;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
class FormDirective {
|
|
293
|
+
constructor() {
|
|
294
|
+
this.ngForm = inject(NgForm, { self: true, optional: false });
|
|
295
|
+
/**
|
|
296
|
+
* The value of the form, this is needed for the validation part
|
|
297
|
+
*/
|
|
298
|
+
this.formValue = input(null);
|
|
299
|
+
/**
|
|
300
|
+
* Static vest suite that will be used to feed our angular validators
|
|
301
|
+
*/
|
|
302
|
+
this.suite = input(null);
|
|
303
|
+
/**
|
|
304
|
+
* The shape of our form model. This is a deep required version of the form model
|
|
305
|
+
* The goal is to add default values to the shape so when the template-driven form
|
|
306
|
+
* contains values that shouldn't be there (typo's) that the developer gets run-time
|
|
307
|
+
* errors in dev mode
|
|
308
|
+
*/
|
|
309
|
+
this.formShape = input(null);
|
|
310
|
+
/**
|
|
311
|
+
* Updates the validation config which is a dynamic object that will be used to
|
|
312
|
+
* trigger validations on the dependant fields
|
|
313
|
+
* Eg: ```typescript
|
|
314
|
+
* validationConfig = {
|
|
315
|
+
* 'passwords.password': ['passwords.confirmPassword']
|
|
316
|
+
* }
|
|
317
|
+
* ```
|
|
318
|
+
*
|
|
319
|
+
* This will trigger the updateValueAndValidity on passwords.confirmPassword every time the passwords.password gets a new value
|
|
320
|
+
*
|
|
321
|
+
* @param v
|
|
322
|
+
*/
|
|
323
|
+
this.validationConfig = input(null);
|
|
324
|
+
this.destroyRef = inject(DestroyRef);
|
|
325
|
+
this.statusChanges$ = this.ngForm.form.events.pipe(filter(v => v instanceof StatusChangeEvent), map(v => v.status), distinctUntilChanged());
|
|
326
|
+
/**
|
|
327
|
+
* Fired when the form is idle. This fixes the PENDING state issues
|
|
328
|
+
* that are introduced due to the nature of asynchronous validations
|
|
329
|
+
*/
|
|
330
|
+
this.idle$ = this.statusChanges$.pipe(filter(v => v !== 'PENDING'), distinctUntilChanged());
|
|
331
|
+
this.valueChanges$ = this.ngForm.form.events.pipe(filter(v => v instanceof ValueChangeEvent), map(v => v.value), map(() => mergeValuesAndRawValues(this.ngForm.form)));
|
|
332
|
+
/**
|
|
333
|
+
* Triggered as soon as the form value changes
|
|
334
|
+
*/
|
|
335
|
+
this.formValueChange = this.valueChanges$.pipe(debounceTime(0) // wait until all form elements are rendered
|
|
336
|
+
);
|
|
337
|
+
this.dirtyChanges$ = this.ngForm.form.events.pipe(filter(v => v instanceof PristineChangeEvent), map(v => !v.pristine), distinctUntilChanged());
|
|
338
|
+
/**
|
|
339
|
+
* Triggered as soon as the form becomes dirty
|
|
340
|
+
*/
|
|
341
|
+
this.dirtyChange = this.dirtyChanges$;
|
|
342
|
+
this.validChanges$ = this.statusChanges$.pipe(filter(e => e === 'VALID' || e === 'INVALID'), map(v => v === 'VALID'), switchMap((v) => this.idle$), map(() => this.ngForm?.form.valid), distinctUntilChanged());
|
|
343
|
+
/**
|
|
344
|
+
* Triggered When the form becomes valid but waits until the form is idle
|
|
345
|
+
*/
|
|
346
|
+
this.validChange = this.validChanges$;
|
|
347
|
+
/**
|
|
348
|
+
* Emits an object with all the errors of the form
|
|
349
|
+
*/
|
|
350
|
+
this.errorsChange = this.valueChanges$.pipe(switchMap(() => this.idle$), map(() => getAllFormErrors(this.ngForm.form)));
|
|
351
|
+
/**
|
|
352
|
+
* Used to debounce formValues to make sure vest isn't triggered all the time
|
|
353
|
+
*/
|
|
354
|
+
this.formValueCache = {};
|
|
355
|
+
// When the validation config changes
|
|
356
|
+
// Listen to changes of the left-side of the config and trigger the updateValueAndValidity
|
|
357
|
+
// function on the dependant controls or groups at the right-side of the config
|
|
358
|
+
toObservable(this.validationConfig)
|
|
359
|
+
.pipe(filter((conf) => !!conf), switchMap((conf) => {
|
|
360
|
+
if (!conf) {
|
|
361
|
+
return of(null);
|
|
362
|
+
}
|
|
363
|
+
const streams = Object.keys(conf).map((key) => {
|
|
364
|
+
return this.ngForm?.form.get(key)?.valueChanges.pipe(
|
|
365
|
+
// wait until the form is idle
|
|
366
|
+
switchMap(() => this.idle$), map(() => this.ngForm?.form.get(key)?.value), takeUntilDestroyed(this.destroyRef), tap((v) => {
|
|
367
|
+
conf[key]?.forEach((path) => {
|
|
368
|
+
this.ngForm?.form.get(path)?.updateValueAndValidity({
|
|
369
|
+
onlySelf: true,
|
|
370
|
+
emitEvent: true
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
}));
|
|
374
|
+
});
|
|
375
|
+
return zip(streams);
|
|
376
|
+
}))
|
|
377
|
+
.subscribe();
|
|
378
|
+
/**
|
|
379
|
+
* Trigger shape validations if the form gets updated
|
|
380
|
+
* This is how we can throw run-time errors
|
|
381
|
+
*/
|
|
382
|
+
this.valueChanges$.pipe(switchMap((v) => this.idle$.pipe(map(() => v))), takeUntilDestroyed(this.destroyRef)).subscribe(v => {
|
|
383
|
+
if (this.formShape()) {
|
|
384
|
+
validateShape(v, this.formShape());
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
/**
|
|
388
|
+
* Mark all the fields as touched when the form is submitted
|
|
389
|
+
*/
|
|
390
|
+
this.ngForm.ngSubmit.subscribe(() => {
|
|
391
|
+
this.ngForm.form.markAllAsTouched();
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* This will feed the formValueCache, debounce it till the next tick
|
|
396
|
+
* and create an asynchronous validator that runs a vest suite
|
|
397
|
+
* @param field
|
|
398
|
+
* @param model
|
|
399
|
+
* @param suite
|
|
400
|
+
* @returns an asynchronous validator function
|
|
401
|
+
*/
|
|
402
|
+
createAsyncValidator(field) {
|
|
403
|
+
if (!this.suite()) {
|
|
404
|
+
return () => of(null);
|
|
405
|
+
}
|
|
406
|
+
return (value) => {
|
|
407
|
+
if (!this.formValue()) {
|
|
408
|
+
return of(null);
|
|
409
|
+
}
|
|
410
|
+
const mod = cloneDeep(this.formValue());
|
|
411
|
+
set(mod, field, value); // Update the property with path
|
|
412
|
+
if (!this.formValueCache[field]) {
|
|
413
|
+
this.formValueCache[field] = {
|
|
414
|
+
sub$$: new ReplaySubject(1), // Keep track of the last model
|
|
415
|
+
};
|
|
416
|
+
this.formValueCache[field].debounced = this.formValueCache[field].sub$$.pipe(debounceTime(0));
|
|
417
|
+
}
|
|
418
|
+
// Next the latest model in the cache for a certain field
|
|
419
|
+
this.formValueCache[field].sub$$.next(mod);
|
|
420
|
+
return this.formValueCache[field].debounced.pipe(
|
|
421
|
+
// When debounced, take the latest value and perform the asynchronous vest validation
|
|
422
|
+
take(1), switchMap(() => {
|
|
423
|
+
return new Observable((observer) => {
|
|
424
|
+
this.suite()(mod, field).done((result) => {
|
|
425
|
+
const errors = result.getErrors()[field];
|
|
426
|
+
observer.next((errors ? { error: errors[0], errors } : null));
|
|
427
|
+
observer.complete();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
}), takeUntilDestroyed(this.destroyRef));
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
434
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "18.0.1", type: FormDirective, isStandalone: true, selector: "form[scVestForm]", inputs: { formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, formShape: { classPropertyName: "formShape", publicName: "formShape", isSignal: true, isRequired: false, transformFunction: null }, validationConfig: { classPropertyName: "validationConfig", publicName: "validationConfig", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { formValueChange: "formValueChange", dirtyChange: "dirtyChange", validChange: "validChange", errorsChange: "errorsChange" }, ngImport: i0 }); }
|
|
435
|
+
}
|
|
436
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormDirective, decorators: [{
|
|
437
|
+
type: Directive,
|
|
438
|
+
args: [{
|
|
439
|
+
selector: 'form[scVestForm]',
|
|
440
|
+
standalone: true,
|
|
441
|
+
}]
|
|
442
|
+
}], ctorParameters: () => [], propDecorators: { formValueChange: [{
|
|
443
|
+
type: Output
|
|
444
|
+
}], dirtyChange: [{
|
|
445
|
+
type: Output
|
|
446
|
+
}], validChange: [{
|
|
447
|
+
type: Output
|
|
448
|
+
}], errorsChange: [{
|
|
449
|
+
type: Output
|
|
450
|
+
}] } });
|
|
451
|
+
|
|
452
|
+
class ControlWrapperComponent {
|
|
453
|
+
constructor() {
|
|
454
|
+
this.cdRef = inject(ChangeDetectorRef);
|
|
455
|
+
this.formDirective = inject(FormDirective);
|
|
456
|
+
this.destroyRef = inject(DestroyRef);
|
|
457
|
+
this.ngModelGroup = inject(NgModelGroup, {
|
|
458
|
+
optional: true,
|
|
459
|
+
self: true,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
get control() {
|
|
463
|
+
return this.ngModelGroup ? this.ngModelGroup.control : this.ngModel?.control;
|
|
464
|
+
}
|
|
465
|
+
get invalid() {
|
|
466
|
+
return this.control?.touched && this.errors;
|
|
467
|
+
}
|
|
468
|
+
get errors() {
|
|
469
|
+
if (this.control?.pending) {
|
|
470
|
+
return this.previousError;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
this.previousError = this.control?.errors?.['errors'];
|
|
474
|
+
}
|
|
475
|
+
return this.control?.errors?.['errors'];
|
|
476
|
+
}
|
|
477
|
+
ngAfterViewInit() {
|
|
478
|
+
// Wait until the form is idle
|
|
479
|
+
// Then, listen to all events of the ngModelGroup or ngModel
|
|
480
|
+
// and mark the component and its ancestors as dirty
|
|
481
|
+
// This allows us to use the OnPush ChangeDetection Strategy
|
|
482
|
+
this.formDirective.idle$
|
|
483
|
+
.pipe(switchMap(() => this.ngModelGroup?.control?.events || of(null)), mergeWith(this.control?.events || of(null)), takeUntilDestroyed(this.destroyRef))
|
|
484
|
+
.subscribe(() => {
|
|
485
|
+
this.cdRef.markForCheck();
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
489
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.1", type: ControlWrapperComponent, isStandalone: true, selector: "[sc-control-wrapper]", host: { properties: { "class.sc-control-wrapper--invalid": "this.invalid" } }, queries: [{ propertyName: "ngModel", first: true, predicate: NgModel, descendants: true }], ngImport: i0, template: "<div class=\"sc-control-wrapper\">\n <div class=\"sc-control-wrapper__content\">\n <ng-content></ng-content>\n </div>\n <div class=\"sc-control-wrapper__errors\">\n <ul [hidden]=\"!invalid\">\n @for (error of errors; track error) {\n <li>{{ error }}</li>\n }\n </ul>\n </div>\n</div>\n", styles: [""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
490
|
+
}
|
|
491
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: ControlWrapperComponent, decorators: [{
|
|
492
|
+
type: Component,
|
|
493
|
+
args: [{ selector: '[sc-control-wrapper]', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"sc-control-wrapper\">\n <div class=\"sc-control-wrapper__content\">\n <ng-content></ng-content>\n </div>\n <div class=\"sc-control-wrapper__errors\">\n <ul [hidden]=\"!invalid\">\n @for (error of errors; track error) {\n <li>{{ error }}</li>\n }\n </ul>\n </div>\n</div>\n" }]
|
|
494
|
+
}], propDecorators: { ngModel: [{
|
|
495
|
+
type: ContentChild,
|
|
496
|
+
args: [NgModel]
|
|
497
|
+
}], invalid: [{
|
|
498
|
+
type: HostBinding,
|
|
499
|
+
args: ['class.sc-control-wrapper--invalid']
|
|
500
|
+
}] } });
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Hooks into the ngModel selector and triggers an asynchronous validation for a form model
|
|
504
|
+
* It will use a vest suite behind the scenes
|
|
505
|
+
*/
|
|
506
|
+
class FormModelDirective {
|
|
507
|
+
constructor() {
|
|
508
|
+
this.formDirective = inject(FormDirective);
|
|
509
|
+
}
|
|
510
|
+
validate(control) {
|
|
511
|
+
const { ngForm, suite, formValue } = this.formDirective;
|
|
512
|
+
const field = getFormControlField(ngForm.control, control);
|
|
513
|
+
return this.formDirective.createAsyncValidator(field)(control.getRawValue());
|
|
514
|
+
}
|
|
515
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
516
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.0.1", type: FormModelDirective, isStandalone: true, selector: "[ngModel]", providers: [
|
|
517
|
+
{ provide: NG_ASYNC_VALIDATORS, useExisting: FormModelDirective, multi: true },
|
|
518
|
+
], ngImport: i0 }); }
|
|
519
|
+
}
|
|
520
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormModelDirective, decorators: [{
|
|
521
|
+
type: Directive,
|
|
522
|
+
args: [{
|
|
523
|
+
selector: '[ngModel]',
|
|
524
|
+
standalone: true,
|
|
525
|
+
providers: [
|
|
526
|
+
{ provide: NG_ASYNC_VALIDATORS, useExisting: FormModelDirective, multi: true },
|
|
527
|
+
],
|
|
528
|
+
}]
|
|
529
|
+
}] });
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Hooks into the ngModelGroup selector and triggers an asynchronous validation for a form group
|
|
533
|
+
* It will use a vest suite behind the scenes
|
|
534
|
+
*/
|
|
535
|
+
class FormModelGroupDirective {
|
|
536
|
+
constructor() {
|
|
537
|
+
this.formDirective = inject(FormDirective);
|
|
538
|
+
}
|
|
539
|
+
validate(control) {
|
|
540
|
+
const { ngForm } = this.formDirective;
|
|
541
|
+
const field = getFormGroupField(ngForm.control, control);
|
|
542
|
+
return this.formDirective.createAsyncValidator(field)(control.value);
|
|
543
|
+
}
|
|
544
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
545
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.0.1", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup]", providers: [
|
|
546
|
+
{ provide: NG_ASYNC_VALIDATORS, useExisting: FormModelGroupDirective, multi: true },
|
|
547
|
+
], ngImport: i0 }); }
|
|
548
|
+
}
|
|
549
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormModelGroupDirective, decorators: [{
|
|
550
|
+
type: Directive,
|
|
551
|
+
args: [{
|
|
552
|
+
selector: '[ngModelGroup]',
|
|
553
|
+
standalone: true,
|
|
554
|
+
providers: [
|
|
555
|
+
{ provide: NG_ASYNC_VALIDATORS, useExisting: FormModelGroupDirective, multi: true },
|
|
556
|
+
],
|
|
557
|
+
}]
|
|
558
|
+
}] });
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* This is borrowed from [https://github.com/wardbell/ngc-validate/blob/main/src/app/core/form-container-view-provider.ts](https://github.com/wardbell/ngc-validate/blob/main/src/app/core/form-container-view-provider.ts)
|
|
562
|
+
* Thank you so much Ward Bell for your effort!:
|
|
563
|
+
*
|
|
564
|
+
* Provide a ControlContainer to a form component from the
|
|
565
|
+
* nearest parent NgModelGroup (preferred) or NgForm.
|
|
566
|
+
*
|
|
567
|
+
* Required for Reactive Forms as well (unless you write CVA)
|
|
568
|
+
*
|
|
569
|
+
* @example
|
|
570
|
+
* ```
|
|
571
|
+
* @Component({
|
|
572
|
+
* ...
|
|
573
|
+
* viewProviders[ formViewProvider ]
|
|
574
|
+
* })
|
|
575
|
+
* ```
|
|
576
|
+
* @see Kara's AngularConnect 2017 talk: https://youtu.be/CD_t3m2WMM8?t=1826
|
|
577
|
+
*
|
|
578
|
+
* Without this provider
|
|
579
|
+
* - Controls are not registered with parent NgForm or NgModelGroup
|
|
580
|
+
* - Form-level flags say "untouched" and "valid"
|
|
581
|
+
* - No form-level validation roll-up
|
|
582
|
+
* - Controls still validate, update model, and update their statuses
|
|
583
|
+
* - If within NgForm, no compiler error because ControlContainer is optional for ngModel
|
|
584
|
+
*
|
|
585
|
+
* Note: if the SubForm Component that uses this Provider
|
|
586
|
+
* is not within a Form or NgModelGroup, the provider returns `null`
|
|
587
|
+
* resulting in an error, something like
|
|
588
|
+
* ```
|
|
589
|
+
* preview-fef3604083950c709c52b.js:1 ERROR Error:
|
|
590
|
+
* ngModelGroup cannot be used with a parent formGroup directive.
|
|
591
|
+
*```
|
|
592
|
+
*/
|
|
593
|
+
const formViewProvider = {
|
|
594
|
+
provide: ControlContainer,
|
|
595
|
+
useFactory: _formViewProviderFactory,
|
|
596
|
+
deps: [
|
|
597
|
+
[new Optional(), NgForm],
|
|
598
|
+
[new Optional(), NgModelGroup]
|
|
599
|
+
]
|
|
600
|
+
};
|
|
601
|
+
function _formViewProviderFactory(ngForm, ngModelGroup) {
|
|
602
|
+
return ngModelGroup || ngForm || null;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* The providers we need in every child component that holds an ngModelGroup
|
|
606
|
+
*/
|
|
607
|
+
const vestFormsViewProviders = [
|
|
608
|
+
{ provide: ControlContainer, useExisting: NgForm },
|
|
609
|
+
formViewProvider // very important if we want nested components with ngModelGroup
|
|
610
|
+
];
|
|
611
|
+
/**
|
|
612
|
+
* Exports all the stuff we need to use the template driven forms
|
|
613
|
+
*/
|
|
614
|
+
const vestForms = [ValidateRootFormDirective, ControlWrapperComponent, FormDirective, FormsModule, FormModelDirective, FormModelGroupDirective];
|
|
615
|
+
|
|
616
|
+
function arrayToObject(arr) {
|
|
617
|
+
return arr.reduce((acc, value, index) => ({ ...acc, [index]: value }), {});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/*
|
|
621
|
+
* Public API Surface of ngx-vest-forms
|
|
622
|
+
*/
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Generated bundle index. Do not edit.
|
|
626
|
+
*/
|
|
627
|
+
|
|
628
|
+
export { ControlWrapperComponent, FormDirective, FormModelDirective, FormModelGroupDirective, ROOT_FORM, ShapeMismatchError, ValidateRootFormDirective, arrayToObject, cloneDeep, getAllFormErrors, getFormControlField, getFormGroupField, mergeValuesAndRawValues, set, validateShape, vestForms, vestFormsViewProviders };
|
|
629
|
+
//# sourceMappingURL=ngx-vest-forms.mjs.map
|