Skip to content

Commit

Permalink
feat: add a validation fallback directive
Browse files Browse the repository at this point in the history
I chose to design it the following way
- name it valFallback instead of valDefaultError, to avoid confusion with the default-validation-errors directive
- expose the label, the error, and the type of the error to the fallback template
- if the display mode is ALL, then all the errors are displayed. So if two or more errors are not handled by specific error directives, the fallback template is displayed several time. I expect this to rarely happen. But it allows using the error type as an i18n key to display all the errors the same way. If the fallback is used to handle forgotten error types, then it will at least make it clear that several types have been forgotten.
- if the display mode is ONE, then the fallback template is only displayed once, if no error is handled by a specific error directive. Since there might be several errors handled by the fallback, the order is undefined, and we thus have no guarantee over which of the error will be displayed.

fix #264
  • Loading branch information
jnizet committed Apr 3, 2021
1 parent a19a0bf commit d16d844
Show file tree
Hide file tree
Showing 12 changed files with 218 additions and 61 deletions.
13 changes: 3 additions & 10 deletions projects/demo/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
{
"extends": "../../.eslintrc.json",
"ignorePatterns": [
"!**/*"
],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": [
"*.ts"
],
"files": ["*.ts"],
"parserOptions": {
"project": [
"projects/demo/tsconfig.app.json",
"projects/demo/tsconfig.spec.json"
],
"project": ["projects/demo/tsconfig.app.json", "projects/demo/tsconfig.spec.json"],
"createDefaultProgram": true
},
"rules": {
Expand Down
4 changes: 4 additions & 0 deletions projects/demo/src/app/home/home.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ <h3>Customisable</h3>
<li>I18n possible</li>
<li>HTML in messages possible</li>
<li>Use of pipes in messages possible</li>
<li>
A fallback message can be specified to handle forgotten errors or to display multiple error types the same way using i18n (see API
documentation)
</li>
</ul>
</div>
</div>
Expand Down
13 changes: 3 additions & 10 deletions projects/ngx-valdemort/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
{
"extends": "../../.eslintrc.json",
"ignorePatterns": [
"!**/*"
],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": [
"*.ts"
],
"files": ["*.ts"],
"parserOptions": {
"project": [
"projects/ngx-valdemort/tsconfig.lib.json",
"projects/ngx-valdemort/tsconfig.spec.json"
],
"project": ["projects/ngx-valdemort/tsconfig.lib.json", "projects/ngx-valdemort/tsconfig.spec.json"],
"createDefaultProgram": true
},
"rules": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,25 @@ import { ValdemortModule } from './valdemort.module';
{{ label }} must have at least {{ error.requiredLength }} characters
</ng-template>
<ng-template valError="pattern" let-label>{{ label }} is not correct</ng-template>
<ng-template valFallback let-label let-error="error" let-type="type">
{{ label }} has an error of type {{ type }} with value {{ error.requiredLength }}
</ng-template>
</val-default-errors>
<input [formControl]="name" />
<val-errors label="The name" [control]="name">
<input id="name" [formControl]="name" />
<val-errors id="name-errors" label="The name" [control]="name">
<ng-template valError="pattern">only letters</ng-template>
</val-errors>
<input id="street" [formControl]="street" />
<val-errors id="street-errors" label="The street" [control]="street">
<ng-template valFallback>oops</ng-template>
</val-errors>
`
})
class TestComponent {
name = new FormControl('', [Validators.required, Validators.minLength(2), Validators.pattern(/^[a-z]*$/)]);
name = new FormControl('', [Validators.required, Validators.minLength(2), Validators.pattern(/^[a-z]*$/), Validators.maxLength(5)]);
street = new FormControl('', [Validators.maxLength(5)]);
}

class DefaultErrorsComponentTester extends ComponentTester<TestComponent> {
Expand All @@ -31,11 +40,19 @@ class DefaultErrorsComponentTester extends ComponentTester<TestComponent> {
}

get name() {
return this.input('input')!;
return this.input('#name')!;
}

get nameErrors() {
return this.element('#name-errors')!;
}

get street() {
return this.input('#street')!;
}

get errors() {
return this.element('val-errors')!;
get streetErrors() {
return this.element('#street-errors')!;
}
}
describe('DefaultValidationErrorsDirective', () => {
Expand All @@ -55,14 +72,29 @@ describe('DefaultValidationErrorsDirective', () => {
it('should validate with default errors', () => {
tester.name.dispatchEventOfType('blur');

expect(tester.errors).toContainText('The name is required');
expect(tester.nameErrors).toContainText('The name is required');
});

it('should respect order of errors, allow overriding message, and expose the error', () => {
tester.name.fillWith('1');
tester.name.dispatchEventOfType('blur');

expect(tester.errors.elements('div')[0]).toContainText('The name must have at least 2 characters');
expect(tester.errors.elements('div')[1]).toContainText('only letters');
expect(tester.nameErrors.elements('div')[0]).toContainText('The name must have at least 2 characters');
expect(tester.nameErrors.elements('div')[1]).toContainText('only letters');
});

it('should display the fallback error is not handled', () => {
tester.name.fillWith('abcdef1');
tester.name.dispatchEventOfType('blur');

expect(tester.nameErrors.elements('div')[0]).toContainText('only letters');
expect(tester.nameErrors.elements('div')[1]).toContainText('The name has an error of type maxlength with value 5');
});

it('should favor custom fallback over default fallback', () => {
tester.street.fillWith('too long street');
tester.street.dispatchEventOfType('blur');

expect(tester.streetErrors.elements('div')[0]).toContainText('oops');
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable @angular-eslint/directive-selector,@angular-eslint/no-host-metadata-property */
import { AfterContentInit, ContentChildren, Directive, QueryList } from '@angular/core';
import { AfterContentInit, ContentChild, ContentChildren, Directive, QueryList } from '@angular/core';
import { DefaultValidationErrors } from './default-validation-errors.service';
import { ValidationErrorDirective } from './validation-error.directive';
import { ValidationFallbackDirective } from './validation-fallback.directive';

/**
* Directive allowing to register default templates for validation error messages. It's supposed to be used once,
Expand All @@ -27,6 +28,18 @@ import { ValidationErrorDirective } from './validation-error.directive';
* </val-default-errors>
* ```
*
* A fallback template can also be provided. This fallback template is used for all the errors that exist on the form control
* but are not handled by any of the specific error templates:
* ```
* <val-default-errors>
* <ng-template valError="required" let-label>{{ label }} is mandatory</ng-template>
* <ng-template valError="max" let-error="error" let-label>{{ label }} must be at most {{ error.max | number }}</ng-template>
* <ng-template valFallback let-label let-type="type" let-error="error">{{ label }} has an unhandled error of type {{ type }}: {{ error | json }}</ng-template>
* </val-default-errors>
* ```
* Using the fallback can also be used to handle all the errors the same way, for example by using the error type as an i18n key
* to display the appropriate error message.
*
* This directive stores the default template references in a service, that is then injected in the validation errors components
* to be reused.
*/
Expand All @@ -41,12 +54,19 @@ export class DefaultValidationErrorsDirective implements AfterContentInit {

/**
* The list of validation error directives (i.e. <ng-template valError="...">)
* contained inside the component element.
* contained inside the directive element.
*/
@ContentChildren(ValidationErrorDirective)
errorDirectives!: QueryList<ValidationErrorDirective>;

/**
* The validation fallback directive (i.e. <ng-template valFallback>) contained inside the directive element.
*/
@ContentChild(ValidationFallbackDirective)
fallbackDirective: ValidationFallbackDirective | undefined;

ngAfterContentInit(): void {
this.defaultValidationErrors.directives = this.errorDirectives.toArray();
this.defaultValidationErrors.fallback = this.fallbackDirective;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable } from '@angular/core';
import { ValidationErrorDirective } from './validation-error.directive';
import { ValidationFallbackDirective } from './validation-fallback.directive';

/**
* Service used by the default validation errors directive to store the default error template references. This
Expand All @@ -10,4 +11,5 @@ import { ValidationErrorDirective } from './validation-error.directive';
})
export class DefaultValidationErrors {
directives: Array<ValidationErrorDirective> = [];
fallback: ValidationFallbackDirective | undefined;
}
5 changes: 3 additions & 2 deletions projects/ngx-valdemort/src/lib/valdemort.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { ValidationErrorsComponent } from './validation-errors.component';
import { CommonModule } from '@angular/common';
import { DefaultValidationErrorsDirective } from './default-validation-errors.directive';
import { ValidationErrorDirective } from './validation-error.directive';
import { ValidationFallbackDirective } from './validation-fallback.directive';

@NgModule({
imports: [CommonModule],
declarations: [ValidationErrorsComponent, ValidationErrorDirective, DefaultValidationErrorsDirective],
exports: [ValidationErrorsComponent, ValidationErrorDirective, DefaultValidationErrorsDirective]
declarations: [ValidationErrorsComponent, ValidationErrorDirective, ValidationFallbackDirective, DefaultValidationErrorsDirective],
exports: [ValidationErrorsComponent, ValidationErrorDirective, ValidationFallbackDirective, DefaultValidationErrorsDirective]
})
export class ValdemortModule {}
27 changes: 19 additions & 8 deletions projects/ngx-valdemort/src/lib/validation-errors.component.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
<ng-container *ngIf="shouldDisplayErrors">
<div [class]="errorClasses" *ngFor="let errorDirective of errorDirectivesToDisplay">
<ng-container
*ngTemplateOutlet="errorDirective!.templateRef; context: {
$implicit: label,
error: actualControl.errors![errorDirective.type]
}"
></ng-container>
</div>
<ng-container *ngIf="errorsToDisplay as e">
<div [class]="errorClasses" *ngFor="let errorDirective of e.errors">
<ng-container
*ngTemplateOutlet="errorDirective!.templateRef; context: {
$implicit: label,
error: actualControl!.errors![errorDirective.type]
}"
></ng-container>
</div>
<div [class]="errorClasses" *ngFor="let error of e.fallbackErrors">
<ng-container
*ngTemplateOutlet="e.fallback!.templateRef; context: {
$implicit: label,
type: error.type,
error: error.value
}"
></ng-container>
</div>
</ng-container>
</ng-container>
38 changes: 36 additions & 2 deletions projects/ngx-valdemort/src/lib/validation-errors.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function matchValidator(group: AbstractControl) {
}

@Component({
selector: 'val-reactival-test',
selector: 'val-reactive-test',
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="firstName" id="firstName" />
Expand Down Expand Up @@ -65,6 +65,12 @@ function matchValidator(group: AbstractControl) {
</div>
</div>
<input formControlName="email" id="email" />
<val-errors id="emailErrors" controlName="email" label="The email">
<ng-template valError="email">email must be a valid email address</ng-template>
<ng-template valFallback let-label let-type="type">{{ label }} has an unhandled error of type {{ type }}</ng-template>
</val-errors>
<button id="submit">Submit</button>
</form>
`
Expand All @@ -86,7 +92,8 @@ class ReactiveTestComponent {
validators: matchValidator
}
),
hobbies: fb.array([['', Validators.required]])
hobbies: fb.array([['', Validators.required]]),
email: ['', [Validators.email, Validators.maxLength(10), Validators.pattern(/^[a-z.@]*$/)]]
});
}

Expand Down Expand Up @@ -147,6 +154,14 @@ class ReactiveComponentTester extends ComponentTester<ReactiveTestComponent> {
return this.element('#credentialsControlErrors')!;
}

get email() {
return this.input('#email')!;
}

get emailErrors() {
return this.element('#emailErrors')!;
}

get submit() {
return this.button('#submit')!;
}
Expand Down Expand Up @@ -397,6 +412,15 @@ describe('ValidationErrorsComponent', () => {
tester.submit.click();
expect(tester.credentialsControlErrors).toContainText('match with control error');
});

it('should display fallback errors', () => {
tester.email.fillWith('long invalid email with 1234');
tester.submit.click();
expect(tester.emailErrors.elements('div').length).toBe(3);
expect(tester.emailErrors.elements('div')[0]).toContainText('email must be a valid email address');
expect(tester.emailErrors).toContainText('The email has an unhandled error of type maxlength');
expect(tester.emailErrors).toContainText('The email has an unhandled error of type pattern');
});
});

describe('standalone controls', () => {
Expand Down Expand Up @@ -520,6 +544,16 @@ describe('ValidationErrorsComponent', () => {
expect(tester.lastNameErrors).toContainText('min length: 2');
});

it('should display the first error in case of fallback', () => {
tester.email.fillWith('long email with 1234');
expect(tester.emailErrors.elements('div').length).toBe(1);
expect(tester.emailErrors).toContainText('email must be a valid email address');

tester.email.fillWith('long-rejected-email@mail.com');
expect(tester.emailErrors.elements('div').length).toBe(1);
expect(tester.emailErrors).toContainText('The email has an unhandled error of type');
});

it('should add CSS classes to the errors component', () => {
tester.lastName.fillWith('1');
expect(tester.lastNameErrors).toHaveClass('a');
Expand Down
Loading

0 comments on commit d16d844

Please sign in to comment.