diff --git a/modules/angular2/src/core/facade/promise.dart b/modules/angular2/src/core/facade/promise.dart index 53920b2e17..f8a6c3f617 100644 --- a/modules/angular2/src/core/facade/promise.dart +++ b/modules/angular2/src/core/facade/promise.dart @@ -1,6 +1,7 @@ library angular2.core.facade.promise; import 'dart:async'; +import 'dart:async' as async; export 'dart:async' show Future; class PromiseWrapper { @@ -29,6 +30,10 @@ class PromiseWrapper { return promise.catchError(onError); } + static void scheduleMicrotask(fn) { + async.scheduleMicrotask(fn); + } + static PromiseCompleter completer() => new PromiseCompleter(new Completer()); } diff --git a/modules/angular2/src/core/facade/promise.ts b/modules/angular2/src/core/facade/promise.ts index 1fcd901b8d..27bbc41a7f 100644 --- a/modules/angular2/src/core/facade/promise.ts +++ b/modules/angular2/src/core/facade/promise.ts @@ -40,6 +40,10 @@ export class PromiseWrapper { }); } + static scheduleMicrotask(computation: () => any): void { + PromiseWrapper.then(PromiseWrapper.resolve(null), computation, (_) => {}); + } + static completer(): PromiseCompleter { var resolve; var reject; diff --git a/modules/angular2/src/core/forms/directives/ng_form.ts b/modules/angular2/src/core/forms/directives/ng_form.ts index 3ba5d5a74a..3c23e3d7d4 100644 --- a/modules/angular2/src/core/forms/directives/ng_form.ts +++ b/modules/angular2/src/core/forms/directives/ng_form.ts @@ -105,7 +105,7 @@ export class NgForm extends ControlContainer implements Form { get controls(): {[key: string]: AbstractControl} { return this.form.controls; } addControl(dir: NgControl): void { - this._later(_ => { + PromiseWrapper.scheduleMicrotask(() => { var container = this._findContainer(dir.path); var ctrl = new Control(); setUpControl(ctrl, dir); @@ -117,7 +117,7 @@ export class NgForm extends ControlContainer implements Form { getControl(dir: NgControl): Control { return this.form.find(dir.path); } removeControl(dir: NgControl): void { - this._later(_ => { + PromiseWrapper.scheduleMicrotask(() => { var container = this._findContainer(dir.path); if (isPresent(container)) { container.removeControl(dir.name); @@ -127,7 +127,7 @@ export class NgForm extends ControlContainer implements Form { } addControlGroup(dir: NgControlGroup): void { - this._later(_ => { + PromiseWrapper.scheduleMicrotask(() => { var container = this._findContainer(dir.path); var group = new ControlGroup({}); setUpControlGroup(group, dir); @@ -137,7 +137,7 @@ export class NgForm extends ControlContainer implements Form { } removeControlGroup(dir: NgControlGroup): void { - this._later(_ => { + PromiseWrapper.scheduleMicrotask(() => { var container = this._findContainer(dir.path); if (isPresent(container)) { container.removeControl(dir.name); @@ -151,7 +151,7 @@ export class NgForm extends ControlContainer implements Form { } updateModel(dir: NgControl, value: any): void { - this._later(_ => { + PromiseWrapper.scheduleMicrotask(() => { var ctrl = this.form.find(dir.path); ctrl.updateValue(value); }); @@ -167,7 +167,4 @@ export class NgForm extends ControlContainer implements Form { path.pop(); return ListWrapper.isEmpty(path) ? this.form : this.form.find(path); } - - /** @internal */ - _later(fn): void { PromiseWrapper.then(PromiseWrapper.resolve(null), fn, (_) => {}); } } diff --git a/modules/angular2/src/core/forms/model.ts b/modules/angular2/src/core/forms/model.ts index b6ea86531c..d952ff73ef 100644 --- a/modules/angular2/src/core/forms/model.ts +++ b/modules/angular2/src/core/forms/model.ts @@ -59,8 +59,9 @@ export abstract class AbstractControl { private _pristine: boolean = true; private _touched: boolean = false; private _parent: ControlGroup | ControlArray; + private _asyncValidationSubscription; - constructor(public validator: Function) {} + constructor(public validator: Function, public asyncValidator: Function) {} get value(): any { return this._value; } @@ -119,10 +120,14 @@ export abstract class AbstractControl { this._updateValue(); - this._errors = this.validator(this); + this._errors = this._runValidator(); this._controlsErrors = this._calculateControlsErrors(); this._status = this._calculateStatus(); + if (this._status == VALID || this._status == PENDING) { + this._runAsyncValidator(); + } + if (emitEvent) { ObservableWrapper.callNext(this._valueChanges, this._value); } @@ -132,6 +137,23 @@ export abstract class AbstractControl { } } + private _runValidator() { return isPresent(this.validator) ? this.validator(this) : null; } + + private _runAsyncValidator() { + if (isPresent(this.asyncValidator)) { + this._status = PENDING; + this._cancelExistingSubscription(); + this._asyncValidationSubscription = + ObservableWrapper.subscribe(this.asyncValidator(this), res => this.setErrors(res)); + } + } + + private _cancelExistingSubscription(): void { + if (isPresent(this._asyncValidationSubscription)) { + ObservableWrapper.dispose(this._asyncValidationSubscription); + } + } + /** * Sets errors on a control. * @@ -190,13 +212,18 @@ export abstract class AbstractControl { } private _calculateStatus(): string { - return isPresent(this._errors) || isPresent(this._controlsErrors) ? INVALID : VALID; + if (isPresent(this._errors)) return INVALID; + if (this._anyControlsHaveStatus(PENDING)) return PENDING; + if (this._anyControlsHaveStatus(INVALID)) return INVALID; + return VALID; } /** @internal */ abstract _updateValue(): void; /** @internal */ abstract _calculateControlsErrors(): any; + /** @internal */ + abstract _anyControlsHaveStatus(status: string): boolean; } /** @@ -219,8 +246,8 @@ export class Control extends AbstractControl { /** @internal */ _onChange: Function; - constructor(value: any = null, validator: Function = Validators.nullValidator) { - super(validator); + constructor(value: any = null, validator: Function = null, asyncValidator: Function = null) { + super(validator, asyncValidator); this._value = value; this.updateValueAndValidity({onlySelf: true, emitEvent: false}); this._valueChanges = new EventEmitter(); @@ -259,6 +286,11 @@ export class Control extends AbstractControl { */ _calculateControlsErrors() { return null; } + /** + * @internal + */ + _anyControlsHaveStatus(status: string): boolean { return false; } + /** * Register a listener for change events. */ @@ -282,9 +314,9 @@ export class ControlGroup extends AbstractControl { private _optionals: {[key: string]: boolean}; constructor(public controls: {[key: string]: AbstractControl}, - optionals: {[key: string]: boolean} = null, - validator: Function = Validators.nullValidator) { - super(validator); + optionals: {[key: string]: boolean} = null, validator: Function = null, + asyncValidator: Function = null) { + super(validator, asyncValidator); this._optionals = isPresent(optionals) ? optionals : {}; this._valueChanges = new EventEmitter(); @@ -348,6 +380,15 @@ export class ControlGroup extends AbstractControl { return StringMapWrapper.isEmpty(res) ? null : res; } + /** @internal */ + _anyControlsHaveStatus(status: string): boolean { + var res = false; + StringMapWrapper.forEach(this.controls, (control, name) => { + res = res || (this.contains(name) && control.status == status); + }); + return res; + } + /** @internal */ _reduceValue() { return this._reduceChildren({}, (acc, control, name) => { @@ -396,8 +437,9 @@ export class ControlGroup extends AbstractControl { * ### Example ([live demo](http://plnkr.co/edit/23DESOpbNnBpBHZt1BR4?p=preview)) */ export class ControlArray extends AbstractControl { - constructor(public controls: AbstractControl[], validator: Function = Validators.nullValidator) { - super(validator); + constructor(public controls: AbstractControl[], validator: Function = null, + asyncValidator: Function = null) { + super(validator, asyncValidator); this._valueChanges = new EventEmitter(); @@ -457,6 +499,12 @@ export class ControlArray extends AbstractControl { return anyErrors ? res : null; } + /** @internal */ + _anyControlsHaveStatus(status: string): boolean { + return ListWrapper.any(this.controls, c => c.status == status); + } + + /** @internal */ _setParentForControls(): void { this.controls.forEach((control) => { control.setParent(this); }); diff --git a/modules/angular2/test/core/forms/form_builder_spec.ts b/modules/angular2/test/core/forms/form_builder_spec.ts index 5e061fa7c1..d9874a2cf2 100644 --- a/modules/angular2/test/core/forms/form_builder_spec.ts +++ b/modules/angular2/test/core/forms/form_builder_spec.ts @@ -50,12 +50,6 @@ export function main() { expect(g.validator).toBe(Validators.nullValidator); }); - it("should use default validators when no validators are provided", () => { - var g = b.group({"login": "some value"}); - expect(g.controls["login"].validator).toBe(Validators.nullValidator); - expect(g.validator).toBe(Validators.nullValidator); - }); - it("should create control arrays", () => { var c = b.control("three"); var a = b.array(["one", ["two", Validators.required], c, b.array(['four'])]); diff --git a/modules/angular2/test/core/forms/integration_spec.ts b/modules/angular2/test/core/forms/integration_spec.ts index 533fa3a3af..fa5efb136e 100644 --- a/modules/angular2/test/core/forms/integration_spec.ts +++ b/modules/angular2/test/core/forms/integration_spec.ts @@ -38,6 +38,7 @@ import {By} from 'angular2/src/core/debug'; import {ListWrapper} from 'angular2/src/core/facade/collection'; import {ObservableWrapper} from 'angular2/src/core/facade/async'; import {CONST_EXPR} from 'angular2/src/core/facade/lang'; +import {PromiseWrapper} from "angular2/src/core/facade/promise"; export function main() { describe("integration tests", () => { @@ -445,7 +446,7 @@ export function main() { }); })); - it("should use validators defined in the model", + it("should use sync validators defined in the model", inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => { var form = new ControlGroup({"login": new Control("aa", Validators.required)}); @@ -467,6 +468,41 @@ export function main() { async.done(); }); })); + + it("should use async validators defined in the model", + inject([TestComponentBuilder], fakeAsync((tcb: TestComponentBuilder) => { + var control = + new Control("", Validators.required, uniqLoginAsyncValidator("expected")); + var form = new ControlGroup({"login": control}); + + var t = `
+ +
`; + + var rootTC; + tcb.overrideTemplate(MyComp, t).createAsync(MyComp).then((root) => rootTC = root); + tick(); + + rootTC.debugElement.componentInstance.form = form; + rootTC.detectChanges(); + + expect(form.hasError("required", ["login"])).toEqual(true); + + var input = rootTC.debugElement.query(By.css("input")); + input.nativeElement.value = "wrong value"; + dispatchEvent(input.nativeElement, "change"); + + expect(form.pending).toEqual(true); + tick(); + + expect(form.hasError("uniqLogin", ["login"])).toEqual(true); + + input.nativeElement.value = "expected"; + dispatchEvent(input.nativeElement, "change"); + tick(); + + expect(form.valid).toEqual(true); + }))); }); describe("nested forms", () => { @@ -923,6 +959,15 @@ class MyInput implements ControlValueAccessor { } } +function uniqLoginAsyncValidator(expectedValue: string) { + return (c) => { + var e = new EventEmitter(); + var res = (c.value == expectedValue) ? null : {"uniqLogin": true}; + PromiseWrapper.scheduleMicrotask(() => ObservableWrapper.callNext(e, res)); + return e; + }; +} + function loginIsEmptyGroupValidator(c: ControlGroup) { return c.controls["login"].value == "" ? {"loginIsEmpty": true} : null; } diff --git a/modules/angular2/test/core/forms/model_spec.ts b/modules/angular2/test/core/forms/model_spec.ts index dee17c62fc..a070e8b4b4 100644 --- a/modules/angular2/test/core/forms/model_spec.ts +++ b/modules/angular2/test/core/forms/model_spec.ts @@ -14,16 +14,32 @@ import { inject } from 'angular2/testing_internal'; import {ControlGroup, Control, ControlArray, Validators} from 'angular2/core'; -import {ObservableWrapper} from 'angular2/src/core/facade/async'; +import {isPresent, CONST_EXPR} from 'angular2/src/core/facade/lang'; +import {EventEmitter, TimerWrapper, ObservableWrapper} from 'angular2/src/core/facade/async'; import {IS_DART} from '../../platform'; +import {PromiseWrapper} from "angular2/src/core/facade/promise"; export function main() { + function asyncValidator(expected, timeouts = CONST_EXPR({})) { + return (c) => { + var e = new EventEmitter(); + var t = isPresent(timeouts[c.value]) ? timeouts[c.value] : 0; + var res = c.value != expected ? {"async": true} : null; + + if (t == 0) { + PromiseWrapper.scheduleMicrotask(() => { ObservableWrapper.callNext(e, res); }); + } else { + TimerWrapper.setTimeout(() => { ObservableWrapper.callNext(e, res); }, t); + } + return e; + }; + } + describe("Form Model", () => { describe("Control", () => { it("should default the value to null", () => { var c = new Control(); expect(c.value).toBe(null); - expect(c.validator).toBe(Validators.nullValidator); }); describe("validator", () => { @@ -44,6 +60,60 @@ export function main() { }); }); + describe("asyncValidator", () => { + it("should run validator with the initial value", fakeAsync(() => { + var c = new Control("value", null, asyncValidator("expected")); + tick(); + + expect(c.valid).toEqual(false); + expect(c.errors).toEqual({"async": true}); + })); + + it("should rerun the validator when the value changes", fakeAsync(() => { + var c = new Control("value", null, asyncValidator("expected")); + + c.updateValue("expected"); + tick(); + + expect(c.valid).toEqual(true); + })); + + it("should run the async validator only when the sync validator passes", fakeAsync(() => { + var c = new Control("", Validators.required, asyncValidator("expected")); + tick(); + + expect(c.errors).toEqual({"required": true}); + + c.updateValue("some value"); + tick(); + + expect(c.errors).toEqual({"async": true}); + })); + + it("should mark the control as pending while running the async validation", + fakeAsync(() => { + var c = new Control("", null, asyncValidator("expected")); + + expect(c.pending).toEqual(true); + + tick(); + + expect(c.pending).toEqual(false); + })); + + it("should only use the latest async validation run", fakeAsync(() => { + var c = + new Control("", null, asyncValidator("expected", {"long": 200, "expected": 100})); + + c.updateValue("long"); + c.updateValue("expected"); + + tick(300); + + expect(c.valid).toEqual(true); + })); + }); + describe("dirty", () => { it("should be false after creating a control", () => { var c = new Control("value"); @@ -154,7 +224,7 @@ export function main() { describe("setErrors", () => { it("should set errors on a control", () => { - var c = new Control("someValue", Validators.nullValidator); + var c = new Control("someValue"); c.setErrors({"someError": true}); @@ -456,6 +526,42 @@ export function main() { expect(g.getError("required", ["invalid"])).toEqual(null); }); }); + + describe("asyncValidator", () => { + it("should run the async validator", fakeAsync(() => { + var c = new Control("value"); + var g = new ControlGroup({"one": c}, null, null, asyncValidator("expected")); + + expect(g.pending).toEqual(true); + + tick(1); + + expect(g.errors).toEqual({"async": true}); + expect(g.pending).toEqual(false); + })); + + it("should set the parent group's status to pending", fakeAsync(() => { + var c = new Control("value", null, asyncValidator("expected")); + var g = new ControlGroup({"one": c}); + + expect(g.pending).toEqual(true); + + tick(1); + + expect(g.pending).toEqual(false); + })); + + it("should run the parent group's async validator when children are pending", + fakeAsync(() => { + var c = new Control("value", null, asyncValidator("expected")); + var g = new ControlGroup({"one": c}, null, null, asyncValidator("expected")); + + tick(1); + + expect(g.errors).toEqual({"async": true}); + expect(g.find(["one"]).errors).toEqual({"async": true}); + })); + }) }); describe("ControlArray", () => { @@ -697,6 +803,20 @@ export function main() { expect(g.find(["array", 0]).value).toEqual("111"); }); }); + + describe("asyncValidator", () => { + it("should run the async validator", fakeAsync(() => { + var c = new Control("value"); + var g = new ControlArray([c], null, asyncValidator("expected")); + + expect(g.pending).toEqual(true); + + tick(1); + + expect(g.errors).toEqual({"async": true}); + expect(g.pending).toEqual(false); + })); + }) }); }); } diff --git a/modules/angular2/test/public_api_spec.ts b/modules/angular2/test/public_api_spec.ts index 58f0062369..703b47fbf9 100644 --- a/modules/angular2/test/public_api_spec.ts +++ b/modules/angular2/test/public_api_spec.ts @@ -61,6 +61,8 @@ var NG_API = [ 'AbstractControl.valid', 'AbstractControl.validator', 'AbstractControl.validator=', + 'AbstractControl.asyncValidator', + 'AbstractControl.asyncValidator=', 'AbstractControl.value', 'AbstractControl.valueChanges', 'AbstractControlDirective', @@ -299,6 +301,8 @@ var NG_API = [ 'Control.valid', 'Control.validator', 'Control.validator=', + 'Control.asyncValidator', + 'Control.asyncValidator=', 'Control.value', 'Control.valueChanges', 'Control.setErrors()', @@ -329,6 +333,8 @@ var NG_API = [ 'ControlArray.valid', 'ControlArray.validator', 'ControlArray.validator=', + 'ControlArray.asyncValidator', + 'ControlArray.asyncValidator=', 'ControlArray.value', 'ControlArray.valueChanges', 'ControlArray.setErrors()', @@ -373,6 +379,8 @@ var NG_API = [ 'ControlGroup.valid', 'ControlGroup.validator', 'ControlGroup.validator=', + 'ControlGroup.asyncValidator', + 'ControlGroup.asyncValidator=', 'ControlGroup.value', 'ControlGroup.valueChanges', 'ControlGroup.setErrors()',