diff --git a/docs/content/guide/directive.ngdoc b/docs/content/guide/directive.ngdoc index 26df46dfd952..8657a5d4406f 100644 --- a/docs/content/guide/directive.ngdoc +++ b/docs/content/guide/directive.ngdoc @@ -321,34 +321,32 @@ compiler}. The attributes are: parent scope.
The 'isolate' scope takes an object hash which defines a set of local scope properties derived from the parent scope. These local properties are useful for aliasing values for - templates. Locals definition is a hash of normalized element attribute name to their - corresponding binding strategy. Valid binding strategies are: - - * `attribute` - one time read of element attribute value and save it to widget scope.
- Given `` and widget definition of `scope: {myAttr:'attribute'}`, - then widget scope property `myAttr` will be `"abc"`. - - * `evaluate` - one time evaluation of expression stored in the attribute.
Given - `` and widget definition of `scope: {myAttr:'evaluate'}`, and - parent scope `{name:'angular'}` then widget scope property `myAttr` will be `"angular"`. - - * `bind` - Set up one way binding from the element attribute to the widget scope.
- Given `` and widget definition of `scope: {myAttr:'bind'}`, - and parent scope `{name:'angular'}` then widget scope property `myAttr` will be - `"angular"`, but any changes in the parent scope will be reflected in the widget scope. - - * `accessor` - Set up getter/setter function for the expression in the widget element - attribute to the widget scope.
Given `` and widget definition - of `scope: {myAttr:'prop'}`, and parent scope `{name:'angular'}` then widget scope - property `myAttr` will be a function such that `myAttr()` will return `"angular"` and - `myAttr('new value')` will update the parent scope `name` property. This is useful for - treating the element as a data-model for reading/writing. - - * `expression` - Treat element attribute as an expression to be executed on the parent scope. -
- Given `` and widget definition of `scope: - {myAttr:'expression'}`, and parent scope `{doSomething:function() {}}` then calling the - widget scope function `myAttr` will execute the expression against the parent scope. + templates. Locals definition is a hash of local scope property to its source: + + * `@` or `@attr` - bind a local scope property to the DOM attribute. The result is always a + string since DOM attributes are strings. If no `attr` name is specified then the local name + and attribute name are same. Given `` and widget definition + of `scope: { localName:'@myAttr' }`, then widget scope property `localName` will reflect + the interpolated value of `hello {{name}}`. As the `name` attribute changes so will the + `localName` property on the widget scope. The `name` is read from the parent scope (not + component scope). + + * `=` or `=expression` - set up bi-directional binding between a local scope property and the + parent scope property. If no `attr` name is specified then the local name and attribute + name are same. Given `` and widget definition of + `scope: { localModel:'=myAttr' }`, then widget scope property `localName` will reflect the + value of `parentModel` on the parent scope. Any changes to `parentModel` will be reflected + in `localModel` and any changes in `localModel` will reflect in `parentModel`. + + * `&` or `&attr` - provides a way to execute an expression in the context of the parent scope. + If no `attr` name is specified then the local name and attribute name are same. + Given `` and widget definition of + `scope: { localFn:'increment()' }`, then isolate scope property `localFn` will point to + a function wrapper for the `increment()` expression. Often it's desirable to pass data from + the isolate scope via an expression and to the parent scope, this can be done by passing a + map of local variable names and values into the expression wrapper fn. For example if the + expression is `increment(amount)` then we can specify the amount value by calling the + `localFn` as `localFn({amount: 22})`. * `controller` - Controller constructor function. The controller is instantiated before the pre-linking phase and it is shared with other directives if they request it by name (see @@ -369,32 +367,6 @@ compiler}. The attributes are: * `^` - Look for the controller on parent elements as well. - * `inject` (object hash) - Specifies a way to inject bindings into a controller. Injection - definition is a hash of normalized element attribute names to their corresponding binding - strategy. Valid binding strategies are: - - * `attribute` - inject attribute value.
- Given `` and widget definition of `inject: {myAttr:'attribute'}`, then - `myAttr` will inject `"abc"`. - - * `evaluate` - inject one time evaluation of expression stored in the attribute.
- Given `` and widget definition of `inject: {myAttr:'evaluate'}`, and - parent scope `{name:'angular'}` then `myAttr` will inject `"angular"`. - - * `accessor` - inject a getter/setter function for the expression in the widget element - attribute to the widget scope.
- Given `` and widget definition of `inject: {myAttr:'prop'}`, and - parent scope `{name:'angular'}` then injecting `myAttr` will inject a function such - that `myAttr()` will return `"angular"` and `myAttr('new value')` will update the parent - scope `name` property. This is usefull for treating the element as a data-model for - reading/writing. - - * `expression` - Inject expression function.
- Given `` and widget definition of - `inject: {myAttr:'expression'}`, and parent scope `{doSomething:function() {}}` then - injecting `myAttr` will inject a function which when called will execute the expression - against the parent scope. - * `restrict` - String of subset of `EACM` which restricts the directive to a specific directive declaration style. If omitted directives are allowed on attributes only. @@ -649,9 +621,9 @@ Following is an example of building a reusable widget. // This HTML will replace the zippy directive. replace: true, transclude: true, - scope: { zippyTitle:'bind' }, + scope: { title:'@zippyTitle' }, template: '
' + - '
{{zippyTitle}}
' + + '
{{title}}
' + '
' + '
', // The linking function will add behavior to the template diff --git a/src/ng/compile.js b/src/ng/compile.js index ee12026385b1..e1aba35b8db3 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -18,6 +18,9 @@ */ +var NON_ASSIGNABLE_MODEL_EXPRESSION = 'Non-assignable model expression: '; + + /** * @ngdoc function * @name angular.module.ng.$compile @@ -225,47 +228,6 @@ function $CompileProvider($provide) { function($injector, $interpolate, $exceptionHandler, $http, $templateCache, $parse, $controller, $rootScope) { - var LOCAL_MODE = { - attribute: function(localName, mode, parentScope, scope, attr) { - scope[localName] = attr[localName]; - }, - - evaluate: function(localName, mode, parentScope, scope, attr) { - scope[localName] = parentScope.$eval(attr[localName]); - }, - - bind: function(localName, mode, parentScope, scope, attr) { - var getter = $interpolate(attr[localName]); - scope.$watch( - function() { return getter(parentScope); }, - function(v) { scope[localName] = v; } - ); - }, - - accessor: function(localName, mode, parentScope, scope, attr) { - var getter = noop, - setter = noop, - exp = attr[localName]; - - if (exp) { - getter = $parse(exp); - setter = getter.assign || function() { - throw Error("Expression '" + exp + "' not assignable."); - }; - } - - scope[localName] = function(value) { - return arguments.length ? setter(parentScope, value) : getter(parentScope); - }; - }, - - expression: function(localName, mode, parentScope, scope, attr) { - scope[localName] = function(locals) { - $parse(attr[localName])(parentScope, locals); - }; - } - }; - var Attributes = function(element, attr) { this.$$element = element; this.$attr = attr || {}; @@ -746,9 +708,67 @@ function $CompileProvider($provide) { $element = attrs.$$element; if (newScopeDirective && isObject(newScopeDirective.scope)) { - forEach(newScopeDirective.scope, function(mode, name) { - (LOCAL_MODE[mode] || wrongMode)(name, mode, - scope.$parent || scope, scope, attrs); + var LOCAL_REGEXP = /^\s*([@=&])\s*(\w*)\s*$/; + + var parentScope = scope.$parent || scope; + + forEach(newScopeDirective.scope, function(definiton, scopeName) { + var match = definiton.match(LOCAL_REGEXP) || [], + attrName = match[2]|| scopeName, + mode = match[1], // @, =, or & + lastValue, + parentGet, parentSet; + + switch (mode) { + + case '@': { + attrs.$observe(attrName, function(value) { + scope[scopeName] = value; + }); + attrs.$$observers[attrName].$$scope = parentScope; + break; + } + + case '=': { + parentGet = $parse(attrs[attrName]); + parentSet = parentGet.assign || function() { + // reset the change, or we will throw this exception on every $digest + lastValue = scope[scopeName] = parentGet(parentScope); + throw Error(NON_ASSIGNABLE_MODEL_EXPRESSION + attrs[attrName] + + ' (directive: ' + newScopeDirective.name + ')'); + }; + lastValue = scope[scopeName] = parentGet(parentScope); + scope.$watch(function() { + var parentValue = parentGet(parentScope); + + if (parentValue !== scope[scopeName]) { + // we are out of sync and need to copy + if (parentValue !== lastValue) { + // parent changed and it has precedence + lastValue = scope[scopeName] = parentValue; + } else { + // if the parent can be assigned then do so + parentSet(parentScope, lastValue = scope[scopeName]); + } + } + return parentValue; + }); + break; + } + + case '&': { + parentGet = $parse(attrs[attrName]); + scope[scopeName] = function(locals) { + return parentGet(parentScope, locals); + } + break; + } + + default: { + throw Error('Invalid isolate scope definition for directive ' + + newScopeDirective.name + ': ' + definiton); + } + } }); } @@ -761,12 +781,6 @@ function $CompileProvider($provide) { $transclude: boundTranscludeFn }; - - forEach(directive.inject || {}, function(mode, name) { - (LOCAL_MODE[mode] || wrongMode)(name, mode, - newScopeDirective ? scope.$parent || scope : scope, locals, attrs); - }); - controller = directive.controller; if (controller == '@') { controller = attrs[directive.name]; @@ -1007,9 +1021,10 @@ function $CompileProvider($provide) { attr[name] = undefined; ($$observers[name] || ($$observers[name] = [])).$$inter = true; - scope.$watch(interpolateFn, function(value) { - attr.$set(name, value); - }); + (attr.$$observers && attr.$$observers[name].$$scope || scope). + $watch(interpolateFn, function(value) { + attr.$set(name, value); + }); }) }); } diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index aa79082b45eb..04af4c2a6cb9 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -857,8 +857,8 @@ var VALID_CLASS = 'ng-valid', * * */ -var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$element', - function($scope, $exceptionHandler, $attr, ngModel, $element) { +var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', + function($scope, $exceptionHandler, $attr, $element, $parse) { this.$viewValue = Number.NaN; this.$modelValue = Number.NaN; this.$parsers = []; @@ -870,6 +870,14 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$e this.$invalid = false; this.$name = $attr.name; + var ngModelGet = $parse($attr.ngModel), + ngModelSet = ngModelGet.assign; + + if (!ngModelSet) { + throw Error(NON_ASSIGNABLE_MODEL_EXPRESSION + $attr.ngModel + + ' (' + startingTag($element) + ')'); + } + /** * @ngdoc function * @name angular.module.ng.$compileProvider.directive.ngModel.NgModelController#$render @@ -974,7 +982,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$e if (this.$modelValue !== value) { this.$modelValue = value; - ngModel(value); + ngModelSet($scope, value); forEach(this.$viewChangeListeners, function(listener) { try { listener(); @@ -987,9 +995,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$e // model -> value var ctrl = this; - $scope.$watch(function() { - return ngModel(); - }, function(value) { + $scope.$watch(ngModelGet, function(value) { // ignore change from view if (ctrl.$modelValue === value) return; @@ -1044,9 +1050,6 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$e */ var ngModelDirective = function() { return { - inject: { - ngModel: 'accessor' - }, require: ['ngModel', '^?form'], controller: NgModelController, link: function(scope, element, attr, ctrls) { diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index d2b0360cdd1b..93183b931354 100644 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe('$compile', function() { - var element, directive; + var element, directive, $compile, $rootScope; beforeEach(module(provideLog, function($provide, $compileProvider){ element = null; @@ -54,8 +54,17 @@ describe('$compile', function() { priority: -100, // even with negative priority we still should be able to stop descend terminal: true })); + + return function(_$compile_, _$rootScope_) { + $rootScope = _$rootScope_; + $compile = _$compile_; + }; })); + function compile(html) { + element = angular.element(html); + $compile(element)($rootScope); + } afterEach(function(){ dealoc(element); @@ -1633,105 +1642,166 @@ describe('$compile', function() { }); - describe('locals', function() { - it('should marshal to locals', function() { - module(function() { - directive('widget', function(log) { - return { - scope: { - attr: 'attribute', - prop: 'evaluate', - bind: 'bind', - assign: 'accessor', - read: 'accessor', - exp: 'expression', - nonExist: 'accessor', - nonExistExpr: 'expression' - }, - link: function(scope, element, attrs) { - scope.nonExist(); // noop - scope.nonExist(123); // noop - scope.nonExistExpr(); // noop - scope.nonExistExpr(123); // noop - log(scope.attr); - log(scope.prop); - log(scope.assign()); - log(scope.read()); - log(scope.assign('ng')); - scope.exp({myState:'OK'}); - expect(function() { scope.read(undefined); }). - toThrow("Expression ''D'' not assignable."); - scope.$watch('bind', log); - } - }; - }); + describe('isolated locals', function() { + var componentScope; + + beforeEach(module(function() { + directive('myComponent', function() { + return { + scope: { + attr: '@', + attrAlias: '@attr', + ref: '=', + refAlias: '= ref', + expr: '&', + exprAlias: '&expr' + }, + link: function(scope) { + componentScope = scope; + } + }; }); - inject(function(log, $compile, $rootScope) { - $rootScope.myProp = 'B'; - $rootScope.bi = {nd: 'C'}; - $rootScope.name = 'C'; - element = $compile( - '
{{bind}}
') - ($rootScope); - expect(log).toEqual('A; B; C; D; ng'); - expect($rootScope.name).toEqual('ng'); - expect($rootScope.state).toEqual('OK'); - log.reset(); + directive('badDeclaration', function() { + return { + scope: { attr: 'xxx' } + }; + }); + })); + + describe('attribute', function() { + it('should copy simple attribute', inject(function() { + compile('
'); + expect(componentScope.attr).toEqual(undefined); + expect(componentScope.attrAlias).toEqual(undefined); + $rootScope.$apply(); - expect(element.text()).toEqual('C'); - expect(log).toEqual('C'); - $rootScope.bi.nd = 'c'; + + expect(componentScope.attr).toEqual('some text'); + expect(componentScope.attrAlias).toEqual('some text'); + expect(componentScope.attrAlias).toEqual(componentScope.attr); + })); + + + it('should update when interpolated attribute updates', inject(function() { + compile('
'); + expect(componentScope.attr).toEqual(undefined); + expect(componentScope.attrAlias).toEqual(undefined); + + $rootScope.name = 'misko'; $rootScope.$apply(); - expect(log).toEqual('C; c'); - }); + + expect(componentScope.attr).toEqual('hello misko'); + expect(componentScope.attrAlias).toEqual('hello misko'); + + $rootScope.name = 'igor'; + $rootScope.$apply(); + + expect(componentScope.attr).toEqual('hello igor'); + expect(componentScope.attrAlias).toEqual('hello igor'); + })); }); - }); - describe('controller', function() { - it('should inject locals to controller', function() { - module(function() { - directive('widget', function(log) { - return { - controller: function(attr, prop, assign, read, exp){ - log(attr); - log(prop); - log(assign()); - log(read()); - log(assign('ng')); - exp(); - expect(function() { read(undefined); }). - toThrow("Expression ''D'' not assignable."); - this.result = 'OK'; - }, - inject: { - attr: 'attribute', - prop: 'evaluate', - assign: 'accessor', - read: 'accessor', - exp: 'expression' - }, - link: function(scope, element, attrs, controller) { - log(controller.result); - } - }; - }); - }); - inject(function(log, $compile, $rootScope) { - $rootScope.myProp = 'B'; - $rootScope.bi = {nd: 'C'}; - $rootScope.name = 'C'; - element = $compile( - '
{{bind}}
') - ($rootScope); - expect(log).toEqual('A; B; C; D; ng; OK'); - expect($rootScope.name).toEqual('ng'); - }); + describe('object reference', function() { + it('should update local when origin changes', inject(function() { + compile('
'); + expect(componentScope.ref).toBe(undefined); + expect(componentScope.refAlias).toBe(componentScope.ref); + + $rootScope.name = 'misko'; + $rootScope.$apply(); + expect(componentScope.ref).toBe($rootScope.name); + expect(componentScope.refAlias).toBe($rootScope.name); + + $rootScope.name = {}; + $rootScope.$apply(); + expect(componentScope.ref).toBe($rootScope.name); + expect(componentScope.refAlias).toBe($rootScope.name); + })); + + + it('should update local when origin changes', inject(function() { + compile('
'); + expect(componentScope.ref).toBe(undefined); + expect(componentScope.refAlias).toBe(componentScope.ref); + + componentScope.ref = 'misko'; + $rootScope.$apply(); + expect($rootScope.name).toBe('misko'); + expect(componentScope.ref).toBe('misko'); + expect($rootScope.name).toBe(componentScope.ref); + expect(componentScope.refAlias).toBe(componentScope.ref); + + componentScope.name = {}; + $rootScope.$apply(); + expect($rootScope.name).toBe(componentScope.ref); + expect(componentScope.refAlias).toBe(componentScope.ref); + })); + + + it('should update local when both change', inject(function() { + compile('
'); + $rootScope.name = {mark:123}; + componentScope.ref = 'misko'; + + $rootScope.$apply(); + expect($rootScope.name).toEqual({mark:123}) + expect(componentScope.ref).toBe($rootScope.name); + expect(componentScope.refAlias).toBe($rootScope.name); + + $rootScope.name = 'igor'; + componentScope.ref = {}; + $rootScope.$apply(); + expect($rootScope.name).toEqual('igor') + expect(componentScope.ref).toBe($rootScope.name); + expect(componentScope.refAlias).toBe($rootScope.name); + })); + + it('should complain on non assignable changes', inject(function() { + compile('
'); + $rootScope.name = 'world'; + $rootScope.$apply(); + expect(componentScope.ref).toBe('hello world'); + + componentScope.ref = 'ignore me'; + expect($rootScope.$apply). + toThrow("Non-assignable model expression: 'hello ' + name (directive: myComponent)"); + expect(componentScope.ref).toBe('hello world'); + // reset since the exception was rethrown which prevented phase clearing + $rootScope.$$phase = null; + + $rootScope.name = 'misko'; + $rootScope.$apply(); + expect(componentScope.ref).toBe('hello misko'); + })); + }); + + + describe('executable expression', function() { + it('should allow expression execution with locals', inject(function() { + compile('
'); + $rootScope.count = 2; + + expect(typeof componentScope.expr).toBe('function'); + expect(typeof componentScope.exprAlias).toBe('function'); + + expect(componentScope.expr({offset: 1})).toEqual(3); + expect($rootScope.count).toEqual(3); + + expect(componentScope.exprAlias({offset: 10})).toEqual(13); + expect($rootScope.count).toEqual(13); + })); }); + it('should throw on unknown definition', inject(function() { + expect(function() { + compile('
'); + }).toThrow('Invalid isolate scope definition for directive badDeclaration: xxx'); + })); + }); + + describe('controller', function() { it('should get required controller', function() { module(function() { directive('main', function(log) { @@ -1986,11 +2056,11 @@ describe('$compile', function() { module(function() { directive('box', valueFn({ transclude: 'content', - scope: { name: 'evaluate', show: 'accessor' }, + scope: { name: '=', show: '=' }, template: '

Hello: {{name}}!

', link: function(scope, element) { scope.$watch( - function() { return scope.show(); }, + 'show', function(show) { if (!show) { element.find('div').find('div').remove(); diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index d7ca7aea3520..3b511011d1a6 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -4,7 +4,7 @@ describe('NgModelController', function() { var ctrl, scope, ngModelAccessor, element, parentFormCtrl; beforeEach(inject(function($rootScope, $controller) { - var attrs = {name: 'testAlias'}; + var attrs = {name: 'testAlias', ngModel: 'value'}; parentFormCtrl = { $setValidity: jasmine.createSpy('$setValidity'), @@ -17,12 +17,7 @@ describe('NgModelController', function() { scope = $rootScope; ngModelAccessor = jasmine.createSpy('ngModel accessor'); ctrl = $controller(NgModelController, { - $scope: scope, $element: element.find('input'), ngModel: ngModelAccessor, $attrs: attrs - }); - // mock accessor (locals) - ngModelAccessor.andCallFake(function(val) { - if (isDefined(val)) scope.value = val; - return scope.value; + $scope: scope, $element: element.find('input'), $attrs: attrs }); })); @@ -32,6 +27,26 @@ describe('NgModelController', function() { }); + it('should fail on non-assignable model binding', inject(function($controller) { + var exception; + + try { + $controller(NgModelController, { + $scope: null, + $element: jqLite(''), + $attrs: { + ngModel: '1+2' + } + }); + } catch (e) { + exception = e; + } + + expect(exception.message). + toMatch(/Non-assignable model expression: 1\+2 \(\)/); + })); + + it('should init the properties', function() { expect(ctrl.$dirty).toBe(false); expect(ctrl.$pristine).toBe(true);