Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

feat(ngModelOptions): support submit trigger #7116

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions src/ng/directive/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ function FormController(element, attrs, $scope, $animate) {
$animate.addClass(element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey);
}

/**
* @ngdoc method
* @name form.FormController#$commitViewValue
*
* @description
* Commit all form controls pending updates to the `$modelValue`.
*
* Updates may be pending by a debounced event or because the input is waiting for a some future
* event defined in `ng-model-options`. This method is rarely needed as `NgModelController`
* usually handles calling this in response to input events.
*/
form.$commitViewValue = function() {
forEach(controls, function(control) {
control.$commitViewValue();
});
};

/**
* @ngdoc method
* @name form.FormController#$addControl
Expand Down Expand Up @@ -286,6 +303,10 @@ function FormController(element, attrs, $scope, $animate) {
* hitting enter in any of the input fields will trigger the click handler on the *first* button or
* input[type=submit] (`ngClick`) *and* a submit handler on the enclosing form (`ngSubmit`)
*
* Any pending `ngModelOptions` changes will take place immediately when an enclosing form is
* submitted. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit`
* to have access to the updated model.
*
* @param {string=} name Name of the form. If specified, the form controller will be published into
* related scope, under this name.
*
Expand Down Expand Up @@ -381,19 +402,23 @@ var formDirectiveFactory = function(isNgForm) {
// IE 9 is not affected because it doesn't fire a submit event and try to do a full
// page reload if the form was destroyed by submission of the form via a click handler
// on a button in the form. Looks like an IE9 specific bug.
var preventDefaultListener = function(event) {
var handleFormSubmission = function(event) {
scope.$apply(function() {
controller.$commitViewValue();
});

event.preventDefault
? event.preventDefault()
: event.returnValue = false; // IE
};

addEventListenerFn(formElement[0], 'submit', preventDefaultListener);
addEventListenerFn(formElement[0], 'submit', handleFormSubmission);

// unregister the preventDefault listener so that we don't not leak memory but in a
// way that will achieve the prevention of the default action.
formElement.on('$destroy', function() {
$timeout(function() {
removeEventListenerFn(formElement[0], 'submit', preventDefaultListener);
removeEventListenerFn(formElement[0], 'submit', handleFormSubmission);
}, 0, false);
});
}
Expand Down
172 changes: 92 additions & 80 deletions src/ng/directive/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ var DATETIMELOCAL_REGEXP = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)$/;
var WEEK_REGEXP = /^(\d{4})-W(\d\d)$/;
var MONTH_REGEXP = /^(\d{4})-(\d\d)$/;
var TIME_REGEXP = /^(\d\d):(\d\d)$/;
var DEFAULT_REGEXP = /(\b|^)default(\b|$)/;
var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/;

var inputType = {

Expand Down Expand Up @@ -934,51 +934,42 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) {
}
};

// Allow adding/overriding bound events
if (ctrl.$options && ctrl.$options.updateOn) {
// bind to user-defined events
element.on(ctrl.$options.updateOn, listener);
}

// setup default events if requested
if (!ctrl.$options || ctrl.$options.updateOnDefault) {
// if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the
// input event on backspace, delete or cut
if ($sniffer.hasEvent('input')) {
element.on('input', listener);
} else {
var timeout;

var deferListener = function(ev) {
if (!timeout) {
timeout = $browser.defer(function() {
listener(ev);
timeout = null;
});
}
};
// if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the
// input event on backspace, delete or cut
if ($sniffer.hasEvent('input')) {
element.on('input', listener);
} else {
var timeout;

var deferListener = function(ev) {
if (!timeout) {
timeout = $browser.defer(function() {
listener(ev);
timeout = null;
});
}
};

element.on('keydown', function(event) {
var key = event.keyCode;
element.on('keydown', function(event) {
var key = event.keyCode;

// ignore
// command modifiers arrows
if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return;
// ignore
// command modifiers arrows
if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return;

deferListener(event);
});
deferListener(event);
});

// if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it
if ($sniffer.hasEvent('paste')) {
element.on('paste cut', deferListener);
}
// if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it
if ($sniffer.hasEvent('paste')) {
element.on('paste cut', deferListener);
}

// if user paste into input using mouse on older browser
// or form autocomplete on newer browser, we need "change" event to catch it
element.on('change', listener);
}

// if user paste into input using mouse on older browser
// or form autocomplete on newer browser, we need "change" event to catch it
element.on('change', listener);

ctrl.$render = function() {
element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue);
};
Expand Down Expand Up @@ -1221,15 +1212,7 @@ function radioInputType(scope, element, attr, ctrl) {
}
};

// Allow adding/overriding bound events
if (ctrl.$options && ctrl.$options.updateOn) {
// bind to user-defined events
element.on(ctrl.$options.updateOn, listener);
}

if (!ctrl.$options || ctrl.$options.updateOnDefault) {
element.on('click', listener);
}
element.on('click', listener);

ctrl.$render = function() {
var value = attr.value;
Expand All @@ -1252,15 +1235,7 @@ function checkboxInputType(scope, element, attr, ctrl) {
});
};

// Allow adding/overriding bound events
if (ctrl.$options && ctrl.$options.updateOn) {
// bind to user-defined events
element.on(ctrl.$options.updateOn, listener);
}

if (!ctrl.$options || ctrl.$options.updateOnDefault) {
element.on('click', listener);
}
element.on('click', listener);

ctrl.$render = function() {
element[0].checked = ctrl.$viewValue;
Expand Down Expand Up @@ -1704,22 +1679,22 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$

/**
* @ngdoc method
* @name ngModel.NgModelController#$cancelUpdate
* @name ngModel.NgModelController#$rollbackViewValue
*
* @description
* Cancel an update and reset the input element's value to prevent an update to the `$viewValue`,
* Cancel an update and reset the input element's value to prevent an update to the `$modelValue`,
* which may be caused by a pending debounced event or because the input is waiting for a some
* future event.
*
* If you have an input that uses `ng-model-options` to set up debounced events or events such
* as blur you can have a situation where there is a period when the value of the input element
* is out of synch with the ngModel's `$viewValue`.
* as blur you can have a situation where there is a period when the `$viewValue`
* is out of synch with the ngModel's `$modelValue`.
*
* In this case, you can run into difficulties if you try to update the ngModel's `$modelValue`
* programmatically before these debounced/future events have resolved/occurred, because Angular's
* dirty checking mechanism is not able to tell whether the model has actually changed or not.
*
* The `$cancelUpdate()` method should be called before programmatically changing the model of an
* The `$rollbackViewValue()` method should be called before programmatically changing the model of an
* input which may have such events pending. This is important in order to make sure that the
* input field will be updated with the new model value and any pending operations are cancelled.
*
Expand All @@ -1730,7 +1705,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* .controller('CancelUpdateCtrl', function($scope) {
* $scope.resetWithCancel = function (e) {
* if (e.keyCode == 27) {
* $scope.myForm.myInput1.$cancelUpdate();
* $scope.myForm.myInput1.$rollbackViewValue();
* $scope.myValue = '';
* }
* };
Expand All @@ -1749,26 +1724,39 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* <p>Now see what happens if you start typing then press the Escape key</p>
*
* <form name="myForm" ng-model-options="{ updateOn: 'blur' }">
* <p>With $cancelUpdate()</p>
* <p>With $rollbackViewValue()</p>
* <input name="myInput1" ng-model="myValue" ng-keydown="resetWithCancel($event)"><br/>
* myValue: "{{ myValue }}"
*
* <p>Without $cancelUpdate()</p>
* <p>Without $rollbackViewValue()</p>
* <input name="myInput2" ng-model="myValue" ng-keydown="resetWithoutCancel($event)"><br/>
* myValue: "{{ myValue }}"
* </form>
* </div>
* </file>
* </example>
*/
this.$cancelUpdate = function() {
this.$rollbackViewValue = function() {
$timeout.cancel(pendingDebounce);
ctrl.$viewValue = ctrl.$$lastCommittedViewValue;
ctrl.$render();
};

// update the view value
this.$$realSetViewValue = function(value) {
ctrl.$viewValue = value;
/**
* @ngdoc method
* @name ngModel.NgModelController#$commitViewValue
*
* @description
* Commit a pending update to the `$modelValue`.
*
* Updates may be pending by a debounced event or because the input is waiting for a some future
* event defined in `ng-model-options`. this method is rarely needed as `NgModelController`
* usually handles calling this in response to input events.
*/
this.$commitViewValue = function() {
var value = ctrl.$viewValue;
ctrl.$$lastCommittedViewValue = value;
$timeout.cancel(pendingDebounce);

// change to dirty
if (ctrl.$pristine) {
Expand Down Expand Up @@ -1813,6 +1801,9 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
*
* Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called.
*
* In case the {@link ng.directive:ngModelOptions ngModelOptions} directive is used with `updateOn`
* and the `default` trigger is not listed, all those actions will remain pending until one of the
* `updateOn` events is triggered on the DOM element.
* All these actions will be debounced if the {@link ng.directive:ngModelOptions ngModelOptions}
* directive is used with a custom debounce for this particular event.
*
Expand All @@ -1822,6 +1813,13 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* @param {string} trigger Event that triggered the update.
*/
this.$setViewValue = function(value, trigger) {
ctrl.$viewValue = value;
if (!ctrl.$options || ctrl.$options.updateOnDefault) {
ctrl.$$debounceViewValueCommit(trigger);
}
};

this.$$debounceViewValueCommit = function(trigger) {
var debounceDelay = 0,
options = ctrl.$options,
debounce;
Expand All @@ -1840,10 +1838,10 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
$timeout.cancel(pendingDebounce);
if (debounceDelay) {
pendingDebounce = $timeout(function() {
ctrl.$$realSetViewValue(value);
ctrl.$commitViewValue();
}, debounceDelay);
} else {
ctrl.$$realSetViewValue(value);
ctrl.$commitViewValue();
}
};

Expand All @@ -1863,7 +1861,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
}

if (ctrl.$viewValue !== value) {
ctrl.$viewValue = value;
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = value;
ctrl.$render();
}
}
Expand Down Expand Up @@ -2001,6 +1999,16 @@ var ngModelDirective = function() {
scope.$on('$destroy', function() {
formCtrl.$removeControl(modelCtrl);
});
},
post: function(scope, element, attr, ctrls) {
var modelCtrl = ctrls[0];
if (modelCtrl.$options && modelCtrl.$options.updateOn) {
element.on(modelCtrl.$options.updateOn, function(ev) {
scope.$apply(function() {
modelCtrl.$$debounceViewValueCommit(ev && ev.type);
});
});
}
}
}
};
Expand Down Expand Up @@ -2279,14 +2287,18 @@ var ngValueDirective = function() {
*
* Given the nature of `ngModelOptions`, the value displayed inside input fields in the view might
* be different than the value in the actual model. This means that if you update the model you
* should also invoke {@link ngModel.NgModelController `$cancelUpdate`} on the relevant input field in
* should also invoke {@link ngModel.NgModelController `$rollbackViewValue`} on the relevant input field in
* order to make sure it is synchronized with the model and that any debounced action is canceled.
*
* The easiest way to reference the control's {@link ngModel.NgModelController `$cancelUpdate`}
* The easiest way to reference the control's {@link ngModel.NgModelController `$rollbackViewValue`}
* method is by making sure the input is placed inside a form that has a `name` attribute. This is
* important because `form` controllers are published to the related scope under the name in their
* `name` attribute.
*
* Any pending changes will take place immediately when an enclosing form is submitted via the
* `submit` event. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit`
* to have access to the updated model.
*
* @param {Object} ngModelOptions options to apply to the current model. Valid keys are:
* - `updateOn`: string specifying which event should be the input bound to. You can set several
* events using an space delimited list. There is a special event called `default` that
Expand Down Expand Up @@ -2324,7 +2336,7 @@ var ngValueDirective = function() {

$scope.cancel = function (e) {
if (e.keyCode == 27) {
$scope.userForm.userName.$cancelUpdate();
$scope.userForm.userName.$rollbackViewValue();
}
};
}
Expand All @@ -2342,7 +2354,7 @@ var ngValueDirective = function() {
expect(model.getText()).toEqual('say hello');
});

it('should $cancelUpdate when model changes', function() {
it('should $rollbackViewValue when model changes', function() {
input.sendKeys(' hello');
expect(input.getAttribute('value')).toEqual('say hello');
input.sendKeys(protractor.Key.ESCAPE);
Expand All @@ -2364,7 +2376,7 @@ var ngValueDirective = function() {
<input type="text" name="userName"
ng-model="user.name"
ng-model-options="{ debounce: 1000 }" />
<button ng-click="userForm.userName.$cancelUpdate(); user.name=''">Clear</button><br />
<button ng-click="userForm.userName.$rollbackViewValue(); user.name=''">Clear</button><br />
</form>
<pre>user.name = <span ng-bind="user.name"></span></pre>
</div>
Expand All @@ -2382,13 +2394,13 @@ var ngModelOptionsDirective = function() {
var that = this;
this.$options = $scope.$eval($attrs.ngModelOptions);
// Allow adding/overriding bound events
if (this.$options.updateOn) {
if (this.$options.updateOn !== undefined) {
this.$options.updateOnDefault = false;
// extract "default" pseudo-event from list of events that can trigger a model update
this.$options.updateOn = this.$options.updateOn.replace(DEFAULT_REGEXP, function() {
this.$options.updateOn = trim(this.$options.updateOn.replace(DEFAULT_REGEXP, function() {
that.$options.updateOnDefault = true;
return ' ';
});
}));
} else {
this.$options.updateOnDefault = true;
}
Expand Down
Loading