From 78656fe0dfc99c341ce02d71e7006e9c05b1fe3f Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Fri, 27 Jan 2012 16:18:16 -0800 Subject: [PATCH] feat($compile) add locals, isolate scope, transclusion --- src/AngularPublic.js | 3 +- src/angular-bootstrap.js | 1 + src/angular-mocks.js | 3 + src/directives.js | 69 ++++- src/service/compiler.js | 330 +++++++++++++++++++---- src/service/controller.js | 17 +- src/service/formFactory.js | 2 +- src/service/route.js | 2 +- src/service/scope.js | 34 ++- src/widgets.js | 2 +- test/service/compilerSpec.js | 471 +++++++++++++++++++++++++++++++-- test/service/controllerSpec.js | 2 +- test/service/scopeSpec.js | 9 + 13 files changed, 838 insertions(+), 107 deletions(-) diff --git a/src/AngularPublic.js b/src/AngularPublic.js index d052c35b707b..ac7d4243af10 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -94,7 +94,8 @@ function publishExternalAPI(angular){ ngStyle: ngStyleDirective, ngSwitch: ngSwitchDirective, ngOptions: ngOptionsDirective, - ngView: ngViewDirective + ngView: ngViewDirective, + ngTransclude: ngTranscludeDirective }). directive(ngEventDirectives). directive(ngAttributeAliasDirectives); diff --git a/src/angular-bootstrap.js b/src/angular-bootstrap.js index 778eee6b18f9..5b6e59373610 100644 --- a/src/angular-bootstrap.js +++ b/src/angular-bootstrap.js @@ -101,6 +101,7 @@ globalVars = {}; bindJQuery(); + publishExternalAPI(window.angular); angularInit(document, angular.bootstrap); } diff --git a/src/angular-mocks.js b/src/angular-mocks.js index c024343ee2e3..0a8b573bf894 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -1458,6 +1458,9 @@ window.jstestdriver && (function(window) { args.push(angular.mock.dump(arg)); }); jstestdriver.console.log.apply(jstestdriver.console, args); + if (window.console) { + window.console.log.apply(window.console, args); + } }; })(window); diff --git a/src/directives.js b/src/directives.js index c6cc0b151fef..39308b1a629d 100644 --- a/src/directives.js +++ b/src/directives.js @@ -133,17 +133,7 @@ var ngInitDirective = valueFn({ var ngControllerDirective = ['$controller', '$window', function($controller, $window) { return { scope: true, - compile: function() { - return { - pre: function(scope, element, attr) { - var expression = attr.ngController, - Controller = getter(scope, expression, true) || getter($window, expression, true); - - assertArgFn(Controller, expression); - $controller(Controller, scope); - } - }; - } + controller: '@' } }]; @@ -264,6 +254,7 @@ var ngBindHtmlDirective = ['$sanitize', function($sanitize) { var ngBindTemplateDirective = ['$interpolate', function($interpolate) { return function(scope, element, attr) { var interpolateFn = $interpolate(attr.ngBindTemplate); + var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); element.addClass('ng-binding').data('$binding', interpolateFn); scope.$watch(interpolateFn, function(value) { element.text(value); @@ -921,3 +912,59 @@ function ngAttributeAliasDirective(propName, attrName) { var ngAttributeAliasDirectives = {}; forEach(BOOLEAN_ATTR, ngAttributeAliasDirective); ngAttributeAliasDirective(null, 'src'); + +/** + * @ngdoc directive + * @name angular.module.ng.$compileProvider.directive.ng:transclude + * + * @description + * Insert the transcluded DOM here. + * + * @element ANY + * + * @example + + + +
+
+
+ {{text}} +
+
+ + it('should have transcluded', function() { + input('title').enter('TITLE'); + input('text').enter('TEXT'); + expect(binding('title')).toEqual('TITLE'); + expect(binding('text')).toEqual('TEXT'); + }); + +
+ * + */ +var ngTranscludeDirective = valueFn({ + controller: ['$transclude', '$element', function($transclude, $element) { + $transclude(function(clone) { + $element.append(clone); + }); + }] +}); diff --git a/src/service/compiler.js b/src/service/compiler.js index ed4537491388..ef049b5072ba 100644 --- a/src/service/compiler.js +++ b/src/service/compiler.js @@ -72,6 +72,9 @@ * * * @param {string|DOMElement} element Element or HTML string to compile into a template function. + * @param {function(angular.Scope[, cloneAttachFn]} transclude function available to directives. + * @param {number} maxPriority only apply directives lower then given priority (Only effects the + * root element(s), not their children) * @returns {function(scope[, cloneAttachFn])} a link function which is used to bind template * (a DOM element/tree) to a scope. Where: * @@ -157,7 +160,8 @@ function $CompileProvider($provide) { directive.compile = valueFn(directive.link); } directive.priority = directive.priority || 0; - directive.name = name; + directive.name = directive.name || name; + directive.require = directive.require || (directive.controller && directive.name); directive.restrict = directive.restrict || 'EACM'; directives.push(directive); } catch (e) { @@ -175,10 +179,58 @@ function $CompileProvider($provide) { }; - this.$get = ['$injector', '$interpolate', '$exceptionHandler', '$http', '$templateCache', - function($injector, $interpolate, $exceptionHandler, $http, $templateCache) { + this.$get = [ + '$injector', '$interpolate', '$exceptionHandler', '$http', '$templateCache', '$parse', + '$controller', + function($injector, $interpolate, $exceptionHandler, $http, $templateCache, $parse, + $controller) { + + 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); + }; + } + }; + + return compile; - return function(templateElement) { + //================================ + + function compile(templateElement, transcludeFn, maxPriority) { templateElement = jqLite(templateElement); // We can not compile top level text elements since text nodes can be merged and we will // not be able to attach scope data to them, so we will wrap them in @@ -187,7 +239,7 @@ function $CompileProvider($provide) { templateElement[index] = jqLite(node).wrap('').parent()[0]; } }); - var linkingFn = compileNodes(templateElement, templateElement); + var linkingFn = compileNodes(templateElement, transcludeFn, templateElement, maxPriority); return function(scope, cloneConnectFn){ assertArg(scope, 'scope'); // important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart @@ -200,9 +252,11 @@ function $CompileProvider($provide) { if (linkingFn) linkingFn(scope, element, element); return element; }; - }; + } - //================================ + function wrongMode(localName, mode) { + throw Error("Unsupported '" + mode + "' for '" + localName + "'."); + } /** * Compile function matches each node in nodeList against the directives. Once all directives @@ -211,12 +265,15 @@ function $CompileProvider($provide) { * function, which is the a linking function for the node. * * @param {NodeList} nodeList an array of nodes to compile + * @param {function(angular.Scope[, cloneAttachFn]} transcludeFn A linking function, where the + * scope argument is auto-generated to the new child of the transcluded parent scope. * @param {DOMElement=} rootElement If the nodeList is the root of the compilation tree then the * rootElement must be set the jqLite collection of the compile root. This is * needed so that the jqLite collection items can be replaced with widgets. + * @param {number=} max directive priority * @returns {?function} A composite linking function of all of the matched directives or null. */ - function compileNodes(nodeList, rootElement) { + function compileNodes(nodeList, transcludeFn, rootElement, maxPriority) { var linkingFns = [], directiveLinkingFn, childLinkingFn, directives, attrs, linkingFnFound; @@ -227,15 +284,16 @@ function $CompileProvider($provide) { $set: attrSetter }; // we must always refer to nodeList[i] since the nodes can be replaced underneath us. - directives = collectDirectives(nodeList[i], [], attrs); + directives = collectDirectives(nodeList[i], [], attrs, maxPriority); directiveLinkingFn = (directives.length) - ? applyDirectivesToNode(directives, nodeList[i], attrs, rootElement) + ? applyDirectivesToNode(directives, nodeList[i], attrs, transcludeFn, rootElement) : null; childLinkingFn = (directiveLinkingFn && directiveLinkingFn.terminal) ? null - : compileNodes(nodeList[i].childNodes); + : compileNodes(nodeList[i].childNodes, + directiveLinkingFn ? directiveLinkingFn.transclude : transcludeFn); linkingFns.push(directiveLinkingFn); linkingFns.push(childLinkingFn); @@ -245,28 +303,42 @@ function $CompileProvider($provide) { // return a linking function if we have found anything, null otherwise return linkingFnFound ? linkingFn : null; - function linkingFn(scope, nodeList, rootElement) { + /* nodesetLinkingFn */ function linkingFn(scope, nodeList, rootElement, boundTranscludeFn) { if (linkingFns.length != nodeList.length * 2) { throw Error('Template changed structure!'); } - var childLinkingFn, directiveLinkingFn, node, childScope; + var childLinkingFn, directiveLinkingFn, node, childScope, childTransclusionFn; for(var i=0, n=0, ii=linkingFns.length; i - addDirective(directives, directiveNormalize(nodeName_(node).toLowerCase()), 'E'); + addDirective(directives, + directiveNormalize(nodeName_(node).toLowerCase()), 'E', maxPriority); // iterate over the attributes for (var attr, name, nName, value, nAttrs = node.attributes, @@ -305,15 +379,15 @@ function $CompileProvider($provide) { if (BOOLEAN_ATTR[nName]) { attrs[nName] = true; // presence means true } - addAttrInterpolateDirective(directives, value, nName); - addDirective(directives, nName, 'A'); + addAttrInterpolateDirective(directives, value, nName) + addDirective(directives, nName, 'A', maxPriority); } // use class as directive className = node.className; while (match = CLASS_DIRECTIVE_REGEXP.exec(className)) { nName = directiveNormalize(match[2]); - if (addDirective(directives, nName, 'C')) { + if (addDirective(directives, nName, 'C', maxPriority)) { attrs[nName] = trim(match[3]); } className = className.substr(match.index + match[0].length); @@ -326,7 +400,7 @@ function $CompileProvider($provide) { match = COMMENT_DIRECTIVE_REGEXP.exec(node.nodeValue); if (match) { nName = directiveNormalize(match[1]); - if (addDirective(directives, nName, 'M')) { + if (addDirective(directives, nName, 'M', maxPriority)) { attrs[nName] = trim(match[2]); } } @@ -347,40 +421,81 @@ function $CompileProvider($provide) { * this needs to be pre-sorted by priority order. * @param {Node} templateNode The raw DOM node to apply the compile functions to * @param {Object} templateAttrs The shared attribute function + * @param {function(angular.Scope[, cloneAttachFn]} transcludeFn A linking function, where the + * scope argument is auto-generated to the new child of the transcluded parent scope. * @param {DOMElement} rootElement If we are working on the root of the compile tree then this * argument has the root jqLite array so that we can replace widgets on it. * @returns linkingFn */ - function applyDirectivesToNode(directives, templateNode, templateAttrs, rootElement) { + function applyDirectivesToNode(directives, templateNode, templateAttrs, transcludeFn, rootElement) { var terminalPriority = -Number.MAX_VALUE, preLinkingFns = [], postLinkingFns = [], newScopeDirective = null, + newIsolatedScopeDirective = null, templateDirective = null, delayedLinkingFn = null, element = templateAttrs.$element = jqLite(templateNode), - directive, linkingFn; + directive, + directiveName, + template, + transcludeDirective, + childTranscludeFn = transcludeFn, + controllerDirectives, + linkingFn, + directiveValue; // executes all directives on the current element for(var i = 0, ii = directives.length; i < ii; i++) { directive = directives[i]; + template = undefined; if (terminalPriority > directive.priority) { break; // prevent further processing of directives } - if (directive.scope) { - assertNoDuplicate('new scope', newScopeDirective, directive, element); + if (directiveValue = directive.scope) { + assertNoDuplicate('isolated scope', newIsolatedScopeDirective, directive, element); + if (isObject(directiveValue)) { + element.addClass('ng-isolate-scope'); + newIsolatedScopeDirective = directive; + } element.addClass('ng-scope'); - newScopeDirective = directive; + newScopeDirective = newScopeDirective || directive; } - if (directive.template) { + directiveName = directive.name; + + if (directiveValue = directive.controller) { + controllerDirectives = controllerDirectives || {}; + assertNoDuplicate("'" + directiveName + "' controller", + controllerDirectives[directiveName], directive, element); + controllerDirectives[directiveName] = directive; + } + + if (directiveValue = directive.transclude) { + assertNoDuplicate('transclusion', transcludeDirective, directive, element); + transcludeDirective = directive; + terminalPriority = directive.priority; + if (directiveValue == 'element') { + template = jqLite(templateNode); + templateNode = (element = templateAttrs.$element = jqLite( + ''))[0]; + template.replaceWith(templateNode); + childTranscludeFn = compile(template, transcludeFn, terminalPriority); + } else { + template = jqLite(JQLiteClone(templateNode)); + element.html(''); // clear contents + childTranscludeFn = compile(template.contents(), transcludeFn); + } + } + + if (directiveValue = directive.template) { assertNoDuplicate('template', templateDirective, directive, element); templateDirective = directive; // include the contents of the original element into the template and replace the element - var content = directive.template.replace(CONTENT_REGEXP, element.html()); + var content = directiveValue.replace(CONTENT_REGEXP, element.html()); templateNode = jqLite(content)[0]; if (directive.replace) { replaceWith(rootElement, element, templateNode); @@ -411,16 +526,16 @@ function $CompileProvider($provide) { assertNoDuplicate('template', templateDirective, directive, element); templateDirective = directive; delayedLinkingFn = compileTemplateUrl(directives.splice(i, directives.length - i), - compositeLinkFn, element, templateAttrs, rootElement, directive.replace); + /* directiveLinkingFn */ compositeLinkFn, element, templateAttrs, rootElement, + directive.replace, childTranscludeFn); ii = directives.length; } else if (directive.compile) { try { - linkingFn = directive.compile(element, templateAttrs); + linkingFn = directive.compile(element, templateAttrs, childTranscludeFn); if (isFunction(linkingFn)) { - postLinkingFns.push(linkingFn); + addLinkingFns(null, linkingFn); } else if (linkingFn) { - if (linkingFn.pre) preLinkingFns.push(linkingFn.pre); - if (linkingFn.post) postLinkingFns.push(linkingFn.post); + addLinkingFns(linkingFn.pre, linkingFn.post); } } catch (e) { $exceptionHandler(e, startingTag(element)); @@ -433,16 +548,57 @@ function $CompileProvider($provide) { } } - compositeLinkFn.scope = !!newScopeDirective; + + linkingFn = delayedLinkingFn || compositeLinkFn; + linkingFn.scope = newScopeDirective && newScopeDirective.scope; + linkingFn.transclude = transcludeDirective && childTranscludeFn; // if we have templateUrl, then we have to delay linking - return delayedLinkingFn || compositeLinkFn; + return linkingFn; //////////////////// + function addLinkingFns(pre, post) { + if (pre) { + pre.require = directive.require; + preLinkingFns.push(pre); + } + if (post) { + post.require = directive.require; + postLinkingFns.push(post); + } + } + - function compositeLinkFn(childLinkingFn, scope, linkNode) { - var attrs, element, i, ii; + function getControllers(require, element) { + var value, retrievalMethod = 'data', optional = false; + if (isString(require)) { + while((value = require.charAt(0)) == '^' || value == '?') { + require = require.substr(1); + if (value == '^') { + retrievalMethod = 'inheritedData'; + } + optional = optional || value == '?'; + } + value = element[retrievalMethod]('$' + require + 'Controller'); + if (!value && !optional) { + throw Error("No controller: " + require); + } + return value; + } else if (isArray(require)) { + value = []; + forEach(require, function(require) { + value.push(getControllers(require, element)); + }); + } + return value; + } + + + /* directiveLinkingFn */ + function compositeLinkFn(/* nodesetLinkingFn */ childLinkingFn, + scope, linkNode, rootElement, boundTranscludeFn) { + var attrs, element, i, ii, linkingFn, controller; if (templateNode === linkNode) { attrs = templateAttrs; @@ -452,22 +608,59 @@ 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); + }); + } + + if (controllerDirectives) { + forEach(controllerDirectives, function(directive) { + var locals = { + $scope: scope, + $element: element, + $attrs: attrs, + $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]; + } + + element.data( + '$' + directive.name + 'Controller', + $controller(controller, locals)); + }); + } + // PRELINKING for(i = 0, ii = preLinkingFns.length; i < ii; i++) { try { - preLinkingFns[i](scope, element, attrs); + linkingFn = preLinkingFns[i]; + linkingFn(scope, element, attrs, + linkingFn.require && getControllers(linkingFn.require, element)); } catch (e) { $exceptionHandler(e, startingTag(element)); } } // RECURSION - childLinkingFn && childLinkingFn(scope, linkNode.childNodes); + childLinkingFn && childLinkingFn(scope, linkNode.childNodes, undefined, boundTranscludeFn); // POSTLINKING for(i = 0, ii = postLinkingFns.length; i < ii; i++) { try { - postLinkingFns[i](scope, element, attrs); + linkingFn = postLinkingFns[i]; + linkingFn(scope, element, attrs, + linkingFn.require && getControllers(linkingFn.require, element)); } catch (e) { $exceptionHandler(e, startingTag(element)); } @@ -490,14 +683,15 @@ function $CompileProvider($provide) { * * `M`: comment * @returns true if directive was added. */ - function addDirective(tDirectives, name, location) { + function addDirective(tDirectives, name, location, maxPriority) { var match = false; if (hasDirectives.hasOwnProperty(name)) { for(var directive, directives = $injector.get(name + Suffix), i=0, ii = directives.length; i directive.priority) && + directive.restrict.indexOf(location) != -1) { tDirectives.push(directive); match = true; } @@ -540,15 +734,15 @@ function $CompileProvider($provide) { } - function compileTemplateUrl(directives, beforeWidgetLinkFn, tElement, tAttrs, rootElement, - replace) { + function compileTemplateUrl(directives, /* directiveLinkingFn */ beforeWidgetLinkFn, + tElement, tAttrs, rootElement, replace, transcludeFn) { var linkQueue = [], afterWidgetLinkFn, afterWidgetChildrenLinkFn, originalWidgetNode = tElement[0], asyncWidgetDirective = directives.shift(), // The fact that we have to copy and patch the directive seems wrong! - syncWidgetDirective = extend({}, asyncWidgetDirective, {templateUrl:null}), + syncWidgetDirective = extend({}, asyncWidgetDirective, {templateUrl:null, transclude:null}), html = tElement.html(); tElement.html(''); @@ -574,12 +768,13 @@ function $CompileProvider($provide) { } directives.unshift(syncWidgetDirective); - afterWidgetLinkFn = applyDirectivesToNode(directives, tElement, tAttrs); - afterWidgetChildrenLinkFn = compileNodes(tElement.contents()); + afterWidgetLinkFn = /* directiveLinkingFn */ applyDirectivesToNode(directives, tElement, tAttrs, transcludeFn); + afterWidgetChildrenLinkFn = /* nodesetLinkingFn */ compileNodes(tElement.contents(), transcludeFn); while(linkQueue.length) { - var linkRootElement = linkQueue.pop(), + var controller = linkQueue.pop(), + linkRootElement = linkQueue.pop(), cLinkNode = linkQueue.pop(), scope = linkQueue.pop(), node = templateNode; @@ -590,8 +785,8 @@ function $CompileProvider($provide) { replaceWith(linkRootElement, jqLite(cLinkNode), node); } afterWidgetLinkFn(function() { - beforeWidgetLinkFn(afterWidgetChildrenLinkFn, scope, node); - }, scope, node); + beforeWidgetLinkFn(afterWidgetChildrenLinkFn, scope, node, rootElement, controller); + }, scope, node, rootElement, controller); } linkQueue = null; }). @@ -599,15 +794,17 @@ function $CompileProvider($provide) { throw Error('Failed to load template: ' + config.url); }); - return function(ignoreChildLinkingFn, scope, node, rootElement) { + return /* directiveLinkingFn */ function(ignoreChildLinkingFn, scope, node, rootElement, + controller) { if (linkQueue) { linkQueue.push(scope); linkQueue.push(node); linkQueue.push(rootElement); + linkQueue.push(controller); } else { afterWidgetLinkFn(function() { - beforeWidgetLinkFn(afterWidgetChildrenLinkFn, scope, node); - }, scope, node); + beforeWidgetLinkFn(afterWidgetChildrenLinkFn, scope, node, rootElement, controller); + }, scope, node, rootElement, controller); } }; } @@ -759,3 +956,24 @@ var PREFIX_REGEXP = /^(x[\:\-_]|data[\:\-_])/i; function directiveNormalize(name) { return camelCase(name.replace(PREFIX_REGEXP, '')); } + + + +/** + * Closure compiler type information + */ + +function nodesetLinkingFn( + /* angular.Scope */ scope, + /* NodeList */ nodeList, + /* Element */ rootElement, + /* function(Function) */ boundTranscludeFn +){} + +function directiveLinkingFn( + /* nodesetLinkingFn */ nodesetLinkingFn, + /* angular.Scope */ scope, + /* Node */ node, + /* Element */ rootElement, + /* function(Function) */ boundTranscludeFn +){} diff --git a/src/service/controller.js b/src/service/controller.js index 22fb3b025260..229ce14aa480 100644 --- a/src/service/controller.js +++ b/src/service/controller.js @@ -1,15 +1,16 @@ 'use strict'; function $ControllerProvider() { - this.$get = ['$injector', function($injector) { + this.$get = ['$injector', '$window', function($injector, $window) { /** * @ngdoc function * @name angular.module.ng.$controller * @requires $injector * - * @param {Function} Class Constructor function of a controller to instantiate. - * @param {Object} scope Related scope. + * @param {Function|string} Class Constructor function of a controller to instantiate, or + * expression to read from current scope or window. + * @param {Object} locals Injection locals for Controller. * @return {Object} Instance of given controller. * * @description @@ -19,8 +20,14 @@ function $ControllerProvider() { * a service, so that one can override this service with {@link https://gist.github.com/1649788 * BC version}. */ - return function(Class, scope) { - return $injector.instantiate(Class, {$scope: scope}); + return function(Class, locals) { + if(isString(Class)) { + var expression = Class; + Class = getter(locals.$scope, expression, true) || getter($window, expression, true); + assertArgFn(Class, expression); + } + + return $injector.instantiate(Class, locals); }; }]; } diff --git a/src/service/formFactory.js b/src/service/formFactory.js index 807f41134ba5..b051f7b9c792 100644 --- a/src/service/formFactory.js +++ b/src/service/formFactory.js @@ -139,7 +139,7 @@ function $FormFactoryProvider() { function formFactory(parent) { var scope = (parent || formFactory.rootForm).$new(); - $controller(FormController, scope); + $controller(FormController, {$scope: scope}); return scope; } diff --git a/src/service/route.js b/src/service/route.js index 9b52c4b0b6b0..932e26d54d2e 100644 --- a/src/service/route.js +++ b/src/service/route.js @@ -280,7 +280,7 @@ function $RouteProvider(){ copy(next.params, $routeParams); next.scope = parentScope.$new(); if (next.controller) { - $controller(next.controller, next.scope); + $controller(next.controller, {$scope: next.scope}); } } } diff --git a/src/service/scope.js b/src/service/scope.js index 9b9e9215c870..da1062a8bf31 100644 --- a/src/service/scope.js +++ b/src/service/scope.js @@ -136,20 +136,36 @@ function $RootScopeProvider(){ * the scope and its child scopes to be permanently detached from the parent and thus stop * participating in model change detection and listener notification by invoking. * + * @params {boolean} isolate if true then the scoped does not prototypically inherit from the + * parent scope. The scope is isolated, as it can not se parent scope properties. + * When creating widgets it is useful for the widget to not accidently read parent + * state. + * * @returns {Object} The newly created child scope. * */ - $new: function() { - var Child = function() {}; // should be anonymous; This is so that when the minifier munges - // the name it does not become random set of chars. These will then show up as class - // name in the debugger. - var child; - Child.prototype = this; - child = new Child(); + $new: function(isolate) { + var Child, + child; + + if (isFunction(isolate)) { + // TODO: remove at some point + throw Error('API-CHANGE: Use $controller to instantiate controllers.'); + } + if (isolate) { + child = new Scope(); + child.$root = this.$root; + } else { + Child = function() {}; // should be anonymous; This is so that when the minifier munges + // the name it does not become random set of chars. These will then show up as class + // name in the debugger. + Child.prototype = this; + child = new Child(); + child.$id = nextUid(); + } child['this'] = child; child.$$listeners = {}; child.$parent = this; - child.$id = nextUid(); child.$$asyncQueue = []; child.$$watchers = child.$$nextSibling = child.$$childHead = child.$$childTail = null; child.$$prevSibling = this.$$childTail; @@ -277,7 +293,7 @@ function $RootScopeProvider(){ * `'Maximum iteration limit exceeded.'` if the number of iterations exceeds 100. * * Usually you don't call `$digest()` directly in - * {@link angular.module.ng.$compileProvider.directive.ng:controller controllers} or in + * {@link angular.module.ng.$compileProvider.directive.ng:controller controllers} or in * {@link angular.module.ng.$compileProvider.directive directives}. * Instead a call to {@link angular.module.ng.$rootScope.Scope#$apply $apply()} (typically from within a * {@link angular.module.ng.$compileProvider.directive directives}) will force a `$digest()`. diff --git a/src/widgets.js b/src/widgets.js index 18ac27c3628d..a465bc887c89 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -760,7 +760,7 @@ var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interp var BRACE = /{}/g; return function(scope, element, attr) { var numberExp = attr.count, - whenExp = attr.when, + whenExp = element.attr(attr.$attr.when), // this is becaues we have {{}} in attrs offset = attr.offset || 0, whens = scope.$eval(whenExp), whensExpFns = {}; diff --git a/test/service/compilerSpec.js b/test/service/compilerSpec.js index 4de4641a024f..c998765c371a 100644 --- a/test/service/compilerSpec.js +++ b/test/service/compilerSpec.js @@ -207,7 +207,12 @@ describe('$compile', function() { forEach(parts, function(value, key){ if (value.substring(0,3) == 'ng-') { } else { - list.push(value.replace('=""', '')); + value = value.replace('=""', ''); + var match = value.match(/=(.*)/); + if (match && match[1].charAt(0) != '"') { + value = value.replace(/=(.*)/, '="$1"'); + } + list.push(value); } }); return '<' + list.join(' ') + '>'; @@ -864,6 +869,7 @@ describe('$compile', function() { describe('scope', function() { + var iscope; beforeEach(module(function($compileProvider) { forEach(['', 'a', 'b'], function(name) { @@ -878,6 +884,31 @@ describe('$compile', function() { } }; }); + $compileProvider.directive('iscope' + uppercase(name), function(log) { + return { + scope: {}, + compile: function() { + return function (scope, element) { + iscope = scope; + log(scope.$id); + expect(element.data('$scope')).toBe(scope); + }; + } + }; + }); + $compileProvider.directive('tiscope' + uppercase(name), function(log) { + return { + scope: {}, + templateUrl: 'tiscope.html', + compile: function() { + return function (scope, element) { + iscope = scope; + log(scope.$id); + expect(element.data('$scope')).toBe(scope); + }; + } + }; + }); }); $compileProvider.directive('log', function(log) { return function(scope) { @@ -894,37 +925,80 @@ describe('$compile', function() { })); - it('should correctly create the scope hierachy properly', inject( - function($rootScope, $compile, log) { - element = $compile( - '
' + //1 - '' + //2 - '' + //3 - '' + - '' + - '' + //4 - '' + - '' + - '
' - )($rootScope); - expect(log).toEqual('LOG; log-003-002; 003; LOG; log-002-001; 002; LOG; log-004-001; 004'); + it('should allow creation of new isolated scopes', inject(function($rootScope, $compile, log) { + element = $compile('
')($rootScope); + expect(log).toEqual('LOG; log-002-001; 002'); + $rootScope.name = 'abc'; + expect(iscope.$parent).toBe($rootScope); + expect(iscope.name).toBeUndefined(); + })); + + + it('should allow creation of new isolated scopes', inject( + function($rootScope, $compile, log, $httpBackend) { + $httpBackend.expect('GET', 'tiscope.html').respond(''); + element = $compile('
')($rootScope); + $httpBackend.flush(); + expect(log).toEqual('LOG; log-002-001; 002'); + $rootScope.name = 'abc'; + expect(iscope.$parent).toBe($rootScope); + expect(iscope.name).toBeUndefined(); })); - it('should not allow more then one scope creation per element', inject( + it('should correctly create the scope hierachy properly', inject( + function($rootScope, $compile, log) { + element = $compile( + '
' + //1 + '' + //2 + '' + //3 + '' + + '' + + '' + //4 + '' + + '' + + '
' + )($rootScope); + expect(log).toEqual('LOG; log-003-002; 003; LOG; log-002-001; 002; LOG; log-004-001; 004'); + }) + ); + + + it('should allow more then one scope creation per element', inject( + function($rootScope, $compile, log) { + $compile('
')($rootScope); + expect(log).toEqual('001; 001'); + }) + ); + + it('should not allow more then one isolate scope creation per element', inject( + function($rootScope, $compile) { + expect(function(){ + $compile('
'); + }).toThrow('Multiple directives [iscopeA, scopeB] asking for isolated scope on: ' + + '<' + (msie < 9 ? 'DIV' : 'div') + + ' class="iscope-a; scope-b ng-isolate-scope ng-scope">'); + }) + ); + + + it('should not allow more then one isolate scope creation per element', inject( function($rootScope, $compile) { expect(function(){ - $compile('
'); - }).toThrow('Multiple directives [scopeA, scopeB] asking for new scope on: ' + - '<' + (msie < 9 ? 'DIV' : 'div') + ' class="scope-a; scope-b ng-scope">'); - })); + $compile('
'); + }).toThrow('Multiple directives [iscopeA, iscopeB] asking for isolated scope on: ' + + '<' + (msie < 9 ? 'DIV' : 'div') + + ' class="iscope-a; iscope-b ng-isolate-scope ng-scope">'); + }) + ); it('should treat new scope on new template as noop', inject( function($rootScope, $compile, log) { element = $compile('
')($rootScope); expect(log).toEqual('001'); - })); + }) + ); }); }); }); @@ -1193,4 +1267,359 @@ describe('$compile', function() { }) }); }); + + + describe('locals', function() { + it('should marshal to locals', function() { + module(function($compileProvider) { + $compileProvider.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); + } + }; + }); + }); + 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(); + $rootScope.$apply(); + expect(element.text()).toEqual('C'); + expect(log).toEqual('C'); + $rootScope.bi.nd = 'c'; + $rootScope.$apply(); + expect(log).toEqual('C; c'); + }); + }); + }); + + + describe('controller', function() { + it('should inject locals to controller', function() { + module(function($compileProvider) { + $compileProvider.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'); + }); + }); + + + it('should get required controller', function() { + module(function($compileProvider) { + $compileProvider.directive('main', function(log) { + return { + priority: 2, + controller: function() { + this.name = 'main'; + }, + link: function(scope, element, attrs, controller) { + log(controller.name); + } + }; + }); + $compileProvider.directive('dep', function(log) { + return { + priority: 1, + require: 'main', + link: function(scope, element, attrs, controller) { + log('dep:' + controller.name); + } + }; + }); + $compileProvider.directive('other', function(log) { + return { + link: function(scope, element, attrs, controller) { + log(!!controller); // should be false + } + }; + }); + }); + inject(function(log, $compile, $rootScope) { + element = $compile('
')($rootScope); + expect(log).toEqual('main; dep:main; false'); + }); + }); + + + it('should require controller on parent element',function() { + module(function($compileProvider) { + $compileProvider.directive('main', function(log) { + return { + controller: function() { + this.name = 'main'; + } + }; + }); + $compileProvider.directive('dep', function(log) { + return { + require: '^main', + link: function(scope, element, attrs, controller) { + log('dep:' + controller.name); + } + }; + }); + }); + inject(function(log, $compile, $rootScope) { + element = $compile('
')($rootScope); + expect(log).toEqual('dep:main'); + }); + }); + + + it('should have optional controller on current element', function() { + module(function($compileProvider) { + $compileProvider.directive('dep', function(log) { + return { + require: '?main', + link: function(scope, element, attrs, controller) { + log('dep:' + !!controller); + } + }; + }); + }); + inject(function(log, $compile, $rootScope) { + element = $compile('
')($rootScope); + expect(log).toEqual('dep:false'); + }); + }); + + + it('should support multiple controllers', function() { + module(function($compileProvider) { + $compileProvider.directive('c1', valueFn({ + controller: function() { this.name = 'c1'; } + })); + $compileProvider.directive('c2', valueFn({ + controller: function() { this.name = 'c2'; } + })); + $compileProvider.directive('dep', function(log) { + return { + require: ['^c1', '^c2'], + link: function(scope, element, attrs, controller) { + log('dep:' + controller[0].name + '-' + controller[1].name); + } + }; + }); + }); + inject(function(log, $compile, $rootScope) { + element = $compile('
')($rootScope); + expect(log).toEqual('dep:c1-c2'); + }); + + }); + }); + + + describe('transclude', function() { + it('should compile get templateFn', function() { + module(function($compileProvider) { + $compileProvider.directive('trans', function(log) { + return { + transclude: 'element', + priority: 2, + controller: function($transclude) { this.$transclude = $transclude; }, + compile: function(element, attrs, template) { + log('compile: ' + angular.mock.dump(element)); + return function(scope, element, attrs, ctrl) { + log('link'); + var cursor = element; + template(scope.$new(), function(clone) {cursor.after(cursor = clone)}); + ctrl.$transclude(function(clone) {cursor.after(clone)}); + }; + } + } + }); + }); + inject(function(log, $rootScope, $compile) { + element = $compile('
{{$parent.$id}}-{{$id}};
') + ($rootScope); + $rootScope.$apply(); + expect(log).toEqual('compile: ; HIGH; link; LOG; LOG'); + expect(element.text()).toEqual('001-002;001-003;'); + }); + }); + + + it('should support transclude directive', function() { + module(function($compileProvider) { + $compileProvider.directive('trans', function() { + return { + transclude: 'content', + replace: true, + scope: true, + template: '
  • W:{{$parent.$id}}-{{$id}};
' + } + }); + }); + inject(function(log, $rootScope, $compile) { + element = $compile('
T:{{$parent.$id}}-{{$id}};
') + ($rootScope); + $rootScope.$apply(); + expect(element.text()).toEqual('W:001-002;T:001-003;'); + expect(jqLite(element.find('span')[0]).text()).toEqual('T:001-003'); + expect(jqLite(element.find('span')[1]).text()).toEqual(';'); + }); + }); + + + it('should transclude transcluded content', function() { + module(function($compileProvider) { + $compileProvider.directive('book', valueFn({ + transclude: 'content', + template: '
book-
(
)
' + })); + $compileProvider.directive('chapter', valueFn({ + transclude: 'content', + templateUrl: 'chapter.html' + })); + $compileProvider.directive('section', valueFn({ + transclude: 'content', + template: '
section-!
!
' + })); + return function($httpBackend) { + $httpBackend. + expect('GET', 'chapter.html'). + respond('
chapter-
[
]
'); + } + }); + inject(function(log, $rootScope, $compile, $httpBackend) { + element = $compile('
paragraph
')($rootScope); + $rootScope.$apply(); + + expect(element.text()).toEqual('book-'); + + $httpBackend.flush(); + $rootScope.$apply(); + expect(element.text()).toEqual('book-chapter-section-![(paragraph)]!'); + }); + }); + + + it('should only allow one transclude per element', function() { + module(function($compileProvider) { + $compileProvider.directive('first', valueFn({ + scope: {}, + transclude: 'content' + })); + $compileProvider.directive('second', valueFn({ + transclude: 'content' + })); + }); + inject(function($compile) { + expect(function() { + $compile('
'); + }).toThrow('Multiple directives [first, second] asking for transclusion on: <' + + (msie <= 8 ? 'DIV' : 'div') + ' class="first second ng-isolate-scope ng-scope">'); + }); + }); + + + it('should remove transclusion scope, when the DOM is destroyed', function() { + module(function($compileProvider) { + $compileProvider.directive('box', valueFn({ + transclude: 'content', + scope: { name: 'evaluate', show: 'accessor' }, + template: '

Hello: {{name}}!

', + link: function(scope, element) { + scope.$watch( + function() { return scope.show(); }, + function(show) { + if (!show) { + element.find('div').find('div').remove(); + } + } + ); + } + })); + }); + inject(function($compile, $rootScope) { + $rootScope.username = 'Misko'; + $rootScope.select = true; + element = $compile( + '
user: {{username}}
') + ($rootScope); + $rootScope.$apply(); + expect(element.text()).toEqual('Hello: Misko!user: Misko'); + + var widgetScope = $rootScope.$$childHead; + var transcludeScope = widgetScope.$$nextSibling; + expect(widgetScope.name).toEqual('Misko'); + expect(widgetScope.$parent).toEqual($rootScope); + expect(transcludeScope.$parent).toEqual($rootScope); + + var removed = 0; + $rootScope.$on('$destroy', function() { removed++; }); + $rootScope.select = false; + $rootScope.$apply(); + expect(element.text()).toEqual('Hello: Misko!'); + expect(removed).toEqual(1); + expect(widgetScope.$$nextSibling).toEqual(null); + }); + }); + + }); }); diff --git a/test/service/controllerSpec.js b/test/service/controllerSpec.js index 8b12eceb283c..2c0f8c62df7d 100644 --- a/test/service/controllerSpec.js +++ b/test/service/controllerSpec.js @@ -31,7 +31,7 @@ describe('$controller', function() { }; var scope = {}, - ctrl = $controller(MyClass, scope); + ctrl = $controller(MyClass, {$scope: scope}); expect(ctrl.$scope).toBe(scope); }); diff --git a/test/service/scopeSpec.js b/test/service/scopeSpec.js index c3a09cc87a87..179ff162aeee 100644 --- a/test/service/scopeSpec.js +++ b/test/service/scopeSpec.js @@ -53,6 +53,15 @@ describe('Scope', function() { $rootScope.a = 123; expect(child.a).toEqual(123); })); + + it('should create a non prototypically inherited child scope', inject(function($rootScope) { + var child = $rootScope.$new(true); + $rootScope.a = 123; + expect(child.a).toBeUndefined(); + expect(child.$parent).toEqual($rootScope); + expect(child.$new).toBe($rootScope.$new); + expect(child.$root).toBe($rootScope); + })); });