feat(forms): implemented template-driven forms

This commit is contained in:
vsavkin 2015-05-30 11:56:00 -07:00
parent 5c53cf6486
commit a9d6fd9afa
19 changed files with 835 additions and 349 deletions

View File

@ -1,279 +1,23 @@
import {Directive, Ancestor} from 'angular2/src/core/annotations/decorators'; import {Type, CONST_EXPR} from 'angular2/src/facade/lang';
import {Optional} from 'angular2/src/di/decorators'; import {ControlNameDirective} from './directives/control_name_directive';
import {ElementRef} from 'angular2/src/core/compiler/element_ref'; import {FormControlDirective} from './directives/form_control_directive';
import {Renderer} from 'angular2/src/render/api'; import {ControlGroupDirective} from './directives/control_group_directive';
import { import {FormModelDirective} from './directives/form_model_directive';
isPresent, import {TemplateDrivenFormDirective} from './directives/template_driven_form_directive';
isString, import {DefaultValueAccessor} from './directives/default_value_accessor';
CONST_EXPR, import {CheckboxControlValueAccessor} from './directives/checkbox_value_accessor';
isBlank, import {SelectControlValueAccessor} from './directives/select_control_value_accessor';
BaseException,
Type
} from 'angular2/src/facade/lang';
import {ListWrapper} from 'angular2/src/facade/collection';
import {ControlGroup, Control, isControl} from './model';
import {Validators} from './validators';
function _lookupControl(groupDirective: ControlGroupDirective, controlOrName: any): any { export {ControlNameDirective} from './directives/control_name_directive';
if (isControl(controlOrName)) { export {FormControlDirective} from './directives/form_control_directive';
return controlOrName; export {ControlDirective} from './directives/control_directive';
} export {ControlGroupDirective} from './directives/control_group_directive';
export {FormModelDirective} from './directives/form_model_directive';
if (isBlank(groupDirective)) { export {TemplateDrivenFormDirective} from './directives/template_driven_form_directive';
throw new BaseException(`No control group found for "${controlOrName}"`); export {ControlValueAccessor} from './directives/control_value_accessor';
} export {DefaultValueAccessor} from './directives/default_value_accessor';
export {CheckboxControlValueAccessor} from './directives/checkbox_value_accessor';
var control = groupDirective.findControl(controlOrName); export {SelectControlValueAccessor} from './directives/select_control_value_accessor';
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: "<form [control-group]='loginForm'>" +
* "Login <input type='text' control='login'>" +
* "Password <input type='password' control='password'>" +
* "<button (click)="onLogin()">Login</button>" +
* "</form>"
* })
* 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<ControlDirective>;
_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: "<input type='text' [control]='loginControl'>"
* })
* 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
* ```
* <input type="text" [control]="loginControl">
* ```
*
* @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
* ```
* <input type="text" [control]="loginControl">
* ```
*
* @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
* ```
* <input type="checkbox" [control]="rememberLogin">
* ```
*
* @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)
}
}
/** /**
* *
@ -284,9 +28,14 @@ export class CheckboxControlValueAccessor {
* @exportedAs angular2/forms * @exportedAs angular2/forms
*/ */
export const formDirectives: List<Type> = CONST_EXPR([ export const formDirectives: List<Type> = CONST_EXPR([
ControlNameDirective,
ControlGroupDirective, ControlGroupDirective,
ControlDirective,
CheckboxControlValueAccessor, FormControlDirective,
FormModelDirective,
TemplateDrivenFormDirective,
DefaultValueAccessor, DefaultValueAccessor,
CheckboxControlValueAccessor,
SelectControlValueAccessor SelectControlValueAccessor
]); ]);

View File

@ -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
* ```
* <input type="checkbox" [control]="rememberLogin">
* ```
*
* @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; }
}

View File

@ -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<string> { return null; }
}

View File

@ -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<string> { return null; }
constructor() { this.validator = Validators.nullValidator; }
}

View File

@ -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: "<form [control-group]='loginForm'>" +
* "Login <input type='text' control='login'>" +
* "Password <input type='password' control='password'>" +
* "<button (click)="onLogin()">Login</button>" +
* "</form>"
* })
* 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<string> { return controlPath(this.name, this._parent); }
get formDirective(): any { return this._parent.formDirective; }
}

View File

@ -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: "<input type='text' [control]='loginControl'>"
* })
* 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<string> { return controlPath(this.name, this._parent); }
get formDirective(): any { return this._parent.formDirective; }
}

View File

@ -0,0 +1,4 @@
export interface ControlValueAccessor {
writeValue(obj: any): void;
registerOnChange(fun: any): void;
}

View File

@ -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
* ```
* <input type="text" [control]="loginControl">
* ```
*
* @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; }
}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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<ControlDirective>;
constructor() {
super();
this.directives = [];
}
onChange(_) { this._updateDomValue(); }
get formDirective(): FormDirective { return this; }
get path(): List<string> { 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);
});
}
}

View File

@ -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
* ```
* <input type="text" [control]="loginControl">
* ```
*
* @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; }
}

View File

@ -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}'`);
}

View File

@ -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<string> { return []; }
get controls(): StringMap<string, AbstractControl> { 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<string>): ControlGroup {
ListWrapper.removeLast(path);
return <ControlGroup>this.form.find(path);
}
_later(fn) {
var c = PromiseWrapper.completer();
PromiseWrapper.then(c.promise, fn, (_) => {});
c.resolve(null);
}
}

View File

@ -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 {Observable, EventEmitter, ObservableWrapper} from 'angular2/src/facade/async';
import {StringMap, StringMapWrapper, ListWrapper, List} from 'angular2/src/facade/collection'; import {StringMap, StringMapWrapper, ListWrapper, List} from 'angular2/src/facade/collection';
import {Validators} from './validators'; import {Validators} from './validators';
@ -42,6 +42,8 @@ export class AbstractControl {
get value(): any { return this._value; } get value(): any { return this._value; }
set value(v) { this._value = v; }
get status(): string { return this._status; } get status(): string { return this._status; }
get valid(): boolean { return this._status === VALID; } get valid(): boolean { return this._status === VALID; }
@ -61,6 +63,14 @@ export class AbstractControl {
this._parent._updateValue(); 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(); this._setValueErrorsStatus();
} }
addControl(name: string, c: AbstractControl) { this.controls[name] = c; }
removeControl(name: string) { StringMapWrapper.delete(this.controls, name); }
include(controlName: string): void { include(controlName: string): void {
StringMapWrapper.set(this._optionals, controlName, true); StringMapWrapper.set(this._optionals, controlName, true);
this._updateValue(); this._updateValue();
@ -144,6 +158,18 @@ export class ControlGroup extends AbstractControl {
return c && this._included(controlName); return c && this._included(controlName);
} }
find(path: string | List<string>): AbstractControl {
if (!(path instanceof List)) {
path = StringWrapper.split(<string>path, new RegExp("/"));
}
return ListWrapper.reduce(
<List<string>>path, (v, name) => v instanceof ControlGroup && isPresent(v.controls[name]) ?
v.controls[name] :
null,
this);
}
_setParentForControls() { _setParentForControls() {
StringMapWrapper.forEach(this.controls, (control, name) => { control.setParent(this); }); StringMapWrapper.forEach(this.controls, (control, name) => { control.setParent(this); });
} }

View File

@ -1,5 +1,4 @@
import {Directive} from 'angular2/src/core/annotations/decorators'; import {Directive} from 'angular2/angular2';
import {Validators} from './validators'; import {Validators} from './validators';
import {ControlDirective} from './directives'; import {ControlDirective} from './directives';

View File

@ -1,6 +1,8 @@
import { import {
ddescribe, ddescribe,
describe, describe,
fakeAsync,
flushMicrotasks,
it, it,
iit, iit,
xit, xit,
@ -11,24 +13,176 @@ import {
AsyncTestCompleter, AsyncTestCompleter,
inject inject
} from 'angular2/test_lib'; } 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() { export function main() {
describe("Form Directives", () => { describe("Form Directives", () => {
describe("Control", () => { describe("FormModelDirective", () => {
it("should throw when the group is not found and the control is not set", () => { var form;
var c = new ControlDirective(null); var formModel;
expect(() => { c.controlOrName = 'login'; }) var loginControlDir;
.toThrowError(new RegExp('No control group found for "login"'));
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", () => { describe("addControl", () => {
var emptyGroup = new ControlGroupDirective(null); it("should throw when no control found", () => {
emptyGroup.controlOrName = new ControlGroup({}); var dir = new ControlNameDirective(form);
dir.name = "invalidName";
var c = new ControlDirective(emptyGroup); expect(() => form.addControl(dir))
expect(() => { c.controlOrName = 'login'; }) .toThrowError(new RegExp("Cannot find control 'invalidName'"));
.toThrowError(new RegExp('Cannot find control "login"')); });
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((<any>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((<any>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);
}); });
}); });
}); });

View File

@ -1,3 +1,4 @@
import {Component, Directive, View} from 'angular2/angular2';
import { import {
afterEach, afterEach,
AsyncTestCompleter, AsyncTestCompleter,
@ -5,6 +6,8 @@ import {
ddescribe, ddescribe,
describe, describe,
dispatchEvent, dispatchEvent,
fakeAsync,
flushMicrotasks,
el, el,
expect, expect,
iit, iit,
@ -12,16 +15,19 @@ import {
it, it,
xit xit
} from 'angular2/test_lib'; } 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 {TestBed} from 'angular2/src/test_lib/test_bed';
import {NgIf} from 'angular2/directives';
import { import {
Control, Control,
ControlGroup, ControlGroup,
ControlDirective,
RequiredValidatorDirective, RequiredValidatorDirective,
TemplateDrivenFormDirective,
formDirectives,
Validators, Validators,
formDirectives ControlDirective,
ControlValueAccessor
} from 'angular2/forms'; } from 'angular2/forms';
export function main() { export function main() {
@ -30,13 +36,14 @@ export function main() {
inject([TestBed, AsyncTestCompleter], (tb, async) => { inject([TestBed, AsyncTestCompleter], (tb, async) => {
var ctx = MyComp.create({form: new ControlGroup({"login": new Control("loginValue")})}); var ctx = MyComp.create({form: new ControlGroup({"login": new Control("loginValue")})});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<input type="text" control="login"> <input type="text" control="login">
</div>`; </div>`;
tb.createView(MyComp, {context: ctx, html: t}) tb.createView(MyComp, {context: ctx, html: t})
.then((view) => { .then((view) => {
view.detectChanges(); view.detectChanges();
var input = view.querySelector("input"); var input = view.querySelector("input");
expect(input.value).toEqual("loginValue"); expect(input.value).toEqual("loginValue");
async.done(); async.done();
@ -48,7 +55,7 @@ export function main() {
var form = new ControlGroup({"login": new Control("oldValue")}); var form = new ControlGroup({"login": new Control("oldValue")});
var ctx = MyComp.create({form: form}); var ctx = MyComp.create({form: form});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<input type="text" control="login"> <input type="text" control="login">
</div>`; </div>`;
@ -69,7 +76,7 @@ export function main() {
var control = new Control("loginValue"); var control = new Control("loginValue");
var ctx = MyComp.create({form: control}); var ctx = MyComp.create({form: control});
var t = `<div><input type="text" [control]="form"></div>`; var t = `<div><input type="text" [form-control]="form"></div>`;
tb.createView(MyComp, {context: ctx, html: t}) tb.createView(MyComp, {context: ctx, html: t})
.then((view) => { .then((view) => {
@ -90,9 +97,9 @@ export function main() {
var form = new ControlGroup({"login": new Control("oldValue")}); var form = new ControlGroup({"login": new Control("oldValue")});
var ctx = MyComp.create({form: form}); var ctx = MyComp.create({form: form});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<input type="text" control="login"> <input type="text" control="login">
</div>`; </div>`;
tb.createView(MyComp, {context: ctx, html: t}) tb.createView(MyComp, {context: ctx, html: t})
.then((view) => { .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 = `<div [control-group]="form">
<input type="text" [control]="name">
</div>`;
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", () => { describe("different control types", () => {
it("should support <input type=text>", inject([TestBed, AsyncTestCompleter], (tb, async) => { it("should support <input type=text>", inject([TestBed, AsyncTestCompleter], (tb, async) => {
var ctx = MyComp.create({form: new ControlGroup({"text": new Control("old")})}); var ctx = MyComp.create({form: new ControlGroup({"text": new Control("old")})});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<input type="text" control="text"> <input type="text" control="text">
</div>`; </div>`;
@ -157,7 +139,7 @@ export function main() {
inject([TestBed, AsyncTestCompleter], (tb, async) => { inject([TestBed, AsyncTestCompleter], (tb, async) => {
var ctx = MyComp.create({form: new ControlGroup({"text": new Control("old")})}); var ctx = MyComp.create({form: new ControlGroup({"text": new Control("old")})});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<input control="text"> <input control="text">
</div>`; </div>`;
@ -178,7 +160,7 @@ export function main() {
it("should support <textarea>", inject([TestBed, AsyncTestCompleter], (tb, async) => { it("should support <textarea>", inject([TestBed, AsyncTestCompleter], (tb, async) => {
var ctx = MyComp.create({form: new ControlGroup({"text": new Control('old')})}); var ctx = MyComp.create({form: new ControlGroup({"text": new Control('old')})});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<textarea control="text"></textarea> <textarea control="text"></textarea>
</div>`; </div>`;
@ -199,7 +181,7 @@ export function main() {
it("should support <type=checkbox>", inject([TestBed, AsyncTestCompleter], (tb, async) => { it("should support <type=checkbox>", inject([TestBed, AsyncTestCompleter], (tb, async) => {
var ctx = MyComp.create({form: new ControlGroup({"checkbox": new Control(true)})}); var ctx = MyComp.create({form: new ControlGroup({"checkbox": new Control(true)})});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<input type="checkbox" control="checkbox"> <input type="checkbox" control="checkbox">
</div>`; </div>`;
@ -220,7 +202,7 @@ export function main() {
it("should support <select>", inject([TestBed, AsyncTestCompleter], (tb, async) => { it("should support <select>", inject([TestBed, AsyncTestCompleter], (tb, async) => {
var ctx = MyComp.create({form: new ControlGroup({"city": new Control("SF")})}); var ctx = MyComp.create({form: new ControlGroup({"city": new Control("SF")})});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<select control="city"> <select control="city">
<option value="SF"></option> <option value="SF"></option>
<option value="NYC"></option> <option value="NYC"></option>
@ -233,17 +215,13 @@ export function main() {
var select = view.querySelector("select"); var select = view.querySelector("select");
var sfOption = view.querySelector("option"); var sfOption = view.querySelector("option");
expect(select.value).toEqual('SF'); expect(select.value).toEqual('SF');
if (DOM.supportsDOMEvents()) { expect(sfOption.selected).toBe(true);
expect(sfOption.selected).toBe(true);
}
select.value = 'NYC'; select.value = 'NYC';
dispatchEvent(select, "change"); dispatchEvent(select, "change");
expect(ctx.form.value).toEqual({"city": 'NYC'}); expect(ctx.form.value).toEqual({"city": 'NYC'});
if (DOM.supportsDOMEvents()) { expect(sfOption.selected).toBe(false);
expect(sfOption.selected).toBe(false);
}
async.done(); async.done();
}); });
})); }));
@ -252,7 +230,7 @@ export function main() {
inject([TestBed, AsyncTestCompleter], (tb, async) => { inject([TestBed, AsyncTestCompleter], (tb, async) => {
var ctx = MyComp.create({form: new ControlGroup({"name": new Control("aa")})}); var ctx = MyComp.create({form: new ControlGroup({"name": new Control("aa")})});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<input type="text" control="name" wrapped-value> <input type="text" control="name" wrapped-value>
</div>`; </div>`;
@ -277,7 +255,7 @@ export function main() {
var form = new ControlGroup({"login": new Control("aa")}); var form = new ControlGroup({"login": new Control("aa")});
var ctx = MyComp.create({form: form}); var ctx = MyComp.create({form: form});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<input type="text" control="login" required> <input type="text" control="login" required>
</div>`; </div>`;
@ -301,7 +279,7 @@ export function main() {
var form = new ControlGroup({"login": new Control("aa", Validators.required)}); var form = new ControlGroup({"login": new Control("aa", Validators.required)});
var ctx = MyComp.create({form: form}); var ctx = MyComp.create({form: form});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<input type="text" control="login"> <input type="text" control="login">
</div>`; </div>`;
@ -328,7 +306,7 @@ export function main() {
new ControlGroup({"nested": new ControlGroup({"login": new Control("value")})}); new ControlGroup({"nested": new ControlGroup({"login": new Control("value")})});
var ctx = MyComp.create({form: form}); var ctx = MyComp.create({form: form});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<div control-group="nested"> <div control-group="nested">
<input type="text" control="login"> <input type="text" control="login">
</div> </div>
@ -349,7 +327,7 @@ export function main() {
new ControlGroup({"nested": new ControlGroup({"login": new Control("value")})}); new ControlGroup({"nested": new ControlGroup({"login": new Control("value")})});
var ctx = MyComp.create({form: form}); var ctx = MyComp.create({form: form});
var t = `<div [control-group]="form"> var t = `<div [form-model]="form">
<div control-group="nested"> <div control-group="nested">
<input type="text" control="login"> <input type="text" control="login">
</div> </div>
@ -358,9 +336,9 @@ export function main() {
tb.createView(MyComp, {context: ctx, html: t}) tb.createView(MyComp, {context: ctx, html: t})
.then((view) => { .then((view) => {
view.detectChanges(); view.detectChanges();
var input = view.querySelector("input") var input = view.querySelector("input");
input.value = "updatedValue"; input.value = "updatedValue";
dispatchEvent(input, "change"); dispatchEvent(input, "change");
expect(form.value).toEqual({"nested": {"login": "updatedValue"}}); 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 = `<div form>
<div control-group="user">
<input type="text" control="login">
</div>
</div>`;
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 = `<div form>
<div *ng-if="name == 'show'">
<input type="text" control="login">
</div>
</div>`;
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 = `<div form>
<div *ng-if="name=='show'" control-group="user">
<input type="text" control="login">
</div>
</div>`;
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)'}, hostListeners: {'change': 'handleOnChange($event.target.value)'},
hostProperties: {'value': 'value'} hostProperties: {'value': 'value'}
}) })
class WrappedValue { class WrappedValue implements ControlValueAccessor {
value; value;
onChange: Function; onChange: Function;
@ -384,16 +447,18 @@ class WrappedValue {
writeValue(value) { this.value = `!${value}!`; } writeValue(value) { this.value = `!${value}!`; }
registerOnChange(fn) { this.onChange = fn; }
handleOnChange(value) { this.onChange(value.substring(1, value.length - 1)); } handleOnChange(value) { this.onChange(value.substring(1, value.length - 1)); }
} }
@Component({selector: "my-comp"}) @Component({selector: "my-comp"})
@View({directives: [WrappedValue, formDirectives, RequiredValidatorDirective]}) @View({directives: [formDirectives, WrappedValue, RequiredValidatorDirective, NgIf]})
class MyComp { class MyComp {
form: any; form: any;
name: string; name: string;
static create({form, name}: {form?, name?}) { static create({form, name}: {form?: any, name?: any}) {
var mc = new MyComp(); var mc = new MyComp();
mc.form = form; mc.form = form;
mc.name = name; mc.name = name;

View File

@ -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", () => { describe("validator", () => {
it("should run the validator with the initial value (valid)", () => { it("should run the validator with the initial value (valid)", () => {
var g = new ControlGroup({"one": new Control('value', Validators.required)}); var g = new ControlGroup({"one": new Control('value', Validators.required)});