diff --git a/.gitignore b/.gitignore index 2752eb92..6090a706 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ .DS_Store +myangular.js +myangular.min.js diff --git a/example-app/app.js b/example-app/app.js new file mode 100644 index 00000000..5ff80695 --- /dev/null +++ b/example-app/app.js @@ -0,0 +1,10 @@ +angular.module('myExampleApp', []) + .controller('ExampleController', function() { + this.counter = 1; + this.increment = function() { + this.counter++; + }; + this.decrement = function() { + this.counter--; + }; + }); diff --git a/example-app/index.html b/example-app/index.html new file mode 100644 index 00000000..b03d7bf9 --- /dev/null +++ b/example-app/index.html @@ -0,0 +1,14 @@ + + + + + +
+ {{ctrl.counter}} + + +
+ + + + diff --git a/package.json b/package.json index de7e36fa..d8168a7a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "karma-phantomjs-launcher": "^1.0.0", "phantomjs-prebuilt": "^2.1.7", "sinon": "^1.17.2", - "watchify": "^3.7.0" + "watchify": "^3.7.0", + "uglifyjs": "^2.4.10" }, "dependencies": { "jquery": "^2.1.4", @@ -20,6 +21,8 @@ }, "scripts": { "lint": "jshint src test", - "test": "karma start" + "test": "karma start", + "build": "browserify src/bootstrap.js > myangular.js", + "build:minified": "browserify src/bootstrap.js | uglifyjs -mc > myangular.min.js" } } diff --git a/src/angular_public.js b/src/angular_public.js index b632b46c..f7d427d6 100644 --- a/src/angular_public.js +++ b/src/angular_public.js @@ -17,7 +17,10 @@ function publishExternalAPI() { ngModule.provider('$httpParamSerializerJQLike', require('./http').$HttpParamSerializerJQLikeProvider); ngModule.provider('$compile', require('./compile')); ngModule.provider('$controller', require('./controller')); + ngModule.provider('$interpolate', require('./interpolate')); ngModule.directive('ngController', require('./directives/ng_controller')); + ngModule.directive('ngTransclude', require('./directives/ng_transclude')); + ngModule.directive('ngClick', require('./directives/ng_click')); } module.exports = publishExternalAPI; diff --git a/src/bootstrap.js b/src/bootstrap.js new file mode 100644 index 00000000..cb6754fa --- /dev/null +++ b/src/bootstrap.js @@ -0,0 +1,52 @@ +'use strict'; + +var $ = require('jquery'); +var _ = require('lodash'); +var publishExternalAPI = require('./angular_public'); +var createInjector = require('./injector'); + +publishExternalAPI(); + +window.angular.bootstrap = function(element, modules, config) { + var $element = $(element); + modules = modules || []; + config = config || {}; + modules.unshift(['$provide', function($provide) { + $provide.value('$rootElement', $element); + }]); + modules.unshift('ng'); + var injector = createInjector(modules, config.strictDi); + $element.data('$injector', injector); + injector.invoke(['$compile', '$rootScope', function($compile, $rootScope) { + $rootScope.$apply(function() { + $compile($element)($rootScope); + }); + }]); + return injector; +}; + +var ngAttrPrefixes = ['ng-', 'data-ng-', 'ng:', 'x-ng-']; +$(document).ready(function() { + var foundAppElement, foundModule, config = {}; + _.forEach(ngAttrPrefixes, function(prefix) { + var attrName = prefix + 'app'; + var selector = '[' + attrName.replace(':', '\\:') + ']'; + var element; + if (!foundAppElement && + (element = document.querySelector(selector))) { + foundAppElement = element; + foundModule = element.getAttribute(attrName); + } + }); + if (foundAppElement) { + config.strictDi = _.some(ngAttrPrefixes, function(prefix) { + var attrName = prefix + 'strict-di'; + return foundAppElement.hasAttribute(attrName); + }); + window.angular.bootstrap( + foundAppElement, + foundModule ? [foundModule] : [], + config + ); + } +}); diff --git a/src/compile.js b/src/compile.js index 9fb2ea66..561762df 100644 --- a/src/compile.js +++ b/src/compile.js @@ -115,8 +115,17 @@ function $CompileProvider($provide) { } }; - this.$get = ['$injector', '$parse', '$controller', '$rootScope', '$http', - function($injector, $parse, $controller, $rootScope, $http) { + this.$get = ['$injector', '$parse', '$controller', '$rootScope', '$http', '$interpolate', + function($injector, $parse, $controller, $rootScope, $http, $interpolate) { + + var startSymbol = $interpolate.startSymbol(); + var endSymbol = $interpolate.endSymbol(); + var denormalizeTemplate = (startSymbol === '{{' && endSymbol === '}}') ? + _.identity : + function(template) { + return template.replace(/\{\{/g, startSymbol) + .replace(/\}\}/g, endSymbol); + }; function Attributes(element) { this.$$element = element; @@ -129,7 +138,9 @@ function $CompileProvider($provide) { this.$$observers[key] = this.$$observers[key] || []; this.$$observers[key].push(fn); $rootScope.$evalAsync(function() { - fn(self[key]); + if (!self.$$observers[key].$$inter) { + fn(self[key]); + } }); return function() { var index = self.$$observers[key].indexOf(fn); @@ -192,28 +203,45 @@ function $CompileProvider($provide) { } }; - function compile($compileNodes) { - var compositeLinkFn = compileNodes($compileNodes); + function compile($compileNodes, maxPriority) { + var compositeLinkFn = compileNodes($compileNodes, maxPriority); - return function publicLinkFn(scope) { - $compileNodes.data('$scope', scope); - compositeLinkFn(scope, $compileNodes); + return function publicLinkFn(scope, cloneAttachFn, options) { + options = options || {}; + var parentBoundTranscludeFn = options.parentBoundTranscludeFn; + var transcludeControllers = options.transcludeControllers; + if (parentBoundTranscludeFn && parentBoundTranscludeFn.$$boundTransclude) { + parentBoundTranscludeFn = parentBoundTranscludeFn.$$boundTransclude; + } + var $linkNodes; + if (cloneAttachFn) { + $linkNodes = $compileNodes.clone(); + cloneAttachFn($linkNodes, scope); + } else { + $linkNodes = $compileNodes; + } + _.forEach(transcludeControllers, function(controller, name) { + $linkNodes.data('$' + name + 'Controller', controller.instance); + }); + $linkNodes.data('$scope', scope); + compositeLinkFn(scope, $linkNodes, parentBoundTranscludeFn); + return $linkNodes; }; } - function compileNodes($compileNodes) { + function compileNodes($compileNodes, maxPriority) { var linkFns = []; - _.forEach($compileNodes, function(node, i) { - var attrs = new Attributes($(node)); - var directives = collectDirectives(node, attrs); + _.times($compileNodes.length, function(i) { + var attrs = new Attributes($($compileNodes[i])); + var directives = collectDirectives($compileNodes[i], attrs, maxPriority); var nodeLinkFn; if (directives.length) { - nodeLinkFn = applyDirectivesToNode(directives, node, attrs); + nodeLinkFn = applyDirectivesToNode(directives, $compileNodes[i], attrs); } var childLinkFn; if ((!nodeLinkFn || !nodeLinkFn.terminal) && - node.childNodes && node.childNodes.length) { - childLinkFn = compileNodes(node.childNodes); + $compileNodes[i].childNodes && $compileNodes[i].childNodes.length) { + childLinkFn = compileNodes($compileNodes[i].childNodes); } if (nodeLinkFn && nodeLinkFn.scope) { attrs.$$element.addClass('ng-scope'); @@ -227,7 +255,7 @@ function $CompileProvider($provide) { } }); - function compositeLinkFn(scope, linkNodes) { + function compositeLinkFn(scope, linkNodes, parentBoundTranscludeFn) { var stableNodeList = []; _.forEach(linkFns, function(linkFn) { var nodeIdx = linkFn.idx; @@ -237,19 +265,44 @@ function $CompileProvider($provide) { _.forEach(linkFns, function(linkFn) { var node = stableNodeList[linkFn.idx]; if (linkFn.nodeLinkFn) { + var childScope; if (linkFn.nodeLinkFn.scope) { - scope = scope.$new(); - $(node).data('$scope', scope); + childScope = scope.$new(); + $(node).data('$scope', childScope); + } else { + childScope = scope; + } + + var boundTranscludeFn; + if (linkFn.nodeLinkFn.transcludeOnThisElement) { + boundTranscludeFn = function(transcludedScope, cloneAttachFn, transcludeControllers, containingScope) { + if (!transcludedScope) { + transcludedScope = scope.$new(false, containingScope); + } + var didTransclude = linkFn.nodeLinkFn.transclude(transcludedScope, cloneAttachFn, { + transcludeControllers: transcludeControllers, + parentBoundTranscludeFn: parentBoundTranscludeFn + }); + if (didTransclude.length === 0 && parentBoundTranscludeFn) { + didTransclude = parentBoundTranscludeFn(transcludedScope, cloneAttachFn); + } + return didTransclude; + }; + } else if (parentBoundTranscludeFn) { + boundTranscludeFn = parentBoundTranscludeFn; } + linkFn.nodeLinkFn( linkFn.childLinkFn, - scope, - node + childScope, + node, + boundTranscludeFn ); } else { linkFn.childLinkFn( scope, - node.childNodes + node.childNodes, + parentBoundTranscludeFn ); } }); @@ -279,12 +332,12 @@ function $CompileProvider($provide) { return false; } - function collectDirectives(node, attrs) { + function collectDirectives(node, attrs, maxPriority) { var directives = []; var match; if (node.nodeType === Node.ELEMENT_NODE) { var normalizedNodeName = directiveNormalize(nodeName(node).toLowerCase()); - addDirective(directives, normalizedNodeName, 'E'); + addDirective(directives, normalizedNodeName, 'E', maxPriority); _.forEach(node.attributes, function(attr) { var attrStartName, attrEndName; var name = attr.name; @@ -309,8 +362,8 @@ function $CompileProvider($provide) { } } normalizedAttrName = directiveNormalize(name.toLowerCase()); - addDirective(directives, normalizedAttrName, 'A', attrStartName, attrEndName); - attr[normalizedAttrName] = attr.value.trim(); + addAttrInterpolateDirective(directives, attr.value, normalizedAttrName); + addDirective(directives, normalizedAttrName, 'A', maxPriority, attrStartName, attrEndName); if (isNgAttr || !attrs.hasOwnProperty(normalizedAttrName)) { attrs[normalizedAttrName] = attr.value.trim(); if (isBooleanAttribute(node, normalizedAttrName)) { @@ -323,7 +376,7 @@ function $CompileProvider($provide) { if (_.isString(className) && !_.isEmpty(className)) { while ((match = /([\d\w\-_]+)(?:\:([^;]+))?;?/.exec(className))) { var normalizedClassName = directiveNormalize(match[1]); - if (addDirective(directives, normalizedClassName, 'C')) { + if (addDirective(directives, normalizedClassName, 'C', maxPriority)) { attrs[normalizedClassName] = match[2] ? match[2].trim() : undefined; } className = className.substr(match.index + match[0].length); @@ -333,10 +386,12 @@ function $CompileProvider($provide) { match = /^\s*directive\:\s*([\d\w\-_]+)\s*(.*)$/.exec(node.nodeValue); if (match) { var normalizedName = directiveNormalize(match[1]); - if (addDirective(directives, normalizedName, 'M')) { + if (addDirective(directives, normalizedName, 'M', maxPriority)) { attrs[normalizedName] = match[2] ? match[2].trim() : undefined; } } + } else if (node.nodeType === Node.TEXT_NODE) { + addTextInterpolateDirective(directives, node.nodeValue); } directives.sort(byPriority); return directives; @@ -352,7 +407,7 @@ function $CompileProvider($provide) { destination[scopeName] = newAttrValue; }); if (attrs[attrName]) { - destination[scopeName] = attrs[attrName]; + destination[scopeName] = $interpolate(attrs[attrName])(scope); } break; case '<': @@ -405,12 +460,13 @@ function $CompileProvider($provide) { }); } - function addDirective(directives, name, mode, attrStartName, attrEndName) { + function addDirective(directives, name, mode, maxPriority, attrStartName, attrEndName) { var match; if (hasDirectives.hasOwnProperty(name)) { var foundDirectives = $injector.get(name + 'Directive'); var applicableDirectives = _.filter(foundDirectives, function(dir) { - return dir.restrict.indexOf(mode) !== -1; + return (maxPriority === undefined || maxPriority > dir.priority) && + dir.restrict.indexOf(mode) !== -1; }); _.forEach(applicableDirectives, function(directive) { if (attrStartName) { @@ -426,9 +482,65 @@ function $CompileProvider($provide) { return match; } + function addTextInterpolateDirective(directives, text) { + var interpolateFn = $interpolate(text, true); + if (interpolateFn) { + directives.push({ + priority: 0, + compile: function() { + return function link(scope, element) { + var bindings = element.parent().data('$binding') || []; + bindings = bindings.concat(interpolateFn.expressions); + element.parent().data('$binding', bindings); + element.parent().addClass('ng-binding'); + + scope.$watch(interpolateFn, function(newValue) { + element[0].nodeValue = newValue; + }); + }; + } + }); + } + } + + function addAttrInterpolateDirective(directives, value, name) { + var interpolateFn = $interpolate(value, true); + if (interpolateFn) { + directives.push({ + priority: 100, + compile: function() { + return { + pre: function link(scope, element, attrs) { + if (/^(on[a-z]+|formaction)$/.test(name)) { + throw 'Interpolations for HTML DOM event attributes not allowed'; + } + + var newValue = attrs[name]; + if (newValue !== value) { + interpolateFn = newValue && $interpolate(newValue, true); + } + if (!interpolateFn) { + return; + } + + attrs.$$observers = attrs.$$observers || {}; + attrs.$$observers[name] = attrs.$$observers[name] || []; + attrs.$$observers[name].$$inter = true; + + attrs[name] = interpolateFn(scope); + scope.$watch(interpolateFn, function(newValue) { + attrs.$set(name, newValue); + }); + } + }; + } + }); + } + } + function compileTemplateUrl(directives, $compileNode, attrs, previousCompileContext) { var origAsyncDirective = directives.shift(); - var derivedSyncDirective = _.extend({}, origAsyncDirective, {templateUrl: null}); + var derivedSyncDirective = _.extend({}, origAsyncDirective, {templateUrl: null, transclude: null}); var templateUrl = _.isFunction(origAsyncDirective.templateUrl) ? origAsyncDirective.templateUrl($compileNode, attrs) : origAsyncDirective.templateUrl; @@ -436,21 +548,22 @@ function $CompileProvider($provide) { var linkQueue = []; $compileNode.empty(); $http.get(templateUrl).success(function(template) { + template = denormalizeTemplate(template); directives.unshift(derivedSyncDirective); $compileNode.html(template); afterTemplateNodeLinkFn = applyDirectivesToNode(directives, $compileNode, attrs, previousCompileContext); afterTemplateChildLinkFn = compileNodes($compileNode[0].childNodes); _.forEach(linkQueue, function(linkCall) { - afterTemplateNodeLinkFn(afterTemplateChildLinkFn, linkCall.scope, linkCall.linkNode); + afterTemplateNodeLinkFn(afterTemplateChildLinkFn, linkCall.scope, linkCall.linkNode, linkCall.boundTranscludeFn); }); linkQueue = null; }); - return function delayedNodeLinkFn(_ignoreChildLinkFn, scope, linkNode) { + return function delayedNodeLinkFn(_ignoreChildLinkFn, scope, linkNode, boundTranscludeFn) { if (linkQueue) { - linkQueue.push({scope: scope, linkNode: linkNode}); + linkQueue.push({scope: scope, linkNode: linkNode, boundTranscludeFn: boundTranscludeFn}); } else { - afterTemplateNodeLinkFn(afterTemplateChildLinkFn, scope, linkNode); + afterTemplateNodeLinkFn(afterTemplateChildLinkFn, scope, linkNode, boundTranscludeFn); } }; } @@ -463,9 +576,13 @@ function $CompileProvider($provide) { var preLinkFns = previousCompileContext.preLinkFns || []; var postLinkFns = previousCompileContext.postLinkFns || []; var controllers = {}; - var newScopeDirective, newIsolateScopeDirective; + var newScopeDirective; + var newIsolateScopeDirective = previousCompileContext.newIsolateScopeDirective; var templateDirective = previousCompileContext.templateDirective; - var controllerDirectives; + var controllerDirectives = previousCompileContext.controllerDirectives; + var childTranscludeFn; + var hasTranscludeDirective = previousCompileContext.hasTranscludeDirective; + var hasElementTranscludeDirective; function getControllers(require, $element) { if (_.isArray(require)) { @@ -554,14 +671,34 @@ function $CompileProvider($provide) { controllerDirectives = controllerDirectives || {}; controllerDirectives[directive.name] = directive; } + if (directive.transclude) { + if (hasTranscludeDirective) { + throw 'Multiple directives asking for transclude'; + } + hasTranscludeDirective = true; + if (directive.transclude === 'element') { + hasElementTranscludeDirective = true; + var $originalCompileNode = $compileNode; + $compileNode = attrs.$$element = $(document.createComment(' ' + directive.name + ': ' + attrs[directive.name] + ' ')); + $originalCompileNode.replaceWith($compileNode); + terminalPriority = directive.priority; + childTranscludeFn = compile($originalCompileNode, terminalPriority); + } else { + var $transcludedNodes = $compileNode.clone().contents(); + childTranscludeFn = compile($transcludedNodes); + $compileNode.empty(); + } + } if (directive.template) { if (templateDirective) { throw 'Multiple directives asking for template'; } templateDirective = directive; - $compileNode.html(_.isFunction(directive.template) ? + var template = _.isFunction(directive.template) ? directive.template($compileNode, attrs) : - directive.template); + directive.template; + template = denormalizeTemplate(template); + $compileNode.html(template); } if (directive.templateUrl) { if (templateDirective) { @@ -574,6 +711,9 @@ function $CompileProvider($provide) { attrs, { templateDirective: templateDirective, + newIsolateScopeDirective: newIsolateScopeDirective, + controllerDirectives: controllerDirectives, + hasTranscludeDirective: hasTranscludeDirective, preLinkFns: preLinkFns, postLinkFns: postLinkFns } @@ -597,7 +737,7 @@ function $CompileProvider($provide) { } }); - function nodeLinkFn(childLinkFn, scope, linkNode) { + function nodeLinkFn(childLinkFn, scope, linkNode, boundTranscludeFn) { var $element = $(linkNode); var isolateScope; @@ -612,6 +752,7 @@ function $CompileProvider($provide) { var locals = { $scope: directive === newIsolateScopeDirective ? isolateScope : scope, $element: $element, + $transclude: scopeBoundTranscludeFn, $attrs: attrs }; var controllerName = directive.controller; @@ -659,12 +800,26 @@ function $CompileProvider($provide) { } }); + function scopeBoundTranscludeFn(transcludedScope, cloneAttachFn) { + var transcludeControllers; + if (!transcludedScope || !transcludedScope.$watch || !transcludedScope.$evalAsync) { + cloneAttachFn = transcludedScope; + transcludedScope = undefined; + } + if (hasElementTranscludeDirective) { + transcludeControllers = controllers; + } + return boundTranscludeFn(transcludedScope, cloneAttachFn, transcludeControllers, scope); + } + scopeBoundTranscludeFn.$$boundTransclude = boundTranscludeFn; + _.forEach(preLinkFns, function(linkFn) { linkFn( linkFn.isolateScope ? isolateScope : scope, $element, attrs, - linkFn.require && getControllers(linkFn.require, $element) + linkFn.require && getControllers(linkFn.require, $element), + scopeBoundTranscludeFn ); }); if (childLinkFn) { @@ -672,20 +827,23 @@ function $CompileProvider($provide) { if (newIsolateScopeDirective && newIsolateScopeDirective.template) { scopeToChild = isolateScope; } - childLinkFn(scopeToChild, linkNode.childNodes); + childLinkFn(scopeToChild, linkNode.childNodes, boundTranscludeFn); } _.forEachRight(postLinkFns, function(linkFn) { linkFn( linkFn.isolateScope ? isolateScope : scope, $element, attrs, - linkFn.require && getControllers(linkFn.require, $element) + linkFn.require && getControllers(linkFn.require, $element), + scopeBoundTranscludeFn ); }); } nodeLinkFn.terminal = terminal; nodeLinkFn.scope = newScopeDirective && newScopeDirective.scope; + nodeLinkFn.transcludeOnThisElement = hasTranscludeDirective; + nodeLinkFn.transclude = childTranscludeFn; return nodeLinkFn; } @@ -712,9 +870,9 @@ function $CompileProvider($provide) { } function groupElementsLinkFnWrapper(linkFn, attrStart, attrEnd) { - return function(scope, element, attrs, ctrl) { + return function(scope, element, attrs, ctrl, transclude) { var group = groupScan(element[0], attrStart, attrEnd); - return linkFn(scope, group, attrs, ctrl); + return linkFn(scope, group, attrs, ctrl, transclude); }; } diff --git a/src/directives/ng_click.js b/src/directives/ng_click.js new file mode 100644 index 00000000..5cdbb4ff --- /dev/null +++ b/src/directives/ng_click.js @@ -0,0 +1,15 @@ +'use strict'; + +function ngClickDirective() { + return { + restrict: 'A', + link: function(scope, element, attrs) { + element.on('click', function(evt) { + scope.$eval(attrs.ngClick, {$event: evt}); + scope.$apply(); + }); + } + }; +} + +module.exports = ngClickDirective; diff --git a/src/directives/ng_transclude.js b/src/directives/ng_transclude.js new file mode 100644 index 00000000..b8a4e732 --- /dev/null +++ b/src/directives/ng_transclude.js @@ -0,0 +1,17 @@ +'use strict'; + +var ngTranscludeDirective = function() { + + return { + restrict: 'EAC', + link: function(scope, element, attrs, ctrl, transclude) { + transclude(function(clone) { + element.empty(); + element.append(clone); + }); + } + }; + +}; + +module.exports = ngTranscludeDirective; diff --git a/src/interpolate.js b/src/interpolate.js new file mode 100644 index 00000000..7b3bc5c9 --- /dev/null +++ b/src/interpolate.js @@ -0,0 +1,120 @@ +'use strict'; + +var _ = require('lodash'); + +function $InterpolateProvider() { + var startSymbol = '{{'; + var endSymbol = '}}'; + + this.startSymbol = function(value) { + if (value) { + startSymbol = value; + return this; + } else { + return startSymbol; + } + }; + + this.endSymbol = function(value) { + if (value) { + endSymbol = value; + return this; + } else { + return endSymbol; + } + }; + + function stringify(value) { + if (_.isNull(value) || _.isUndefined(value)) { + return ''; + } else if (_.isObject(value)) { + return JSON.stringify(value); + } else { + return '' + value; + } + } + + function escapeChar(char) { + return '\\\\\\' + char; + } + + this.$get = ['$parse', function($parse) { + var escapedStartMatcher = new RegExp(startSymbol.replace(/./g, escapeChar), 'g'); + var escapedEndMatcher = new RegExp(endSymbol.replace(/./g, escapeChar), 'g'); + + function unescapeText(text) { + return text.replace(escapedStartMatcher, startSymbol) + .replace(escapedEndMatcher, endSymbol); + } + + function $interpolate(text, mustHaveExpressions) { + var index = 0; + var parts = []; + var expressions = []; + var expressionFns = []; + var expressionPositions = []; + var startIndex, endIndex, exp, expFn; + while (index < text.length) { + startIndex = text.indexOf(startSymbol, index); + if (startIndex !== -1) { + endIndex = text.indexOf(endSymbol, startIndex + startSymbol.length); + } + if (startIndex !== -1 && endIndex !== -1) { + if (startIndex !== index) { + parts.push(unescapeText(text.substring(index, startIndex))); + } + exp = text.substring(startIndex + startSymbol.length, endIndex); + expFn = $parse(exp); + expressions.push(exp); + expressionFns.push(expFn); + expressionPositions.push(parts.length); + parts.push(expFn); + index = endIndex + endSymbol.length; + } else { + parts.push(unescapeText(text.substring(index))); + break; + } + } + + function compute(values) { + _.forEach(values, function(value, i) { + parts[expressionPositions[i]] = stringify(value); + }); + return parts.join(''); + } + + + if (expressions.length || !mustHaveExpressions) { + return _.extend(function interpolationFn(context) { + var values = _.map(expressionFns, function(expressionFn) { + return expressionFn(context); + }); + return compute(values); + }, { + expressions: expressions, + $$watchDelegate: function(scope, listener) { + var lastValue; + return scope.$watchGroup(expressionFns, function(newValues, oldValues) { + var newValue = compute(newValues); + listener( + newValue, + (newValues === oldValues ? newValue : lastValue), + scope + ); + lastValue = newValue; + }); + } + }); + } + + } + + $interpolate.startSymbol = _.constant(startSymbol); + $interpolate.endSymbol = _.constant(endSymbol); + + return $interpolate; + }]; + +} + +module.exports = $InterpolateProvider; diff --git a/src/scope.js b/src/scope.js index 90fd1373..b89821e8 100644 --- a/src/scope.js +++ b/src/scope.js @@ -312,13 +312,7 @@ function $RootScopeProvider() { }; Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) { - if (valueEq) { - return _.isEqual(newValue, oldValue); - } else { - return newValue === oldValue || - (typeof newValue === 'number' && typeof oldValue === 'number' && - isNaN(newValue) && isNaN(oldValue)); - } + return _.isEqual(newValue, oldValue); }; Scope.prototype.$$everyScope = function(fn) { diff --git a/test/bootstrap_spec.js b/test/bootstrap_spec.js new file mode 100644 index 00000000..8b3918ee --- /dev/null +++ b/test/bootstrap_spec.js @@ -0,0 +1,123 @@ +'use strict'; + +var $ = require('jquery'); +var bootstrap = require('../src/bootstrap'); + +describe('bootstrap', function() { + + describe('manual', function() { + + it('is available', function() { + expect(window.angular.bootstrap).toBeDefined(); + }); + + it('creates and returns an injector', function() { + var element = $('
'); + var injector = window.angular.bootstrap(element); + expect(injector).toBeDefined(); + expect(injector.invoke).toBeDefined(); + }); + + it('attaches the injector to the bootstrapped element', function() { + var element = $('
'); + var injector = window.angular.bootstrap(element); + expect(element.data('$injector')).toBe(injector); + }); + + it('loads built-ins into the injector', function() { + var element = $('
'); + window.angular.bootstrap(element); + + var injector = element.data('$injector'); + expect(injector.has('$compile')).toBe(true); + expect(injector.has('$rootScope')).toBe(true); + }); + + it('loads other specified modules into the injector', function() { + var element = $('
'); + + window.angular.module('myModule', []) + .constant('aValue', 42); + window.angular.module('mySecondModule', []) + .constant('aSecondValue', 43); + window.angular.bootstrap(element, ['myModule', 'mySecondModule']); + + var injector = element.data('$injector'); + expect(injector.get('aValue')).toBe(42); + expect(injector.get('aSecondValue')).toBe(43); + }); + + it('makes root element available for injection', function() { + var element = $('
'); + + window.angular.bootstrap(element); + + var injector = element.data('$injector'); + expect(injector.has('$rootElement')).toBe(true); + expect(injector.get('$rootElement')[0]).toBe(element[0]); + }); + + it('compiles the element', function() { + var element = $('
'); + var compileSpy = jasmine.createSpy(); + + window.angular.module('myModule', []) + .directive('myDirective', function() { + return {compile: compileSpy}; + }); + window.angular.bootstrap(element, ['myModule']); + + expect(compileSpy).toHaveBeenCalled(); + }); + + it('links the element', function() { + var element = $('
'); + var linkSpy = jasmine.createSpy(); + + window.angular.module('myModule', []) + .directive('myDirective', function() { + return {link: linkSpy}; + }); + window.angular.bootstrap(element, ['myModule']); + + expect(linkSpy).toHaveBeenCalled(); + expect(linkSpy.calls.mostRecent().args[0]).toEqual( + element.data('$injector').get('$rootScope') + ); + }); + + it('runs a digest', function() { + var element = $('
{{expr}}
'); + var linkSpy = jasmine.createSpy(); + + window.angular.module('myModule', []) + .directive('myDirective', function() { + return { + link: function(scope) { + scope.expr = '42'; + } + }; + }); + window.angular.bootstrap(element, ['myModule']); + + expect(element.find('div').text()).toBe('42'); + }); + + it('supports enabling strictDi mode', function() { + var element = $('
'); + var compileSpy = jasmine.createSpy(); + + window.angular.module('myModule', []) + .constant('aValue', 42) + .directive('myDirective', function(aValue) { + return {}; + }); + + expect(function() { + window.angular.bootstrap(element, ['myModule'], {strictDi: true}); + }).toThrow(); + }); + + }); + +}); diff --git a/test/compile_spec.js b/test/compile_spec.js index b5c026ef..55510b8c 100644 --- a/test/compile_spec.js +++ b/test/compile_spec.js @@ -2924,6 +2924,1106 @@ describe('$compile', function() { }); }); + it('retains isolate scope directives from earlier', function() { + var linkSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myDirective: function() { + return { + scope: {val: '=myDirective'}, + link: linkSpy + }; + }, + myOtherDirective: function() { + return {templateUrl: '/my_other_directive.html'}; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + var linkFunction = $compile(el); + $rootScope.$apply(); + + linkFunction($rootScope); + + requests[0].respond(200, {}, '
'); + + expect(linkSpy).toHaveBeenCalled(); + expect(linkSpy.calls.first().args[0]).toBeDefined(); + expect(linkSpy.calls.first().args[0]).not.toBe($rootScope); + expect(linkSpy.calls.first().args[0].val).toBe(42); + }); + }); + + it('sets up controllers for all controller directives', function() { + var myDirectiveControllerInstantiated, myOtherDirectiveControllerInstantiated; + var injector = makeInjectorWithDirectives({ + myDirective: function() { + return { + controller: function MyDirectiveController() { + myDirectiveControllerInstantiated = true; + } + }; + }, + myOtherDirective: function() { + return { + templateUrl: '/my_other_directive.html', + controller: function MyOtherDirectiveController() { + myOtherDirectiveControllerInstantiated = true; + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + $rootScope.$apply(); + + requests[0].respond(200, {}, '
'); + + expect(myDirectiveControllerInstantiated).toBe(true); + expect(myOtherDirectiveControllerInstantiated).toBe(true); + }); + }); + + describe('with transclusion', function() { + + it('makes transclusion available to link fn when template arrives first', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + templateUrl: 'my_template.html', + link: function(scope, element, attrs, ctrl, transclude) { + element.find('[in-template]').append(transclude()); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + var linkFunction = $compile(el); + $rootScope.$apply(); + requests[0].respond(200, {}, '
'); // respond first + linkFunction($rootScope); // then link + + expect(el.find('> [in-template] > [in-transclude]').length).toBe(1); + }); + }); + + it('makes transclusion available to link fn when template arrives after', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + templateUrl: 'my_template.html', + link: function(scope, element, attrs, ctrl, transclude) { + element.find('[in-template]').append(transclude()); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + var linkFunction = $compile(el); + $rootScope.$apply(); + linkFunction($rootScope); // link first + requests[0].respond(200, {}, '
'); // then respond + + expect(el.find('> [in-template] > [in-transclude]').length).toBe(1); + }); + }); + + it('is only allowed once', function() { + var otherCompileSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + priority: 1, + transclude: true, + templateUrl: 'my_template.html' + }; + }, + mySecondTranscluder: function() { + return { + priority: 0, + transclude: true, + compile: otherCompileSpy + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el); + $rootScope.$apply(); + requests[0].respond(200, {}, '
'); + + expect(otherCompileSpy).not.toHaveBeenCalled(); + }); + }); + + }); + + }); + + describe('transclude', function() { + + it('removes the children of the element from the DOM', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return {transclude: true}; + } + }); + injector.invoke(function($compile) { + var el = $('
Must go
'); + + $compile(el); + + expect(el.is(':empty')).toBe(true); + }); + }); + + it('compiles child elements', function() { + var insideCompileSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return {transclude: true}; + }, + insideTranscluder: function() { + return {compile: insideCompileSpy}; + } + }); + injector.invoke(function($compile) { + var el = $('
'); + + $compile(el); + + expect(insideCompileSpy).toHaveBeenCalled(); + }); + }); + + it('makes contents available to link function', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + template: '
', + link: function(scope, element, attrs, ctrl, transclude) { + element.find('[in-template]').append(transclude()); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + expect(el.find('> [in-template] > [in-transcluder]').length).toBe(1); + }); + }); + + it('is only allowed once per element', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return {transclude: true}; + }, + mySecondTranscluder: function() { + return {transclude: true}; + } + }); + injector.invoke(function($compile) { + var el = $('
'); + + expect(function() { + $compile(el); + }).toThrow(); + }); + }); + + it('makes scope available to link functions inside', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + link: function(scope, element, attrs, ctrl, transclude) { + element.append(transclude()); + } + }; + }, + myInnerDirective: function() { + return { + link: function(scope, element) { + element.html(scope.anAttr); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $rootScope.anAttr = 'Hello from root'; + $compile(el)($rootScope); + expect(el.find('> [my-inner-directive]').html()).toBe('Hello from root'); + }); + }); + + it('does not use the inherited scope of the directive', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + scope: true, + link: function(scope, element, attrs, ctrl, transclude) { + scope.anAttr = 'Shadowed attribute'; + element.append(transclude()); + } + }; + }, + myInnerDirective: function() { + return { + link: function(scope, element) { + element.html(scope.anAttr); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $rootScope.anAttr = 'Hello from root'; + $compile(el)($rootScope); + expect(el.find('> [my-inner-directive]').html()).toBe('Hello from root'); + }); + }); + + it('contents are destroyed along with transcluding directive', function() { + var watchSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + scope: true, + link: function(scope, element, attrs, ctrl, transclude) { + element.append(transclude()); + scope.$on('destroyNow', function() { + scope.$destroy(); + }); + } + }; + }, + myInnerDirective: function() { + return { + link: function(scope) { + scope.$watch(watchSpy); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + $compile(el)($rootScope); + + $rootScope.$apply(); + expect(watchSpy.calls.count()).toBe(2); + + $rootScope.$apply(); + expect(watchSpy.calls.count()).toBe(3); + + $rootScope.$broadcast('destroyNow'); + $rootScope.$apply(); + expect(watchSpy.calls.count()).toBe(3); + }); + }); + + it('allows passing another scope to transclusion function', function() { + var otherLinkSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + scope: {}, + template: '
', + link: function(scope, element, attrs, ctrl, transclude) { + var mySpecialScope = scope.$new(true); + mySpecialScope.specialAttr = 42; + transclude(mySpecialScope); + } + }; + }, + myOtherDirective: function() { + return {link: otherLinkSpy}; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + var transcludedScope = otherLinkSpy.calls.first().args[0]; + expect(transcludedScope.specialAttr).toBe(42); + }); + }); + + it('makes contents available to child elements', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + template: '
' + }; + }, + inTemplate: function() { + return { + link: function(scope, element, attrs, ctrl, transcludeFn) { + element.append(transcludeFn()); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + expect(el.find('> [in-template] > [in-transclude]').length).toBe(1); + }); + }); + + it('makes contents available to indirect child elements', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + template: '
' + }; + }, + inTemplate: function() { + return { + link: function(scope, element, attrs, ctrl, transcludeFn) { + element.append(transcludeFn()); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + expect(el.find('> div > [in-template] > [in-transclude]').length).toBe(1); + }); + }); + + it('supports passing transclusion function to public link function', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function($compile) { + return { + transclude: true, + link: function(scope, element, attrs, ctrl, transclude) { + var customTemplate = $('
'); + element.append(customTemplate); + $compile(customTemplate)(scope, undefined, { + parentBoundTranscludeFn: transclude + }); + } + }; + }, + inCustomTemplate: function() { + return { + link: function(scope, element, attrs, ctrl, transclude) { + element.append(transclude()); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + expect(el.find('> [in-custom-template] > [in-transclude]').length).toBe(1); + }); + }); + + it('destroys scope passed through public link fn at the right time', function() { + var watchSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myTranscluder: function($compile) { + return { + transclude: true, + link: function(scope, element, attrs, ctrl, transclude) { + var customTemplate = $('
'); + element.append(customTemplate); + $compile(customTemplate)(scope, undefined, { + parentBoundTranscludeFn: transclude + }); + } + }; + }, + inCustomTemplate: function() { + return { + scope: true, + link: function(scope, element, attrs, ctrl, transclude) { + element.append(transclude()); + scope.$on('destroyNow', function() { + scope.$destroy(); + }); + } + }; + }, + inTransclude: function() { + return { + link: function(scope) { + scope.$watch(watchSpy); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + $rootScope.$apply(); + expect(watchSpy.calls.count()).toBe(2); + + $rootScope.$apply(); + expect(watchSpy.calls.count()).toBe(3); + + $rootScope.$broadcast('destroyNow'); + $rootScope.$apply(); + expect(watchSpy.calls.count()).toBe(3); + }); + }); + + it('makes contents available to controller', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + template: '
', + controller: function($element, $transclude) { + $element.find('[in-template]').append($transclude()); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + $compile(el)($rootScope); + + expect(el.find('> [in-template] > [in-transclude]').length).toBe(1); + }); + }); + + it('can be used with multi-element directives', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function($compile) { + return { + transclude: true, + multiElement: true, + template: '
', + link: function(scope, element, attrs, ctrl, transclude) { + element.find('[in-template]').append(transclude()); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + $compile(el)($rootScope); + expect(el.find('[my-transcluder-start] [in-template] [in-transclude]').length).toBe(1); + }); + }); + + }); + + describe('clone attach function', function() { + + it('can be passed to public link fn', function() { + var injector = makeInjectorWithDirectives({}); + injector.invoke(function($compile, $rootScope) { + var el = $('
Hello
'); + var myScope = $rootScope.$new(); + var gotEl, gotScope; + + $compile(el)(myScope, function(el, scope) { + gotEl = el; + gotScope = scope; + }); + + expect(gotEl[0].isEqualNode(el[0])).toBe(true); + expect(gotScope).toBe(myScope); + }); + }); + + it('causes compiled elements to be cloned', function() { + var injector = makeInjectorWithDirectives({}); + injector.invoke(function($compile, $rootScope) { + var el = $('
Hello
'); + var myScope = $rootScope.$new(); + var gotClonedEl; + + $compile(el)(myScope, function(clonedEl) { + gotClonedEl = clonedEl; + }); + + expect(gotClonedEl[0].isEqualNode(el[0])).toBe(true); + expect(gotClonedEl[0]).not.toBe(el[0]); + }); + }); + + it('causes cloned DOM to be linked', function() { + var gotCompileEl, gotLinkEl; + var injector = makeInjectorWithDirectives({ + myDirective: function() { + return { + compile: function(compileEl) { + gotCompileEl = compileEl; + return function link(scope, linkEl) { + gotLinkEl = linkEl; + }; + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + var myScope = $rootScope.$new(); + + $compile(el)(myScope, function() {}); + + expect(gotCompileEl[0]).not.toBe(gotLinkEl[0]); + }); + }); + + it('allows connecting transcluded content', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + template: '
', + link: function(scope, element, attrs, ctrl, transcludeFn) { + var myScope = scope.$new(); + transcludeFn(myScope, function(transclNode) { + element.find('[in-template]').append(transclNode); + }); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + expect(el.find('> [in-template] > [in-transclude]').length).toBe(1); + }); + }); + + it('can be used with default transclusion scope', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + template: '
', + link: function(scope, element, attrs, ctrl, transcludeFn) { + transcludeFn(function(transclNode) { + element.find('[in-template]').append(transclNode); + }); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + expect(el.find('> [in-template] > [in-transclusion]').length).toBe(1); + }); + }); + + it('allows passing data to transclusion', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: true, + template: '
', + link: function(scope, element, attrs, ctrl, transcludeFn) { + transcludeFn(function(transclNode, transclScope) { + transclScope.dataFromTranscluder = 'Hello from transcluder'; + element.find('[in-template]').append(transclNode); + }); + } + }; + }, + myOtherDirective: function() { + return { + link: function(scope, element) { + element.html(scope.dataFromTranscluder); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + expect(el.find('> [in-template] > [my-other-directive]').html()).toEqual('Hello from transcluder'); + }); + }); + + }); + + describe('element transclusion', function() { + + it('removes the element from the DOM', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: 'element' + }; + } + }); + injector.invoke(function($compile) { + var el = $('
'); + + $compile(el); + + expect(el.is(':empty')).toBe(true); + }); + }); + + it('replaces the element with a comment', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: 'element' + }; + } + }); + injector.invoke(function($compile) { + var el = $('
'); + + $compile(el); + + expect(el.html()).toEqual(''); + }); + }); + + it('includes directive attribute value in comment', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return {transclude: 'element'}; + } + }); + injector.invoke(function($compile) { + var el = $('
'); + + $compile(el); + + expect(el.html()).toEqual(''); + }); + }); + + it('calls directive compile and link with comment', function() { + var gotCompiledEl, gotLinkedEl; + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: 'element', + compile: function(compiledEl) { + gotCompiledEl = compiledEl; + return function(scope, linkedEl) { + gotLinkedEl = linkedEl; + }; + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + expect(gotCompiledEl[0].nodeType).toBe(Node.COMMENT_NODE); + expect(gotLinkedEl[0].nodeType).toBe(Node.COMMENT_NODE); + }); + }); + + it('calls lower priority compile with original', function() { + var gotCompiledEl; + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + priority: 2, + transclude: 'element' + }; + }, + myOtherDirective: function() { + return { + priority: 1, + compile: function(compiledEl) { + gotCompiledEl = compiledEl; + } + }; + } + }); + injector.invoke(function($compile) { + var el = $('
'); + + $compile(el); + + expect(gotCompiledEl[0].nodeType).toBe(Node.ELEMENT_NODE); + }); + }); + + it('calls compile on child element directives', function() { + var compileSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: 'element' + }; + }, + myOtherDirective: function() { + return { + compile: compileSpy + }; + } + }); + injector.invoke(function($compile) { + var el = $('
'); + + $compile(el); + + expect(compileSpy).toHaveBeenCalled(); + }); + }); + + it('compiles original element contents once', function() { + var compileSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return {transclude: 'element'}; + }, + myOtherDirective: function() { + return { + compile: compileSpy + }; + } + }); + injector.invoke(function($compile) { + var el = $('
'); + + $compile(el); + + expect(compileSpy.calls.count()).toBe(1); + }); + }); + + it('makes original element available for transclusion', function() { + var injector = makeInjectorWithDirectives({ + myDouble: function() { + return { + transclude: 'element', + link: function(scope, el, attrs, ctrl, transclude) { + transclude(function(clone) { + el.after(clone); + }); + transclude(function(clone) { + el.after(clone); + }); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
Hello
'); + + $compile(el)($rootScope); + + expect(el.find('[my-double]').length).toBe(2); + }); + }); + + it('sets directive attributes element to comment', function() { + var injector = makeInjectorWithDirectives({ + myTranscluder: function() { + return { + transclude: 'element', + link: function(scope, element, attrs, ctrl, transclude) { + attrs.$set('testing', '42'); + element.after(transclude()); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + + $compile(el)($rootScope); + + expect(el.find('[my-transcluder]').attr('testing')).toBeUndefined(); + }); + }); + + it('supports requiring controllers', function() { + var MyController = function() { }; + var gotCtrl; + var injector = makeInjectorWithDirectives({ + myCtrlDirective: function() { + return {controller: MyController}; + }, + myTranscluder: function() { + return { + transclude: 'element', + link: function(scope, el, attrs, ctrl, transclude) { + el.after(transclude()); + } + }; + }, + myOtherDirective: function() { + return { + require: '^myCtrlDirective', + link: function(scope, el, attrs, ctrl, transclude) { + gotCtrl = ctrl; + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
Hello
'); + + $compile(el)($rootScope); + + expect(gotCtrl).toBeDefined(); + expect(gotCtrl instanceof MyController).toBe(true); + }); + }); + + }); + + describe('interpolation', function() { + + it('is done for text nodes', function() { + var injector = makeInjectorWithDirectives({}); + injector.invoke(function($compile, $rootScope) { + var el = $('
My expression: {{myExpr}}
'); + $compile(el)($rootScope); + + $rootScope.$apply(); + expect(el.html()).toEqual('My expression: '); + + $rootScope.myExpr = 'Hello'; + $rootScope.$apply(); + expect(el.html()).toEqual('My expression: Hello'); + }); + }); + + it('adds binding class to text node parents', function() { + var injector = makeInjectorWithDirectives({}); + injector.invoke(function($compile, $rootScope) { + var el = $('
My expression: {{myExpr}}
'); + $compile(el)($rootScope); + + expect(el.hasClass('ng-binding')).toBe(true); + }); + }); + + it('adds binding data to text node parents', function() { + var injector = makeInjectorWithDirectives({}); + injector.invoke(function($compile, $rootScope) { + var el = $('
{{myExpr}} and {{myOtherExpr}}
'); + $compile(el)($rootScope); + + expect(el.data('$binding')).toEqual(['myExpr', 'myOtherExpr']); + }); + }); + + it('adds binding data to parent from multiple text nodes', function() { + var injector = makeInjectorWithDirectives({}); + injector.invoke(function($compile, $rootScope) { + var el = $('
{{myExpr}} and {{myOtherExpr}}
'); + $compile(el)($rootScope); + + expect(el.data('$binding')).toEqual(['myExpr', 'myOtherExpr']); + }); + }); + + it('is done for attributes', function() { + var injector = makeInjectorWithDirectives({}); + injector.invoke(function($compile, $rootScope) { + var el = $('{{myAltText}}'); + $compile(el)($rootScope); + + $rootScope.$apply(); + expect(el.attr('alt')).toEqual(''); + + $rootScope.myAltText = 'My favourite photo'; + $rootScope.$apply(); + expect(el.attr('alt')).toEqual('My favourite photo'); + }); + }); + + it('fires observers on attribute expression changes', function() { + var observerSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myDirective: function() { + return { + link: function(scope, element, attrs) { + attrs.$observe('alt', observerSpy); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('{{myAltText}}'); + $compile(el)($rootScope); + + $rootScope.myAltText = 'My favourite photo'; + $rootScope.$apply(); + expect(observerSpy.calls.mostRecent().args[0]).toEqual('My favourite photo'); + }); + }); + + it('fires observers just once upon registration', function() { + var observerSpy = jasmine.createSpy(); + var injector = makeInjectorWithDirectives({ + myDirective: function() { + return { + link: function(scope, element, attrs) { + attrs.$observe('alt', observerSpy); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('{{myAltText}}'); + $compile(el)($rootScope); + $rootScope.$apply(); + + expect(observerSpy.calls.count()).toBe(1); + }); + }); + + it('is done for attributes by the time other directive is linked', function() { + var gotMyAttr; + var injector = makeInjectorWithDirectives({ + myDirective: function() { + return { + link: function(scope, element, attrs) { + gotMyAttr = attrs.myAttr; + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + $rootScope.myExpr = 'Hello'; + $compile(el)($rootScope); + + expect(gotMyAttr).toEqual('Hello'); + }); + }); + + it('is done for attributes by the time bound to iso scope', function() { + var gotMyAttr; + var injector = makeInjectorWithDirectives({ + myDirective: function() { + return { + scope: {myAttr: '@'}, + link: function(scope, element, attrs) { + gotMyAttr = scope.myAttr; + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + $rootScope.myExpr = 'Hello'; + $compile(el)($rootScope); + + expect(gotMyAttr).toEqual('Hello'); + }); + }); + + it('is done for attributes so that changes during compile are reflected', function() { + var injector = makeInjectorWithDirectives({ + myDirective: function() { + return { + compile: function(element, attrs) { + attrs.$set('myAttr', '{{myDifferentExpr}}'); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + $rootScope.myExpr = 'Hello'; + $rootScope.myDifferentExpr = 'Other Hello'; + $compile(el)($rootScope); + $rootScope.$apply(); + + expect(el.attr('my-attr')).toEqual('Other Hello'); + }); + }); + + it('is done for attributes so that removal during compile is reflected', function() { + var injector = makeInjectorWithDirectives({ + myDirective: function() { + return { + compile: function(element, attrs) { + attrs.$set('myAttr', null); + } + }; + } + }); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + $rootScope.myExpr = 'Hello'; + $compile(el)($rootScope); + $rootScope.$apply(); + + expect(el.attr('my-attr')).toBeFalsy(); + }); + }); + + it('cannot be done for event handler attributes', function() { + var injector = makeInjectorWithDirectives({}); + injector.invoke(function($compile, $rootScope) { + $rootScope.myFunction = function() { }; + var el = $(''); + expect(function() { + $compile(el)($rootScope); + }).toThrow(); + }); + }); + + it('denormalizes directive templates', function() { + var injector = createInjector(['ng', function($interpolateProvider, $compileProvider) { + $interpolateProvider.startSymbol('[[').endSymbol(']]'); + $compileProvider.directive('myDirective', function() { + return { + template: 'Value is {{myExpr}}' + }; + }); + }]); + injector.invoke(function($compile, $rootScope) { + var el = $('
'); + $rootScope.myExpr = 42; + $compile(el)($rootScope); + $rootScope.$apply(); + + expect(el.html()).toEqual('Value is 42'); + }); + }); + }); }); diff --git a/test/directives/ng_click_spec.js b/test/directives/ng_click_spec.js new file mode 100644 index 00000000..98729fc4 --- /dev/null +++ b/test/directives/ng_click_spec.js @@ -0,0 +1,51 @@ +'use strict'; + +var $ = require('jquery'); +var publishExternalAPI = require('../../src/angular_public'); +var createInjector = require('../../src/injector'); + +describe('ngClick', function() { + + var $compile, $rootScope; + + beforeEach(function() { + delete window.angular; + publishExternalAPI(); + var injector = createInjector(['ng']); + $compile = injector.get('$compile'); + $rootScope = injector.get('$rootScope'); + }); + + it('starts a digest on click', function() { + var watchSpy = jasmine.createSpy(); + $rootScope.$watch(watchSpy); + + var button = $(''); + $compile(button)($rootScope); + + button.click(); + expect(watchSpy).toHaveBeenCalled(); + }); + + it('evaluates given expression on click', function() { + $rootScope.doSomething = jasmine.createSpy(); + var button = $(''); + $compile(button)($rootScope); + + button.click(); + expect($rootScope.doSomething).toHaveBeenCalled(); + }); + + it('passes $event to expression', function() { + $rootScope.doSomething = jasmine.createSpy(); + var button = $(''); + $compile(button)($rootScope); + + button.click(); + var evt = $rootScope.doSomething.calls.mostRecent().args[0]; + expect(evt).toBeDefined(); + expect(evt.type).toBe('click'); + expect(evt.target).toBeDefined(); + }); + +}); diff --git a/test/directives/ng_transclude_spec.js b/test/directives/ng_transclude_spec.js new file mode 100644 index 00000000..f0f087ae --- /dev/null +++ b/test/directives/ng_transclude_spec.js @@ -0,0 +1,69 @@ +'use strict'; + +var $ = require('jquery'); +var publishExternalAPI = require('../../src/angular_public'); +var createInjector = require('../../src/injector'); + +describe('ngTransclude', function() { + + beforeEach(function() { + delete window.angular; + publishExternalAPI(); + }); + + function createInjectorWithTranscluderTemplate(template) { + return createInjector(['ng', function($compileProvider) { + $compileProvider.directive('myTranscluder', function() { + return { + transclude: true, + template: template + }; + }); + }]); + } + + it('transcludes the parent directive transclusion', function() { + var injector = createInjectorWithTranscluderTemplate( + '
' + ); + injector.invoke(function($compile, $rootScope) { + var el = $('
Hello
'); + $compile(el)($rootScope); + expect(el.find('> [ng-transclude]').html()).toEqual('Hello'); + }); + }); + + it('empties existing contents', function() { + var injector = createInjectorWithTranscluderTemplate( + '
Existing contents
' + ); + injector.invoke(function($compile, $rootScope) { + var el = $('
Hello
'); + $compile(el)($rootScope); + expect(el.find('> [ng-transclude]').html()).toEqual('Hello'); + }); + }); + + it('may be used as element', function() { + var injector = createInjectorWithTranscluderTemplate( + 'Existing contents' + ); + injector.invoke(function($compile, $rootScope) { + var el = $('
Hello
'); + $compile(el)($rootScope); + expect(el.find('> ng-transclude').html()).toEqual('Hello'); + }); + }); + + it('may be used as class', function() { + var injector = createInjectorWithTranscluderTemplate( + '
Existing contents
' + ); + injector.invoke(function($compile, $rootScope) { + var el = $('
Hello
'); + $compile(el)($rootScope); + expect(el.find('> .ng-transclude').html()).toEqual('Hello'); + }); + }); + +}); diff --git a/test/interpolate_spec.js b/test/interpolate_spec.js new file mode 100644 index 00000000..fe809f2b --- /dev/null +++ b/test/interpolate_spec.js @@ -0,0 +1,187 @@ +'use strict'; + +var publishExternalAPI = require('../src/angular_public'); +var createInjector = require('../src/injector'); + +describe('$interpolate', function() { + + beforeEach(function() { + delete window.angular; + publishExternalAPI(); + }); + + it('should exist', function() { + var injector = createInjector(['ng']); + expect(injector.has('$interpolate')).toBe(true); + }); + + it('produces an identity function for static content', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('hello'); + expect(interp instanceof Function).toBe(true); + expect(interp()).toEqual('hello'); + }); + + it('evaluates a single expression', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('{{anAttr}}'); + expect(interp({anAttr: '42'})).toEqual('42'); + }); + + it('evaluates many expressions', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('First {{anAttr}}, then {{anotherAttr}}!'); + expect(interp({anAttr: '42', anotherAttr: '43'})).toEqual('First 42, then 43!'); + }); + + it('passes through ill-defined interpolations', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('why u no }}work{{'); + expect(interp({})).toEqual('why u no }}work{{'); + }); + + it('turns nulls into empty strings', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('{{aNull}}'); + expect(interp({aNull: null})).toEqual(''); + }); + + it('turns undefineds into empty strings', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('{{anUndefined}}'); + expect(interp({})).toEqual(''); + }); + + it('turns numbers into strings', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('{{aNumber}}'); + expect(interp({aNumber: 42})).toEqual('42'); + }); + + it('turns booleans into strings', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('{{aBoolean}}'); + expect(interp({aBoolean: true})).toEqual('true'); + }); + + it('turns arrays into JSON strings', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('{{anArray}}'); + expect(interp({anArray: [1, 2, [3]]})).toEqual('[1,2,[3]]'); + }); + + it('turns objects into JSON strings', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('{{anObject}}'); + expect(interp({anObject: {a: 1, b: '2'}})).toEqual('{"a":1,"b":"2"}'); + }); + + it('unescapes escaped sequences', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('\\{\\{expr\\}\\} {{expr}} \\{\\{expr\\}\\}'); + expect(interp({expr: 'value'})).toEqual('{{expr}} value {{expr}}'); + }); + + it('does not return function for when flagged and no expressions', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('static content only', true); + expect(interp).toBeFalsy(); + }); + + it('returns function when flagged and has expressions', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + + var interp = $interpolate('has an {{expr}}', true); + expect(interp).not.toBeFalsy(); + }); + + it('uses a watch delegate', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + var interp = $interpolate('has an {{expr}}'); + expect(interp.$$watchDelegate).toBeDefined(); + }); + + it('correctly returns new and old value when watched', function() { + var injector = createInjector(['ng']); + var $interpolate = injector.get('$interpolate'); + var $rootScope = injector.get('$rootScope'); + + var interp = $interpolate('{{expr}}'); + var listenerSpy = jasmine.createSpy(); + + $rootScope.$watch(interp, listenerSpy); + $rootScope.expr = 42; + + $rootScope.$apply(); + expect(listenerSpy.calls.mostRecent().args[0]).toEqual('42'); + expect(listenerSpy.calls.mostRecent().args[1]).toEqual('42'); + + $rootScope.expr++; + $rootScope.$apply(); + expect(listenerSpy.calls.mostRecent().args[0]).toEqual('43'); + expect(listenerSpy.calls.mostRecent().args[1]).toEqual('42'); + }); + + it('allows configuring start and end symbols', function() { + var injector = createInjector(['ng', function($interpolateProvider) { + $interpolateProvider.startSymbol('FOO').endSymbol('OOF'); + }]); + var $interpolate = injector.get('$interpolate'); + expect($interpolate.startSymbol()).toEqual('FOO'); + expect($interpolate.endSymbol()).toEqual('OOF'); + }); + + it('works with start and end symbols that differ from default', function() { + var injector = createInjector(['ng', function($interpolateProvider) { + $interpolateProvider.startSymbol('FOO').endSymbol('OOF'); + }]); + var $interpolate = injector.get('$interpolate'); + var interpFn = $interpolate('FOOmyExprOOF'); + expect(interpFn({myExpr: 42})).toEqual('42'); + }); + + it('does not work with default start and end symbols when reconfigured', function() { + var injector = createInjector(['ng', function($interpolateProvider) { + $interpolateProvider.startSymbol('FOO').endSymbol('OOF'); + }]); + var $interpolate = injector.get('$interpolate'); + var interpFn = $interpolate('{{myExpr}}'); + expect(interpFn({myExpr: 42})).toEqual('{{myExpr}}'); + }); + + it('supports unescaping for reconfigured symbols', function() { + var injector = createInjector(['ng', function($interpolateProvider) { + $interpolateProvider.startSymbol('FOO').endSymbol('OOF'); + }]); + var $interpolate = injector.get('$interpolate'); + var interpFn = $interpolate('\\F\\O\\OmyExpr\\O\\O\\F'); + expect(interpFn({})).toEqual('FOOmyExprOOF'); + }); + +});