From c0747ddb63fe12509cf462c221f89fe9443d07c3 Mon Sep 17 00:00:00 2001 From: Neville Samuell Date: Fri, 31 Oct 2014 15:14:02 -0400 Subject: [PATCH] feat(ngModelOptions): add getterSetterContext to specify context for getter/setter bindings Along with getterSetter, allow users to provide an expression via the getterSetterContext option. This expression is evaluated to determine the context that should be used when invoking the ngModel as a getter/setter function. For example, would previously invoke 'someObject.value()' from the global context. Now, users can specify context, like ng-model-options="{ getterSetter: true, getterSetterContext: 'someObject'}", which would invoke 'someObject.value()' using 'someObject' as the calling context. If getterSetterContext is not provided, fallback to using the current scope as the context. Closes #9394 BREAKING CHANGE: previously, getter/setter functions would always be called from the global context. This behaviour was unexpected by some users, as described in #9394, and is not particularly nice anyways. Applications that relied on this behaviour can use `$window` instead of `this` to access the global object... but they probably shouldn't be storing global state anyways! --- src/ng/directive/input.js | 10 ++++- test/ng/directive/inputSpec.js | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 1b393676cd79..2a6adadaf6a9 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1716,13 +1716,14 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ var parsedNgModel = $parse($attr.ngModel), + parsedNgModelContext = null, pendingDebounce = null, ctrl = this; var ngModelGet = function ngModelGet() { var modelValue = parsedNgModel($scope); if (ctrl.$options && ctrl.$options.getterSetter && isFunction(modelValue)) { - modelValue = modelValue(); + modelValue = modelValue.call(parsedNgModelContext ? parsedNgModelContext($scope) : $scope); } return modelValue; }; @@ -1732,7 +1733,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ if (ctrl.$options && ctrl.$options.getterSetter && isFunction(getterSetter = parsedNgModel($scope))) { - getterSetter(ctrl.$modelValue); + getterSetter.call(parsedNgModelContext ? parsedNgModelContext($scope) : $scope, ctrl.$modelValue); } else { parsedNgModel.assign($scope, ctrl.$modelValue); } @@ -1741,6 +1742,11 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ this.$$setOptions = function(options) { ctrl.$options = options; + if (ctrl.$options && ctrl.$options.getterSetter && ctrl.$options.getterSetterContext) { + // Use the provided context expression to specify the context used when invoking the + // getter/setter function + parsedNgModelContext = $parse(ctrl.$options.getterSetterContext); + } if (!parsedNgModel.assign && (!options || !options.getterSetter)) { throw $ngModelMinErr('nonassign', "Expression '{0}' is non-assignable. Element: {1}", $attr.ngModel, startingTag($element)); diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 8d4e47763609..565073e05379 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -1975,6 +1975,83 @@ describe('input', function() { 'ng-model-options="{ getterSetter: true }" />'); }); + it('should try to invoke a model with default context if getterSetter is true and getterSetterContext is not provided', function() { + scope.value = 'scopeContext'; + compileInput( + ''); + + scope.someService = { + value: 'b', + getterSetter: function(newValue) { + this.value = newValue || this.value; + return this.value; + } + }; + spyOn(scope.someService, 'getterSetter').andCallThrough(); + scope.$apply(); + expect(inputElm.val()).toBe('scopeContext'); + expect(scope.someService.getterSetter).toHaveBeenCalledWith(); + expect(scope.someService.value).toBe('b'); // 'this' is not bound to the service w/o ngModelContext + expect(scope.value).toBe('scopeContext'); + + changeInputValueTo('a'); + expect(scope.someService.getterSetter).toHaveBeenCalledWith('a'); + expect(scope.someService.value).toBe('b'); + expect(scope.value).toBe('a'); + + scope.someService.value = 'c'; + scope.$apply(); + expect(inputElm.val()).toBe('a'); + expect(scope.someService.getterSetter).toHaveBeenCalledWith(); + expect(scope.someService.value).toBe('c'); + expect(scope.value).toBe('a'); + + scope.value = 'd'; + scope.$apply(); + expect(inputElm.val()).toBe('d'); + expect(scope.someService.getterSetter).toHaveBeenCalledWith(); + expect(scope.someService.value).toBe('c'); + expect(scope.value).toBe('d'); + }); + + it('should try to invoke a model with the provided context if getterSetter is true and getterSetterContext is an expression', function() { + compileInput( + ''); + + scope.someService = { + value: 'b', + getterSetter: function(newValue) { + this.value = newValue || this.value; + return this.value; + } + }; + spyOn(scope.someService, 'getterSetter').andCallThrough(); + scope.$apply(); + expect(inputElm.val()).toBe('b'); + expect(scope.someService.getterSetter).toHaveBeenCalledWith(); + expect(scope.someService.value).toBe('b'); + + changeInputValueTo('a'); + expect(scope.someService.getterSetter).toHaveBeenCalledWith('a'); + expect(scope.someService.value).toBe('a'); + + scope.someService.value = 'c'; + scope.$apply(); + expect(inputElm.val()).toBe('c'); + expect(scope.someService.getterSetter).toHaveBeenCalledWith(); + expect(scope.someService.value).toBe('c'); + }); + + it('should fail to parse if getterSetterContext is an invalid expression', function() { + expect(function() { + compileInput( + ''); + }).toThrowMinErr("$parse", "syntax", "Syntax Error: Token 'error' is an unexpected token at column 7 of the expression [throw error] starting at [error]."); + }); + it('should assign invalid values to the scope if allowInvalid is true', function() { compileInput('');