From b272203f7704d2f66873d974beb20e4ac9fd4e7f Mon Sep 17 00:00:00 2001 From: Olivier Louvignes Date: Sun, 12 Jul 2015 18:22:04 +0200 Subject: [PATCH] feat(modal, aside, alert): refactor with $bsCompiler to add support for `controller`, `controllerAs`, `template`, `templateUrl` options (fixes #732, fixes #728, fixes #1394, fixes #1735) --- src/alert/alert.js | 5 +- src/alert/test/alert.spec.js | 2 +- src/aside/aside.js | 5 +- src/aside/test/aside.spec.js | 2 +- src/helpers/compiler.js | 173 +++++++++++++++++++++++++++++ src/modal/docs/modal.demo.html | 7 ++ src/modal/docs/modal.demo.js | 18 +-- src/modal/docs/modal.demo.tpl.html | 1 + src/modal/modal.js | 53 +++------ src/modal/test/modal.spec.js | 22 +++- 10 files changed, 239 insertions(+), 49 deletions(-) create mode 100644 src/helpers/compiler.js diff --git a/src/alert/alert.js b/src/alert/alert.js index fa792a606..c8dcc9d88 100644 --- a/src/alert/alert.js +++ b/src/alert/alert.js @@ -13,7 +13,8 @@ angular.module('mgcrea.ngStrap.alert', ['mgcrea.ngStrap.modal']) prefixClass: 'alert', prefixEvent: 'alert', placement: null, - template: 'alert/alert.tpl.html', + templateUrl: 'alert/alert.tpl.html', + template: '', container: false, element: null, backdrop: false, @@ -74,7 +75,7 @@ angular.module('mgcrea.ngStrap.alert', ['mgcrea.ngStrap.modal']) // Directive options var options = {scope: scope, element: element, show: false}; - angular.forEach(['template', 'placement', 'keyboard', 'html', 'container', 'animation', 'duration', 'dismissable'], function(key) { + angular.forEach(['template', 'templateUrl', 'placement', 'keyboard', 'html', 'container', 'animation', 'duration', 'dismissable'], function(key) { if(angular.isDefined(attr[key])) options[key] = attr[key]; }); diff --git a/src/alert/test/alert.spec.js b/src/alert/test/alert.spec.js index b61fb705e..d49ad35b4 100644 --- a/src/alert/test/alert.spec.js +++ b/src/alert/test/alert.spec.js @@ -61,7 +61,7 @@ describe('alert', function() { }, 'options-template': { scope: {alert: {title: 'Title', content: 'Hello alert!', counter: 0}, items: ['foo', 'bar', 'baz']}, - element: 'click me' + element: 'click me' } }; diff --git a/src/aside/aside.js b/src/aside/aside.js index 436e09445..49e4ab3c9 100644 --- a/src/aside/aside.js +++ b/src/aside/aside.js @@ -9,7 +9,8 @@ angular.module('mgcrea.ngStrap.aside', ['mgcrea.ngStrap.modal']) prefixClass: 'aside', prefixEvent: 'aside', placement: 'right', - template: 'aside/aside.tpl.html', + templateUrl: 'aside/aside.tpl.html', + template: '', contentTemplate: false, container: false, element: null, @@ -50,7 +51,7 @@ angular.module('mgcrea.ngStrap.aside', ['mgcrea.ngStrap.modal']) link: function postLink(scope, element, attr, transclusion) { // Directive options var options = {scope: scope, element: element, show: false}; - angular.forEach(['template', 'contentTemplate', 'placement', 'backdrop', 'keyboard', 'html', 'container', 'animation'], function(key) { + angular.forEach(['template', 'templateUrl', 'contentTemplate', 'placement', 'backdrop', 'keyboard', 'html', 'container', 'animation'], function(key) { if(angular.isDefined(attr[key])) options[key] = attr[key]; }); diff --git a/src/aside/test/aside.spec.js b/src/aside/test/aside.spec.js index 80eed89bb..882758939 100644 --- a/src/aside/test/aside.spec.js +++ b/src/aside/test/aside.spec.js @@ -40,7 +40,7 @@ describe('aside', function () { }, 'options-template': { scope: {aside: {title: 'Title', content: 'Hello aside!', counter: 0}, items: ['foo', 'bar', 'baz']}, - element: 'click me' + element: 'click me' }, 'options-html': { scope: {aside: {title: 'title
next', content: 'content
next'}}, diff --git a/src/helpers/compiler.js b/src/helpers/compiler.js new file mode 100644 index 000000000..11f3c8a51 --- /dev/null +++ b/src/helpers/compiler.js @@ -0,0 +1,173 @@ +'use strict'; + +// NOTICE: This file was forked from the angular-material project (github.com/angular/material) +// MIT Licensed - Copyright (c) 2014-2015 Google, Inc. http://angularjs.org + +angular.module('mgcrea.ngStrap.core', []) + .service('$bsCompiler', bsCompilerService); + +function bsCompilerService($q, $http, $injector, $compile, $controller, $templateCache) { + + /* + * @ngdoc service + * @name $bsCompiler + * @module material.core + * @description + * The $bsCompiler service is an abstraction of angular's compiler, that allows the developer + * to easily compile an element with a templateUrl, controller, and locals. + * + * @usage + * + * $bsCompiler.compile({ + * templateUrl: 'modal.html', + * controller: 'ModalCtrl', + * locals: { + * modal: myModalInstance; + * } + * }).then(function(compileData) { + * compileData.element; // modal.html's template in an element + * compileData.link(myScope); //attach controller & scope to element + * }); + * + */ + + /* + * @ngdoc method + * @name $bsCompiler#compile + * @description A helper to compile an HTML template/templateUrl with a given controller, + * locals, and scope. + * @param {object} options An options object, with the following properties: + * + * - `controller` - `{(string=|function()=}` Controller fn that should be associated with + * newly created scope or the name of a registered controller if passed as a string. + * - `controllerAs` - `{string=}` A controller alias name. If present the controller will be + * published to scope under the `controllerAs` name. + * - `template` - `{string=}` An html template as a string. + * - `templateUrl` - `{string=}` A path to an html template. + * - `transformTemplate` - `{function(template)=}` A function which transforms the template after + * it is loaded. It will be given the template string as a parameter, and should + * return a a new string representing the transformed template. + * - `resolve` - `{Object.=}` - An optional map of dependencies which should + * be injected into the controller. If any of these dependencies are promises, the compiler + * will wait for them all to be resolved, or if one is rejected before the controller is + * instantiated `compile()` will fail.. + * * `key` - `{string}`: a name of a dependency to be injected into the controller. + * * `factory` - `{string|function}`: If `string` then it is an alias for a service. + * Otherwise if function, then it is injected and the return value is treated as the + * dependency. If the result is a promise, it is resolved before its value is + * injected into the controller. + * + * @returns {object=} promise A promise, which will be resolved with a `compileData` object. + * `compileData` has the following properties: + * + * - `element` - `{element}`: an uncompiled element matching the provided template. + * - `link` - `{function(scope)}`: A link function, which, when called, will compile + * the element and instantiate the provided controller (if given). + * - `locals` - `{object}`: The locals which will be passed into the controller once `link` is + * called. If `bindToController` is true, they will be coppied to the ctrl instead + * - `bindToController` - `bool`: bind the locals to the controller, instead of passing them in. + */ + this.compile = function(options) { + + if(options.template && /\.html$/.test(options.template)) { + console.warn('Deprecated use of `template` option to pass a file. Please use the `templateUrl` option instead.'); + options.templateUrl = options.template; + options.template = ''; + } + + var templateUrl = options.templateUrl; + var template = options.template || ''; + var controller = options.controller; + var controllerAs = options.controllerAs; + var resolve = angular.copy(options.resolve || {}); + var locals = angular.copy(options.locals || {}); + var transformTemplate = options.transformTemplate || angular.identity; + var bindToController = options.bindToController; + + // Take resolve values and invoke them. + // Resolves can either be a string (value: 'MyRegisteredAngularConst'), + // or an invokable 'factory' of sorts: (value: function ValueGetter($dependency) {}) + angular.forEach(resolve, function(value, key) { + if (angular.isString(value)) { + resolve[key] = $injector.get(value); + } else { + resolve[key] = $injector.invoke(value); + } + }); + // Add the locals, which are just straight values to inject + // eg locals: { three: 3 }, will inject three into the controller + angular.extend(resolve, locals); + + if (templateUrl) { + resolve.$template = fetchTemplate(templateUrl); + } else { + resolve.$template = $q.when(template); + } + + if (options.contentTemplate) { + // TODO(mgcrea): deprecate? + resolve.$template = $q.all([resolve.$template, fetchTemplate(options.contentTemplate)]) + .then(function(templates) { + var templateEl = angular.element(templates[0]); + var contentEl = findElement('[ng-bind="content"]', templateEl[0]).removeAttr('ng-bind').html(templates[1]); + // Drop the default footer as you probably don't want it if you use a custom contentTemplate + if(!options.templateUrl) contentEl.next().remove(); + return templateEl[0].outerHTML; + }); + } + + // Wait for all the resolves to finish if they are promises + return $q.all(resolve).then(function(locals) { + + var template = transformTemplate(locals.$template); + if (options.html) { + template = template.replace(/ng-bind="/ig, 'ng-bind-html="'); + } + // var element = options.element || angular.element('
').html(template.trim()).contents(); + var element = angular.element('
').html(template.trim()).contents(); + var linkFn = $compile(element); + + // Return a linking function that can be used later when the element is ready + return { + locals: locals, + element: element, + link: function link(scope) { + locals.$scope = scope; + + // Instantiate controller if it exists, because we have scope + if (controller) { + var invokeCtrl = $controller(controller, locals, true); + if (bindToController) { + angular.extend(invokeCtrl.instance, locals); + } + var ctrl = invokeCtrl(); + // See angular-route source for this logic + element.data('$ngControllerController', ctrl); + element.children().data('$ngControllerController', ctrl); + + if (controllerAs) { + scope[controllerAs] = ctrl; + } + } + + return linkFn.apply(null, arguments); + } + }; + }); + + }; + + function findElement(query, element) { + return angular.element((element || document).querySelectorAll(query)); + } + + var fetchPromises = {}; + function fetchTemplate(template) { + if(fetchPromises[template]) return fetchPromises[template]; + return (fetchPromises[template] = $http.get(template, {cache: $templateCache}) + .then(function(res) { + return res.data; + })); + } + +} diff --git a/src/modal/docs/modal.demo.html b/src/modal/docs/modal.demo.html index 0e0412eef..645f0305a 100644 --- a/src/modal/docs/modal.demo.html +++ b/src/modal/docs/modal.demo.html @@ -26,6 +26,13 @@

Live demo (using data-template) + + + +

diff --git a/src/modal/docs/modal.demo.js b/src/modal/docs/modal.demo.js index 2916cb4ce..2703a81db 100644 --- a/src/modal/docs/modal.demo.js +++ b/src/modal/docs/modal.demo.js @@ -13,12 +13,16 @@ angular.module('mgcrea.ngStrapDocs') // Controller usage example // - // var myModal = $modal({title: 'Title', content: 'Hello Modal
This is a multiline message!', show: false}); - // $scope.showModal = function() { - // myModal.$promise.then(myModal.show); - // }; - // $scope.hideModal = function() { - // myModal.$promise.then(myModal.hide); - // }; + function MyModalController($scope, $q) { + console.warn('in'); + $scope.foo = 'bar'; + } + var myModal = $modal({title: 'Title', content: 'Hello Modal
This is a multiline message!', controller: MyModalController, template: 'modal/docs/modal.demo.tpl.html', show: false}); + $scope.showModal = function() { + myModal.$promise.then(myModal.show); + }; + $scope.hideModal = function() { + myModal.$promise.then(myModal.hide); + }; }); diff --git a/src/modal/docs/modal.demo.tpl.html b/src/modal/docs/modal.demo.tpl.html index 17684222f..2c5f5a68f 100644 --- a/src/modal/docs/modal.demo.tpl.html +++ b/src/modal/docs/modal.demo.tpl.html @@ -10,6 +10,7 @@

Text in a modal

2 + 3 = {{ 2 + 3 }}
+
{{ foo }}

Popover in a modal

This button should trigger a popover on click.

diff --git a/src/modal/modal.js b/src/modal/modal.js index 0ccce110c..efae44194 100644 --- a/src/modal/modal.js +++ b/src/modal/modal.js @@ -1,6 +1,6 @@ 'use strict'; -angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.helpers.dimensions']) +angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.core', 'mgcrea.ngStrap.helpers.dimensions']) .provider('$modal', function() { @@ -10,7 +10,8 @@ angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.helpers.dimensions']) prefixClass: 'modal', prefixEvent: 'modal', placement: 'top', - template: 'modal/modal.tpl.html', + templateUrl: 'modal/modal.tpl.html', + template: '', contentTemplate: false, container: false, element: null, @@ -20,13 +21,12 @@ angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.helpers.dimensions']) show: true }; - this.$get = function($window, $rootScope, $compile, $q, $templateCache, $http, $animate, $timeout, $sce, dimensions) { + this.$get = function($window, $rootScope, $bsCompiler, $compile, $q, $templateCache, $http, $animate, $timeout, $sce, dimensions) { var forEach = angular.forEach; var trim = String.prototype.trim; var requestAnimationFrame = $window.requestAnimationFrame || $window.setTimeout; var bodyElement = angular.element($window.document.body); - var htmlReplaceRegExp = /ng-bind="/ig; function ModalFactory(config) { @@ -34,7 +34,10 @@ angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.helpers.dimensions']) // Common vars var options = $modal.$options = angular.extend({}, defaults, config); - $modal.$promise = fetchTemplate(options.template); + + var compilePromise = $modal.$promise = $bsCompiler.compile(options) + var compileData; + var scope = $modal.$scope = options.scope && options.scope.$new() || $rootScope.$new(); if(!options.element && !options.container) { options.container = 'body'; @@ -69,29 +72,12 @@ angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.helpers.dimensions']) // Publish isShown as a protected var on scope $modal.$isShown = scope.$isShown = false; - // Support contentTemplate option - if(options.contentTemplate) { - $modal.$promise = $modal.$promise.then(function(template) { - var templateEl = angular.element(template); - return fetchTemplate(options.contentTemplate) - .then(function(contentTemplate) { - var contentEl = findElement('[ng-bind="content"]', templateEl[0]).removeAttr('ng-bind').html(contentTemplate); - // Drop the default footer as you probably don't want it if you use a custom contentTemplate - if(!config.template) contentEl.next().remove(); - return templateEl[0].outerHTML; - }); - }); - } - // Fetch, compile then initialize modal var modalLinker, modalElement, modalScope; var backdropElement = angular.element('
'); backdropElement.css({position:'fixed', top:'0px', left:'0px', bottom:'0px', right:'0px', 'z-index': 1038}); - $modal.$promise.then(function(template) { - if(angular.isObject(template)) template = template.data; - if(options.html) template = template.replace(htmlReplaceRegExp, 'ng-bind-html="'); - template = trim.apply(template); - modalLinker = $compile(template); + compilePromise.then(function(data) { + compileData = data; $modal.init(); }); @@ -144,8 +130,8 @@ angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.helpers.dimensions']) // create a new scope, so we can destroy it and all child scopes // when destroying the modal element modalScope = $modal.$scope.$new(); - // Fetch a cloned element linked from template - modalElement = $modal.$element = modalLinker(modalScope, function(clonedElement, scope) {}); + // Fetch a cloned element linked from template (noop callback is required) + modalElement = $modal.$element = compileData.link(modalScope, function(clonedElement, scope) {}); if(scope.$emit(options.prefixEvent + '.show.before', $modal).defaultPrevented) { return; @@ -323,14 +309,6 @@ angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.helpers.dimensions']) return angular.element((element || document).querySelectorAll(query)); } - var fetchPromises = {}; - function fetchTemplate(template) { - if(fetchPromises[template]) return fetchPromises[template]; - return (fetchPromises[template] = $http.get(template, {cache: $templateCache}).then(function(res) { - return res.data; - })); - } - return ModalFactory; }; @@ -346,10 +324,15 @@ angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.helpers.dimensions']) // Directive options var options = {scope: scope, element: element, show: false}; - angular.forEach(['template', 'contentTemplate', 'placement', 'backdrop', 'keyboard', 'html', 'container', 'animation', 'id', 'prefixEvent', 'prefixClass'], function(key) { + angular.forEach(['template', 'templateUrl', 'contentTemplate', 'controller', 'placement', 'backdrop', 'keyboard', 'html', 'container', 'animation', 'id', 'prefixEvent', 'prefixClass'], function(key) { if(angular.isDefined(attr[key])) options[key] = attr[key]; }); + if(options.template && /\.html$/.test(options.template)) { + options.templateUrl = options.template; + options.template = ''; + } + // use string regex match boolean attr falsy values, leave truthy values be var falseValueRegExp = /^(false|0|)$/i; angular.forEach(['backdrop', 'keyboard', 'html', 'container'], function(key) { diff --git a/src/modal/test/modal.spec.js b/src/modal/test/modal.spec.js index 401b3c293..b89621fa5 100644 --- a/src/modal/test/modal.spec.js +++ b/src/modal/test/modal.spec.js @@ -9,6 +9,12 @@ describe('modal', function() { beforeEach(module('ngAnimate')); beforeEach(module('ngAnimateMock')); beforeEach(module('mgcrea.ngStrap.modal')); + beforeEach(module(function($controllerProvider) { + $controllerProvider.register('MyModalController', function($scope) { + $scope.title = 'foo'; + $scope.content = 'bar'; + }); + })); beforeEach(inject(function($injector) { $rootScope = $injector.get('$rootScope'); @@ -54,6 +60,9 @@ describe('modal', function() { 'markup-ngClick-service': { element: 'click me' }, + 'options-controller': { + element: 'click me' + }, 'options-placement': { element: 'click me' }, @@ -75,7 +84,7 @@ describe('modal', function() { }, 'options-template': { scope: {modal: {title: 'Title', content: 'Hello Modal!', counter: 0}, items: ['foo', 'bar', 'baz']}, - element: 'click me' + element: 'click me' }, 'options-contentTemplate': { scope: {modal: {title: 'Title', content: 'Hello Modal!', counter: 0}, items: ['foo', 'bar', 'baz']}, @@ -417,6 +426,17 @@ describe('modal', function() { }); + describe('controller', function() { + + it('should properly invoke our passed controller', function() { + var elm = compileDirective('options-controller'); + angular.element(elm[0]).triggerHandler('click'); + expect(sandboxEl.find('.modal-title').html()).toBe('foo'); + expect(sandboxEl.find('.modal-body').html()).toBe('bar'); + }); + + }); + describe('placement', function() { it('should default to `top` placement', function() {