From f021ee30c0b0cef58cdfa233c12b33f9116cf5da Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Thu, 1 Sep 2016 10:52:30 +0100 Subject: [PATCH] feat(ngModelOptions): allow options to be inherited from ancestor ngModelOptions Previously, you had to apply the complete set of ngModelOptions at every point where you might want to modify just one or two settings. This change allows more general settings to be applied nearer to the top of the DOM and then for more specific settings to override those general settings further down in the DOM. Furher there is now a new service `$modelOptions` that acts as the top level options that are inherited by all ngModelOptions directives that do not already have an ngModelOptions ancestor directive. Closes #10922 BREAKING CHANGE: Previously, if a setting was not applied on ngModelOptions, then it would default to undefined. Now the setting will be inherited from the nearest ngModelOptions ancestor. It is possible that an ngModelOptions directive that does not set a property, has an ancestor ngModelOptions that does set this property to a value other than undefined. This would cause the ngModel and input controls below this ngModelOptions directive to display different behaviour. This is fixed by explictly setting the property in the ngModelOptions to prevent it from inheriting from the ancestor. For example if you had the following HTML: ```
``` Then before this change the input would update on the default event not blur. After this change the input will inherit the option to update on blur. If you want the original behaviour then you will need to specify the option on the input as well: ```
``` The programmatic API for `ngModelOptions` has changed. You must now read options via the `getOption` method, rather than accessing the option directly as a property of the options object. This does not affect the usage in templates and only affects custom directives that might have been reading options for their own purposes. --- src/AngularPublic.js | 2 + src/ng/directive/input.js | 2 +- src/ng/directive/ngModel.js | 469 ++++++++++++++++++++----------- test/ng/directive/formSpec.js | 10 +- test/ng/directive/ngModelSpec.js | 96 +++++++ 5 files changed, 407 insertions(+), 172 deletions(-) diff --git a/src/AngularPublic.js b/src/AngularPublic.js index b1b5f332ce8a..3edd34e3abe9 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -79,6 +79,7 @@ $jsonpCallbacksProvider, $LocationProvider, $LogProvider, + $ModelOptionsProvider, $ParseProvider, $RootScopeProvider, $QProvider, @@ -246,6 +247,7 @@ function publishExternalAPI(angular) { $jsonpCallbacks: $jsonpCallbacksProvider, $location: $LocationProvider, $log: $LogProvider, + $modelOptions: $ModelOptionsProvider, $parse: $ParseProvider, $rootScope: $RootScopeProvider, $q: $QProvider, diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 13990b4c0b68..dfb0aadda4ca 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1424,7 +1424,7 @@ function createDateInputType(type, regexp, parseDate, format) { return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { badInputChecker(scope, element, attr, ctrl); baseInputType(scope, element, attr, ctrl, $sniffer, $browser); - var timezone = ctrl && ctrl.$options && ctrl.$options.timezone; + var timezone = ctrl && ctrl.$options.getOption('timezone'); var previousDate; ctrl.$$parserName = type; diff --git a/src/ng/directive/ngModel.js b/src/ng/directive/ngModel.js index 6ef8ba408187..59424d0e2173 100644 --- a/src/ng/directive/ngModel.js +++ b/src/ng/directive/ngModel.js @@ -5,7 +5,8 @@ PRISTINE_CLASS: true, DIRTY_CLASS: true, UNTOUCHED_CLASS: true, - TOUCHED_CLASS: true + TOUCHED_CLASS: true, + $ModelOptionsProvider: true */ var VALID_CLASS = 'ng-valid', @@ -220,8 +221,8 @@ is set to `true`. The parse error is stored in `ngModel.$error.parse`. * * */ -var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', '$q', '$interpolate', - /** @this */ function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q, $interpolate) { +var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', '$q', '$interpolate', '$modelOptions', + /** @this */ function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q, $interpolate, $modelOptions) { this.$viewValue = Number.NaN; this.$modelValue = Number.NaN; this.$$rawModelValue = undefined; // stores the parsed modelValue / model set from scope regardless of validity. @@ -241,6 +242,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ this.$pending = undefined; // keep pending keys here this.$name = $interpolate($attr.name || '', false)($scope); this.$$parentForm = nullFormCtrl; + this.$options = $modelOptions; var parsedNgModel = $parse($attr.ngModel), parsedNgModelAssign = parsedNgModel.assign, @@ -250,9 +252,10 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ parserValid, ctrl = this; - this.$$setOptions = function(options) { - ctrl.$options = options; - if (options && options.getterSetter) { + + this.$$initGetterSetters = function() { + + if (ctrl.$options.getOption('getterSetter')) { var invokeModelGetter = $parse($attr.ngModel + '()'), invokeModelSetter = $parse($attr.ngModel + '($$$p)'); @@ -276,6 +279,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ } }; + /** * @ngdoc method * @name ngModel.NgModelController#$render @@ -562,7 +566,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ var prevValid = ctrl.$valid; var prevModelValue = ctrl.$modelValue; - var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid; + var allowInvalid = ctrl.$options.getOption('allowInvalid'); ctrl.$$runValidators(modelValue, viewValue, function(allValid) { // If there was no change in validity, don't update the model @@ -724,7 +728,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ ctrl.$modelValue = ngModelGet($scope); } var prevModelValue = ctrl.$modelValue; - var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid; + var allowInvalid = ctrl.$options.getOption('allowInvalid'); ctrl.$$rawModelValue = modelValue; if (allowInvalid) { @@ -815,25 +819,19 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ */ this.$setViewValue = function(value, trigger) { ctrl.$viewValue = value; - if (!ctrl.$options || ctrl.$options.updateOnDefault) { + if (ctrl.$options.getOption('updateOnDefault')) { ctrl.$$debounceViewValueCommit(trigger); } }; this.$$debounceViewValueCommit = function(trigger) { - var debounceDelay = 0, - options = ctrl.$options, - debounce; - - if (options && isDefined(options.debounce)) { - debounce = options.debounce; - if (isNumber(debounce)) { - debounceDelay = debounce; - } else if (isNumber(debounce[trigger])) { - debounceDelay = debounce[trigger]; - } else if (isNumber(debounce['default'])) { - debounceDelay = debounce['default']; - } + var options = ctrl.$options, + debounceDelay = options.getOption('debounce'); + + if (isNumber(debounceDelay[trigger])) { + debounceDelay = debounceDelay[trigger]; + } else if (isNumber(debounceDelay['default'])) { + debounceDelay = debounceDelay['default']; } $timeout.cancel(pendingDebounce); @@ -1096,9 +1094,14 @@ var ngModelDirective = ['$rootScope', function($rootScope) { return { pre: function ngModelPreLink(scope, element, attr, ctrls) { var modelCtrl = ctrls[0], - formCtrl = ctrls[1] || modelCtrl.$$parentForm; + formCtrl = ctrls[1] || modelCtrl.$$parentForm, + optionsCtrl = ctrls[2]; + + if (optionsCtrl) { + modelCtrl.$options = optionsCtrl.$options; + } - modelCtrl.$$setOptions(ctrls[2] && ctrls[2].$options); + modelCtrl.$$initGetterSetters(); // notify others, especially parent forms formCtrl.$addControl(modelCtrl); @@ -1115,8 +1118,8 @@ var ngModelDirective = ['$rootScope', function($rootScope) { }, post: function ngModelPostLink(scope, element, attr, ctrls) { var modelCtrl = ctrls[0]; - if (modelCtrl.$options && modelCtrl.$options.updateOn) { - element.on(modelCtrl.$options.updateOn, function(ev) { + if (modelCtrl.$options.getOption('updateOn')) { + element.on(modelCtrl.$options.getOption('updateOn'), function(ev) { modelCtrl.$$debounceViewValueCommit(ev && ev.type); }); } @@ -1137,17 +1140,47 @@ var ngModelDirective = ['$rootScope', function($rootScope) { }]; - -var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; - /** * @ngdoc directive * @name ngModelOptions * * @description - * Allows tuning how model updates are done. Using `ngModelOptions` you can specify a custom list of - * events that will trigger a model update and/or a debouncing delay so that the actual update only - * takes place when a timer expires; this timer will be reset after another change takes place. + * This directive allows you to modify the behaviour of ngModel and input directives within your + * application. You can specify an ngModelOptions directive on any element and the settings affect + * the ngModel and input directives on all descendent elements. + * + * The ngModelOptions settings are found by evaluating the value of the ngModelOptions attribute as + * an Angular expression. This expression should evaluate to an object, whose properties contain + * the settings. + * + * If a setting is not specified as a property on the object for a particular ngModelOptions directive + * then it will inherit that setting from the first ngModelOptions directive found by traversing up the + * DOM tree. If there is no ancestor element containing an ngModelOptions directive then the settings in + * {@link $modelOptions} will be used. + * + * For example given the following fragment of HTML + * + * + * ```html + *
+ *
+ * + *
+ *
+ * ``` + * + * the `input` element will have the following settings + * + * ```js + * { allowInvalid: true, updateOn: 'default' } + * ``` + * + * + * ## Triggering and debouncing model updates + * + * The `updateOn` and `debounce` properties allow you to specify a custom list of events that will + * trigger a model update and/or a debouncing delay so that the actual update only takes place when + * a timer expires; this timer will be reset after another change takes place. * * Given the nature of `ngModelOptions`, the value displayed inside input fields in the view might * be different from the value in the actual model. This means that if you update the model you @@ -1163,7 +1196,130 @@ var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; * `submit` event. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit` * to have access to the updated model. * - * `ngModelOptions` has an effect on the element it's declared on and its descendants. + * The following example shows how to override immediate updates. Changes on the inputs within the + * form will update the model only when the control loses focus (blur event). If `escape` key is + * pressed while the input field is focused, the value is reset to the value in the current model. + * + * + * + *
+ *
+ * Name: + *
+ * + * Other data: + *
+ *
+ *
user.name = 
+ *
+ *
+ * + * angular.module('optionsExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.user = { name: 'say', data: '' }; + * + * $scope.cancel = function(e) { + * if (e.keyCode === 27) { + * $scope.userForm.userName.$rollbackViewValue(); + * } + * }; + * }]); + * + * + * var model = element(by.binding('user.name')); + * var input = element(by.model('user.name')); + * var other = element(by.model('user.data')); + * + * it('should allow custom events', function() { + * input.sendKeys(' hello'); + * input.click(); + * expect(model.getText()).toEqual('say'); + * other.click(); + * expect(model.getText()).toEqual('say hello'); + * }); + * + * it('should $rollbackViewValue when model changes', function() { + * input.sendKeys(' hello'); + * expect(input.getAttribute('value')).toEqual('say hello'); + * input.sendKeys(protractor.Key.ESCAPE); + * expect(input.getAttribute('value')).toEqual('say'); + * other.click(); + * expect(model.getText()).toEqual('say'); + * }); + * + *
+ * + * The next example shows how to debounce model changes. Model will be updated only 1 sec after last change. + * If the `Clear` button is pressed, any debounced action is canceled and the value becomes empty. + * + * + * + *
+ *
+ * Name: + * + *
+ *
+ *
user.name = 
+ *
+ *
+ * + * angular.module('optionsExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.user = { name: 'say' }; + * }]); + * + *
+ * + * ## Model updates and validation + * + * The default behaviour in `ngModel` is that the model value is set to `null` when the validation + * determines that the value is invalid. By setting the `allowInvalid` property to true, the model + * will still be updated even if the value is invalid. + * + * + * ## Connecting to the scope + * + * By setting the `getterSetter` property to true you are telling ngModel that the `ngModel` expression + * on the scope refers to a "getter/setter" function rather than the value itself. + * + * The following example shows how to bind to getter/setters: + * + * + * + *
+ *
+ * Name: + * + *
+ *
user.name = 
+ *
+ *
+ * + * angular.module('getterSetterExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * var _name = 'Brian'; + * $scope.user = { + * name: function(newName) { + * return angular.isDefined(newName) ? (_name = newName) : _name; + * } + * }; + * }]); + * + *
+ * + * + * ## Specifying timezones + * + * You can specify the timezone that date/time input directives expect by providing its name in the + * `timezone` property. * * @param {Object} ngModelOptions options to apply to the current model. Valid keys are: * - `updateOn`: string specifying which event should the input be bound to. You can set several @@ -1176,153 +1332,134 @@ var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; * - `allowInvalid`: boolean value which indicates that the model can be set with values that did * not validate correctly instead of the default behavior of setting the model to undefined. * - `getterSetter`: boolean value which determines whether or not to treat functions bound to - `ngModel` as getters/setters. + * `ngModel` as getters/setters. * - `timezone`: Defines the timezone to be used to read/write the `Date` instance in the model for * ``, ``, ... . It understands UTC/GMT and the * continental US time zone abbreviations, but for general use, use a time zone offset, for * example, `'+0430'` (4 hours, 30 minutes east of the Greenwich meridian) * If not specified, the timezone of the browser will be used. * - * @example - - The following example shows how to override immediate updates. Changes on the inputs within the - form will update the model only when the control loses focus (blur event). If `escape` key is - pressed while the input field is focused, the value is reset to the value in the current model. - - - -
-
-
-
-
-
user.name = 
-
user.data = 
-
-
- - angular.module('optionsExample', []) - .controller('ExampleController', ['$scope', function($scope) { - $scope.user = { name: 'John', data: '' }; - - $scope.cancel = function(e) { - if (e.keyCode === 27) { - $scope.userForm.userName.$rollbackViewValue(); - } - }; - }]); - - - var model = element(by.binding('user.name')); - var input = element(by.model('user.name')); - var other = element(by.model('user.data')); - - it('should allow custom events', function() { - input.sendKeys(' Doe'); - input.click(); - expect(model.getText()).toEqual('John'); - other.click(); - expect(model.getText()).toEqual('John Doe'); - }); + */ +var ngModelOptionsDirective = ['$modelOptions', function($modelOptions) { + return { + restrict: 'A', + // ngModelOptions needs to run before ngModel and input directives + priority: 10, + require: ['ngModelOptions', '?^^ngModelOptions'], + controller: function NgModelOptionsController() {}, + link: { + pre: function ngModelOptionsPreLinkFn(scope, element, attrs, ctrls) { + var optionsCtrl = ctrls[0]; + var parentOptions = ctrls[1] ? ctrls[1].$options : $modelOptions; + optionsCtrl.$options = parentOptions.createChild(scope.$eval(attrs.ngModelOptions)); + } + } + }; +}]; - it('should $rollbackViewValue when model changes', function() { - input.sendKeys(' Doe'); - expect(input.getAttribute('value')).toEqual('John Doe'); - input.sendKeys(protractor.Key.ESCAPE); - expect(input.getAttribute('value')).toEqual('John'); - other.click(); - expect(model.getText()).toEqual('John'); - }); - -
- This one shows how to debounce model changes. Model will be updated only 1 sec after last change. - If the `Clear` button is pressed, any debounced action is canceled and the value becomes empty. +/** + * @ngdoc provider + * @name $modelOptionsProvider + * @description + * + * Here, you can change the default settings from which {@link ngModelOptions} + * directives inherit. + * + * See the {@link ngModelOptions} directive for a list of the available options. + */ +function $ModelOptionsProvider() { + return { + /** + * @ngdoc property + * @name $modelOptionsProvider#defaultOptions + * @type {Object} + * @description + * The default options to fall back on when there are no more ngModelOption + * directives as ancestors. + * Use this property to specify the defaultOptions for the application as a whole. + * + * The initial default options are: + * + * * `updateOn`: `default` + * * `debounce`: `0` + * * `allowInvalid`: `undefined` + * * `getterSetter`: `undefined` + * * `timezone`: 'undefined' + */ + defaultOptions: { + updateOn: 'default', + debounce: 0 + }, - - -
-
- - -
-
-
user.name = 
-
-
- - angular.module('optionsExample', []) - .controller('ExampleController', ['$scope', function($scope) { - $scope.user = { name: 'Igor' }; - }]); - -
+ /** + * @ngdoc service + * @name $modelOptions + * @type ModelOptions + * @description + * + * This service provides the application wide default {@link ModelOptions} options that + * will be used by {@link ngModel} directives if no {@link ngModelOptions} directive is + * specified. + */ + $get: function() { + return new ModelOptions(this.defaultOptions); + } + }; +} - This one shows how to bind to getter/setters: - - -
-
- -
-
user.name = 
-
-
- - angular.module('getterSetterExample', []) - .controller('ExampleController', ['$scope', function($scope) { - var _name = 'Brian'; - $scope.user = { - name: function(newName) { - // Note that newName can be undefined for two reasons: - // 1. Because it is called as a getter and thus called with no arguments - // 2. Because the property should actually be set to undefined. This happens e.g. if the - // input is invalid - return arguments.length ? (_name = newName) : _name; - } - }; - }]); - -
+/** + * @ngdoc type + * @name ModelOptions + * @description + * A container for the options set by the {@link ngModelOptions} directive + * and the {@link $modelOptions} service. */ -var ngModelOptionsDirective = function() { - return { - restrict: 'A', - controller: ['$scope', '$attrs', function NgModelOptionsController($scope, $attrs) { - var that = this; - this.$options = copy($scope.$eval($attrs.ngModelOptions)); - // Allow adding/overriding bound events - if (isDefined(this.$options.updateOn)) { - this.$options.updateOnDefault = false; - // extract "default" pseudo-event from list of events that can trigger a model update - this.$options.updateOn = trim(this.$options.updateOn.replace(DEFAULT_REGEXP, function() { - that.$options.updateOnDefault = true; - return ' '; - })); - } else { - this.$options.updateOnDefault = true; - } - }] - }; -}; +var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; +function ModelOptions(options, parentOptions) { + + // Extend the parent's options with these new ones + var _options = extend({}, parentOptions, options); + + // do extra processing on the options + + // updateOn and updateOnDefault + if (isDefined(_options.updateOn) && _options.updateOn.trim()) { + _options.updateOnDefault = false; + // extract "default" pseudo-event from list of events that can trigger a model update + _options.updateOn = trim(_options.updateOn.replace(DEFAULT_REGEXP, function() { + _options.updateOnDefault = true; + return ' '; + })); + } else if (parentOptions) { + _options.updateOn = parentOptions.updateOn; + _options.updateOnDefault = parentOptions.updateOnDefault; + } else { + _options.updateOnDefault = true; + } + /** + * @ngdoc method + * @name ModelOptions#getOption + * @param {string} name the name of the option to retrieve + * @returns {*} the value of the option + * @description + * Returns the value of the given option + */ + this.getOption = function(name) { return _options[name]; }; + + /** + * @ngdoc method + * @name ModelOptions#createChild + * @param {Object} options a hash of options for the new child that will override the parent's options + * @return {ModelOptions} a new `ModelOptions` object initialized with the given options. + */ + this.createChild = function(options) { + return new ModelOptions(options, _options); + }; +} // helper methods function addSetValidityMethod(context) { diff --git a/test/ng/directive/formSpec.js b/test/ng/directive/formSpec.js index f878c4826070..e1d681956b23 100644 --- a/test/ng/directive/formSpec.js +++ b/test/ng/directive/formSpec.js @@ -290,7 +290,7 @@ describe('form', function() { describe('triggering commit value on submit', function() { it('should trigger update on form submit', function() { var form = $compile( - '
' + + '' + '' + '
')(scope); scope.$digest(); @@ -305,7 +305,7 @@ describe('form', function() { it('should trigger update on form submit with nested forms', function() { var form = $compile( - '
' + + '' + '
' + '' + '
' + @@ -323,7 +323,7 @@ describe('form', function() { it('should trigger update before ng-submit is invoked', function() { var form = $compile( '' + + 'ng-model-options="{ updateOn: \'submit\' }" >' + '' + '
')(scope); scope.$digest(); @@ -342,7 +342,7 @@ describe('form', function() { describe('rollback view value', function() { it('should trigger rollback on form controls', function() { var form = $compile( - '
' + + '' + '' + '