Skip to content

Commit

Permalink
feat: add support for async validators (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
rhutchison committed Jul 6, 2020
1 parent 6f9eb25 commit 5e21452
Show file tree
Hide file tree
Showing 7 changed files with 110 additions and 19 deletions.
1 change: 1 addition & 0 deletions .all-contributorsrc
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"profile": "https://github.com/rhutchison",
"contributions": [
"bug",
"doc",
"code",
"test"
]
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,24 @@ The library adds a `form-submitted` to the submitted form. You can use it to sty
}
```

- `controlErrorsOnBlur` - To modify the error display behavior and show the errors on submission alone, set the following input:
- `controlErrorsOnBlur` - To modify the error display behavior to not show errors on blur, set the following input:

```html
<input [controlErrorsOnBlur]="false" formControlName="name" />
```

- `controlErrorsOnAsync` - To modify the error display behavior to not show errors from async validators, set the following input:

```html
<input [controlErrorsOnAsync]="false" formControlName="name" />
```

- To modify the error display behavior and show the errors on submission alone, set the following input:

```html
<input [controlErrorsOnBlur]="false" [controlErrorsOnAsync]="false" formControlName="name" />
```

## Recipes

### I18n Example
Expand Down
6 changes: 3 additions & 3 deletions karma.conf.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html

module.exports = function (config) {
module.exports = function(config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
Expand All @@ -25,8 +25,8 @@ module.exports = function (config) {
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
browsers: [process.env.CI ? 'ChromeHeadless' : 'Chrome'],
singleRun: process.env.CI,
restartOnFileChange: true
});
};
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
"hooks:pre-commit": "node hooks/pre-commit.js",
"commit": "git-cz",
"build:lib": "ng build @ngneat/error-tailor --prod && npm run schematics:build && cp -r README.md dist/ngneat/error-tailor",
"test:lib": "ng test @ngneat/error-tailor",
"release": "cd projects/ngneat/error-tailor && standard-version --infile ../../../CHANGELOG.md",
"test:lib:headless": "cross-env CI=true npm run test:lib",
"schematics:build": "tsc -p tsconfig.schematics.json && npm run schematics:copy",
"schematics:copy": "cp schematics/src/collection.json dist/ngneat/error-tailor/schematics/src && cp schematics/src/ng-add/schema.json dist/ngneat/error-tailor/schematics/src/ng-add",
"test:headless": "cross-env CI=true npm run test",
"test:lib": "ng test @ngneat/error-tailor",
"test:lib:headless": "cross-env CI=true npm run test:lib",
"test:schematics": "TS_NODE_PROJECT='tsconfig.schematics.spec.json' node -r ts-node/register node_modules/.bin/jasmine ./schematics/**/*.spec.ts"
},
"private": true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { Component, Type } from '@angular/core';
import { FormArray, FormBuilder, FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import {
FormArray,
FormBuilder,
FormControl,
FormsModule,
ReactiveFormsModule,
Validators,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { byPlaceholder, byText, createComponentFactory, Spectator } from '@ngneat/spectator';
import { ErrorTailorModule } from '@ngneat/error-tailor';
import { tick, fakeAsync } from '@angular/core/testing';
import { Observable, asyncScheduler, scheduled } from 'rxjs';
import { map } from 'rxjs/operators';

function getComponentFactory<C>(component: Type<C>) {
return createComponentFactory({
Expand Down Expand Up @@ -47,6 +58,8 @@ describe('ControlErrorDirective', () => {
</div>
</div>
<input formControlName="username" placeholder="Username" />
<button type="submit">Submit</button>
</form>
`
Expand All @@ -56,7 +69,8 @@ describe('ControlErrorDirective', () => {
name: this.createName(),
terms: [false, Validators.requiredTrue],
ignored: ['', Validators.required],
names: this.builder.array([this.createName(), this.createName()], this.validator)
names: this.builder.array([this.createName(), this.createName()], this.validator),
username: ['', null, this.usernameValidator.bind(this)]
});

constructor(private builder: FormBuilder) {}
Expand All @@ -68,6 +82,20 @@ describe('ControlErrorDirective', () => {
validator({ controls }: FormArray) {
return controls.some(control => control.valid) ? null : { requiredone: true };
}

usernameValidator(ctrl: AbstractControl): Observable<ValidationErrors | null> {
return scheduled([ctrl.value], asyncScheduler).pipe(
map(value => {
if (value === 'error') {
return {
serverError: 'async validation error'
};
}

return null;
})
);
}
}

let spectator: Spectator<FormGroupComponent>;
Expand Down Expand Up @@ -137,6 +165,35 @@ describe('ControlErrorDirective', () => {

expect(spectator.query(byText(serverError))).toBeTruthy();
}));

it('should show errors from async validators', fakeAsync(() => {
const serverError = 'async validation error';
const usernameInput = spectator.query<HTMLInputElement>(byPlaceholder('Username'));

spectator.typeInElement('no error', usernameInput);

tick();

spectator.detectChanges();

expect(spectator.query(byText(serverError))).toBeFalsy();

spectator.typeInElement('error', usernameInput);

tick();

spectator.detectChanges();

expect(spectator.query(byText(serverError))).toBeTruthy();

spectator.typeInElement('no error', usernameInput);

tick();

spectator.detectChanges();

expect(spectator.query(byText(serverError))).toBeFalsy();
}));
});

describe('FormControl', () => {
Expand Down
15 changes: 12 additions & 3 deletions projects/ngneat/error-tailor/src/lib/control-error.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ErrorTailorConfig, ErrorTailorConfigProvider, FORM_ERRORS } from './pro
import { distinctUntilChanged, startWith, switchMap, takeUntil } from 'rxjs/operators';
import { FormSubmitDirective } from './form-submit.directive';
import { ErrorsMap } from './types';
import { threadId } from 'worker_threads';

@Directive({
selector:
Expand All @@ -29,6 +30,7 @@ export class ControlErrorsDirective implements OnInit, OnDestroy {
@Input('controlErrors') customErrors: ErrorsMap = {};
@Input() controlErrorsClass: string | undefined;
@Input() controlErrorsTpl: TemplateRef<any> | undefined;
@Input() controlErrorsOnAsync = true;
@Input() controlErrorsOnBlur = true;
@Input() controlErrorAnchor: ControlErrorAnchorDirective;

Expand Down Expand Up @@ -57,23 +59,30 @@ export class ControlErrorsDirective implements OnInit, OnDestroy {
ngOnInit() {
this.anchor = this.resolveAnchor();
this.control = (this.controlContainer || this.ngControl).control;
const hasAsyncValidator = !!this.control.asyncValidator;
const isInput = this.mergedConfig.blurPredicate(this.host.nativeElement);

const statusChanges$ = this.control.statusChanges.pipe(distinctUntilChanged());
const valueChanges$ = this.control.valueChanges;
const controlChanges$ = merge(statusChanges$, valueChanges$);
let changesOnAsync$: Observable<any> = EMPTY;
let changesOnBlur$: Observable<any> = EMPTY;

if (this.controlErrorsOnAsync && hasAsyncValidator) {
// hasAsyncThenUponStatusChange
changesOnAsync$ = statusChanges$.pipe(startWith(true));
}

if (this.controlErrorsOnBlur && isInput) {
const blur$ = fromEvent(this.host.nativeElement, 'focusout');
// blurFirstThanUponChange
// blurFirstThenUponChange
changesOnBlur$ = blur$.pipe(switchMap(() => valueChanges$.pipe(startWith(true))));
}

// submitFirstThanUponChanges
// submitFirstThenUponChanges
const changesOnSubmit$ = this.submit$.pipe(switchMap(() => controlChanges$.pipe(startWith(true))));

merge(changesOnSubmit$, changesOnBlur$)
merge(changesOnAsync$, changesOnBlur$, changesOnSubmit$)
.pipe(takeUntil(this.destroy))
.subscribe(() => this.valueChanges());
}
Expand Down
27 changes: 19 additions & 8 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ErrorTailorModule } from '@ngneat/error-tailor';

describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule],
imports: [
RouterTestingModule,
FormsModule,
ReactiveFormsModule,
ErrorTailorModule.forRoot({
errors: {
useFactory() {
return {
required: 'This field is required',
minlength: ({ requiredLength, actualLength }) => `Expect ${requiredLength} but got ${actualLength}`,
invalidAddress: error => `Address not valid`
};
},
deps: []
}
})
],
declarations: [AppComponent]
}).compileComponents();
}));
Expand All @@ -15,11 +33,4 @@ describe('AppComponent', () => {
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});

it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.content span').textContent).toContain('error-tailor-playground app is running!');
});
});

0 comments on commit 5e21452

Please sign in to comment.