Skip to content
This repository has been archived by the owner on May 3, 2024. It is now read-only.

feat: rich error message POC #4

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions projects/ngx-errors/src/lib/error.base.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
111 changes: 7 additions & 104 deletions projects/ngx-errors/src/lib/error.directive.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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);
}
}
5 changes: 3 additions & 2 deletions projects/ngx-errors/src/lib/errors.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
66 changes: 66 additions & 0 deletions projects/ngx-errors/src/lib/rich-error.directive.ts
Original file line number Diff line number Diff line change
@@ -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
* <div *ngxRichError="'minlength'; let actualLength=actualLength; let requiredLength=requiredLength">
* Value should be at least {{requiredLength}} long; Right now it's {{actualLength}}
* </div>
* ```
*/
@Directive({
selector: '[ngxRichError]',
})
export class RichErrorDirective extends ErrorBase {
private ref: EmbeddedViewRef<ObservablesContext> | undefined;
private _context = new ObservablesContext();

@Input() set ngxRichError(errorName: string) {
this.errorName = errorName;
}

constructor(
private template: TemplateRef<ObservablesContext>,
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{ $implicit: this._context } to enable syntax I mentioned in the general comments

);
}
} else {
this.viewContainer.clear();
this.ref = undefined;
}
}
}
19 changes: 17 additions & 2 deletions projects/playground/src/app/lazy/lazy.component.html
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
<form [formGroup]="form" (submit)="submit()">
<input type="text" formControlName="firstName" />
<input type="text" formControlName="firstName" placeholder="firstName" />

<div ngxErrors="firstName">
<div ngxError="required">First name is required</div>
</div>

<div formGroupName="address">
<input type="text" formControlName="street" />
<input type="text" formControlName="street" placeholder="street" />

<div ngxErrors="street">
<div ngxError="required">Street name is required</div>
</div>

<input type="text" formControlName="zip" placeholder="zip" />

<div ngxErrors="zip">
<div
*ngxRichError="
'minlength';
let actualLength = actualLength;
let requiredLength = requiredLength
"
>
Value should be at least {{ requiredLength }} long; Right now it's
{{ actualLength }}
</div>
</div>
</div>

<button type="submit">Submit</button>
Expand Down
1 change: 1 addition & 0 deletions projects/playground/src/app/lazy/lazy.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export class LazyComponent implements OnInit {
firstName: ['', Validators.required],
address: fb.group({
street: ['', Validators.required],
zip: ['', Validators.minLength(5)],
}),
});
}
Expand Down
2 changes: 1 addition & 1 deletion projects/playground/src/app/lazy/lazy.module.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down