From 876be3d469ebc0443d539fceea7650690046916e Mon Sep 17 00:00:00 2001 From: Michael Trefilov Date: Sun, 10 May 2020 17:44:05 +0300 Subject: [PATCH] feat: rich error message POC --- projects/ngx-errors/src/lib/error.base.ts | 118 ++++++++++++++++++ .../ngx-errors/src/lib/error.directive.ts | 111 ++-------------- projects/ngx-errors/src/lib/errors.module.ts | 5 +- .../src/lib/rich-error.directive.ts | 66 ++++++++++ .../src/app/lazy/lazy.component.html | 19 ++- .../playground/src/app/lazy/lazy.component.ts | 1 + .../playground/src/app/lazy/lazy.module.ts | 2 +- 7 files changed, 213 insertions(+), 109 deletions(-) create mode 100644 projects/ngx-errors/src/lib/error.base.ts create mode 100644 projects/ngx-errors/src/lib/rich-error.directive.ts diff --git a/projects/ngx-errors/src/lib/error.base.ts b/projects/ngx-errors/src/lib/error.base.ts new file mode 100644 index 0000000..e30a38a --- /dev/null +++ b/projects/ngx-errors/src/lib/error.base.ts @@ -0,0 +1,118 @@ +import { + AfterViewInit, + ChangeDetectorRef, + HostBinding, + Input, + OnDestroy, +} from '@angular/core'; +import { AbstractControl } from '@angular/forms'; +import { merge, NEVER, Subject } from 'rxjs'; +import { filter, map, switchMap, takeUntil, tap } from 'rxjs/operators'; +import { ErrorsConfiguration } from './errors-configuration'; +import { ErrorsDirective } from './errors.directive'; +import { NoParentNgxErrorsError, ValueMustBeStringError } from './ngx-errors'; + +export const defaultConfig = new ErrorsConfiguration(); + +export class ErrorBase implements AfterViewInit, OnDestroy { + @HostBinding('hidden') + hidden = true; + + @Input('ngxError') errorName: string; + + private destroy = new Subject(); + + constructor( + private cdr: ChangeDetectorRef, + private errorsDirective: ErrorsDirective, + private config: ErrorsConfiguration + ) { + this.initConfig(); + } + + ngAfterViewInit() { + this.validateDirective(); + this.listenForm(); + } + + listenForm(): void { + const ngSubmit$ = + this.config.showErrorsWhenFormSubmitted && this.errorsDirective.parentForm + ? this.errorsDirective.parentForm.ngSubmit + : NEVER; + + this.errorsDirective.control$ + .pipe( + takeUntil(this.destroy), + filter((c): c is AbstractControl => !!c), + tap((control) => this.calcShouldDisplay(control)), + switchMap((control) => + merge(control.valueChanges, ngSubmit$).pipe( + takeUntil(this.destroy), + map(() => control) + ) + ), + tap((control) => this.calcShouldDisplay(control)) + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy.next(); + this.destroy.complete(); + } + + private calcShouldDisplay(control: AbstractControl) { + const hasError = control.hasError(this.errorName); + + // could show error if there is one + let couldShowError = false; + + const canShowBasedOnControlDirty = this.canShowBasedOnControlDirty(control); + + const form = this.errorsDirective.parentForm; + if (this.config.showErrorsWhenFormSubmitted) { + couldShowError = form ? form.submitted : canShowBasedOnControlDirty; + } else { + couldShowError = canShowBasedOnControlDirty; + } + + this.changeErrorVisibility( + couldShowError && hasError && control.getError(this.errorName) + ); + this.cdr.detectChanges(); + } + + changeErrorVisibility(error: any): void { + this.hidden = !error; + } + + private initConfig() { + if (!this.config) { + this.config = defaultConfig; + return; + } + if (this.config.showErrorsOnlyIfInputDirty == null) { + this.config.showErrorsOnlyIfInputDirty = + defaultConfig.showErrorsOnlyIfInputDirty; + } + if (this.config.showErrorsWhenFormSubmitted == null) { + this.config.showErrorsWhenFormSubmitted = + defaultConfig.showErrorsWhenFormSubmitted; + } + } + + private validateDirective() { + if (this.errorsDirective == null) { + throw new NoParentNgxErrorsError(); + } + + if (typeof this.errorName !== 'string' || this.errorName.trim() === '') { + throw new ValueMustBeStringError(); + } + } + + private canShowBasedOnControlDirty(control: AbstractControl) { + return !this.config.showErrorsOnlyIfInputDirty || control.dirty; + } +} diff --git a/projects/ngx-errors/src/lib/error.directive.ts b/projects/ngx-errors/src/lib/error.directive.ts index 175483e..310b95a 100644 --- a/projects/ngx-errors/src/lib/error.directive.ts +++ b/projects/ngx-errors/src/lib/error.directive.ts @@ -1,20 +1,7 @@ -import { - AfterViewInit, - ChangeDetectorRef, - Directive, - HostBinding, - Input, - OnDestroy, - Optional, -} from '@angular/core'; -import { AbstractControl } from '@angular/forms'; -import { merge, NEVER, Subject } from 'rxjs'; -import { filter, map, switchMap, takeUntil, tap } from 'rxjs/operators'; +import { ChangeDetectorRef, Directive, Optional } from '@angular/core'; import { ErrorsConfiguration } from './errors-configuration'; import { ErrorsDirective } from './errors.directive'; -import { NoParentNgxErrorsError, ValueMustBeStringError } from './ngx-errors'; - -const defaultConfig = new ErrorsConfiguration(); +import { ErrorBase } from './error.base'; /** * Directive to provide a validation error for a specific error name. @@ -30,98 +17,14 @@ const defaultConfig = new ErrorsConfiguration(); @Directive({ selector: '[ngxError]', }) -export class ErrorDirective implements AfterViewInit, OnDestroy { - @HostBinding('hidden') - hidden = true; - - @Input('ngxError') errorName: string; - - private destroy = new Subject(); - +export class ErrorDirective extends ErrorBase { constructor( - private cdr: ChangeDetectorRef, + cdr: ChangeDetectorRef, // ErrorsDirective is actually required. // use @Optional so that we can throw a custom error - @Optional() private errorsDirective: ErrorsDirective, - @Optional() private config: ErrorsConfiguration + @Optional() errorsDirective: ErrorsDirective, + @Optional() config: ErrorsConfiguration ) { - this.initConfig(); - } - - ngAfterViewInit() { - this.validateDirective(); - - const ngSubmit$ = - this.config.showErrorsWhenFormSubmitted && this.errorsDirective.parentForm - ? this.errorsDirective.parentForm.ngSubmit - : NEVER; - - this.errorsDirective.control$ - .pipe( - takeUntil(this.destroy), - filter((c): c is AbstractControl => !!c), - tap((control) => this.calcShouldDisplay(control)), - switchMap((control) => - merge(control.valueChanges, ngSubmit$).pipe( - takeUntil(this.destroy), - map(() => control) - ) - ), - tap((control) => this.calcShouldDisplay(control)) - ) - .subscribe(); - } - - ngOnDestroy() { - this.destroy.next(); - this.destroy.complete(); - } - - private calcShouldDisplay(control: AbstractControl) { - const hasError = control.hasError(this.errorName); - - // could show error if there is one - let couldShowError = false; - - const canShowBasedOnControlDirty = this.canShowBasedOnControlDirty(control); - - const form = this.errorsDirective.parentForm; - if (this.config.showErrorsWhenFormSubmitted) { - couldShowError = form ? form.submitted : canShowBasedOnControlDirty; - } else { - couldShowError = canShowBasedOnControlDirty; - } - - this.hidden = !(couldShowError && hasError); - this.cdr.detectChanges(); - } - - private initConfig() { - if (!this.config) { - this.config = defaultConfig; - return; - } - if (this.config.showErrorsOnlyIfInputDirty == null) { - this.config.showErrorsOnlyIfInputDirty = - defaultConfig.showErrorsOnlyIfInputDirty; - } - if (this.config.showErrorsWhenFormSubmitted == null) { - this.config.showErrorsWhenFormSubmitted = - defaultConfig.showErrorsWhenFormSubmitted; - } - } - - private validateDirective() { - if (this.errorsDirective == null) { - throw new NoParentNgxErrorsError(); - } - - if (typeof this.errorName !== 'string' || this.errorName.trim() === '') { - throw new ValueMustBeStringError(); - } - } - - private canShowBasedOnControlDirty(control: AbstractControl) { - return !this.config.showErrorsOnlyIfInputDirty || control.dirty; + super(cdr, errorsDirective, config); } } diff --git a/projects/ngx-errors/src/lib/errors.module.ts b/projects/ngx-errors/src/lib/errors.module.ts index 3ace6a7..cf54463 100644 --- a/projects/ngx-errors/src/lib/errors.module.ts +++ b/projects/ngx-errors/src/lib/errors.module.ts @@ -6,11 +6,12 @@ import { IErrorsConfiguration, ErrorsConfiguration, } from './errors-configuration'; +import { RichErrorDirective } from './rich-error.directive'; @NgModule({ imports: [ReactiveFormsModule], - declarations: [ErrorsDirective, ErrorDirective], - exports: [ErrorsDirective, ErrorDirective], + declarations: [ErrorsDirective, ErrorDirective, RichErrorDirective], + exports: [ErrorsDirective, ErrorDirective, RichErrorDirective], }) export class NgxErrorsModule { static configure( diff --git a/projects/ngx-errors/src/lib/rich-error.directive.ts b/projects/ngx-errors/src/lib/rich-error.directive.ts new file mode 100644 index 0000000..2a30d0a --- /dev/null +++ b/projects/ngx-errors/src/lib/rich-error.directive.ts @@ -0,0 +1,66 @@ +import { + ChangeDetectorRef, + Directive, + EmbeddedViewRef, + Input, + Optional, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { ErrorBase } from './error.base'; +import { ErrorsDirective } from './errors.directive'; +import { ErrorsConfiguration } from './errors-configuration'; + +export class ObservablesContext { + [key: string]: any; +} + +/** + * Structural directive to provide a rich validation error message for a specific error name. + * Used as a child of ngxErrors directive. + * + * Example: + * ```html + *
+ * Value should be at least {{requiredLength}} long; Right now it's {{actualLength}} + *
+ * ``` + */ +@Directive({ + selector: '[ngxRichError]', +}) +export class RichErrorDirective extends ErrorBase { + private ref: EmbeddedViewRef | undefined; + private _context = new ObservablesContext(); + + @Input() set ngxRichError(errorName: string) { + this.errorName = errorName; + } + + constructor( + private template: TemplateRef, + private viewContainer: ViewContainerRef, + cdr: ChangeDetectorRef, + // ErrorsDirective is actually required. + // use @Optional so that we can throw a custom error + @Optional() errorsDirective: ErrorsDirective, + @Optional() config: ErrorsConfiguration + ) { + super(cdr, errorsDirective, config); + } + + changeErrorVisibility(error: any): void { + if (error) { + Object.assign(this._context, error); + if (!this.ref) { + this.ref = this.viewContainer.createEmbeddedView( + this.template, + this._context + ); + } + } else { + this.viewContainer.clear(); + this.ref = undefined; + } + } +} diff --git a/projects/playground/src/app/lazy/lazy.component.html b/projects/playground/src/app/lazy/lazy.component.html index 5e90d5b..3752443 100644 --- a/projects/playground/src/app/lazy/lazy.component.html +++ b/projects/playground/src/app/lazy/lazy.component.html @@ -1,16 +1,31 @@
- +
First name is required
- +
Street name is required
+ + + +
+
+ Value should be at least {{ requiredLength }} long; Right now it's + {{ actualLength }} +
+
diff --git a/projects/playground/src/app/lazy/lazy.component.ts b/projects/playground/src/app/lazy/lazy.component.ts index fe54ca7..5e05037 100644 --- a/projects/playground/src/app/lazy/lazy.component.ts +++ b/projects/playground/src/app/lazy/lazy.component.ts @@ -14,6 +14,7 @@ export class LazyComponent implements OnInit { firstName: ['', Validators.required], address: fb.group({ street: ['', Validators.required], + zip: ['', Validators.minLength(5)], }), }); } diff --git a/projects/playground/src/app/lazy/lazy.module.ts b/projects/playground/src/app/lazy/lazy.module.ts index 107fd3f..ec4933c 100644 --- a/projects/playground/src/app/lazy/lazy.module.ts +++ b/projects/playground/src/app/lazy/lazy.module.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; -import { NgxErrorsModule } from '@ngspot/ngx-errors'; +import { NgxErrorsModule } from '../../../../ngx-errors/src/public-api'; import { LazyRoutingModule } from './lazy-routing.module'; import { LazyComponent } from './lazy.component';