UNPKG

ngx-vest-forms

Version:

Opinionated template-driven forms library for Angular

703 lines (689 loc) 30.8 kB
import * as i0 from '@angular/core'; import { input, Directive, isDevMode, inject, Output, ChangeDetectorRef, Component, ChangeDetectionStrategy, ContentChild, HostBinding, Optional } from '@angular/core'; import { FormGroup, FormArray, NG_ASYNC_VALIDATORS, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, NgModelGroup, NgModel, ControlContainer, FormsModule } from '@angular/forms'; import { Subject, of, ReplaySubject, debounceTime, take, switchMap, Observable, takeUntil, filter, map, distinctUntilChanged, startWith, tap, zip, mergeWith } from 'rxjs'; import { toObservable } from '@angular/core/rxjs-interop'; const ROOT_FORM = 'rootForm'; /** * Recursively calculates the path of a form control * @param formGroup * @param control */ function getControlPath(formGroup, control) { for (const key in formGroup.controls) { if (formGroup.controls.hasOwnProperty(key)) { const ctrl = formGroup.get(key); if (ctrl instanceof FormGroup) { const path = getControlPath(ctrl, control); if (path) { return key + '.' + path; } } else if (ctrl === control) { return key; } } } return ''; } /** * Recursively calculates the path of a form group * @param formGroup * @param control */ function getGroupPath(formGroup, control) { for (const key in formGroup.controls) { if (formGroup.controls.hasOwnProperty(key)) { const ctrl = formGroup.get(key); if (ctrl === control) { return key; } if (ctrl instanceof FormGroup) { const path = getGroupPath(ctrl, control); if (path) { return key + '.' + path; } } } } return ''; } /** * Calculates the field name of a form control: Eg: addresses.shippingAddress.street * @param rootForm * @param control */ function getFormControlField(rootForm, control) { return getControlPath(rootForm, control); } /** * Calcuates the field name of a form group Eg: addresses.shippingAddress * @param rootForm * @param control */ function getFormGroupField(rootForm, control) { return getGroupPath(rootForm, control); } /** * This RxJS operator merges the value of the form with the raw value. * By doing this we can assure that we don't lose values of disabled form fields * @param form */ function mergeValuesAndRawValues(form) { // Retrieve the standard values (respecting references) const value = { ...form.value }; // Retrieve the raw values (including disabled values) const rawValue = form.getRawValue(); // Recursive function to merge rawValue into value function mergeRecursive(target, source) { Object.keys(source).forEach((key) => { if (target[key] === undefined) { // If the key is not in the target, add it directly (for disabled fields) target[key] = source[key]; } else if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) { // If the value is an object, merge it recursively mergeRecursive(target[key], source[key]); } // If the target already has the key with a primitive value, it's left as is to maintain references }); } mergeRecursive(value, rawValue); return value; } function isPrimitive(value) { return (value === null || (typeof value !== 'object' && typeof value !== 'function')); } /** * Performs a deep-clone of an object * @param obj */ function cloneDeep(obj) { // Handle primitives (null, undefined, boolean, string, number, function) if (isPrimitive(obj)) { return obj; } // Handle Date if (obj instanceof Date) { return new Date(obj.getTime()); } // Handle Array if (Array.isArray(obj)) { return obj.map((item) => cloneDeep(item)); } // Handle Object if (obj instanceof Object) { const clonedObj = {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { clonedObj[key] = cloneDeep(obj[key]); } } return clonedObj; } throw new Error("Unable to copy object! Its type isn't supported."); } /** * Sets a value in an object in the correct path * @param obj * @param path * @param value */ function set(obj, path, value) { const keys = path.split('.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!current[key]) { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; } /** * Traverses the form and returns the errors by path * @param form */ function getAllFormErrors(form) { const errors = {}; if (!form) { return errors; } function collect(control, path) { if (control instanceof FormGroup || control instanceof FormArray) { Object.keys(control.controls).forEach((key) => { const childControl = control.get(key); const controlPath = path ? `${path}.${key}` : key; if (path && control.errors && control.enabled) { Object.keys(control.errors).forEach((errorKey) => { errors[path] = control.errors[errorKey]; }); } if (childControl) { collect(childControl, controlPath); } }); } else { if (control.errors && control.enabled) { Object.keys(control.errors).forEach((errorKey) => { errors[path] = control.errors[errorKey]; }); } } } collect(form, ''); if (form.errors && form.errors['errors']) { errors[ROOT_FORM] = form.errors && form.errors['errors']; } return errors; } class ValidateRootFormDirective { constructor() { this.validationOptions = input({ debounceTime: 0 }); this.destroy$$ = new Subject(); this.formValue = input(null); this.suite = input(null); /** * Whether the root form should be validated or not * This will use the field rootForm */ this.validateRootForm = input(false); /** * Used to debounce formValues to make sure vest isn't triggered all the time */ this.formValueCache = {}; } validate(control) { if (!this.suite() || !this.formValue()) { return of(null); } return this.createAsyncValidator('rootForm', this.validationOptions())(control.getRawValue()); } createAsyncValidator(field, validationOptions) { if (!this.suite()) { return () => of(null); } return (value) => { if (!this.formValue()) { return of(null); } const mod = cloneDeep(value); set(mod, field, value); // Update the property with path if (!this.formValueCache[field]) { this.formValueCache[field] = { sub$$: new ReplaySubject(1), // Keep track of the last model }; this.formValueCache[field].debounced = this.formValueCache[field].sub$$.pipe(debounceTime(validationOptions.debounceTime)); } // Next the latest model in the cache for a certain field this.formValueCache[field].sub$$.next(mod); return this.formValueCache[field].debounced.pipe( // When debounced, take the latest value and perform the asynchronous vest validation take(1), switchMap(() => { return new Observable((observer) => { this.suite()(mod, field).done((result) => { const errors = result.getErrors()[field]; observer.next(errors ? { error: errors[0], errors } : null); observer.complete(); }); }); }), takeUntil(this.destroy$$)); }; } ngOnDestroy() { this.destroy$$.next(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "18.0.1", type: ValidateRootFormDirective, isStandalone: true, selector: "form[validateRootForm][formValue][suite]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null }, 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 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: ValidateRootFormDirective, decorators: [{ type: Directive, args: [{ selector: 'form[validateRootForm][formValue][suite]', standalone: true, providers: [ { provide: NG_ASYNC_VALIDATORS, useExisting: ValidateRootFormDirective, multi: true, }, ], }] }] }); /** * Clean error that improves the DX when making typo's in the `name` or `ngModelGroup` attributes */ class ShapeMismatchError extends Error { constructor(errorList) { super(`Shape mismatch:\n\n${errorList.join('\n')}\n\n`); } } /** * Validates a form value against a shape * When there is something in the form value that is not in the shape, throw an error * This is how we throw runtime errors in develop when the developer has made a typo in the `name` or `ngModelGroup` * attributes. * @param formVal * @param shape */ function validateShape(formVal, shape) { // Only execute in dev mode if (isDevMode()) { const errors = validateFormValue(formVal, shape); if (errors.length) { throw new ShapeMismatchError(errors); } } } /** * Validates a form value against a shape value to see if it matches * Returns clean errors that have a good DX * @param formValue * @param shape * @param path */ function validateFormValue(formValue, shape, path = '') { const errors = []; for (const key in formValue) { if (Object.keys(formValue).includes(key)) { // In form arrays we don't know how many items there are // This means that we always need to provide one record in the shape of our form array // so every time reset the key to '0' when the key is a number and is bigger than 0 let keyToCompareWith = key; if (parseFloat(key) > 0) { keyToCompareWith = '0'; } const newPath = path ? `${path}.${key}` : key; if (typeof formValue[key] === 'object' && formValue[key] !== null) { if ((typeof shape[keyToCompareWith] !== 'object' || shape[keyToCompareWith] === null) && isNaN(parseFloat(key))) { errors.push(`[ngModelGroup] Mismatch: '${newPath}'`); } errors.push(...validateFormValue(formValue[key], shape[keyToCompareWith], newPath)); } else if ((shape ? !(key in shape) : true) && isNaN(parseFloat(key))) { errors.push(`[ngModel] Mismatch '${newPath}'`); } } } return errors; } class FormDirective { constructor() { this.ngForm = inject(NgForm, { self: true, optional: false }); /** * The value of the form, this is needed for the validation part */ this.formValue = input(null); /** * Static vest suite that will be used to feed our angular validators */ this.suite = input(null); /** * The shape of our form model. This is a deep required version of the form model * The goal is to add default values to the shape so when the template-driven form * contains values that shouldn't be there (typo's) that the developer gets run-time * errors in dev mode */ this.formShape = input(null); /** * Updates the validation config which is a dynamic object that will be used to * trigger validations on the dependant fields * Eg: ```typescript * validationConfig = { * 'passwords.password': ['passwords.confirmPassword'] * } * ``` * * This will trigger the updateValueAndValidity on passwords.confirmPassword every time the passwords.password gets a new value * * @param v */ this.validationConfig = input(null); this.pending$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v === 'PENDING'), distinctUntilChanged()); /** * Emits every time the form status changes in a state * that is not PENDING * We need this to assure that the form is in 'idle' state */ this.idle$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING'), distinctUntilChanged()); /** * Triggered as soon as the form value changes * Also every time Angular creates a new control or group * It also contains the disabled values (raw values) */ this.formValueChange = this.ngForm.form.events.pipe(filter((v) => v instanceof ValueChangeEvent), map((v) => v.value), map(() => mergeValuesAndRawValues(this.ngForm.form))); /** * Emits an object with all the errors of the form * every time a form control or form groups changes its status to valid or invalid */ this.errorsChange = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING'), map(() => getAllFormErrors(this.ngForm.form))); /** * Triggered as soon as the form becomes dirty */ this.dirtyChange = this.ngForm.form.events.pipe(filter((v) => v instanceof PristineChangeEvent), map((v) => !v.pristine), startWith(this.ngForm.form.dirty), distinctUntilChanged()); this.destroy$$ = new Subject(); /** * Fired when the status of the root form changes. */ this.statusChanges$ = this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status), distinctUntilChanged()); /** * Triggered When the form becomes valid but waits until the form is idle */ this.validChange = this.statusChanges$.pipe(filter((e) => e === 'VALID' || e === 'INVALID'), map((v) => v === 'VALID'), distinctUntilChanged()); /** * Used to debounce formValues to make sure vest isn't triggered all the time */ this.formValueCache = {}; // When the validation config changes // Listen to changes of the left-side of the config and trigger the updateValueAndValidity // function on the dependant controls or groups at the right-side of the config toObservable(this.validationConfig) .pipe(filter((conf) => !!conf), switchMap((conf) => { if (!conf) { return of(null); } const streams = Object.keys(conf).map((key) => { return this.ngForm?.form.get(key)?.valueChanges.pipe( // Wait until something is pending switchMap(() => this.pending$), // Wait until the form is not pending anymore switchMap(() => this.idle$), map(() => this.ngForm?.form.get(key)?.value), takeUntil(this.destroy$$), tap((v) => { conf[key]?.forEach((path) => { this.ngForm?.form.get(path)?.updateValueAndValidity({ onlySelf: true, emitEvent: true, }); }); })); }); return zip(streams); })) .subscribe(); /** * Trigger shape validations if the form gets updated * This is how we can throw run-time errors */ this.formValueChange.pipe(takeUntil(this.destroy$$)).subscribe((v) => { if (this.formShape()) { validateShape(v, this.formShape()); } }); /** * Mark all the fields as touched when the form is submitted */ this.ngForm.ngSubmit.subscribe(() => { this.ngForm.form.markAllAsTouched(); }); } /** * This will feed the formValueCache, debounce it till the next tick * and create an asynchronous validator that runs a vest suite * @param field * @param validationOptions * @returns an asynchronous validator function */ createAsyncValidator(field, validationOptions) { if (!this.suite()) { return () => of(null); } return (value) => { if (!this.formValue()) { return of(null); } const mod = cloneDeep(this.formValue()); set(mod, field, value); // Update the property with path if (!this.formValueCache[field]) { this.formValueCache[field] = { sub$$: new ReplaySubject(1), // Keep track of the last model }; this.formValueCache[field].debounced = this.formValueCache[field].sub$$.pipe(debounceTime(validationOptions.debounceTime)); } // Next the latest model in the cache for a certain field this.formValueCache[field].sub$$.next(mod); return this.formValueCache[field].debounced.pipe( // When debounced, take the latest value and perform the asynchronous vest validation take(1), switchMap(() => { return new Observable((observer) => { this.suite()(mod, field).done((result) => { const errors = result.getErrors()[field]; observer.next(errors ? { error: errors[0], errors } : null); observer.complete(); }); }); }), takeUntil(this.destroy$$)); }; } ngOnDestroy() { this.destroy$$.next(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } 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", errorsChange: "errorsChange", dirtyChange: "dirtyChange", validChange: "validChange" }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormDirective, decorators: [{ type: Directive, args: [{ selector: 'form[scVestForm]', standalone: true, }] }], ctorParameters: () => [], propDecorators: { formValueChange: [{ type: Output }], errorsChange: [{ type: Output }], dirtyChange: [{ type: Output }], validChange: [{ type: Output }] } }); class ControlWrapperComponent { constructor() { this.ngModelGroup = inject(NgModelGroup, { optional: true, self: true, }); this.destroy$$ = new Subject(); this.cdRef = inject(ChangeDetectorRef); this.formDirective = inject(FormDirective); } get invalid() { return this.control?.touched && this.errors; } get errors() { if (this.control?.pending) { return this.previousError; } else { this.previousError = this.control?.errors?.['errors']; } return this.control?.errors?.['errors']; } get control() { return this.ngModelGroup ? this.ngModelGroup.control : this.ngModel?.control; } ngOnDestroy() { this.destroy$$.next(); } ngAfterViewInit() { // Wait until the form is idle // Then, listen to all events of the ngModelGroup or ngModel // and mark the component and its ancestors as dirty // This allows us to use the OnPush ChangeDetection Strategy this.formDirective.idle$ .pipe(switchMap(() => this.ngModelGroup?.control?.events || of(null)), mergeWith(this.control?.events || of(null)), takeUntil(this.destroy$$)) .subscribe(() => { this.cdRef.markForCheck(); }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } 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 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: ControlWrapperComponent, decorators: [{ type: Component, 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" }] }], propDecorators: { ngModel: [{ type: ContentChild, args: [NgModel] }], invalid: [{ type: HostBinding, args: ['class.sc-control-wrapper--invalid'] }] } }); /** * Hooks into the ngModel selector and triggers an asynchronous validation for a form model * It will use a vest suite behind the scenes */ class FormModelDirective { constructor() { this.validationOptions = input({ debounceTime: 0 }); this.formDirective = inject(FormDirective); } validate(control) { const { ngForm, suite, formValue } = this.formDirective; const field = getFormControlField(ngForm.control, control); return this.formDirective.createAsyncValidator(field, this.validationOptions())(control.getRawValue()); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "18.0.1", type: FormModelDirective, isStandalone: true, selector: "[ngModel]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [ { provide: NG_ASYNC_VALIDATORS, useExisting: FormModelDirective, multi: true, }, ], ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormModelDirective, decorators: [{ type: Directive, args: [{ selector: '[ngModel]', standalone: true, providers: [ { provide: NG_ASYNC_VALIDATORS, useExisting: FormModelDirective, multi: true, }, ], }] }] }); /** * Hooks into the ngModelGroup selector and triggers an asynchronous validation for a form group * It will use a vest suite behind the scenes */ class FormModelGroupDirective { constructor() { this.validationOptions = input({ debounceTime: 0 }); this.formDirective = inject(FormDirective); } validate(control) { const { ngForm } = this.formDirective; const field = getFormGroupField(ngForm.control, control); return this.formDirective.createAsyncValidator(field, this.validationOptions())(control.value); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "18.0.1", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [ { provide: NG_ASYNC_VALIDATORS, useExisting: FormModelGroupDirective, multi: true, }, ], ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.1", ngImport: i0, type: FormModelGroupDirective, decorators: [{ type: Directive, args: [{ selector: '[ngModelGroup]', standalone: true, providers: [ { provide: NG_ASYNC_VALIDATORS, useExisting: FormModelGroupDirective, multi: true, }, ], }] }] }); /** * 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) * Thank you so much Ward Bell for your effort!: * * Provide a ControlContainer to a form component from the * nearest parent NgModelGroup (preferred) or NgForm. * * Required for Reactive Forms as well (unless you write CVA) * * @example * ``` * @Component({ * ... * viewProviders[ formViewProvider ] * }) * ``` * @see Kara's AngularConnect 2017 talk: https://youtu.be/CD_t3m2WMM8?t=1826 * * Without this provider * - Controls are not registered with parent NgForm or NgModelGroup * - Form-level flags say "untouched" and "valid" * - No form-level validation roll-up * - Controls still validate, update model, and update their statuses * - If within NgForm, no compiler error because ControlContainer is optional for ngModel * * Note: if the SubForm Component that uses this Provider * is not within a Form or NgModelGroup, the provider returns `null` * resulting in an error, something like * ``` * preview-fef3604083950c709c52b.js:1 ERROR Error: * ngModelGroup cannot be used with a parent formGroup directive. *``` */ const formViewProvider = { provide: ControlContainer, useFactory: _formViewProviderFactory, deps: [ [new Optional(), NgForm], [new Optional(), NgModelGroup], ], }; function _formViewProviderFactory(ngForm, ngModelGroup) { return ngModelGroup || ngForm || null; } /** * The providers we need in every child component that holds an ngModelGroup */ const vestFormsViewProviders = [ { provide: ControlContainer, useExisting: NgForm }, formViewProvider, // very important if we want nested components with ngModelGroup ]; /** * Exports all the stuff we need to use the template driven forms */ const vestForms = [ ValidateRootFormDirective, ControlWrapperComponent, FormDirective, FormsModule, FormModelDirective, FormModelGroupDirective, ]; function arrayToObject(arr) { return arr.reduce((acc, value, index) => ({ ...acc, [index]: value }), {}); } /* * Public API Surface of ngx-vest-forms */ /** * Generated bundle index. Do not edit. */ export { ControlWrapperComponent, FormDirective, FormModelDirective, FormModelGroupDirective, ROOT_FORM, ShapeMismatchError, ValidateRootFormDirective, arrayToObject, cloneDeep, getAllFormErrors, getFormControlField, getFormGroupField, mergeValuesAndRawValues, set, validateShape, vestForms, vestFormsViewProviders }; //# sourceMappingURL=ngx-vest-forms.mjs.map