From ded83e589bf72bfd43fdb44c7623a382e13fee19 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 11 Feb 2015 11:10:31 -0800 Subject: [PATCH] feat(forms): add support for validations --- modules/angular2/angular2.js | 1 + modules/angular2/forms.js | 2 + modules/angular2/src/facade/collection.dart | 8 ++ modules/angular2/src/forms/directives.js | 22 +++++- modules/angular2/src/forms/model.js | 65 ++++++++++++++++- .../src/forms/validator_directives.js | 15 ++++ modules/angular2/src/forms/validators.js | 31 ++++++++ .../angular2/test/forms/integration_spec.js | 31 +++++++- modules/angular2/test/forms/model_spec.js | 43 +++++++++++ .../angular2/test/forms/validators_spec.js | 73 +++++++++++++++++++ 10 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 modules/angular2/src/forms/validator_directives.js create mode 100644 modules/angular2/src/forms/validators.js create mode 100644 modules/angular2/test/forms/validators_spec.js diff --git a/modules/angular2/angular2.js b/modules/angular2/angular2.js index 060153e5ca..2764cf0982 100644 --- a/modules/angular2/angular2.js +++ b/modules/angular2/angular2.js @@ -4,3 +4,4 @@ export * from './change_detection'; export * from './core'; export * from './directives'; +export * from './forms'; diff --git a/modules/angular2/forms.js b/modules/angular2/forms.js index 52e9ae5270..5bbf9237bf 100644 --- a/modules/angular2/forms.js +++ b/modules/angular2/forms.js @@ -1,2 +1,4 @@ export * from './src/forms/model'; export * from './src/forms/directives'; +export * from './src/forms/validators'; +export * from './src/forms/validator_directives'; diff --git a/modules/angular2/src/facade/collection.dart b/modules/angular2/src/facade/collection.dart index 629078294e..71cdccb2af 100644 --- a/modules/angular2/src/facade/collection.dart +++ b/modules/angular2/src/facade/collection.dart @@ -67,6 +67,14 @@ class StringMapWrapper { static void forEach(Map m, fn(v, k)) { m.forEach((k, v) => fn(v, k)); } + static HashMap merge(HashMap a, HashMap b) { + var m = {}; + + a.forEach((k, v) => m[k] = v); + b.forEach((k, v) => m[k] = v); + + return m; + } static bool isEmpty(Map m) => m.isEmpty; } diff --git a/modules/angular2/src/forms/directives.js b/modules/angular2/src/forms/directives.js index f5de056df4..fac04a8261 100644 --- a/modules/angular2/src/forms/directives.js +++ b/modules/angular2/src/forms/directives.js @@ -3,6 +3,7 @@ import {DOM} from 'angular2/src/facade/dom'; import {isBlank, isPresent, CONST} from 'angular2/src/facade/lang'; import {StringMapWrapper, ListWrapper} from 'angular2/src/facade/collection'; import {ControlGroup, Control} from './model'; +import * as validators from './validators'; class ControlGroupDirectiveBase { addDirective(directive):void {} @@ -68,16 +69,25 @@ export class ControlDirectiveBase { type:string; valueAccessor:ControlValueAccessor; + validator:Function; + constructor(groupDecorator, el:NgElement) { this._groupDecorator = groupDecorator; this._el = el; + this.validator = validators.nullValidator; } _initialize() { + this._groupDecorator.addDirective(this); + + if (isPresent(this.validator)) { + var c = this._control(); + c.validator = validators.compose([c.validator, this.validator]); + } + if (isBlank(this.valueAccessor)) { this.valueAccessor = controlValueAccessorFor(this.type); } - this._groupDecorator.addDirective(this); this._updateDomValue(); DOM.on(this._el.domElement, "change", (_) => this._updateControlValue()); } @@ -87,7 +97,7 @@ export class ControlDirectiveBase { } _updateControlValue() { - this._control().value = this.valueAccessor.readValue(this._el.domElement); + this._control().updateValue(this.valueAccessor.readValue(this._el.domElement)); } _control() { @@ -205,6 +215,14 @@ export class NewControlGroupDirective extends ControlGroupDirectiveBase { get value() { return this._controlGroup.value; } + + get errors() { + return this._controlGroup.errors; + } + + get valid() { + return this._controlGroup.valid; + } } export var FormDirectives = [ diff --git a/modules/angular2/src/forms/model.js b/modules/angular2/src/forms/model.js index c654834c07..c714f20471 100644 --- a/modules/angular2/src/forms/model.js +++ b/modules/angular2/src/forms/model.js @@ -1,18 +1,56 @@ -import {StringMapWrapper, StringMap} from 'angular2/src/facade/collection'; +import {isPresent} from 'angular2/src/facade/lang'; +import {StringMap, StringMapWrapper} from 'angular2/src/facade/collection'; +import {nullValidator, controlGroupValidator} from './validators'; + +export const VALID = "VALID"; +export const INVALID = "INVALID"; export class Control { value:any; + validator:Function; + status:string; + errors; + _parent:ControlGroup; - constructor(value:any) { + constructor(value:any, validator:Function = nullValidator) { this.value = value; + this.validator = validator; + this._updateStatus(); + } + + updateValue(value:any) { + this.value = value; + this._updateStatus(); + this._updateParent(); + } + + get valid() { + return this.status === VALID; + } + + _updateStatus() { + this.errors = this.validator(this); + this.status = isPresent(this.errors) ? INVALID : VALID; + } + + _updateParent() { + if (isPresent(this._parent)){ + this._parent._controlChanged(); + } } } export class ControlGroup { - controls: StringMap; + controls; + validator:Function; + status:string; + errors; - constructor(controls:StringMap) { + constructor(controls, validator:Function = controlGroupValidator) { this.controls = controls; + this.validator = validator; + this._setParentForControls(); + this._updateStatus(); } get value() { @@ -22,4 +60,23 @@ export class ControlGroup { }); return res; } + + get valid() { + return this.status === VALID; + } + + _setParentForControls() { + StringMapWrapper.forEach(this.controls, (control, name) => { + control._parent = this; + }); + } + + _updateStatus() { + this.errors = this.validator(this); + this.status = isPresent(this.errors) ? INVALID : VALID; + } + + _controlChanged() { + this._updateStatus(); + } } diff --git a/modules/angular2/src/forms/validator_directives.js b/modules/angular2/src/forms/validator_directives.js new file mode 100644 index 0000000000..28a7d0d1fa --- /dev/null +++ b/modules/angular2/src/forms/validator_directives.js @@ -0,0 +1,15 @@ +import {isBlank, isPresent} from 'angular2/src/facade/lang'; +import {List, ListWrapper, StringMapWrapper} from 'angular2/src/facade/collection'; +import {Decorator} from 'angular2/core'; + +import {ControlGroup, Control, ControlDirective} from 'angular2/forms'; +import * as validators from 'angular2/forms'; + +@Decorator({ + selector: '[required]' +}) +export class RequiredValidatorDirective { + constructor(c:ControlDirective) { + c.validator = validators.compose([c.validator, validators.required]); + } +} \ No newline at end of file diff --git a/modules/angular2/src/forms/validators.js b/modules/angular2/src/forms/validators.js new file mode 100644 index 0000000000..ec43010b0e --- /dev/null +++ b/modules/angular2/src/forms/validators.js @@ -0,0 +1,31 @@ +import {isBlank, isPresent} from 'angular2/src/facade/lang'; +import {List, ListWrapper, StringMapWrapper} from 'angular2/src/facade/collection'; + +import {ControlGroup, Control} from 'angular2/forms'; + +export function required(c:Control) { + return isBlank(c.value) || c.value === "" ? {"required" : true} : null; +} + +export function nullValidator(c:Control) { + return null; +} + +export function compose(validators:List):Function { + return function(c:Control) { + return ListWrapper.reduce(validators, (res, validator) => { + var errors = validator(c); + return isPresent(errors) ? StringMapWrapper.merge(res, errors) : res; + }, {}); + } +} + +export function controlGroupValidator(c:ControlGroup) { + var res = {}; + StringMapWrapper.forEach(c.controls, (control, name) => { + if (isPresent(control.errors)) { + res[name] = control.errors; + } + }); + return StringMapWrapper.isEmpty(res) ? null : res; +} diff --git a/modules/angular2/test/forms/integration_spec.js b/modules/angular2/test/forms/integration_spec.js index 7f4ad6743d..a6a26b5042 100644 --- a/modules/angular2/test/forms/integration_spec.js +++ b/modules/angular2/test/forms/integration_spec.js @@ -17,7 +17,8 @@ import {Injector} from 'angular2/di'; import {Component, Decorator, Template} from 'angular2/core'; import {ControlGroupDirective, ControlNameDirective, ControlDirective, NewControlGroupDirective, - Control, ControlGroup, ControlValueAccessor} from 'angular2/forms'; + Control, ControlGroup, ControlValueAccessor, + RequiredValidatorDirective} from 'angular2/forms'; export function main() { function detectChanges(view) { @@ -210,11 +211,37 @@ export function main() { }); }); + it("should support validators",(done) => { + var t = `
+ +
`; + + compile(MyComp, t, new MyComp(), (view) => { + var form = view.contextWithLocals.get("form"); + expect(form.valid).toEqual(true); + + var input = queryView(view, "input"); + + input.value = ""; + dispatchEvent(input, "change"); + + expect(form.valid).toEqual(false); + done(); + }); + }); }); }); } -@Component({selector: "my-comp"}) +@Component({ + selector: "my-comp" +}) +@Template({ + inline: "", + directives: [ControlGroupDirective, ControlNameDirective, + ControlDirective, NewControlGroupDirective, RequiredValidatorDirective, + WrappedValue] +}) class MyComp { form:ControlGroup; name:string; diff --git a/modules/angular2/test/forms/model_spec.js b/modules/angular2/test/forms/model_spec.js index d0d858704b..700b50a23a 100644 --- a/modules/angular2/test/forms/model_spec.js +++ b/modules/angular2/test/forms/model_spec.js @@ -1,7 +1,28 @@ import {ddescribe, describe, it, iit, xit, expect, beforeEach, afterEach, el} from 'angular2/test_lib'; import {ControlGroup, Control} from 'angular2/forms'; +import * as validations from 'angular2/forms'; export function main() { + describe("Control", () => { + describe("validator", () => { + it("should run validator with the initial value", () => { + var c = new Control("value", validations.required); + expect(c.valid).toEqual(true); + }); + + it("should rerun the validator when the value changes", () => { + var c = new Control("value", validations.required); + c.updateValue(null); + expect(c.valid).toEqual(false); + }); + + it("should return errors", () => { + var c = new Control(null, validations.required); + expect(c.errors).toEqual({"required" : true}); + }); + }); + }); + describe("ControlGroup", () => { describe("value", () => { it("should be the reduced value of the child controls", () => { @@ -17,5 +38,27 @@ export function main() { expect(g.value).toEqual({}) }); }); + + describe("validator", () => { + it("should run the validator with the initial value", () => { + var g = new ControlGroup({ + "one": new Control(null, validations.required) + }); + + expect(g.valid).toEqual(false); + + expect(g.errors).toEqual({"one": {"required" : true}}); + }); + + it("should run the validator with the value changes", () => { + var c = new Control(null, validations.required); + var g = new ControlGroup({"one": c}); + + c.updateValue("some value"); + + expect(g.valid).toEqual(true); + expect(g.errors).toEqual(null); + }); + }); }); } \ No newline at end of file diff --git a/modules/angular2/test/forms/validators_spec.js b/modules/angular2/test/forms/validators_spec.js new file mode 100644 index 0000000000..db3e8d7bcd --- /dev/null +++ b/modules/angular2/test/forms/validators_spec.js @@ -0,0 +1,73 @@ +import {ddescribe, describe, it, iit, xit, expect, beforeEach, afterEach, el} from 'angular2/test_lib'; +import {ControlGroup, Control, required, compose, controlGroupValidator} from 'angular2/forms'; + +export function main() { + function validator(key:string, error:any){ + return function(c:Control) { + var r = {}; + r[key] = error; + return r; + } + } + + describe("Validators", () => { + describe("required", () => { + it("should error on an empty string", () => { + expect(required(new Control(""))).toEqual({"required" : true}); + }); + + it("should error on null", () => { + expect(required(new Control(null))).toEqual({"required" : true}); + }); + + it("should not error on a non-empty string", () => { + expect(required(new Control("not empty"))).toEqual(null); + }); + }); + + describe("compose", () => { + it("should collect errors from all the validators", () => { + var c = compose([validator("a", true), validator("b", true)]); + expect(c(new Control(""))).toEqual({"a" : true, "b" : true}); + }); + + it("should run validators left to right", () => { + var c = compose([validator("a", 1), validator("a", 2)]); + expect(c(new Control(""))).toEqual({"a" : 2}); + }); + }); + + describe("controlGroupValidator", () => { + it("should collect errors from the child controls", () => { + var g = new ControlGroup({ + "one" : new Control("one", validator("a", true)), + "two" : new Control("two", validator("b", true)) + }); + + expect(controlGroupValidator(g)).toEqual({ + "one" : {"a" : true}, + "two" : {"b" : true} + }); + }); + + it("should not include keys for controls that have no errors", () => { + var g = new ControlGroup({ + "one" : new Control("one", validator("a", true)), + "two" : new Control("one") + }); + + expect(controlGroupValidator(g)).toEqual({ + "one" : {"a" : true} + }); + }); + + it("should return null when no errors", () => { + var g = new ControlGroup({ + "one" : new Control("one") + }); + + expect(controlGroupValidator(g)).toEqual(null); + }); + }); + }); +} \ No newline at end of file