Skip to content
Permalink
Browse files

feat(forms): add updateOn support to ngModelOptions

This commit introduces a new option to template-driven forms that
improves performance by delaying form control updates until the
"blur" or "submit" event.  To use it, set the `updateOn` property
in `ngModelOptions`.

```html
<input ngModel [ngModelOptions]="{updateOn: blur}">
```

Like in AngularJS, setting `updateOn` to `blur` or `submit` will
delay the update of the value as well as the validation status.
Updating value and validity together keeps the system easy to reason
about, as the two will always be in sync.  It's also worth noting
that the value/validation pipeline does still run when the form is
initialized (in order to support initial values).

Upcoming PRs will address:

* Support for setting group-level `updateOn` in template-driven forms
* Option for skipping initial validation run or more global error
display configuration
* Better support of reactive validation strategies

See more context in #18408, #18514, and the [design doc](https://docs.google.com/document/d/1dlJjRXYeuHRygryK0XoFrZNqW86jH4wobftCFyYa1PA/edit#heading=h.r6gn0i8f19wz).
  • Loading branch information...
kara authored and hansl committed Aug 8, 2017
1 parent cce2ab2 commit 1cfa79ca4e21788e0323baf544704ee7ef7d63ea
@@ -16,7 +16,7 @@ import {Form} from './form_interface';
import {NgControl} from './ng_control';
import {NgModel} from './ng_model';
import {NgModelGroup} from './ng_model_group';
import {composeAsyncValidators, composeValidators, setUpControl, setUpFormContainer} from './shared';
import {composeAsyncValidators, composeValidators, removeDir, setUpControl, setUpFormContainer, syncPendingControls} from './shared';

export const formDirectiveProvider: any = {
provide: ControlContainer,
@@ -65,6 +65,7 @@ const resolvedPromise = Promise.resolve(null);
})
export class NgForm extends ControlContainer implements Form {
private _submitted: boolean = false;
private _directives: NgModel[] = [];

form: FormGroup;
ngSubmit = new EventEmitter();
@@ -93,6 +94,7 @@ export class NgForm extends ControlContainer implements Form {
dir._control = <FormControl>container.registerControl(dir.name, dir.control);
setUpControl(dir.control, dir);
dir.control.updateValueAndValidity({emitEvent: false});
this._directives.push(dir);
});
}

@@ -104,6 +106,7 @@ export class NgForm extends ControlContainer implements Form {
if (container) {
container.removeControl(dir.name);
}
removeDir<NgModel>(this._directives, dir);
});
}

@@ -139,6 +142,7 @@ export class NgForm extends ControlContainer implements Form {

onSubmit($event: Event): boolean {
this._submitted = true;
syncPendingControls(this.form, this._directives);
this.ngSubmit.emit($event);
return false;
}
@@ -8,7 +8,7 @@

import {Directive, EventEmitter, Host, Inject, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges, forwardRef} from '@angular/core';

import {FormControl} from '../model';
import {FormControl, FormHooks} from '../model';
import {NG_ASYNC_VALIDATORS, NG_VALIDATORS} from '../validators';

import {AbstractFormGroupDirective} from './abstract_form_group_directive';
@@ -119,7 +119,45 @@ export class NgModel extends NgControl implements OnChanges,
@Input() name: string;
@Input('disabled') isDisabled: boolean;
@Input('ngModel') model: any;
@Input('ngModelOptions') options: {name?: string, standalone?: boolean};

/**
* Options object for this `ngModel` instance. You can configure the following properties:
*
* **name**: An alternative to setting the name attribute on the form control element.
* Sometimes, especially with custom form components, the name attribute might be used
* as an `@Input` property for a different purpose. In cases like these, you can configure
* the `ngModel` name through this option.
*
* ```html
* <form>
* <my-person-control name="Nancy" ngModel [ngModelOptions]="{name: 'user'}">
* </my-person-control>
* </form>
* <!-- form value: {user: ''} -->
* ```
*
* **standalone**: Defaults to false. If this is set to true, the `ngModel` will not
* register itself with its parent form, and will act as if it's not in the form. This
* can be handy if you have form meta-controls, a.k.a. form elements nested in
* the `<form>` tag that control the display of the form, but don't contain form data.
*
* ```html
* <form>
* <input name="login" ngModel placeholder="Login">
* <input type="checkbox" ngModel [ngModelOptions]="{standalone: true}"> Show more options?
* </form>
* <!-- form value: {login: ''} -->
* ```
*
* **updateOn**: Defaults to `'change'`. Defines the event upon which the form control
* value and validity will update. Also accepts `'blur'` and `'submit'`.
*
* ```html
* <input [(ngModel)]="firstName" [ngModelOptions]="{updateOn: 'blur'}">
* ```
*
*/
@Input('ngModelOptions') options: {name?: string, standalone?: boolean, updateOn?: FormHooks};

@Output('ngModelChange') update = new EventEmitter();

@@ -170,11 +208,18 @@ export class NgModel extends NgControl implements OnChanges,
}

private _setUpControl(): void {
this._setUpdateStrategy();
this._isStandalone() ? this._setUpStandalone() :
this.formDirective.addControl(this);
this._registered = true;
}

private _setUpdateStrategy(): void {
if (this.options && this.options.updateOn != null) {
this._control._updateOn = this.options.updateOn;
}
}

private _isStandalone(): boolean {
return !this._parent || !!(this.options && this.options.standalone);
}
@@ -12,7 +12,7 @@ import {NG_ASYNC_VALIDATORS, NG_VALIDATORS, Validators} from '../../validators';
import {ControlContainer} from '../control_container';
import {Form} from '../form_interface';
import {ReactiveErrors} from '../reactive_errors';
import {cleanUpControl, composeAsyncValidators, composeValidators, setUpControl, setUpFormContainer} from '../shared';
import {cleanUpControl, composeAsyncValidators, composeValidators, removeDir, setUpControl, setUpFormContainer, syncPendingControls} from '../shared';

import {FormControlName} from './form_control_name';
import {FormArrayName, FormGroupName} from './form_group_name';
@@ -105,7 +105,7 @@ export class FormGroupDirective extends ControlContainer implements Form,

getControl(dir: FormControlName): FormControl { return <FormControl>this.form.get(dir.path); }

removeControl(dir: FormControlName): void { remove(this.directives, dir); }
removeControl(dir: FormControlName): void { removeDir<FormControlName>(this.directives, dir); }

addFormGroup(dir: FormGroupName): void {
const ctrl: any = this.form.get(dir.path);
@@ -134,7 +134,7 @@ export class FormGroupDirective extends ControlContainer implements Form,

onSubmit($event: Event): boolean {
this._submitted = true;
this._syncPendingControls();
syncPendingControls(this.form, this.directives);
this.ngSubmit.emit($event);
return false;
}
@@ -146,15 +146,6 @@ export class FormGroupDirective extends ControlContainer implements Form,
this._submitted = false;
}

/** @internal */
_syncPendingControls() {
this.form._syncPendingControls();
this.directives.forEach(dir => {
if (dir.control.updateOn === 'submit') {
dir.viewToModelUpdate(dir.control._pendingValue);
}
});
}

/** @internal */
_updateDomValue() {
@@ -190,10 +181,3 @@ export class FormGroupDirective extends ControlContainer implements Form,
}
}
}

function remove<T>(list: T[], el: T): void {
const index = list.indexOf(el);
if (index > -1) {
list.splice(index, 1);
}
}
@@ -167,6 +167,16 @@ export function isBuiltInAccessor(valueAccessor: ControlValueAccessor): boolean
return BUILTIN_ACCESSORS.some(a => valueAccessor.constructor === a);
}

export function syncPendingControls(form: FormGroup, directives: NgControl[]): void {
form._syncPendingControls();
directives.forEach(dir => {
const control = dir.control as FormControl;
if (control.updateOn === 'submit') {
dir.viewToModelUpdate(control._pendingValue);
}
});
}

// TODO: vsavkin remove it once https://github.com/angular/angular/issues/3011 is implemented
export function selectValueAccessor(
dir: NgControl, valueAccessors: ControlValueAccessor[]): ControlValueAccessor|null {
@@ -198,3 +208,8 @@ export function selectValueAccessor(
_throwError(dir, 'No valid value accessor for form control with');
return null;
}

export function removeDir<T>(list: T[], el: T): void {
const index = list.indexOf(el);
if (index > -1) list.splice(index, 1);
}
@@ -819,6 +819,22 @@ export function main() {
expect(control.dirty).toBe(true, 'Expected control to update dirty state when blurred.');
});

it('should update touched when control is blurred', () => {
const fixture = initTest(FormControlComp);
const control = new FormControl('', {updateOn: 'blur'});
fixture.componentInstance.control = control;
fixture.detectChanges();

expect(control.touched).toBe(false, 'Expected control to start out untouched.');

const input = fixture.debugElement.query(By.css('input')).nativeElement;
dispatchEvent(input, 'blur');
fixture.detectChanges();

expect(control.touched)
.toBe(true, 'Expected control to update touched state when blurred.');
});

it('should continue waiting for blur to update if previously blurred', () => {
const fixture = initTest(FormControlComp);
const control =
Oops, something went wrong.

0 comments on commit 1cfa79c

Please sign in to comment.
You can’t perform that action at this time.