From a9d6fd9afa04723aa29d69306413813040272530 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Sat, 30 May 2015 11:56:00 -0700 Subject: [PATCH] feat(forms): implemented template-driven forms --- modules/angular2/src/forms/directives.ts | 305 ++---------------- .../directives/checkbox_value_accessor.ts | 37 +++ .../directives/control_container_directive.ts | 8 + .../src/forms/directives/control_directive.ts | 11 + .../directives/control_group_directive.ts | 73 +++++ .../directives/control_name_directive.ts | 65 ++++ .../directives/control_value_accessor.ts | 4 + .../directives/default_value_accessor.ts | 41 +++ .../directives/form_control_directive.ts | 25 ++ .../src/forms/directives/form_directive.ts | 9 + .../forms/directives/form_model_directive.ts | 55 ++++ .../select_control_value_accessor.ts | 41 +++ .../angular2/src/forms/directives/shared.ts | 27 ++ .../template_driven_form_directive.ts | 75 +++++ modules/angular2/src/forms/model.ts | 28 +- .../src/forms/validator_directives.ts | 3 +- .../angular2/test/forms/directives_spec.ts | 178 +++++++++- .../angular2/test/forms/integration_spec.ts | 177 ++++++---- modules/angular2/test/forms/model_spec.ts | 22 ++ 19 files changed, 835 insertions(+), 349 deletions(-) create mode 100644 modules/angular2/src/forms/directives/checkbox_value_accessor.ts create mode 100644 modules/angular2/src/forms/directives/control_container_directive.ts create mode 100644 modules/angular2/src/forms/directives/control_directive.ts create mode 100644 modules/angular2/src/forms/directives/control_group_directive.ts create mode 100644 modules/angular2/src/forms/directives/control_name_directive.ts create mode 100644 modules/angular2/src/forms/directives/control_value_accessor.ts create mode 100644 modules/angular2/src/forms/directives/default_value_accessor.ts create mode 100644 modules/angular2/src/forms/directives/form_control_directive.ts create mode 100644 modules/angular2/src/forms/directives/form_directive.ts create mode 100644 modules/angular2/src/forms/directives/form_model_directive.ts create mode 100644 modules/angular2/src/forms/directives/select_control_value_accessor.ts create mode 100644 modules/angular2/src/forms/directives/shared.ts create mode 100644 modules/angular2/src/forms/directives/template_driven_form_directive.ts diff --git a/modules/angular2/src/forms/directives.ts b/modules/angular2/src/forms/directives.ts index 6f6c8d6639..35215004f6 100644 --- a/modules/angular2/src/forms/directives.ts +++ b/modules/angular2/src/forms/directives.ts @@ -1,279 +1,23 @@ -import {Directive, Ancestor} from 'angular2/src/core/annotations/decorators'; -import {Optional} from 'angular2/src/di/decorators'; -import {ElementRef} from 'angular2/src/core/compiler/element_ref'; -import {Renderer} from 'angular2/src/render/api'; -import { - isPresent, - isString, - CONST_EXPR, - isBlank, - BaseException, - Type -} from 'angular2/src/facade/lang'; -import {ListWrapper} from 'angular2/src/facade/collection'; -import {ControlGroup, Control, isControl} from './model'; -import {Validators} from './validators'; +import {Type, CONST_EXPR} from 'angular2/src/facade/lang'; +import {ControlNameDirective} from './directives/control_name_directive'; +import {FormControlDirective} from './directives/form_control_directive'; +import {ControlGroupDirective} from './directives/control_group_directive'; +import {FormModelDirective} from './directives/form_model_directive'; +import {TemplateDrivenFormDirective} from './directives/template_driven_form_directive'; +import {DefaultValueAccessor} from './directives/default_value_accessor'; +import {CheckboxControlValueAccessor} from './directives/checkbox_value_accessor'; +import {SelectControlValueAccessor} from './directives/select_control_value_accessor'; -function _lookupControl(groupDirective: ControlGroupDirective, controlOrName: any): any { - if (isControl(controlOrName)) { - return controlOrName; - } - - if (isBlank(groupDirective)) { - throw new BaseException(`No control group found for "${controlOrName}"`); - } - - var control = groupDirective.findControl(controlOrName); - - if (isBlank(control)) { - throw new BaseException(`Cannot find control "${controlOrName}"`); - } - - return control; -} - -/** - * 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], - * inline: "
" + - * "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: '[control-group]', properties: ['controlOrName: control-group']}) -export class ControlGroupDirective { - _groupDirective: ControlGroupDirective; - _directives: List; - _controlOrName: any; - - constructor(@Optional() @Ancestor() groupDirective: ControlGroupDirective) { - this._groupDirective = groupDirective; - this._directives = ListWrapper.create(); - } - - set controlOrName(controlOrName) { - this._controlOrName = controlOrName; - this._updateDomValue(); - } - - _updateDomValue() { ListWrapper.forEach(this._directives, (cd) => cd._updateDomValue()); } - - addDirective(c: ControlDirective) { ListWrapper.push(this._directives, c); } - - findControl(name: string): any { return this._getControlGroup().controls[name]; } - - _getControlGroup(): ControlGroup { - return _lookupControl(this._groupDirective, this._controlOrName); - } -} - - -/** - * 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], - * inline: "" - * }) - * class LoginComp { - * loginControl:Control; - * - * constructor() { - * this.loginControl = new Control(''); - * } - * } - * - * ``` - * - * @exportedAs angular2/forms - */ -@Directive({selector: '[control]', properties: ['controlOrName: control']}) -export class ControlDirective { - _groupDirective: ControlGroupDirective; - - _controlOrName: any; - valueAccessor: any; // ControlValueAccessor - - validator: Function; - - constructor(@Optional() @Ancestor() groupDirective: ControlGroupDirective) { - this._groupDirective = groupDirective; - this._controlOrName = null; - this.validator = Validators.nullValidator; - } - - set controlOrName(controlOrName) { - this._controlOrName = controlOrName; - - if (isPresent(this._groupDirective)) { - this._groupDirective.addDirective(this); - } - - var c = this._control(); - c.validator = Validators.compose([c.validator, this.validator]); - - if (isBlank(this.valueAccessor)) { - throw new BaseException(`Cannot find value accessor for control "${controlOrName}"`); - } - - this._updateDomValue(); - this._setUpUpdateControlValue(); - } - - _updateDomValue() { this.valueAccessor.writeValue(this._control().value); } - - _setUpUpdateControlValue() { - this.valueAccessor.onChange = (newValue) => this._control().updateValue(newValue); - } - - _control() { return _lookupControl(this._groupDirective, this._controlOrName); } -} - -/** - * The default accessor for writing a value and listening to changes that is used by a {@link - * Control} directive. - * - * This is the default strategy that Angular uses when no other accessor is applied. - * - * # Example - * ``` - * - * ``` - * - * @exportedAs angular2/forms - */ -@Directive({ - selector: 'input:not([type=checkbox])[control],textarea[control]', - hostListeners: - {'change': 'onChange($event.target.value)', 'input': 'onChange($event.target.value)'}, - hostProperties: {'value': 'value'} -}) -export class DefaultValueAccessor { - value = null; - onChange: Function; - - constructor(cd: ControlDirective, private _elementRef: ElementRef, private _renderer: Renderer) { - this.onChange = (_) => {}; - cd.valueAccessor = this; - } - - writeValue(value) { - this._renderer.setElementProperty(this._elementRef.parentView.render, - this._elementRef.boundElementIndex, 'value', value) - } -} - -/** - * The accessor for writing a value and listening to changes that is used by a {@link - * Control} directive. - * - * This is the default strategy that Angular uses when no other accessor is applied. - * - * # Example - * ``` - * - * ``` - * - * @exportedAs angular2/forms - */ -@Directive({ - selector: 'select[control]', - hostListeners: - {'change': 'onChange($event.target.value)', 'input': 'onChange($event.target.value)'}, - hostProperties: {'value': 'value'} -}) -export class SelectControlValueAccessor { - value = null; - onChange: Function; - - constructor(cd: ControlDirective, private _elementRef: ElementRef, private _renderer: Renderer) { - this.onChange = (_) => {}; - this.value = ''; - cd.valueAccessor = this; - } - - writeValue(value) { - this._renderer.setElementProperty(this._elementRef.parentView.render, - this._elementRef.boundElementIndex, 'value', value) - } -} - -/** - * The accessor for writing a value and listening to changes on a checkbox input element. - * - * - * # Example - * ``` - * - * ``` - * - * @exportedAs angular2/forms - */ -@Directive({ - selector: 'input[type=checkbox][control]', - hostListeners: {'change': 'onChange($event.target.checked)'}, - hostProperties: {'checked': 'checked'} -}) -export class CheckboxControlValueAccessor { - checked: boolean; - onChange: Function; - - constructor(cd: ControlDirective, private _elementRef: ElementRef, private _renderer: Renderer) { - this.onChange = (_) => {}; - cd.valueAccessor = this; - } - - writeValue(value) { - this._renderer.setElementProperty(this._elementRef.parentView.render, - this._elementRef.boundElementIndex, 'checked', value) - } -} +export {ControlNameDirective} from './directives/control_name_directive'; +export {FormControlDirective} from './directives/form_control_directive'; +export {ControlDirective} from './directives/control_directive'; +export {ControlGroupDirective} from './directives/control_group_directive'; +export {FormModelDirective} from './directives/form_model_directive'; +export {TemplateDrivenFormDirective} from './directives/template_driven_form_directive'; +export {ControlValueAccessor} from './directives/control_value_accessor'; +export {DefaultValueAccessor} from './directives/default_value_accessor'; +export {CheckboxControlValueAccessor} from './directives/checkbox_value_accessor'; +export {SelectControlValueAccessor} from './directives/select_control_value_accessor'; /** * @@ -284,9 +28,14 @@ export class CheckboxControlValueAccessor { * @exportedAs angular2/forms */ export const formDirectives: List = CONST_EXPR([ + ControlNameDirective, ControlGroupDirective, - ControlDirective, - CheckboxControlValueAccessor, + + FormControlDirective, + FormModelDirective, + TemplateDrivenFormDirective, + DefaultValueAccessor, + CheckboxControlValueAccessor, SelectControlValueAccessor -]); +]); \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/checkbox_value_accessor.ts b/modules/angular2/src/forms/directives/checkbox_value_accessor.ts new file mode 100644 index 0000000000..fbf3a0fc4f --- /dev/null +++ b/modules/angular2/src/forms/directives/checkbox_value_accessor.ts @@ -0,0 +1,37 @@ +import {ElementRef, Directive} from 'angular2/angular2'; +import {Renderer} from 'angular2/src/render/api'; +import {ControlDirective} from './control_directive'; +import {ControlValueAccessor} from './control_value_accessor'; + +/** + * The accessor for writing a value and listening to changes on a checkbox input element. + * + * + * # Example + * ``` + * + * ``` + * + * @exportedAs angular2/forms + */ +@Directive({ + selector: 'input[type=checkbox][control],input[type=checkbox][form-control]', + hostListeners: {'change': 'onChange($event.target.checked)'}, + hostProperties: {'checked': 'checked'} +}) +export class CheckboxControlValueAccessor implements ControlValueAccessor { + checked: boolean; + onChange: Function; + + constructor(cd: ControlDirective, private _elementRef: ElementRef, private _renderer: Renderer) { + this.onChange = (_) => {}; + cd.valueAccessor = this; + } + + writeValue(value) { + this._renderer.setElementProperty(this._elementRef.parentView.render, + this._elementRef.boundElementIndex, 'checked', value) + } + + registerOnChange(fn) { this.onChange = fn; } +} \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/control_container_directive.ts b/modules/angular2/src/forms/directives/control_container_directive.ts new file mode 100644 index 0000000000..b58dee301f --- /dev/null +++ b/modules/angular2/src/forms/directives/control_container_directive.ts @@ -0,0 +1,8 @@ +import {FormDirective} from './form_directive'; +import {List} from 'angular2/src/facade/collection'; + +export class ControlContainerDirective { + name: string; + get formDirective(): FormDirective { return null; } + get path(): List { return null; } +} diff --git a/modules/angular2/src/forms/directives/control_directive.ts b/modules/angular2/src/forms/directives/control_directive.ts new file mode 100644 index 0000000000..54ebd29c99 --- /dev/null +++ b/modules/angular2/src/forms/directives/control_directive.ts @@ -0,0 +1,11 @@ +import {ControlValueAccessor} from './control_value_accessor'; +import {Validators} from '../validators'; + +export class ControlDirective { + name: string = null; + valueAccessor: ControlValueAccessor = null; + validator: Function; + + get path(): List { return null; } + constructor() { this.validator = Validators.nullValidator; } +} diff --git a/modules/angular2/src/forms/directives/control_group_directive.ts b/modules/angular2/src/forms/directives/control_group_directive.ts new file mode 100644 index 0000000000..da3a30807e --- /dev/null +++ b/modules/angular2/src/forms/directives/control_group_directive.ts @@ -0,0 +1,73 @@ +import {Directive, Ancestor, onDestroy, onInit} from 'angular2/angular2'; +import {Inject, FORWARD_REF, Binding} from 'angular2/di'; +import {List, ListWrapper} from 'angular2/src/facade/collection'; +import {CONST_EXPR} from 'angular2/src/facade/lang'; + +import {ControlContainerDirective} from './control_container_directive'; +import {controlPath} from './shared'; + +const controlGroupBinding = CONST_EXPR( + new Binding(ControlContainerDirective, {toAlias: FORWARD_REF(() => ControlGroupDirective)})); + +/** + * 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: '[control-group]', + hostInjector: [controlGroupBinding], + properties: ['name: control-group'], + lifecycle: [onInit, onDestroy] +}) +export class ControlGroupDirective extends ControlContainerDirective { + _parent: ControlContainerDirective; + constructor(@Ancestor() _parent: ControlContainerDirective) { + super(); + this._parent = _parent; + } + + onInit() { this.formDirective.addControlGroup(this); } + + onDestroy() { this.formDirective.removeControlGroup(this); } + + get path(): List { return controlPath(this.name, this._parent); } + + get formDirective(): any { return this._parent.formDirective; } +} \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/control_name_directive.ts b/modules/angular2/src/forms/directives/control_name_directive.ts new file mode 100644 index 0000000000..3a2b3f5999 --- /dev/null +++ b/modules/angular2/src/forms/directives/control_name_directive.ts @@ -0,0 +1,65 @@ +import {CONST_EXPR} from 'angular2/src/facade/lang'; +import {List} from 'angular2/src/facade/collection'; +import {Directive, Ancestor, onDestroy, onInit} from 'angular2/angular2'; +import {FORWARD_REF, Binding, Inject} from 'angular2/di'; + +import {ControlContainerDirective} from './control_container_directive'; +import {ControlDirective} from './control_directive'; +import {controlPath} from './shared'; + +const controlNameBinding = + CONST_EXPR(new Binding(ControlDirective, {toAlias: FORWARD_REF(() => ControlNameDirective)})); + +/** + * 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: '[control]', + hostInjector: [controlNameBinding], + properties: ['name: control'], + lifecycle: [onDestroy, onInit] +}) +export class ControlNameDirective extends ControlDirective { + _parent: ControlContainerDirective; + constructor(@Ancestor() _parent: ControlContainerDirective) { + super(); + this._parent = _parent; + } + + onInit() { this.formDirective.addControl(this); } + + onDestroy() { this.formDirective.removeControl(this); } + + get path(): List { return controlPath(this.name, this._parent); } + + get formDirective(): any { return this._parent.formDirective; } +} \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/control_value_accessor.ts b/modules/angular2/src/forms/directives/control_value_accessor.ts new file mode 100644 index 0000000000..aa31289200 --- /dev/null +++ b/modules/angular2/src/forms/directives/control_value_accessor.ts @@ -0,0 +1,4 @@ +export interface ControlValueAccessor { + writeValue(obj: any): void; + registerOnChange(fun: any): void; +} \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/default_value_accessor.ts b/modules/angular2/src/forms/directives/default_value_accessor.ts new file mode 100644 index 0000000000..36717fc476 --- /dev/null +++ b/modules/angular2/src/forms/directives/default_value_accessor.ts @@ -0,0 +1,41 @@ +import {ElementRef, Directive} from 'angular2/angular2'; +import {Renderer} from 'angular2/src/render/api'; +import {ControlDirective} from './control_directive'; +import {ControlValueAccessor} from './control_value_accessor'; + +/** + * The default accessor for writing a value and listening to changes that is used by a {@link + * Control} directive. + * + * This is the default strategy that Angular uses when no other accessor is applied. + * + * # Example + * ``` + * + * ``` + * + * @exportedAs angular2/forms + */ +@Directive({ + selector: + 'input:not([type=checkbox])[control],textarea[control],input:not([type=checkbox])[form-control],textarea[form-control]', + hostListeners: + {'change': 'onChange($event.target.value)', 'input': 'onChange($event.target.value)'}, + hostProperties: {'value': 'value'} +}) +export class DefaultValueAccessor implements ControlValueAccessor { + value = null; + onChange: Function; + + constructor(cd: ControlDirective, private _elementRef: ElementRef, private _renderer: Renderer) { + this.onChange = (_) => {}; + cd.valueAccessor = this; + } + + writeValue(value) { + this._renderer.setElementProperty(this._elementRef.parentView.render, + this._elementRef.boundElementIndex, 'value', value) + } + + registerOnChange(fn) { this.onChange = fn; } +} \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/form_control_directive.ts b/modules/angular2/src/forms/directives/form_control_directive.ts new file mode 100644 index 0000000000..f9fa950627 --- /dev/null +++ b/modules/angular2/src/forms/directives/form_control_directive.ts @@ -0,0 +1,25 @@ +import {CONST_EXPR} from 'angular2/src/facade/lang'; +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(() => FormControlDirective)})); + +@Directive({ + selector: '[form-control]', + hostInjector: [formControlBinding], + properties: ['control: form-control'], + lifecycle: [onChange] +}) +export class FormControlDirective extends ControlDirective { + control: Control; + + onChange(_) { + setUpControl(this.control, this); + this.control.updateValidity(); + } +} diff --git a/modules/angular2/src/forms/directives/form_directive.ts b/modules/angular2/src/forms/directives/form_directive.ts new file mode 100644 index 0000000000..a8daf8764b --- /dev/null +++ b/modules/angular2/src/forms/directives/form_directive.ts @@ -0,0 +1,9 @@ +import {ControlDirective} from './control_directive'; +import {ControlGroupDirective} from './control_group_directive'; + +export interface FormDirective { + addControl(dir: ControlDirective): void; + removeControl(dir: ControlDirective): void; + addControlGroup(dir: ControlGroupDirective): void; + removeControlGroup(dir: ControlGroupDirective): 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 new file mode 100644 index 0000000000..655a5aaee9 --- /dev/null +++ b/modules/angular2/src/forms/directives/form_model_directive.ts @@ -0,0 +1,55 @@ +import {CONST_EXPR} from 'angular2/src/facade/lang'; +import {List, ListWrapper} from 'angular2/src/facade/collection'; +import {Directive, onChange} from 'angular2/angular2'; +import {FORWARD_REF, Binding} from 'angular2/di'; +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 {setUpControl} from './shared'; + +const formDirectiveBinding = CONST_EXPR( + new Binding(ControlContainerDirective, {toAlias: FORWARD_REF(() => FormModelDirective)})); + +@Directive({ + selector: '[form-model]', + hostInjector: [formDirectiveBinding], + properties: ['form: form-model'], + lifecycle: [onChange] +}) +export class FormModelDirective extends ControlContainerDirective implements FormDirective { + form: ControlGroup = null; + directives: List; + + constructor() { + super(); + this.directives = []; + } + + onChange(_) { this._updateDomValue(); } + + get formDirective(): FormDirective { return this; } + + get path(): List { return []; } + + addControl(dir: ControlDirective): void { + var c: any = this.form.find(dir.path); + setUpControl(c, dir); + c.updateValidity(); + ListWrapper.push(this.directives, dir); + } + + removeControl(dir: ControlDirective): void { ListWrapper.remove(this.directives, dir); } + + addControlGroup(dir: ControlGroupDirective) {} + + removeControlGroup(dir: ControlGroupDirective) {} + + _updateDomValue() { + ListWrapper.forEach(this.directives, dir => { + var c: any = this.form.find(dir.path); + dir.valueAccessor.writeValue(c.value); + }); + } +} \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/select_control_value_accessor.ts b/modules/angular2/src/forms/directives/select_control_value_accessor.ts new file mode 100644 index 0000000000..069354870c --- /dev/null +++ b/modules/angular2/src/forms/directives/select_control_value_accessor.ts @@ -0,0 +1,41 @@ +import {ElementRef, Directive} from 'angular2/angular2'; +import {Renderer} from 'angular2/src/render/api'; +import {ControlDirective} from './control_directive'; +import {ControlValueAccessor} from './control_value_accessor'; + +/** + * The accessor for writing a value and listening to changes that is used by a {@link + * Control} directive. + * + * This is the default strategy that Angular uses when no other accessor is applied. + * + * # Example + * ``` + * + * ``` + * + * @exportedAs angular2/forms + */ +@Directive({ + selector: 'select[control],select[form-control]', + hostListeners: + {'change': 'onChange($event.target.value)', 'input': 'onChange($event.target.value)'}, + hostProperties: {'value': 'value'} +}) +export class SelectControlValueAccessor implements ControlValueAccessor { + value = null; + onChange: Function; + + constructor(cd: ControlDirective, private _elementRef: ElementRef, private _renderer: Renderer) { + this.onChange = (_) => {}; + this.value = ''; + cd.valueAccessor = this; + } + + writeValue(value) { + this._renderer.setElementProperty(this._elementRef.parentView.render, + this._elementRef.boundElementIndex, 'value', value) + } + + registerOnChange(fn) { this.onChange = fn; } +} \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/shared.ts b/modules/angular2/src/forms/directives/shared.ts new file mode 100644 index 0000000000..9bca2d0263 --- /dev/null +++ b/modules/angular2/src/forms/directives/shared.ts @@ -0,0 +1,27 @@ +import {ListWrapper} from 'angular2/src/facade/collection'; +import {isBlank, BaseException} from 'angular2/src/facade/lang'; + +import {ControlContainerDirective} from './control_container_directive'; +import {ControlDirective} from './control_directive'; +import {Control} from '../model'; +import {Validators} from '../validators'; + +export function controlPath(name, parent: ControlContainerDirective) { + var p = ListWrapper.clone(parent.path); + ListWrapper.push(p, name); + return p; +} + +export function setUpControl(c: Control, dir: ControlDirective) { + if (isBlank(c)) _throwError(dir, "Cannot find control"); + if (isBlank(dir.valueAccessor)) _throwError(dir, "No value accessor for"); + + c.validator = Validators.compose([c.validator, dir.validator]); + dir.valueAccessor.writeValue(c.value); + dir.valueAccessor.registerOnChange(newValue => c.updateValue(newValue)); +} + +function _throwError(dir: ControlDirective, message: string): void { + var path = ListWrapper.join(dir.path, " -> "); + throw new BaseException(`${message} '${path}'`); +} \ No newline at end of file diff --git a/modules/angular2/src/forms/directives/template_driven_form_directive.ts b/modules/angular2/src/forms/directives/template_driven_form_directive.ts new file mode 100644 index 0000000000..5c2ab60be9 --- /dev/null +++ b/modules/angular2/src/forms/directives/template_driven_form_directive.ts @@ -0,0 +1,75 @@ +import {PromiseWrapper} from 'angular2/src/facade/async'; +import {StringMapWrapper, List, ListWrapper} from 'angular2/src/facade/collection'; +import {isPresent, CONST_EXPR} from 'angular2/src/facade/lang'; +import {Directive} from 'angular2/src/core/annotations/decorators'; +import {FORWARD_REF, Binding} from 'angular2/di'; +import {ControlDirective} from './control_directive'; +import {FormDirective} from './form_directive'; +import {ControlGroupDirective} from './control_group_directive'; +import {ControlContainerDirective} from './control_container_directive'; +import {AbstractControl, ControlGroup, Control} from '../model'; +import {setUpControl} from './shared'; + +const formDirectiveBinding = CONST_EXPR(new Binding( + ControlContainerDirective, {toAlias: FORWARD_REF(() => TemplateDrivenFormDirective)})); + +@Directive({selector: '[form]', hostInjector: [formDirectiveBinding]}) +export class TemplateDrivenFormDirective extends ControlContainerDirective implements + FormDirective { + form: ControlGroup; + + constructor() { + super(); + this.form = new ControlGroup({}); + } + + get formDirective(): FormDirective { return this; } + + get path(): List { return []; } + + get controls(): StringMap { return this.form.controls; } + + get value(): any { return this.form.value; } + + addControl(dir: ControlDirective): void { + this._later(_ => { + var group = this._findContainer(dir.path); + var c = new Control(""); + setUpControl(c, dir); + group.addControl(dir.name, c); + }); + } + + removeControl(dir: ControlDirective): void { + this._later(_ => { + var c = this._findContainer(dir.path); + if (isPresent(c)) c.removeControl(dir.name) + }); + } + + addControlGroup(dir: ControlGroupDirective): void { + this._later(_ => { + var group = this._findContainer(dir.path); + var c = new ControlGroup({}); + group.addControl(dir.name, c); + }); + } + + removeControlGroup(dir: ControlGroupDirective): void { + this._later(_ => { + var c = this._findContainer(dir.path); + if (isPresent(c)) c.removeControl(dir.name) + }); + } + + _findContainer(path: List): ControlGroup { + ListWrapper.removeLast(path); + return this.form.find(path); + } + + _later(fn) { + var c = PromiseWrapper.completer(); + PromiseWrapper.then(c.promise, fn, (_) => {}); + c.resolve(null); + } +} diff --git a/modules/angular2/src/forms/model.ts b/modules/angular2/src/forms/model.ts index e0ef41ed63..5aa9ab9d5a 100644 --- a/modules/angular2/src/forms/model.ts +++ b/modules/angular2/src/forms/model.ts @@ -1,4 +1,4 @@ -import {isPresent} from 'angular2/src/facade/lang'; +import {StringWrapper, isPresent} from 'angular2/src/facade/lang'; import {Observable, EventEmitter, ObservableWrapper} from 'angular2/src/facade/async'; import {StringMap, StringMapWrapper, ListWrapper, List} from 'angular2/src/facade/collection'; import {Validators} from './validators'; @@ -42,6 +42,8 @@ export class AbstractControl { get value(): any { return this._value; } + set value(v) { this._value = v; } + get status(): string { return this._status; } get valid(): boolean { return this._status === VALID; } @@ -61,6 +63,14 @@ export class AbstractControl { this._parent._updateValue(); } } + + updateValidity() { + this._errors = this.validator(this); + this._status = isPresent(this._errors) ? INVALID : VALID; + if (isPresent(this._parent)) { + this._parent.updateValidity(); + } + } } /** @@ -129,6 +139,10 @@ export class ControlGroup extends AbstractControl { this._setValueErrorsStatus(); } + addControl(name: string, c: AbstractControl) { this.controls[name] = c; } + + removeControl(name: string) { StringMapWrapper.delete(this.controls, name); } + include(controlName: string): void { StringMapWrapper.set(this._optionals, controlName, true); this._updateValue(); @@ -144,6 +158,18 @@ export class ControlGroup extends AbstractControl { return c && this._included(controlName); } + find(path: string | List): AbstractControl { + if (!(path instanceof List)) { + path = StringWrapper.split(path, new RegExp("/")); + } + + return ListWrapper.reduce( + >path, (v, name) => v instanceof ControlGroup && isPresent(v.controls[name]) ? + v.controls[name] : + null, + this); + } + _setParentForControls() { StringMapWrapper.forEach(this.controls, (control, name) => { control.setParent(this); }); } diff --git a/modules/angular2/src/forms/validator_directives.ts b/modules/angular2/src/forms/validator_directives.ts index 8c13920bfe..994c08f69a 100644 --- a/modules/angular2/src/forms/validator_directives.ts +++ b/modules/angular2/src/forms/validator_directives.ts @@ -1,5 +1,4 @@ -import {Directive} from 'angular2/src/core/annotations/decorators'; - +import {Directive} from 'angular2/angular2'; import {Validators} from './validators'; import {ControlDirective} from './directives'; diff --git a/modules/angular2/test/forms/directives_spec.ts b/modules/angular2/test/forms/directives_spec.ts index 3c9bb02c39..f84347e473 100644 --- a/modules/angular2/test/forms/directives_spec.ts +++ b/modules/angular2/test/forms/directives_spec.ts @@ -1,6 +1,8 @@ import { ddescribe, describe, + fakeAsync, + flushMicrotasks, it, iit, xit, @@ -11,24 +13,176 @@ import { AsyncTestCompleter, inject } from 'angular2/test_lib'; -import {ControlGroup, ControlDirective, ControlGroupDirective} from 'angular2/forms'; +import { + ControlGroup, + Control, + ControlNameDirective, + ControlGroupDirective, + FormModelDirective, + ControlValueAccessor, + Validators, + TemplateDrivenFormDirective, + FormControlDirective +} from 'angular2/forms'; + +class DummyControlValueAccessor implements ControlValueAccessor { + writtenValue; + + registerOnChange(fn) {} + + writeValue(obj: any): void { this.writtenValue = obj; } +} export function main() { describe("Form Directives", () => { - describe("Control", () => { - it("should throw when the group is not found and the control is not set", () => { - var c = new ControlDirective(null); - expect(() => { c.controlOrName = 'login'; }) - .toThrowError(new RegExp('No control group found for "login"')); + describe("FormModelDirective", () => { + var form; + var formModel; + var loginControlDir; + + beforeEach(() => { + form = new FormModelDirective(); + formModel = new ControlGroup({"login": new Control(null)}); + form.form = formModel; + + loginControlDir = new ControlNameDirective(form); + loginControlDir.name = "login"; + loginControlDir.valueAccessor = new DummyControlValueAccessor(); }); - it("should throw when cannot find the control in the group", () => { - var emptyGroup = new ControlGroupDirective(null); - emptyGroup.controlOrName = new ControlGroup({}); + describe("addControl", () => { + it("should throw when no control found", () => { + var dir = new ControlNameDirective(form); + dir.name = "invalidName"; - var c = new ControlDirective(emptyGroup); - expect(() => { c.controlOrName = 'login'; }) - .toThrowError(new RegExp('Cannot find control "login"')); + expect(() => form.addControl(dir)) + .toThrowError(new RegExp("Cannot find control 'invalidName'")); + }); + + it("should throw when no value accessor", () => { + var dir = new ControlNameDirective(form); + dir.name = "login"; + + expect(() => form.addControl(dir)) + .toThrowError(new RegExp("No value accessor for 'login'")); + }); + + it("should set up validator", () => { + loginControlDir.validator = Validators.required; + + expect(formModel.find(["login"]).valid).toBe(true); + + // this will add the required validator and recalculate the validity + form.addControl(loginControlDir); + + expect(formModel.find(["login"]).valid).toBe(false); + }); + + it("should write value to the DOM", () => { + formModel.find(["login"]).value = "initValue"; + + form.addControl(loginControlDir); + + expect((loginControlDir.valueAccessor).writtenValue).toEqual("initValue"); + }); + + it("should add the directive to the list of directives included in the form", () => { + form.addControl(loginControlDir); + expect(form.directives).toEqual([loginControlDir]); + }); + }); + + describe("removeControl", () => { + it("should remove the directive to the list of directives included in the form", () => { + form.addControl(loginControlDir); + form.removeControl(loginControlDir); + expect(form.directives).toEqual([]); + }); + }); + + describe("onChange", () => { + it("should update dom values of all the directives", () => { + form.addControl(loginControlDir); + + formModel.find(["login"]).value = "new value"; + + form.onChange(null); + + expect((loginControlDir.valueAccessor).writtenValue).toEqual("new value"); + }); + }); + }); + + describe("TemplateDrivenFormDirective", () => { + var form; + var formModel; + var loginControlDir; + var personControlGroupDir; + + beforeEach(() => { + form = new TemplateDrivenFormDirective(); + formModel = form.form; + + personControlGroupDir = new ControlGroupDirective(form); + personControlGroupDir.name = "person"; + + loginControlDir = new ControlNameDirective(personControlGroupDir); + loginControlDir.name = "login"; + loginControlDir.valueAccessor = new DummyControlValueAccessor(); + }); + + describe("addControl & addControlGroup", () => { + it("should create a control with the given name", fakeAsync(() => { + form.addControlGroup(personControlGroupDir); + form.addControl(loginControlDir); + + flushMicrotasks(); + + expect(formModel.find(["person", "login"])).not.toBeNull; + })); + + // should update the form's value and validity + }); + + describe("removeControl & removeControlGroup", () => { + it("should remove control", fakeAsync(() => { + form.addControlGroup(personControlGroupDir); + form.addControl(loginControlDir); + + form.removeControlGroup(personControlGroupDir); + form.removeControl(loginControlDir); + + flushMicrotasks(); + + expect(formModel.find(["person"])).toBeNull(); + expect(formModel.find(["person", "login"])).toBeNull(); + })); + + // should update the form's value and validity + }); + }); + + describe("FormControlDirective", () => { + var controlDir; + var control; + + beforeEach(() => { + controlDir = new FormControlDirective(); + controlDir.valueAccessor = new DummyControlValueAccessor(); + + control = new Control(null); + controlDir.control = control; + }); + + it("should set up validator", () => { + controlDir.validator = Validators.required; + + expect(control.valid).toBe(true); + + // this will add the required validator and recalculate the validity + controlDir.onChange(null); + + expect(control.valid).toBe(false); }); }); }); diff --git a/modules/angular2/test/forms/integration_spec.ts b/modules/angular2/test/forms/integration_spec.ts index dca06040ad..30d384a7cd 100644 --- a/modules/angular2/test/forms/integration_spec.ts +++ b/modules/angular2/test/forms/integration_spec.ts @@ -1,3 +1,4 @@ +import {Component, Directive, View} from 'angular2/angular2'; import { afterEach, AsyncTestCompleter, @@ -5,6 +6,8 @@ import { ddescribe, describe, dispatchEvent, + fakeAsync, + flushMicrotasks, el, expect, iit, @@ -12,16 +15,19 @@ import { it, xit } from 'angular2/test_lib'; -import {DOM} from 'angular2/src/dom/dom_adapter'; -import {Component, Directive, View} from 'angular2/angular2'; + import {TestBed} from 'angular2/src/test_lib/test_bed'; +import {NgIf} from 'angular2/directives'; + import { Control, ControlGroup, - ControlDirective, RequiredValidatorDirective, + TemplateDrivenFormDirective, + formDirectives, Validators, - formDirectives + ControlDirective, + ControlValueAccessor } from 'angular2/forms'; export function main() { @@ -30,13 +36,14 @@ export function main() { inject([TestBed, AsyncTestCompleter], (tb, async) => { var ctx = MyComp.create({form: new ControlGroup({"login": new Control("loginValue")})}); - var t = `
+ var t = `
`; tb.createView(MyComp, {context: ctx, html: t}) .then((view) => { view.detectChanges(); + var input = view.querySelector("input"); expect(input.value).toEqual("loginValue"); async.done(); @@ -48,7 +55,7 @@ export function main() { var form = new ControlGroup({"login": new Control("oldValue")}); var ctx = MyComp.create({form: form}); - var t = `
+ var t = `
`; @@ -69,7 +76,7 @@ export function main() { var control = new Control("loginValue"); var ctx = MyComp.create({form: control}); - var t = `
`; + var t = `
`; tb.createView(MyComp, {context: ctx, html: t}) .then((view) => { @@ -90,9 +97,9 @@ export function main() { var form = new ControlGroup({"login": new Control("oldValue")}); var ctx = MyComp.create({form: form}); - var t = `
- -
`; + var t = `
+ +
`; tb.createView(MyComp, {context: ctx, html: t}) .then((view) => { @@ -106,36 +113,11 @@ export function main() { }); })); - it("should update DOM element when rebinding the control name", - inject([TestBed, AsyncTestCompleter], (tb, async) => { - var ctx = MyComp.create({ - form: new ControlGroup({"one": new Control("one"), "two": new Control("two")}), - name: "one" - }); - - var t = `
- -
`; - - tb.createView(MyComp, {context: ctx, html: t}) - .then((view) => { - view.detectChanges(); - var input = view.querySelector("input"); - expect(input.value).toEqual("one"); - - ctx.name = "two"; - view.detectChanges(); - - expect(input.value).toEqual("two"); - async.done(); - }); - })); - describe("different control types", () => { it("should support ", inject([TestBed, AsyncTestCompleter], (tb, async) => { var ctx = MyComp.create({form: new ControlGroup({"text": new Control("old")})}); - var t = `
+ var t = `
`; @@ -157,7 +139,7 @@ export function main() { inject([TestBed, AsyncTestCompleter], (tb, async) => { var ctx = MyComp.create({form: new ControlGroup({"text": new Control("old")})}); - var t = `
+ var t = `
`; @@ -178,7 +160,7 @@ export function main() { it("should support
`; @@ -199,7 +181,7 @@ export function main() { it("should support ", inject([TestBed, AsyncTestCompleter], (tb, async) => { var ctx = MyComp.create({form: new ControlGroup({"checkbox": new Control(true)})}); - var t = `
+ var t = `
`; @@ -220,7 +202,7 @@ export function main() { it("should support @@ -233,17 +215,13 @@ export function main() { var select = view.querySelector("select"); var sfOption = view.querySelector("option"); expect(select.value).toEqual('SF'); - if (DOM.supportsDOMEvents()) { - expect(sfOption.selected).toBe(true); - } + expect(sfOption.selected).toBe(true); select.value = 'NYC'; dispatchEvent(select, "change"); expect(ctx.form.value).toEqual({"city": 'NYC'}); - if (DOM.supportsDOMEvents()) { - expect(sfOption.selected).toBe(false); - } + expect(sfOption.selected).toBe(false); async.done(); }); })); @@ -252,7 +230,7 @@ export function main() { inject([TestBed, AsyncTestCompleter], (tb, async) => { var ctx = MyComp.create({form: new ControlGroup({"name": new Control("aa")})}); - var t = `
+ var t = `
`; @@ -277,7 +255,7 @@ export function main() { var form = new ControlGroup({"login": new Control("aa")}); var ctx = MyComp.create({form: form}); - var t = `
+ var t = `
`; @@ -301,7 +279,7 @@ export function main() { var form = new ControlGroup({"login": new Control("aa", Validators.required)}); var ctx = MyComp.create({form: form}); - var t = `
+ var t = `
`; @@ -328,7 +306,7 @@ export function main() { new ControlGroup({"nested": new ControlGroup({"login": new Control("value")})}); var ctx = MyComp.create({form: form}); - var t = `
+ var t = `
@@ -349,7 +327,7 @@ export function main() { new ControlGroup({"nested": new ControlGroup({"login": new Control("value")})}); var ctx = MyComp.create({form: form}); - var t = `
+ var t = `
@@ -358,9 +336,9 @@ export function main() { tb.createView(MyComp, {context: ctx, html: t}) .then((view) => { view.detectChanges(); - var input = view.querySelector("input") + var input = view.querySelector("input"); - input.value = "updatedValue"; + input.value = "updatedValue"; dispatchEvent(input, "change"); expect(form.value).toEqual({"nested": {"login": "updatedValue"}}); @@ -368,6 +346,91 @@ export function main() { }); })); }); + + describe("template-driven forms", () => { + it("should add new controls and control groups", + inject([TestBed], fakeAsync(tb => { + var ctx = MyComp.create({name: null}); + + var t = `
+
+ +
+
`; + + tb.createView(MyComp, {context: ctx, html: t}) + .then((view) => { + view.detectChanges(); + var form = + view.rawView.elementInjectors[0].get(TemplateDrivenFormDirective); + expect(form.controls['user']).not.toBeDefined(); + + flushMicrotasks(); + + expect(form.controls['user']).toBeDefined(); + expect(form.controls['user'].controls['login']).toBeDefined(); + }); + flushMicrotasks(); + }))); + + it("should remove controls", inject([TestBed], fakeAsync(tb => { + var ctx = MyComp.create({name: 'show'}); + + var t = `
+
+ +
+
`; + + tb.createView(MyComp, {context: ctx, html: t}) + .then((view) => { + view.detectChanges(); + var form = view.rawView.elementInjectors[0].get( + TemplateDrivenFormDirective); + + flushMicrotasks(); + + expect(form.controls['login']).toBeDefined(); + + ctx.name = 'hide'; + view.detectChanges(); + flushMicrotasks(); + + expect(form.controls['login']).not.toBeDefined(); + }); + flushMicrotasks(); + }))); + + it("should remove control groups", + inject([TestBed], fakeAsync(tb => { + var ctx = MyComp.create({name: 'show'}); + + + var t = `
+
+ +
+
`; + + + tb.createView(MyComp, {context: ctx, html: t}) + .then((view) => { + view.detectChanges(); + var form = + view.rawView.elementInjectors[0].get(TemplateDrivenFormDirective); + flushMicrotasks(); + + expect(form.controls['user']).toBeDefined(); + + ctx.name = 'hide'; + view.detectChanges(); + flushMicrotasks(); + + expect(form.controls['user']).not.toBeDefined(); + }); + flushMicrotasks(); + }))); + }); }); } @@ -376,7 +439,7 @@ export function main() { hostListeners: {'change': 'handleOnChange($event.target.value)'}, hostProperties: {'value': 'value'} }) -class WrappedValue { +class WrappedValue implements ControlValueAccessor { value; onChange: Function; @@ -384,16 +447,18 @@ class WrappedValue { writeValue(value) { this.value = `!${value}!`; } + registerOnChange(fn) { this.onChange = fn; } + handleOnChange(value) { this.onChange(value.substring(1, value.length - 1)); } } @Component({selector: "my-comp"}) -@View({directives: [WrappedValue, formDirectives, RequiredValidatorDirective]}) +@View({directives: [formDirectives, WrappedValue, RequiredValidatorDirective, NgIf]}) class MyComp { form: any; name: string; - static create({form, name}: {form?, name?}) { + static create({form, name}: {form?: any, name?: any}) { var mc = new MyComp(); mc.form = form; mc.name = name; diff --git a/modules/angular2/test/forms/model_spec.ts b/modules/angular2/test/forms/model_spec.ts index 7c44fa0aa8..4279a53528 100644 --- a/modules/angular2/test/forms/model_spec.ts +++ b/modules/angular2/test/forms/model_spec.ts @@ -113,6 +113,28 @@ export function main() { }); }); + describe("find", () => { + var g; + beforeEach(() => { + g = new ControlGroup({ + "one": new Control("111"), + "nested": new ControlGroup({"two": new Control("222")}) + }); + }); + + it("should return a control if it is present", () => { + expect(g.find(["nested", "two"]).value).toEqual("222"); + expect(g.find(["one"]).value).toEqual("111"); + expect(g.find("nested/two").value).toEqual("222"); + expect(g.find("one").value).toEqual("111"); + }); + + it("should return null otherwise", () => { + expect(g.find("invalid")).toBeNull(); + expect(g.find("one/invalid")).toBeNull(); + }); + }); + describe("validator", () => { it("should run the validator with the initial value (valid)", () => { var g = new ControlGroup({"one": new Control('value', Validators.required)});