diff --git a/src/ng/compile.js b/src/ng/compile.js index 995eaf23730e..8fc513b1e2b3 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -218,8 +218,18 @@ * definition: `controller: 'myCtrl as myAlias'`. * * When an isolate scope is used for a directive (see above), `bindToController: true` will - * allow a component to have its properties bound to the controller, rather than to scope. When the controller - * is instantiated, the initial values of the isolate scope bindings will be available if the controller is not an ES6 class. + * allow a component to have its properties bound to the controller, rather than to scope. + * + * After the controller is instantiated, the initial values of the isolate scope bindings will be bound to the controller + * properties. You can access these bindings once they have been initialized by providing a controller method called + * `$onInit`, which is called after all the controllers on an element have been constructed and had their bindings + * initialized. + * + *
+ * **Deprecation warning:** although bindings for non-ES6 class controllers are currently + * bound to `this` before the controller constructor is called, this use is now deprecated. Please place initialization + * code that relies upon bindings inside a `$onInit` method on the controller, instead. + *
* * It is also possible to set `bindToController` to an object hash with the same format as the `scope` property. * This will set up the scope bindings to the controller directly. Note that `scope` can still be used @@ -255,12 +265,29 @@ * The `$transclude` function also has a method on it, `$transclude.isSlotFilled(slotName)`, which returns * `true` if the specified slot contains content (i.e. one or more DOM nodes). * + * The controller can provide the following methods that act as life-cycle hooks: + * * `$onInit` - Called on each controller after all the controllers on an element have been constructed and + * had their bindings initialized (and before the pre & post linking functions for the directives on + * this element). This is a good place to put initialization code for your controller. + * * #### `require` * Require another directive and inject its controller as the fourth argument to the linking function. The - * `require` takes a string name (or array of strings) of the directive(s) to pass in. If an array is used, the - * injected argument will be an array in corresponding order. If no such directive can be - * found, or if the directive does not have a controller, then an error is raised (unless no link function - * is specified, in which case error checking is skipped). The name can be prefixed with: + * `require` property can be a string, an array or an object: + * * a **string** containing the name of the directive to pass to the linking function + * * an **array** containing the names of directives to pass to the linking function. The argument passed to the + * linking function will be an array of controllers in the same order as the names in the `require` property + * * an **object** whose property values are the names of the directives to pass to the linking function. The argument + * passed to the linking function will also be an object with matching keys, whose values will hold the corresponding + * controllers. + * + * If the `require` property is an object and `bindToController` is truthy, then the required controllers are + * bound to the controller using the keys of the `require` property. This binding occurs after all the controllers + * have been constructed but before `$onInit` is called. + * See the {@link $compileProvider#component} helper for an example of how this can be used. + * + * If no such required directive(s) can be found, or if the directive does not have a controller, then an error is + * raised (unless no link function is specified and the required controllers are not being bound to the directive + * controller, in which case error checking is skipped). The name can be prefixed with: * * * (no prefix) - Locate the required controller on the current element. Throw an error if not found. * * `?` - Attempt to locate the required controller or pass `null` to the `link` fn if not found. @@ -1005,6 +1032,80 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { * * ``` * + * ### Intercomponent Communication + * Directives can require the controllers of other directives to enable communication + * between the directives. This can be achieved in a component by providing an + * object mapping for the `require` property. Here is the tab pane example built + * from components... + * + * + * + * angular.module('docsTabsExample', []) + * .component('myTabs', { + * transclude: true, + * controller: function() { + * var panes = this.panes = []; + * + * this.select = function(pane) { + * angular.forEach(panes, function(pane) { + * pane.selected = false; + * }); + * pane.selected = true; + * }; + * + * this.addPane = function(pane) { + * if (panes.length === 0) { + * this.select(pane); + * } + * panes.push(pane); + * }; + * }, + * templateUrl: 'my-tabs.html' + * }) + * .component('myPane', { + * transclude: true, + * require: {tabsCtrl: '^myTabs'}, + * bindings: { + * title: '@' + * }, + * controller: function() { + * this.$onInit = function() { + * this.tabsCtrl.addPane(this); + * console.log(this); + * }; + * }, + * templateUrl: 'my-pane.html' + * }); + * + * + * + * + *

Hello

+ *

Lorem ipsum dolor sit amet

+ *
+ * + *

World

+ * Mauris elementum elementum enim at suscipit. + *

counter: {{i || 0}}

+ *
+ *
+ *
+ * + *
+ * + *
+ *
+ *
+ * + *
+ *
+ *
+ * + * *
* Components are also useful as route templates (e.g. when using * {@link ngRoute ngRoute}): @@ -1072,7 +1173,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { transclude: options.transclude, scope: {}, bindToController: options.bindings || {}, - restrict: 'E' + restrict: 'E', + require: options.require }; } @@ -2280,6 +2382,11 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { for (var i = 0, ii = require.length; i < ii; i++) { value[i] = getControllers(directiveName, require[i], $element, elementControllers); } + } else if (isObject(require)) { + value = {}; + forEach(require, function(controller, property) { + value[property] = getControllers(directiveName, controller, $element, elementControllers); + }); } return value || null; @@ -2388,6 +2495,21 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } } + // Bind the required controllers to the controller, if `require` is an object and `bindToController` is truthy + forEach(controllerDirectives, function(controllerDirective, name) { + var require = controllerDirective.require; + if (controllerDirective.bindToController && !isArray(require) && isObject(require)) { + extend(elementControllers[name].instance, getControllers(name, require, $element, elementControllers)); + } + }); + + // Trigger the `$onInit` method on all controllers that have one + forEach(elementControllers, function(controller) { + if (isFunction(controller.instance.$onInit)) { + controller.instance.$onInit(); + } + }); + // PRELINKING for (i = 0, ii = preLinkFns.length; i < ii; i++) { linkFn = preLinkFns[i]; diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index f885d3106433..250e924d50c5 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -4244,6 +4244,22 @@ describe('$compile', function() { if (!/chrome/i.test(navigator.userAgent)) return; /*jshint -W061 */ var controllerCalled = false; + var Controller = eval( + "class Foo {\n" + + " constructor($scope) {}\n" + + " $onInit() { this.check(); }\n" + + " check() {\n" + + " expect(this.data).toEqualData({\n" + + " 'foo': 'bar',\n" + + " 'baz': 'biz'\n" + + " });\n" + + " expect(this.str).toBe('Hello, world!');\n" + + " expect(this.fn()).toBe('called!');\n" + + " controllerCalled = true;\n" + + " }\n" + + "}"); + spyOn(Controller.prototype, '$onInit').andCallThrough(); + module(function($compileProvider) { $compileProvider.directive('fooDir', valueFn({ template: '

isolate

', @@ -4252,20 +4268,7 @@ describe('$compile', function() { 'str': '@dirStr', 'fn': '&dirFn' }, - controller: eval( - "class Foo {" + - " constructor($scope) {}" + - " check() {" + - " expect(this.data).toEqualData({" + - " 'foo': 'bar'," + - " 'baz': 'biz'" + - " });" + - " expect(this.str).toBe('Hello, world!');" + - " expect(this.fn()).toBe('called!');" + - " controllerCalled = true;" + - " }" + - "}" - ), + controller: Controller, controllerAs: 'test', bindToController: true })); @@ -4280,7 +4283,7 @@ describe('$compile', function() { element = $compile('
')($rootScope); - element.data('$fooDirController').check(); + expect(Controller.prototype.$onInit).toHaveBeenCalled(); expect(controllerCalled).toBe(true); }); /*jshint +W061 */ @@ -4929,6 +4932,32 @@ describe('$compile', function() { }); }); + it('should call `controller.$onInit`, if provided after all the controllers have been constructed', function() { + + function check() { + /*jshint validthis:true */ + expect(this.element.controller('d1').id).toEqual(1); + expect(this.element.controller('d2').id).toEqual(2); + } + + function Controller1($element) { this.id = 1; this.element = $element; } + Controller1.prototype.$onInit = jasmine.createSpy('$onInit').andCallFake(check); + + function Controller2($element) { this.id = 2; this.element = $element; } + Controller2.prototype.$onInit = jasmine.createSpy('$onInit').andCallFake(check); + + angular.module('my', []) + .directive('d1', valueFn({ controller: Controller1 })) + .directive('d2', valueFn({ controller: Controller2 })); + + module('my'); + inject(function($compile, $rootScope) { + element = $compile('
')($rootScope); + expect(Controller1.prototype.$onInit).toHaveBeenCalledOnce(); + expect(Controller2.prototype.$onInit).toHaveBeenCalledOnce(); + }); + }); + describe('should not overwrite @-bound property each digest when not present', function() { it('when creating new scope', function() { module(function($compileProvider) { @@ -5307,6 +5336,195 @@ describe('$compile', function() { }); }); + it('should bind the required controllers to the directive controller, if provided as an object and bindToController is truthy', function() { + var parentController, siblingController; + + function ParentController() { this.name = 'Parent'; } + function SiblingController() { this.name = 'Sibling'; } + function MeController() { this.name = 'Me'; } + MeController.prototype.$onInit = function() { + parentController = this.container; + siblingController = this.friend; + }; + spyOn(MeController.prototype, '$onInit').andCallThrough(); + + angular.module('my', []) + .directive('me', function() { + return { + restrict: 'E', + scope: {}, + require: { container: '^parent', friend: 'sibling' }, + bindToController: true, + controller: MeController, + controllerAs: '$ctrl' + }; + }) + .directive('parent', function() { + return { + restrict: 'E', + scope: {}, + controller: ParentController + }; + }) + .directive('sibling', function() { + return { + controller: SiblingController + }; + }); + + module('my'); + inject(function($compile, $rootScope, meDirective) { + element = $compile('')($rootScope); + expect(MeController.prototype.$onInit).toHaveBeenCalled(); + expect(parentController).toEqual(jasmine.any(ParentController)); + expect(siblingController).toEqual(jasmine.any(SiblingController)); + }); + }); + + + it('should not bind required controllers if bindToController is falsy', function() { + var parentController, siblingController; + + function ParentController() { this.name = 'Parent'; } + function SiblingController() { this.name = 'Sibling'; } + function MeController() { this.name = 'Me'; } + MeController.prototype.$onInit = function() { + parentController = this.container; + siblingController = this.friend; + }; + spyOn(MeController.prototype, '$onInit').andCallThrough(); + + angular.module('my', []) + .directive('me', function() { + return { + restrict: 'E', + scope: {}, + require: { container: '^parent', friend: 'sibling' }, + controller: MeController + }; + }) + .directive('parent', function() { + return { + restrict: 'E', + scope: {}, + controller: ParentController + }; + }) + .directive('sibling', function() { + return { + controller: SiblingController + }; + }); + + module('my'); + inject(function($compile, $rootScope, meDirective) { + element = $compile('')($rootScope); + expect(MeController.prototype.$onInit).toHaveBeenCalled(); + expect(parentController).toBeUndefined(); + expect(siblingController).toBeUndefined(); + }); + }); + + it('should bind required controllers to controller that has an explicit constructor return value', function() { + var parentController, siblingController, meController; + + function ParentController() { this.name = 'Parent'; } + function SiblingController() { this.name = 'Sibling'; } + function MeController() { + meController = { + name: 'Me', + $onInit: function() { + parentController = this.container; + siblingController = this.friend; + } + }; + spyOn(meController, '$onInit').andCallThrough(); + return meController; + } + + angular.module('my', []) + .directive('me', function() { + return { + restrict: 'E', + scope: {}, + require: { container: '^parent', friend: 'sibling' }, + bindToController: true, + controller: MeController, + controllerAs: '$ctrl' + }; + }) + .directive('parent', function() { + return { + restrict: 'E', + scope: {}, + controller: ParentController + }; + }) + .directive('sibling', function() { + return { + controller: SiblingController + }; + }); + + module('my'); + inject(function($compile, $rootScope, meDirective) { + element = $compile('')($rootScope); + expect(meController.$onInit).toHaveBeenCalled(); + expect(parentController).toEqual(jasmine.any(ParentController)); + expect(siblingController).toEqual(jasmine.any(SiblingController)); + }); + }); + + + it('should bind required controllers to controllers that return an explicit constructor return value', function() { + var parentController, containerController, siblingController, friendController, meController; + + function MeController() { + this.name = 'Me'; + this.$onInit = function() { + containerController = this.container; + friendController = this.friend; + }; + } + function ParentController() { + return parentController = { name: 'Parent' }; + } + function SiblingController() { + return siblingController = { name: 'Sibling' }; + } + + angular.module('my', []) + .directive('me', function() { + return { + priority: 1, // make sure it is run before sibling to test this case correctly + restrict: 'E', + scope: {}, + require: { container: '^parent', friend: 'sibling' }, + bindToController: true, + controller: MeController, + controllerAs: '$ctrl' + }; + }) + .directive('parent', function() { + return { + restrict: 'E', + scope: {}, + controller: ParentController + }; + }) + .directive('sibling', function() { + return { + controller: SiblingController + }; + }); + + module('my'); + inject(function($compile, $rootScope, meDirective) { + element = $compile('')($rootScope); + expect(containerController).toEqual(parentController); + expect(friendController).toEqual(siblingController); + }); + }); it('should require controller of an isolate directive from a non-isolate directive on the ' + 'same element', function() { @@ -5669,6 +5887,28 @@ describe('$compile', function() { }); }); + it('should support multiple controllers as an object hash', function() { + module(function() { + directive('c1', valueFn({ + controller: function() { this.name = 'c1'; } + })); + directive('c2', valueFn({ + controller: function() { this.name = 'c2'; } + })); + directive('dep', function(log) { + return { + require: { myC1: '^c1', myC2: '^c2' }, + link: function(scope, element, attrs, controllers) { + log('dep:' + controllers.myC1.name + '-' + controllers.myC2.name); + } + }; + }); + }); + inject(function(log, $compile, $rootScope) { + element = $compile('
')($rootScope); + expect(log).toEqual('dep:c1-c2'); + }); + }); it('should instantiate the controller just once when template/templateUrl', function() { var syncCtrlSpy = jasmine.createSpy('sync controller'),