From 559f54e92b6bec612c203809968ebb83f060314e Mon Sep 17 00:00:00 2001 From: vsavkin Date: Sun, 31 May 2015 12:24:34 -0700 Subject: [PATCH] feat(forms): added ng-model --- modules/angular2/src/forms/directives.ts | 3 + .../directives/checkbox_value_accessor.ts | 3 +- .../directives/control_container_directive.ts | 5 + .../src/forms/directives/control_directive.ts | 7 ++ .../directives/control_group_directive.ts | 16 ++- .../directives/control_name_directive.ts | 49 ++++++-- .../directives/default_value_accessor.ts | 7 +- .../directives/form_control_directive.ts | 50 +++++++- .../src/forms/directives/form_directive.ts | 1 + .../forms/directives/form_model_directive.ts | 49 +++++++- .../forms/directives/ng_model_directive.ts | 51 ++++++++ .../select_control_value_accessor.ts | 2 +- .../angular2/src/forms/directives/shared.ts | 5 +- .../template_driven_form_directive.ts | 8 ++ .../angular2/test/forms/integration_spec.ts | 111 +++++++++++++++++- 15 files changed, 338 insertions(+), 29 deletions(-) create mode 100644 modules/angular2/src/forms/directives/ng_model_directive.ts diff --git a/modules/angular2/src/forms/directives.ts b/modules/angular2/src/forms/directives.ts index 35215004f6..f19b94c0a7 100644 --- a/modules/angular2/src/forms/directives.ts +++ b/modules/angular2/src/forms/directives.ts @@ -1,6 +1,7 @@ import {Type, CONST_EXPR} from 'angular2/src/facade/lang'; import {ControlNameDirective} from './directives/control_name_directive'; import {FormControlDirective} from './directives/form_control_directive'; +import {NgModelDirective} from './directives/ng_model_directive'; import {ControlGroupDirective} from './directives/control_group_directive'; import {FormModelDirective} from './directives/form_model_directive'; import {TemplateDrivenFormDirective} from './directives/template_driven_form_directive'; @@ -10,6 +11,7 @@ import {SelectControlValueAccessor} from './directives/select_control_value_acce export {ControlNameDirective} from './directives/control_name_directive'; export {FormControlDirective} from './directives/form_control_directive'; +export {NgModelDirective} from './directives/ng_model_directive'; export {ControlDirective} from './directives/control_directive'; export {ControlGroupDirective} from './directives/control_group_directive'; export {FormModelDirective} from './directives/form_model_directive'; @@ -32,6 +34,7 @@ export const formDirectives: List = CONST_EXPR([ ControlGroupDirective, FormControlDirective, + NgModelDirective, FormModelDirective, TemplateDrivenFormDirective, diff --git a/modules/angular2/src/forms/directives/checkbox_value_accessor.ts b/modules/angular2/src/forms/directives/checkbox_value_accessor.ts index fbf3a0fc4f..6a97cd235a 100644 --- a/modules/angular2/src/forms/directives/checkbox_value_accessor.ts +++ b/modules/angular2/src/forms/directives/checkbox_value_accessor.ts @@ -15,7 +15,8 @@ import {ControlValueAccessor} from './control_value_accessor'; * @exportedAs angular2/forms */ @Directive({ - selector: 'input[type=checkbox][control],input[type=checkbox][form-control]', + selector: + 'input[type=checkbox][control],input[type=checkbox][form-control],input[type=checkbox][ng-model]', hostListeners: {'change': 'onChange($event.target.checked)'}, hostProperties: {'checked': 'checked'} }) diff --git a/modules/angular2/src/forms/directives/control_container_directive.ts b/modules/angular2/src/forms/directives/control_container_directive.ts index b58dee301f..c4e062a185 100644 --- a/modules/angular2/src/forms/directives/control_container_directive.ts +++ b/modules/angular2/src/forms/directives/control_container_directive.ts @@ -1,6 +1,11 @@ import {FormDirective} from './form_directive'; import {List} from 'angular2/src/facade/collection'; +/** + * A directive that contains a group of [ControlDirective]. + * + * @exportedAs angular2/forms + */ export class ControlContainerDirective { name: string; get formDirective(): FormDirective { return null; } diff --git a/modules/angular2/src/forms/directives/control_directive.ts b/modules/angular2/src/forms/directives/control_directive.ts index 54ebd29c99..97aa4caab2 100644 --- a/modules/angular2/src/forms/directives/control_directive.ts +++ b/modules/angular2/src/forms/directives/control_directive.ts @@ -1,6 +1,11 @@ import {ControlValueAccessor} from './control_value_accessor'; import {Validators} from '../validators'; +/** + * A directive that bind a [Control] object to a DOM element. + * + * @exportedAs angular2/forms + */ export class ControlDirective { name: string = null; valueAccessor: ControlValueAccessor = null; @@ -8,4 +13,6 @@ export class ControlDirective { get path(): List { return null; } constructor() { this.validator = Validators.nullValidator; } + + viewToModelUpdate(newValue: any): void {} } diff --git a/modules/angular2/src/forms/directives/control_group_directive.ts b/modules/angular2/src/forms/directives/control_group_directive.ts index da3a30807e..99cf549d4c 100644 --- a/modules/angular2/src/forms/directives/control_group_directive.ts +++ b/modules/angular2/src/forms/directives/control_group_directive.ts @@ -14,9 +14,8 @@ const controlGroupBinding = CONST_EXPR( * * # Example * - * In this example, we bind the control group to the form element, and we bind the login and - * password controls to the - * login and password elements. + * In this example, we create a control group, and we bind the login and + * password controls to the login and password elements. * * Here we use {@link formDirectives}, rather than importing each form directive individually, e.g. * `ControlDirective`, `ControlGroupDirective`. This is just a shorthand for the same end result. @@ -25,10 +24,13 @@ const controlGroupBinding = CONST_EXPR( * @Component({selector: "login-comp"}) * @View({ * directives: [formDirectives], - * template: "
" + + * template: + * "" + + * "
* "Login " + * "Password " + * "" + + * "
" * "
" * }) * class LoginComp { @@ -36,8 +38,10 @@ const controlGroupBinding = CONST_EXPR( * * constructor() { * this.loginForm = new ControlGroup({ - * login: new Control(""), - * password: new Control("") + * credentials: new ControlGroup({ + * login: new Control(""), + * password: new Control("") + * }) * }); * } * diff --git a/modules/angular2/src/forms/directives/control_name_directive.ts b/modules/angular2/src/forms/directives/control_name_directive.ts index 3a2b3f5999..d6868b5421 100644 --- a/modules/angular2/src/forms/directives/control_name_directive.ts +++ b/modules/angular2/src/forms/directives/control_name_directive.ts @@ -1,6 +1,7 @@ import {CONST_EXPR} from 'angular2/src/facade/lang'; -import {List} from 'angular2/src/facade/collection'; -import {Directive, Ancestor, onDestroy, onInit} from 'angular2/angular2'; +import {EventEmitter, ObservableWrapper} from 'angular2/src/facade/async'; +import {List, StringMapWrapper, StringMap} from 'angular2/src/facade/collection'; +import {Directive, Ancestor, onDestroy, onChange} from 'angular2/angular2'; import {FORWARD_REF, Binding, Inject} from 'angular2/di'; import {ControlContainerDirective} from './control_container_directive'; @@ -11,11 +12,12 @@ const controlNameBinding = CONST_EXPR(new Binding(ControlDirective, {toAlias: FORWARD_REF(() => ControlNameDirective)})); /** - * Binds a control to a DOM element. + * Binds a control with the specified name to a DOM element. * * # Example * - * In this example, we bind the control to an input element. When the value of the input element + * In this example, we bind the login control to an input element. When the value of the input + * element * changes, the value of * the control will reflect that change. Likewise, if the value of the control changes, the input * element reflects that @@ -28,13 +30,23 @@ const controlNameBinding = * @Component({selector: "login-comp"}) * @View({ * directives: [formDirectives], - * template: "" + * template: + * "
" + + * "Login " + + * "" + + * "
" * }) * class LoginComp { - * loginControl:Control; + * loginForm:ControlGroup; * * constructor() { - * this.loginControl = new Control(''); + * this.loginForm = new ControlGroup({ + * login: new Control(""), + * }); + * } + * + * onLogin() { + * // this.loginForm.value * } * } * @@ -45,20 +57,37 @@ const controlNameBinding = @Directive({ selector: '[control]', hostInjector: [controlNameBinding], - properties: ['name: control'], - lifecycle: [onDestroy, onInit] + properties: ['name: control', 'model: ng-model'], + events: ['ngModel'], + lifecycle: [onDestroy, onChange] }) export class ControlNameDirective extends ControlDirective { _parent: ControlContainerDirective; + ngModel: EventEmitter; + model: any; + _added: boolean; + constructor(@Ancestor() _parent: ControlContainerDirective) { super(); this._parent = _parent; + this.ngModel = new EventEmitter(); + this._added = false; } - onInit() { this.formDirective.addControl(this); } + onChange(c: StringMap) { + if (!this._added) { + this.formDirective.addControl(this); + this._added = true; + } + if (StringMapWrapper.contains(c, "model")) { + this.formDirective.updateModel(this, this.model); + } + } onDestroy() { this.formDirective.removeControl(this); } + viewToModelUpdate(newValue: any): void { ObservableWrapper.callNext(this.ngModel, newValue); } + get path(): List { return controlPath(this.name, this._parent); } get formDirective(): any { return this._parent.formDirective; } diff --git a/modules/angular2/src/forms/directives/default_value_accessor.ts b/modules/angular2/src/forms/directives/default_value_accessor.ts index 7d9476d8d3..30dba86228 100644 --- a/modules/angular2/src/forms/directives/default_value_accessor.ts +++ b/modules/angular2/src/forms/directives/default_value_accessor.ts @@ -11,14 +11,15 @@ import {ControlValueAccessor} from './control_value_accessor'; * * # Example * ``` - * + * * ``` * * @exportedAs angular2/forms */ @Directive({ - selector: - 'input:not([type=checkbox])[control],textarea[control],input:not([type=checkbox])[form-control],textarea[form-control]', + selector: 'input:not([type=checkbox])[control],textarea[control],' + + 'input:not([type=checkbox])[form-control],textarea[form-control],' + + 'input:not([type=checkbox])[ng-model],textarea[ng-model]', hostListeners: {'change': 'onChange($event.target.value)', 'input': 'onChange($event.target.value)'}, hostProperties: {'value': 'value'} diff --git a/modules/angular2/src/forms/directives/form_control_directive.ts b/modules/angular2/src/forms/directives/form_control_directive.ts index f9fa950627..0bd1841a83 100644 --- a/modules/angular2/src/forms/directives/form_control_directive.ts +++ b/modules/angular2/src/forms/directives/form_control_directive.ts @@ -1,4 +1,6 @@ import {CONST_EXPR} from 'angular2/src/facade/lang'; +import {EventEmitter, ObservableWrapper} from 'angular2/src/facade/async'; + import {Directive, Ancestor, onChange} from 'angular2/angular2'; import {FORWARD_REF, Binding} from 'angular2/di'; @@ -9,17 +11,63 @@ import {setUpControl} from './shared'; const formControlBinding = CONST_EXPR(new Binding(ControlDirective, {toAlias: FORWARD_REF(() => FormControlDirective)})); +/** + * Binds a control to a DOM element. + * + * # Example + * + * In this example, we bind the control to an input element. When the value of the input element + * changes, the value of + * the control will reflect that change. Likewise, if the value of the control changes, the input + * element reflects that + * change. + * + * Here we use {@link formDirectives}, rather than importing each form directive individually, e.g. + * `ControlDirective`, `ControlGroupDirective`. This is just a shorthand for the same end result. + * + * ``` + * @Component({selector: "login-comp"}) + * @View({ + * directives: [formDirectives], + * template: "" + * }) + * class LoginComp { + * loginControl:Control; + * + * constructor() { + * this.loginControl = new Control(''); + * } + * } + * + * ``` + * + * @exportedAs angular2/forms + */ @Directive({ selector: '[form-control]', hostInjector: [formControlBinding], - properties: ['control: form-control'], + properties: ['control: form-control', 'model: ng-model'], + events: ['ngModel'], lifecycle: [onChange] }) export class FormControlDirective extends ControlDirective { control: Control; + ngModel: EventEmitter; + + constructor() { + super(); + this.ngModel = new EventEmitter(); + } onChange(_) { setUpControl(this.control, this); this.control.updateValidity(); } + + set model(value) { + this.control.updateValue(value); + this.valueAccessor.writeValue(value); + } + + viewToModelUpdate(newValue: any): void { ObservableWrapper.callNext(this.ngModel, newValue); } } diff --git a/modules/angular2/src/forms/directives/form_directive.ts b/modules/angular2/src/forms/directives/form_directive.ts index a8daf8764b..9a4ed0cc83 100644 --- a/modules/angular2/src/forms/directives/form_directive.ts +++ b/modules/angular2/src/forms/directives/form_directive.ts @@ -6,4 +6,5 @@ export interface FormDirective { removeControl(dir: ControlDirective): void; addControlGroup(dir: ControlGroupDirective): void; removeControlGroup(dir: ControlGroupDirective): void; + updateModel(dir: ControlDirective, value: any): void; } \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/form_model_directive.ts b/modules/angular2/src/forms/directives/form_model_directive.ts index 655a5aaee9..1413bf174e 100644 --- a/modules/angular2/src/forms/directives/form_model_directive.ts +++ b/modules/angular2/src/forms/directives/form_model_directive.ts @@ -6,12 +6,53 @@ import {ControlDirective} from './control_directive'; import {ControlGroupDirective} from './control_group_directive'; import {ControlContainerDirective} from './control_container_directive'; import {FormDirective} from './form_directive'; -import {ControlGroup} from '../model'; +import {Control, ControlGroup} from '../model'; import {setUpControl} from './shared'; const formDirectiveBinding = CONST_EXPR( new Binding(ControlContainerDirective, {toAlias: FORWARD_REF(() => FormModelDirective)})); +/** + * Binds a control group to a DOM element. + * + * # Example + * + * In this example, we bind the control group to the form element, and we bind the login and + * password controls to the + * login and password elements. + * + * Here we use {@link formDirectives}, rather than importing each form directive individually, e.g. + * `ControlDirective`, `ControlGroupDirective`. This is just a shorthand for the same end result. + * + * ``` + * @Component({selector: "login-comp"}) + * @View({ + * directives: [formDirectives], + * template: "
" + + * "Login " + + * "Password " + + * "" + + * "
" + * }) + * class LoginComp { + * loginForm:ControlGroup; + * + * constructor() { + * this.loginForm = new ControlGroup({ + * login: new Control(""), + * password: new Control("") + * }); + * } + * + * onLogin() { + * // this.loginForm.value + * } + * } + * + * ``` + * + * @exportedAs angular2/forms + */ @Directive({ selector: '[form-model]', hostInjector: [formDirectiveBinding], @@ -46,6 +87,12 @@ export class FormModelDirective extends ControlContainerDirective implements For removeControlGroup(dir: ControlGroupDirective) {} + updateModel(dir: ControlDirective, value: any): void { + var c  = this.form.find(dir.path); + c.value = value; + dir.valueAccessor.writeValue(value); + } + _updateDomValue() { ListWrapper.forEach(this.directives, dir => { var c: any = this.form.find(dir.path); diff --git a/modules/angular2/src/forms/directives/ng_model_directive.ts b/modules/angular2/src/forms/directives/ng_model_directive.ts new file mode 100644 index 0000000000..c71d28409f --- /dev/null +++ b/modules/angular2/src/forms/directives/ng_model_directive.ts @@ -0,0 +1,51 @@ +import {CONST_EXPR} from 'angular2/src/facade/lang'; +import {EventEmitter, ObservableWrapper} from 'angular2/src/facade/async'; +import {StringMapWrapper} from 'angular2/src/facade/collection'; + +import {Directive, Ancestor, onChange} from 'angular2/angular2'; +import {FORWARD_REF, Binding} from 'angular2/di'; + +import {ControlDirective} from './control_directive'; +import {Control} from '../model'; +import {setUpControl} from './shared'; + +const formControlBinding = + CONST_EXPR(new Binding(ControlDirective, {toAlias: FORWARD_REF(() => NgModelDirective)})); + +@Directive({ + selector: '[ng-model]:not([control]):not([form-control])', + hostInjector: [formControlBinding], + properties: ['model: ng-model'], + events: ['ngModel'], + lifecycle: [onChange] +}) +export class NgModelDirective extends ControlDirective { + control: Control; + ngModel: EventEmitter; + model: any; + _added: boolean; + + constructor() { + super(); + this.control = new Control(""); + this.ngModel = new EventEmitter(); + this._added = false; + } + + onChange(c) { + if (!this._added) { + setUpControl(this.control, this); + this.control.updateValidity(); + this._added = true; + }; + + if (StringMapWrapper.contains(c, "model")) { + this.control.value = this.model; + this.valueAccessor.writeValue(this.model); + } + } + + get path(): List { return []; } + + viewToModelUpdate(newValue: any): void { ObservableWrapper.callNext(this.ngModel, newValue); } +} diff --git a/modules/angular2/src/forms/directives/select_control_value_accessor.ts b/modules/angular2/src/forms/directives/select_control_value_accessor.ts index 99d845443a..0275721187 100644 --- a/modules/angular2/src/forms/directives/select_control_value_accessor.ts +++ b/modules/angular2/src/forms/directives/select_control_value_accessor.ts @@ -17,7 +17,7 @@ import {ControlValueAccessor} from './control_value_accessor'; * @exportedAs angular2/forms */ @Directive({ - selector: 'select[control],select[form-control]', + selector: 'select[control],select[form-control],select[ng-model]', hostListeners: {'change': 'onChange($event.target.value)', 'input': 'onChange($event.target.value)'}, hostProperties: {'value': 'value'} diff --git a/modules/angular2/src/forms/directives/shared.ts b/modules/angular2/src/forms/directives/shared.ts index 9bca2d0263..476255d790 100644 --- a/modules/angular2/src/forms/directives/shared.ts +++ b/modules/angular2/src/forms/directives/shared.ts @@ -18,7 +18,10 @@ export function setUpControl(c: Control, dir: ControlDirective) { c.validator = Validators.compose([c.validator, dir.validator]); dir.valueAccessor.writeValue(c.value); - dir.valueAccessor.registerOnChange(newValue => c.updateValue(newValue)); + dir.valueAccessor.registerOnChange(newValue => { + dir.viewToModelUpdate(newValue); + c.updateValue(newValue); + }); } function _throwError(dir: ControlDirective, message: string): void { diff --git a/modules/angular2/src/forms/directives/template_driven_form_directive.ts b/modules/angular2/src/forms/directives/template_driven_form_directive.ts index 5c2ab60be9..c04826d323 100644 --- a/modules/angular2/src/forms/directives/template_driven_form_directive.ts +++ b/modules/angular2/src/forms/directives/template_driven_form_directive.ts @@ -62,6 +62,14 @@ export class TemplateDrivenFormDirective extends ControlContainerDirective imple }); } + updateModel(dir: ControlDirective, value: any): void { + this._later(_ => { + var c = this.form.find(dir.path); + c.value = value; + dir.valueAccessor.writeValue(value); + }); + } + _findContainer(path: List): ControlGroup { ListWrapper.removeLast(path); return this.form.find(path); diff --git a/modules/angular2/test/forms/integration_spec.ts b/modules/angular2/test/forms/integration_spec.ts index 30d384a7cd..bd76722e1f 100644 --- a/modules/angular2/test/forms/integration_spec.ts +++ b/modules/angular2/test/forms/integration_spec.ts @@ -8,11 +8,12 @@ import { dispatchEvent, fakeAsync, flushMicrotasks, + tick, el, expect, - iit, - inject, it, + inject, + iit, xit } from 'angular2/test_lib'; @@ -347,6 +348,56 @@ export function main() { })); }); + it("should support ng-model for complex forms", + inject( + [TestBed], fakeAsync(tb => { + var form = new ControlGroup({"name": new Control("")}); + var ctx = MyComp.create({name: "oldValue", form: form}); + + var t = + `
`; + + tb.createView(MyComp, {context: ctx, html: t}) + .then((view) => { + view.detectChanges(); + + var input = view.querySelector("input"); + expect(input.value).toEqual("oldValue"); + + input.value = "updatedValue"; + dispatchEvent(input, "change"); + + tick(); + + expect(ctx.name).toEqual("updatedValue"); + }); + flushMicrotasks(); + }))); + + it("should support ng-model for single fields", + inject([TestBed], fakeAsync(tb => { + var form = new Control(""); + var ctx = MyComp.create({name: "oldValue", form: form}); + + var t = `
`; + + tb.createView(MyComp, {context: ctx, html: t}) + .then((view) => { + view.detectChanges(); + + var input = view.querySelector("input"); + expect(input.value).toEqual("oldValue"); + + input.value = "updatedValue"; + dispatchEvent(input, "change"); + + tick(); + + expect(ctx.name).toEqual("updatedValue"); + }); + flushMicrotasks(); + }))); + describe("template-driven forms", () => { it("should add new controls and control groups", inject([TestBed], fakeAsync(tb => { @@ -365,7 +416,7 @@ export function main() { view.rawView.elementInjectors[0].get(TemplateDrivenFormDirective); expect(form.controls['user']).not.toBeDefined(); - flushMicrotasks(); + tick(); expect(form.controls['user']).toBeDefined(); expect(form.controls['user'].controls['login']).toBeDefined(); @@ -388,13 +439,13 @@ export function main() { var form = view.rawView.elementInjectors[0].get( TemplateDrivenFormDirective); - flushMicrotasks(); + tick(); expect(form.controls['login']).toBeDefined(); ctx.name = 'hide'; view.detectChanges(); - flushMicrotasks(); + tick(); expect(form.controls['login']).not.toBeDefined(); }); @@ -430,6 +481,56 @@ export function main() { }); flushMicrotasks(); }))); + + it("should support ng-model for complex forms", + inject([TestBed], fakeAsync(tb => { + var ctx = MyComp.create({name: "oldValue"}); + + var t = `
+ +
`; + + tb.createView(MyComp, {context: ctx, html: t}) + .then((view) => { + view.detectChanges(); + tick(); + + var input = view.querySelector("input"); + expect(input.value).toEqual("oldValue"); + + input.value = "updatedValue"; + dispatchEvent(input, "change"); + + tick(); + + expect(ctx.name).toEqual("updatedValue"); + }); + flushMicrotasks(); + }))); + + + it("should support ng-model for single fields", + inject([TestBed], fakeAsync(tb => { + var ctx = MyComp.create({name: "oldValue"}); + + var t = `
`; + + tb.createView(MyComp, {context: ctx, html: t}) + .then((view) => { + view.detectChanges(); + + var input = view.querySelector("input"); + expect(input.value).toEqual("oldValue"); + + input.value = "updatedValue"; + dispatchEvent(input, "change"); + + tick(); + + expect(ctx.name).toEqual("updatedValue"); + }); + flushMicrotasks(); + }))); }); }); }