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( - '
' + + '' + '' + '