feat(facade): add support for async validators returning observables

Closes #5032
This commit is contained in:
vsavkin 2015-11-05 17:18:16 -08:00 committed by Victor Savkin
parent 2c201d3f34
commit 4439106a1f
6 changed files with 112 additions and 26 deletions

View File

@ -1,5 +1,6 @@
import {StringWrapper, isPresent, isBlank, normalizeBool} from 'angular2/src/core/facade/lang'; import {StringWrapper, isPresent, isBlank, normalizeBool} from 'angular2/src/core/facade/lang';
import {Observable, EventEmitter, ObservableWrapper} from 'angular2/src/core/facade/async'; import {Observable, EventEmitter, ObservableWrapper} from 'angular2/src/core/facade/async';
import {PromiseWrapper} from 'angular2/src/core/facade/promise';
import {StringMapWrapper, ListWrapper} from 'angular2/src/core/facade/collection'; import {StringMapWrapper, ListWrapper} from 'angular2/src/core/facade/collection';
/** /**
@ -42,6 +43,10 @@ function _find(control: AbstractControl, path: Array<string | number>| string) {
}, control); }, control);
} }
function toObservable(r: any): Observable<any> {
return PromiseWrapper.isPromise(r) ? ObservableWrapper.fromPromise(r) : r;
}
/** /**
* *
*/ */
@ -49,9 +54,8 @@ export abstract class AbstractControl {
/** @internal */ /** @internal */
_value: any; _value: any;
/** @internal */ private _valueChanges: EventEmitter<any>;
_valueChanges: EventEmitter<any>; private _statusChanges: EventEmitter<any>;
private _status: string; private _status: string;
private _errors: {[key: string]: any}; private _errors: {[key: string]: any};
private _controlsErrors: any; private _controlsErrors: any;
@ -88,6 +92,8 @@ export abstract class AbstractControl {
get valueChanges(): Observable<any> { return this._valueChanges; } get valueChanges(): Observable<any> { return this._valueChanges; }
get statusChanges(): Observable<any> { return this._statusChanges; }
get pending(): boolean { return this._status == PENDING; } get pending(): boolean { return this._status == PENDING; }
markAsTouched(): void { this._touched = true; } markAsTouched(): void { this._touched = true; }
@ -124,11 +130,12 @@ export abstract class AbstractControl {
this._status = this._calculateStatus(); this._status = this._calculateStatus();
if (this._status == VALID || this._status == PENDING) { if (this._status == VALID || this._status == PENDING) {
this._runAsyncValidator(); this._runAsyncValidator(emitEvent);
} }
if (emitEvent) { if (emitEvent) {
ObservableWrapper.callNext(this._valueChanges, this._value); ObservableWrapper.callNext(this._valueChanges, this._value);
ObservableWrapper.callNext(this._statusChanges, this._status);
} }
if (isPresent(this._parent) && !onlySelf) { if (isPresent(this._parent) && !onlySelf) {
@ -138,13 +145,13 @@ export abstract class AbstractControl {
private _runValidator() { return isPresent(this.validator) ? this.validator(this) : null; } private _runValidator() { return isPresent(this.validator) ? this.validator(this) : null; }
private _runAsyncValidator() { private _runAsyncValidator(emitEvent: boolean): void {
if (isPresent(this.asyncValidator)) { if (isPresent(this.asyncValidator)) {
this._status = PENDING; this._status = PENDING;
this._cancelExistingSubscription(); this._cancelExistingSubscription();
var obs = ObservableWrapper.fromPromise(this.asyncValidator(this)); var obs = toObservable(this.asyncValidator(this));
this._asyncValidationSubscription = this._asyncValidationSubscription =
ObservableWrapper.subscribe(obs, res => this.setErrors(res)); ObservableWrapper.subscribe(obs, res => this.setErrors(res, {emitEvent: emitEvent}));
} }
} }
@ -177,10 +184,16 @@ export abstract class AbstractControl {
* expect(login.valid).toEqual(true); * expect(login.valid).toEqual(true);
* ``` * ```
*/ */
setErrors(errors: {[key: string]: any}): void { setErrors(errors: {[key: string]: any}, {emitEvent}: {emitEvent?: boolean} = {}): void {
emitEvent = isPresent(emitEvent) ? emitEvent : true;
this._errors = errors; this._errors = errors;
this._status = this._calculateStatus(); this._status = this._calculateStatus();
if (emitEvent) {
ObservableWrapper.callNext(this._statusChanges, this._status);
}
if (isPresent(this._parent)) { if (isPresent(this._parent)) {
this._parent._updateControlsErrors(); this._parent._updateControlsErrors();
} }
@ -211,6 +224,13 @@ export abstract class AbstractControl {
} }
} }
/** @internal */
_initObservables() {
this._valueChanges = new EventEmitter();
this._statusChanges = new EventEmitter();
}
private _calculateStatus(): string { private _calculateStatus(): string {
if (isPresent(this._errors)) return INVALID; if (isPresent(this._errors)) return INVALID;
if (this._anyControlsHaveStatus(PENDING)) return PENDING; if (this._anyControlsHaveStatus(PENDING)) return PENDING;
@ -250,7 +270,7 @@ export class Control extends AbstractControl {
super(validator, asyncValidator); super(validator, asyncValidator);
this._value = value; this._value = value;
this.updateValueAndValidity({onlySelf: true, emitEvent: false}); this.updateValueAndValidity({onlySelf: true, emitEvent: false});
this._valueChanges = new EventEmitter(); this._initObservables();
} }
/** /**
@ -318,8 +338,7 @@ export class ControlGroup extends AbstractControl {
asyncValidator: Function = null) { asyncValidator: Function = null) {
super(validator, asyncValidator); super(validator, asyncValidator);
this._optionals = isPresent(optionals) ? optionals : {}; this._optionals = isPresent(optionals) ? optionals : {};
this._valueChanges = new EventEmitter(); this._initObservables();
this._setParentForControls(); this._setParentForControls();
this.updateValueAndValidity({onlySelf: true, emitEvent: false}); this.updateValueAndValidity({onlySelf: true, emitEvent: false});
} }
@ -440,9 +459,7 @@ export class ControlArray extends AbstractControl {
constructor(public controls: AbstractControl[], validator: Function = null, constructor(public controls: AbstractControl[], validator: Function = null,
asyncValidator: Function = null) { asyncValidator: Function = null) {
super(validator, asyncValidator); super(validator, asyncValidator);
this._initObservables();
this._valueChanges = new EventEmitter();
this._setParentForControls(); this._setParentForControls();
this.updateValueAndValidity({onlySelf: true, emitEvent: false}); this.updateValueAndValidity({onlySelf: true, emitEvent: false});
} }

View File

@ -1,5 +1,6 @@
import {isBlank, isPresent, CONST_EXPR} from 'angular2/src/core/facade/lang'; import {isBlank, isPresent, CONST_EXPR} from 'angular2/src/core/facade/lang';
import {PromiseWrapper} from 'angular2/src/core/facade/promise'; import {PromiseWrapper} from 'angular2/src/core/facade/promise';
import {ObservableWrapper} from 'angular2/src/core/facade/async';
import {ListWrapper, StringMapWrapper} from 'angular2/src/core/facade/collection'; import {ListWrapper, StringMapWrapper} from 'angular2/src/core/facade/collection';
import {OpaqueToken} from 'angular2/src/core/di'; import {OpaqueToken} from 'angular2/src/core/di';
@ -89,15 +90,20 @@ export class Validators {
static composeAsync(validators: Function[]): Function { static composeAsync(validators: Function[]): Function {
if (isBlank(validators)) return null; if (isBlank(validators)) return null;
var presentValidators = ListWrapper.filter(validators, isPresent); let presentValidators = ListWrapper.filter(validators, isPresent);
if (presentValidators.length == 0) return null; if (presentValidators.length == 0) return null;
return function(control: modelModule.AbstractControl) { return function(control: modelModule.AbstractControl) {
return PromiseWrapper.all(_executeValidators(control, presentValidators)).then(_mergeErrors); let promises = _executeValidators(control, presentValidators).map(_convertToPromise);
return PromiseWrapper.all(promises).then(_mergeErrors);
}; };
} }
} }
function _convertToPromise(obj: any): any {
return PromiseWrapper.isPromise(obj) ? obj : ObservableWrapper.toPromise(obj);
}
function _executeValidators(control: modelModule.AbstractControl, validators: Function[]): any[] { function _executeValidators(control: modelModule.AbstractControl, validators: Function[]): any[] {
return validators.map(v => v(control)); return validators.map(v => v(control));
} }

View File

@ -1,7 +1,7 @@
import {Inject, Injectable, OpaqueToken} from 'angular2/src/core/di'; import {Inject, Injectable, OpaqueToken} from 'angular2/src/core/di';
import {MapWrapper, Map} from 'angular2/src/core/facade/collection';
import {isPresent, isBlank, CONST_EXPR} from 'angular2/src/core/facade/lang'; import {isPresent, isBlank, CONST_EXPR} from 'angular2/src/core/facade/lang';
import {MapWrapper, Map} from 'angular2/src/core/facade/collection';
import * as viewModule from './view'; import * as viewModule from './view';

View File

@ -14,10 +14,9 @@ import {
inject inject
} from 'angular2/testing_internal'; } from 'angular2/testing_internal';
import {ControlGroup, Control, ControlArray, Validators} from 'angular2/core'; import {ControlGroup, Control, ControlArray, Validators} from 'angular2/core';
import {isPresent, CONST_EXPR} from 'angular2/src/core/facade/lang'; import {IS_DART, isPresent, CONST_EXPR} from 'angular2/src/core/facade/lang';
import {PromiseWrapper} from 'angular2/src/core/facade/promise'; import {PromiseWrapper} from 'angular2/src/core/facade/promise';
import {TimerWrapper, ObservableWrapper} from 'angular2/src/core/facade/async'; import {TimerWrapper, ObservableWrapper, EventEmitter} from 'angular2/src/core/facade/async';
import {IS_DART} from 'angular2/src/core/facade/lang';
export function main() { export function main() {
function asyncValidator(expected, timeouts = CONST_EXPR({})) { function asyncValidator(expected, timeouts = CONST_EXPR({})) {
@ -36,6 +35,12 @@ export function main() {
}; };
} }
function asyncValidatorReturningObservable(c) {
var e = new EventEmitter();
PromiseWrapper.scheduleMicrotask(() => ObservableWrapper.callNext(e, {"async": true}));
return e;
}
describe("Form Model", () => { describe("Form Model", () => {
describe("Control", () => { describe("Control", () => {
it("should default the value to null", () => { it("should default the value to null", () => {
@ -70,6 +75,14 @@ export function main() {
expect(c.errors).toEqual({"async": true}); expect(c.errors).toEqual({"async": true});
})); }));
it("should support validators returning observables", fakeAsync(() => {
var c = new Control("value", null, asyncValidatorReturningObservable);
tick();
expect(c.valid).toEqual(false);
expect(c.errors).toEqual({"async": true});
}));
it("should rerun the validator when the value changes", fakeAsync(() => { it("should rerun the validator when the value changes", fakeAsync(() => {
var c = new Control("value", null, asyncValidator("expected")); var c = new Control("value", null, asyncValidator("expected"));
@ -185,7 +198,7 @@ export function main() {
})); }));
}); });
describe("valueChanges", () => { describe("valueChanges & statusChanges", () => {
var c; var c;
beforeEach(() => { c = new Control("old", Validators.required); }); beforeEach(() => { c = new Control("old", Validators.required); });
@ -200,6 +213,45 @@ export function main() {
c.updateValue("new"); c.updateValue("new");
})); }));
it("should fire an event after the status has been updated to invalid", fakeAsync(() => {
ObservableWrapper.subscribe(c.statusChanges, (status) => {
expect(c.status).toEqual('INVALID');
expect(status).toEqual('INVALID');
});
c.updateValue("");
tick();
}));
it("should fire an event after the status has been updated to pending", fakeAsync(() => {
var c = new Control("old", Validators.required, asyncValidator("expected"));
var log = [];
ObservableWrapper.subscribe(c.valueChanges, (value) => log.push(`value: '${value}'`));
ObservableWrapper.subscribe(c.statusChanges,
(status) => log.push(`status: '${status}'`));
c.updateValue("");
tick();
c.updateValue("nonEmpty");
tick();
c.updateValue("expected");
tick();
expect(log).toEqual([
"" + "value: ''",
"status: 'INVALID'",
"value: 'nonEmpty'",
"status: 'PENDING'",
"status: 'INVALID'",
"value: 'expected'",
"status: 'PENDING'",
"status: 'VALID'",
]);
}));
// TODO: remove the if statement after making observable delivery sync // TODO: remove the if statement after making observable delivery sync
if (!IS_DART) { if (!IS_DART) {
it("should update set errors and status before emitting an event", it("should update set errors and status before emitting an event",

View File

@ -13,7 +13,7 @@ import {
} from 'angular2/testing_internal'; } from 'angular2/testing_internal';
import {ControlGroup, Control, Validators, AbstractControl, ControlArray} from 'angular2/core'; import {ControlGroup, Control, Validators, AbstractControl, ControlArray} from 'angular2/core';
import {PromiseWrapper} from 'angular2/src/core/facade/promise'; import {PromiseWrapper} from 'angular2/src/core/facade/promise';
import {TimerWrapper} from 'angular2/src/core/facade/async'; import {EventEmitter, ObservableWrapper, TimerWrapper} from 'angular2/src/core/facade/async';
import {CONST_EXPR} from 'angular2/src/core/facade/lang'; import {CONST_EXPR} from 'angular2/src/core/facade/lang';
export function main() { export function main() {
@ -95,12 +95,19 @@ export function main() {
}); });
describe("composeAsync", () => { describe("composeAsync", () => {
function asyncValidator(expected, response, timeout = 0) { function asyncValidator(expected, response) {
return (c) => { return (c) => {
var completer = PromiseWrapper.completer(); var emitter = new EventEmitter();
var res = c.value != expected ? response : null; var res = c.value != expected ? response : null;
TimerWrapper.setTimeout(() => { completer.resolve(res); }, timeout);
return completer.promise; PromiseWrapper.scheduleMicrotask(() => {
ObservableWrapper.callNext(emitter, res);
// this is required because of a bug in ObservableWrapper
// where callComplete can fire before callNext
// remove this one the bug is fixed
TimerWrapper.setTimeout(() => { ObservableWrapper.callComplete(emitter); }, 0);
});
return emitter;
}; };
} }

View File

@ -65,6 +65,7 @@ var NG_ALL = [
'AbstractControl.asyncValidator=', 'AbstractControl.asyncValidator=',
'AbstractControl.value', 'AbstractControl.value',
'AbstractControl.valueChanges', 'AbstractControl.valueChanges',
'AbstractControl.statusChanges',
'AbstractControlDirective', 'AbstractControlDirective',
'AbstractControlDirective.control', 'AbstractControlDirective.control',
'AbstractControlDirective.dirty', 'AbstractControlDirective.dirty',
@ -307,6 +308,7 @@ var NG_ALL = [
'Control.asyncValidator=', 'Control.asyncValidator=',
'Control.value', 'Control.value',
'Control.valueChanges', 'Control.valueChanges',
'Control.statusChanges',
'Control.setErrors()', 'Control.setErrors()',
'ControlArray', 'ControlArray',
'ControlArray.at()', 'ControlArray.at()',
@ -339,6 +341,7 @@ var NG_ALL = [
'ControlArray.asyncValidator=', 'ControlArray.asyncValidator=',
'ControlArray.value', 'ControlArray.value',
'ControlArray.valueChanges', 'ControlArray.valueChanges',
'ControlArray.statusChanges',
'ControlArray.setErrors()', 'ControlArray.setErrors()',
'ControlContainer', 'ControlContainer',
'ControlContainer.control', 'ControlContainer.control',
@ -385,6 +388,7 @@ var NG_ALL = [
'ControlGroup.asyncValidator=', 'ControlGroup.asyncValidator=',
'ControlGroup.value', 'ControlGroup.value',
'ControlGroup.valueChanges', 'ControlGroup.valueChanges',
'ControlGroup.statusChanges',
'ControlGroup.setErrors()', 'ControlGroup.setErrors()',
'CurrencyPipe', 'CurrencyPipe',
'CurrencyPipe.transform()', 'CurrencyPipe.transform()',