diff --git a/angularFiles.js b/angularFiles.js index 0233722adfc4..01d9dfd3f0f3 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -74,6 +74,7 @@ var angularFiles = { 'src/ng/directive/ngNonBindable.js', 'src/ng/directive/ngOptions.js', 'src/ng/directive/ngPluralize.js', + 'src/ng/directive/ngRef.js', 'src/ng/directive/ngRepeat.js', 'src/ng/directive/ngShowHide.js', 'src/ng/directive/ngStyle.js', diff --git a/docs/content/error/ngRef/noctrl.ngdoc b/docs/content/error/ngRef/noctrl.ngdoc new file mode 100644 index 000000000000..29d19a9ae134 --- /dev/null +++ b/docs/content/error/ngRef/noctrl.ngdoc @@ -0,0 +1,17 @@ +@ngdoc error +@name ngRef:noctrl +@fullName A controller for the value of `ngRefRead` could not be found on the element. +@description + +This error occurs when the {@link ng.ngRef ngRef directive} specifies +a value in `ngRefRead` that cannot be resolved to a directive / component controller. + +Causes for this error can be: + +1. Your `ngRefRead` value has a typo. +2. You have a typo in the *registered* directive / component name. +3. The directive / component does not have a controller. + +Note that `ngRefRead` takes the name of the component / directive, not the name of controller, and +also not the combination of directive and 'Controller'. For example, for a directive called 'myDirective', +the correct declaration is `
`. diff --git a/docs/content/error/ngRef/nonassign.ngdoc b/docs/content/error/ngRef/nonassign.ngdoc new file mode 100644 index 000000000000..9c1c52ee35b7 --- /dev/null +++ b/docs/content/error/ngRef/nonassign.ngdoc @@ -0,0 +1,27 @@ +@ngdoc error +@name ngRef:nonassign +@fullName Non-Assignable Expression +@description + +This error occurs when ngRef defines an expression that is not-assignable. + +In order for ngRef to work, it must be possible to write the reference into the path defined with the expression. + +For example, the following expressions are non-assignable: + +``` + + + + + + + +``` + +To resolve this error, use a path expression that is assignable: + +``` + + +``` diff --git a/src/AngularPublic.js b/src/AngularPublic.js index c18889911a50..dca14bdd6ffd 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -28,6 +28,7 @@ ngInitDirective, ngNonBindableDirective, ngPluralizeDirective, + ngRefDirective, ngRepeatDirective, ngShowDirective, ngStyleDirective, @@ -194,6 +195,7 @@ function publishExternalAPI(angular) { ngInit: ngInitDirective, ngNonBindable: ngNonBindableDirective, ngPluralize: ngPluralizeDirective, + ngRef: ngRefDirective, ngRepeat: ngRepeatDirective, ngShow: ngShowDirective, ngStyle: ngStyleDirective, diff --git a/src/minErr.js b/src/minErr.js index a2f0ddc2d544..234d244f544f 100644 --- a/src/minErr.js +++ b/src/minErr.js @@ -7,7 +7,8 @@ */ var minErrConfig = { - objectMaxDepth: 5 + objectMaxDepth: 5, + urlErrorParamsEnabled: true }; /** @@ -30,12 +31,21 @@ var minErrConfig = { * * `objectMaxDepth` **{Number}** - The max depth for stringifying objects. Setting to a * non-positive or non-numeric value, removes the max depth limit. * Default: 5 + * + * * `urlErrorParamsEnabled` **{Boolean}** - Specifies wether the generated error url will + * contain the parameters of the thrown error. Disabling the parameters can be useful if the + * generated error url is very long. + * + * Default: true. When used without argument, it returns the current value. */ function errorHandlingConfig(config) { if (isObject(config)) { if (isDefined(config.objectMaxDepth)) { minErrConfig.objectMaxDepth = isValidObjectMaxDepth(config.objectMaxDepth) ? config.objectMaxDepth : NaN; } + if (isDefined(config.urlErrorParamsEnabled) && isBoolean(config.urlErrorParamsEnabled)) { + minErrConfig.urlErrorParamsEnabled = config.urlErrorParamsEnabled; + } } else { return minErrConfig; } @@ -50,6 +60,7 @@ function isValidObjectMaxDepth(maxDepth) { return isNumber(maxDepth) && maxDepth > 0; } + /** * @description * @@ -113,8 +124,10 @@ function minErr(module, ErrorConstructor) { message += '\n' + url + (module ? module + '/' : '') + code; - for (i = 0, paramPrefix = '?'; i < templateArgs.length; i++, paramPrefix = '&') { - message += paramPrefix + 'p' + i + '=' + encodeURIComponent(templateArgs[i]); + if (minErrConfig.urlErrorParamsEnabled) { + for (i = 0, paramPrefix = '?'; i < templateArgs.length; i++, paramPrefix = '&') { + message += paramPrefix + 'p' + i + '=' + encodeURIComponent(templateArgs[i]); + } } return new ErrorConstructor(message); diff --git a/src/ng/directive/ngRef.js b/src/ng/directive/ngRef.js new file mode 100644 index 000000000000..4b3c7a746ba4 --- /dev/null +++ b/src/ng/directive/ngRef.js @@ -0,0 +1,296 @@ +'use strict'; + +/** + * @ngdoc directive + * @name ngRef + * @restrict A + * + * @description + * The `ngRef` attribute tells AngularJS to assign the controller of a component (or a directive) + * to the given property in the current scope. It is also possible to add the jqlite-wrapped DOM + * element to the scope. + * + * If the element with `ngRef` is destroyed `null` is assigned to the property. + * + * Note that if you want to assign from a child into the parent scope, you must initialize the + * target property on the parent scope, otherwise `ngRef` will assign on the child scope. + * This commonly happens when assigning elements or components wrapped in {@link ngIf} or + * {@link ngRepeat}. See the second example below. + * + * + * @element ANY + * @param {string} ngRef property name - A valid AngularJS expression identifier to which the + * controller or jqlite-wrapped DOM element will be bound. + * @param {string=} ngRefRead read value - The name of a directive (or component) on this element, + * or the special string `$element`. If a name is provided, `ngRef` will + * assign the matching controller. If `$element` is provided, the element + * itself is assigned (even if a controller is available). + * + * + * @example + * ### Simple toggle + * This example shows how the controller of the component toggle + * is reused in the template through the scope to use its logic. + * + * + * + * + *
+ * You are using a component in the same template to show it. + *
+ *
+ * + * angular.module('myApp', []) + * .component('myToggle', { + * controller: function ToggleController() { + * var opened = false; + * this.isOpen = function() { return opened; }; + * this.toggle = function() { opened = !opened; }; + * } + * }); + * + * + * it('should publish the toggle into the scope', function() { + * var toggle = element(by.buttonText('Toggle')); + * expect(toggle.evaluate('myToggle.isOpen()')).toEqual(false); + * toggle.click(); + * expect(toggle.evaluate('myToggle.isOpen()')).toEqual(true); + * }); + * + *
+ * + * @example + * ### ngRef inside scopes + * This example shows how `ngRef` works with child scopes. The `ngRepeat`-ed `myWrapper` components + * are assigned to the scope of `myRoot`, because the `toggles` property has been initialized. + * The repeated `myToggle` components are published to the child scopes created by `ngRepeat`. + * `ngIf` behaves similarly - the assignment of `myToggle` happens in the `ngIf` child scope, + * because the target property has not been initialized on the `myRoot` component controller. + * + * + * + * + * + * + * angular.module('myApp', []) + * .component('myRoot', { + * templateUrl: 'root.html', + * controller: function() { + * this.wrappers = []; // initialize the array so that the wrappers are assigned into the parent scope + * } + * }) + * .component('myToggle', { + * template: 'myToggle', + * transclude: true, + * controller: function ToggleController() { + * var opened = false; + * this.isOpen = function() { return opened; }; + * this.toggle = function() { opened = !opened; }; + * } + * }) + * .component('myWrapper', { + * transclude: true, + * template: 'myWrapper' + + * '
ngRepeatToggle.isOpen(): {{$ctrl.ngRepeatToggle.isOpen() | json}}
' + + * '' + * }); + *
+ * + * myRoot + * Outer Toggle + *
outerToggle.isOpen(): {{$ctrl.outerToggle.isOpen() | json}}
+ *
wrappers assigned to root
+ *
+ * wrapper.ngRepeatToggle.isOpen(): {{wrapper.ngRepeatToggle.isOpen() | json}} + *
+ * + *
    + *
  • + * ngRepeat + *
    outerToggle.isOpen(): {{$ctrl.outerToggle.isOpen() | json}}
    + * ngRepeat Toggle {{$index + 1}} + *
  • + *
+ * + *
ngIfToggle.isOpen(): {{ngIfToggle.isOpen()}} // This is always undefined because it's + * assigned to the child scope created by ngIf. + *
+ *
+ ngIf + * ngIf Toggle + *
ngIfToggle.isOpen(): {{ngIfToggle.isOpen() | json}}
+ *
outerToggle.isOpen(): {{$ctrl.outerToggle.isOpen() | json}}
+ *
+ * + * + * ul { + * list-style: none; + * padding-left: 0; + * } + * + * li[ng-repeat] { + * background: lightgreen; + * padding: 8px; + * margin: 8px; + * } + * + * [ng-if] { + * background: lightgrey; + * padding: 8px; + * } + * + * my-root { + * background: lightgoldenrodyellow; + * padding: 8px; + * display: block; + * } + * + * my-wrapper { + * background: lightsalmon; + * padding: 8px; + * display: block; + * } + * + * my-toggle { + * background: lightblue; + * padding: 8px; + * display: block; + * } + * + * + * var OuterToggle = function() { + * this.toggle = function() { + * element(by.buttonText('Outer Toggle')).click(); + * }; + * this.isOpen = function() { + * return element.all(by.binding('outerToggle.isOpen()')).first().getText(); + * }; + * }; + * var NgRepeatToggle = function(i) { + * var parent = element.all(by.repeater('(index, value) in [1,2,3]')).get(i - 1); + * this.toggle = function() { + * element(by.buttonText('ngRepeat Toggle ' + i)).click(); + * }; + * this.isOpen = function() { + * return parent.element(by.binding('ngRepeatToggle.isOpen() | json')).getText(); + * }; + * this.isOuterOpen = function() { + * return parent.element(by.binding('outerToggle.isOpen() | json')).getText(); + * }; + * }; + * var NgRepeatToggles = function() { + * var toggles = [1,2,3].map(function(i) { return new NgRepeatToggle(i); }); + * this.forEach = function(fn) { + * toggles.forEach(fn); + * }; + * this.isOuterOpen = function(i) { + * return toggles[i - 1].isOuterOpen(); + * }; + * }; + * var NgIfToggle = function() { + * var parent = element(by.css('[ng-if]')); + * this.toggle = function() { + * element(by.buttonText('ngIf Toggle')).click(); + * }; + * this.isOpen = function() { + * return by.binding('ngIfToggle.isOpen() | json').getText(); + * }; + * this.isOuterOpen = function() { + * return parent.element(by.binding('outerToggle.isOpen() | json')).getText(); + * }; + * }; + * + * it('should toggle the outer toggle', function() { + * var outerToggle = new OuterToggle(); + * expect(outerToggle.isOpen()).toEqual('outerToggle.isOpen(): false'); + * outerToggle.toggle(); + * expect(outerToggle.isOpen()).toEqual('outerToggle.isOpen(): true'); + * }); + * + * it('should toggle all outer toggles', function() { + * var outerToggle = new OuterToggle(); + * var repeatToggles = new NgRepeatToggles(); + * var ifToggle = new NgIfToggle(); + * expect(outerToggle.isOpen()).toEqual('outerToggle.isOpen(): false'); + * expect(repeatToggles.isOuterOpen(1)).toEqual('outerToggle.isOpen(): false'); + * expect(repeatToggles.isOuterOpen(2)).toEqual('outerToggle.isOpen(): false'); + * expect(repeatToggles.isOuterOpen(3)).toEqual('outerToggle.isOpen(): false'); + * expect(ifToggle.isOuterOpen()).toEqual('outerToggle.isOpen(): false'); + * outerToggle.toggle(); + * expect(outerToggle.isOpen()).toEqual('outerToggle.isOpen(): true'); + * expect(repeatToggles.isOuterOpen(1)).toEqual('outerToggle.isOpen(): true'); + * expect(repeatToggles.isOuterOpen(2)).toEqual('outerToggle.isOpen(): true'); + * expect(repeatToggles.isOuterOpen(3)).toEqual('outerToggle.isOpen(): true'); + * expect(ifToggle.isOuterOpen()).toEqual('outerToggle.isOpen(): true'); + * }); + * + * it('should toggle each repeat iteration separately', function() { + * var repeatToggles = new NgRepeatToggles(); + * + * repeatToggles.forEach(function(repeatToggle) { + * expect(repeatToggle.isOpen()).toEqual('ngRepeatToggle.isOpen(): false'); + * expect(repeatToggle.isOuterOpen()).toEqual('outerToggle.isOpen(): false'); + * repeatToggle.toggle(); + * expect(repeatToggle.isOpen()).toEqual('ngRepeatToggle.isOpen(): true'); + * expect(repeatToggle.isOuterOpen()).toEqual('outerToggle.isOpen(): false'); + * }); + * }); + * + * + * + */ + +var ngRefMinErr = minErr('ngRef'); + +var ngRefDirective = ['$parse', function($parse) { + return { + priority: -1, // Needed for compatibility with element transclusion on the same element + restrict: 'A', + compile: function(tElement, tAttrs) { + // Get the expected controller name, converts into "someThing" + var controllerName = directiveNormalize(nodeName_(tElement)); + + // Get the expression for value binding + var getter = $parse(tAttrs.ngRef); + var setter = getter.assign || function() { + throw ngRefMinErr('nonassign', 'Expression in ngRef="{0}" is non-assignable!', tAttrs.ngRef); + }; + + return function(scope, element, attrs) { + var refValue; + + if (attrs.hasOwnProperty('ngRefRead')) { + if (attrs.ngRefRead === '$element') { + refValue = element; + } else { + refValue = element.data('$' + attrs.ngRefRead + 'Controller'); + + if (!refValue) { + throw ngRefMinErr( + 'noctrl', + 'The controller for ngRefRead="{0}" could not be found on ngRef="{1}"', + attrs.ngRefRead, + tAttrs.ngRef + ); + } + } + } else { + refValue = element.data('$' + controllerName + 'Controller'); + } + + refValue = refValue || element; + + setter(scope, refValue); + + // when the element is removed, remove it (nullify it) + element.on('$destroy', function() { + // only remove it if value has not changed, + // because animations (and other procedures) may duplicate elements + if (getter(scope) === refValue) { + setter(scope, null); + } + }); + }; + } + }; +}]; diff --git a/src/ngAnimate/module.js b/src/ngAnimate/module.js index 0f929f3b0018..d7ef5d873c50 100644 --- a/src/ngAnimate/module.js +++ b/src/ngAnimate/module.js @@ -275,9 +275,22 @@ * .message.ng-enter-prepare { * opacity: 0; * } - * * ``` * + * ### Animating between value changes + * + * Sometimes you need to animate between different expression states, whose values + * don't necessary need to be known or referenced in CSS styles. + * Unless possible with another ["animation aware" directive](#directive-support), that specific + * use case can always be covered with {@link ngAnimate.directive:ngAnimateSwap} as can be seen in + * {@link ngAnimate.directive:ngAnimateSwap#examples this example}. + * + * Note that {@link ngAnimate.directive:ngAnimateSwap} is a *structural directive*, which means it + * creates a new instance of the element (including any other/child directives it may have) and + * links it to a new scope every time *swap* happens. In some cases this might not be desirable + * (e.g. for performance reasons, or when you wish to retain internal state on the original + * element instance). + * * ## JavaScript-based Animations * * ngAnimate also allows for animations to be consumed by JavaScript code. The approach is similar to CSS-based animations (where there is a shared diff --git a/src/ngMessages/messages.js b/src/ngMessages/messages.js index 445122b67102..bc0026a14f7c 100644 --- a/src/ngMessages/messages.js +++ b/src/ngMessages/messages.js @@ -18,7 +18,7 @@ var jqLite; * sequencing based on the order of how the messages are defined in the template. * * Currently, the ngMessages module only contains the code for the `ngMessages`, `ngMessagesInclude` - * `ngMessage` and `ngMessageExp` directives. + * `ngMessage`, `ngMessageExp` and `ngMessageDefault` directives. * * ## Usage * The `ngMessages` directive allows keys in a key/value collection to be associated with a child element @@ -257,7 +257,26 @@ var jqLite; * .some-message.ng-leave.ng-leave-active {} * ``` * - * {@link ngAnimate Click here} to learn how to use JavaScript animations or to learn more about ngAnimate. + * {@link ngAnimate See the ngAnimate docs} to learn how to use JavaScript animations or to learn + * more about ngAnimate. + * + * ## Displaying a default message + * If the ngMessages renders no inner ngMessage directive (i.e. when none of the truthy + * keys are matched by a defined message), then it will render a default message + * using the {@link ngMessageDefault} directive. + * Note that matched messages will always take precedence over unmatched messages. That means + * the default message will not be displayed when another message is matched. This is also + * true for `ng-messages-multiple`. + * + * ```html + *
+ *
This field is required
+ *
This field is too short
+ *
This field has an input error
+ *
+ * ``` + * + */ angular.module('ngMessages', [], function initAngularHelpers() { // Access helpers from AngularJS core. @@ -286,8 +305,11 @@ angular.module('ngMessages', [], function initAngularHelpers() { * at a time and this depends on the prioritization of the messages within the template. (This can * be changed by using the `ng-messages-multiple` or `multiple` attribute on the directive container.) * - * A remote template can also be used to promote message reusability and messages can also be - * overridden. + * A remote template can also be used (With {@link ngMessagesInclude}) to promote message + * reusability and messages can also be overridden. + * + * A default message can also be displayed when no `ngMessage` directive is inserted, using the + * {@link ngMessageDefault} directive. * * {@link module:ngMessages Click here} to learn more about `ngMessages` and `ngMessage`. * @@ -298,6 +320,7 @@ angular.module('ngMessages', [], function initAngularHelpers() { * ... * ... * ... + * ... * * * @@ -305,6 +328,7 @@ angular.module('ngMessages', [], function initAngularHelpers() { * ... * ... * ... + * ... * * ``` * @@ -333,6 +357,7 @@ angular.module('ngMessages', [], function initAngularHelpers() { *
You did not enter a field
*
Your field is too short
*
Your field is too long
+ *
This field has an input error
*
* *
@@ -370,6 +395,7 @@ angular.module('ngMessages', [], function initAngularHelpers() { var unmatchedMessages = []; var matchedKeys = {}; + var truthyKeys = 0; var messageItem = ctrl.head; var messageFound = false; var totalMessages = 0; @@ -382,13 +408,17 @@ angular.module('ngMessages', [], function initAngularHelpers() { var messageUsed = false; if (!messageFound) { forEach(collection, function(value, key) { - if (!messageUsed && truthy(value) && messageCtrl.test(key)) { - // this is to prevent the same error name from showing up twice - if (matchedKeys[key]) return; - matchedKeys[key] = true; + if (truthy(value) && !messageUsed) { + truthyKeys++; + + if (messageCtrl.test(key)) { + // this is to prevent the same error name from showing up twice + if (matchedKeys[key]) return; + matchedKeys[key] = true; - messageUsed = true; - messageCtrl.attach(); + messageUsed = true; + messageCtrl.attach(); + } } }); } @@ -408,7 +438,16 @@ angular.module('ngMessages', [], function initAngularHelpers() { messageCtrl.detach(); }); - if (unmatchedMessages.length !== totalMessages) { + var messageMatched = unmatchedMessages.length !== totalMessages; + var attachDefault = ctrl.default && !messageMatched && truthyKeys > 0; + + if (attachDefault) { + ctrl.default.attach(); + } else if (ctrl.default) { + ctrl.default.detach(); + } + + if (messageMatched || attachDefault) { $animate.setClass($element, ACTIVE_CLASS, INACTIVE_CLASS); } else { $animate.setClass($element, INACTIVE_CLASS, ACTIVE_CLASS); @@ -428,23 +467,31 @@ angular.module('ngMessages', [], function initAngularHelpers() { } }; - this.register = function(comment, messageCtrl) { - var nextKey = latestKey.toString(); - messages[nextKey] = { - message: messageCtrl - }; - insertMessageNode($element[0], comment, nextKey); - comment.$$ngMessageNode = nextKey; - latestKey++; + this.register = function(comment, messageCtrl, isDefault) { + if (isDefault) { + ctrl.default = messageCtrl; + } else { + var nextKey = latestKey.toString(); + messages[nextKey] = { + message: messageCtrl + }; + insertMessageNode($element[0], comment, nextKey); + comment.$$ngMessageNode = nextKey; + latestKey++; + } ctrl.reRender(); }; - this.deregister = function(comment) { - var key = comment.$$ngMessageNode; - delete comment.$$ngMessageNode; - removeMessageNode($element[0], comment, key); - delete messages[key]; + this.deregister = function(comment, isDefault) { + if (isDefault) { + delete ctrl.default; + } else { + var key = comment.$$ngMessageNode; + delete comment.$$ngMessageNode; + removeMessageNode($element[0], comment, key); + delete messages[key]; + } ctrl.reRender(); }; @@ -647,9 +694,41 @@ angular.module('ngMessages', [], function initAngularHelpers() { * * @param {expression} ngMessageExp|whenExp an expression value corresponding to the message key. */ - .directive('ngMessageExp', ngMessageDirectiveFactory()); + .directive('ngMessageExp', ngMessageDirectiveFactory()) + + /** + * @ngdoc directive + * @name ngMessageDefault + * @restrict AE + * @scope + * + * @description + * `ngMessageDefault` is a directive with the purpose to show and hide a default message for + * {@link ngMessages}, when none of provided messages matches. + * + * More information about using `ngMessageDefault` can be found in the + * {@link module:ngMessages `ngMessages` module documentation}. + * + * @usage + * ```html + * + * + * ... + * ... + * ... + * + * + * + * + * ... + * ... + * ... + * + * + */ + .directive('ngMessageDefault', ngMessageDirectiveFactory(true)); -function ngMessageDirectiveFactory() { +function ngMessageDirectiveFactory(isDefault) { return ['$animate', function($animate) { return { restrict: 'AE', @@ -658,25 +737,28 @@ function ngMessageDirectiveFactory() { terminal: true, require: '^^ngMessages', link: function(scope, element, attrs, ngMessagesCtrl, $transclude) { - var commentNode = element[0]; - - var records; - var staticExp = attrs.ngMessage || attrs.when; - var dynamicExp = attrs.ngMessageExp || attrs.whenExp; - var assignRecords = function(items) { - records = items - ? (isArray(items) - ? items - : items.split(/[\s,]+/)) - : null; - ngMessagesCtrl.reRender(); - }; + var commentNode, records, staticExp, dynamicExp; + + if (!isDefault) { + commentNode = element[0]; + staticExp = attrs.ngMessage || attrs.when; + dynamicExp = attrs.ngMessageExp || attrs.whenExp; + + var assignRecords = function(items) { + records = items + ? (isArray(items) + ? items + : items.split(/[\s,]+/)) + : null; + ngMessagesCtrl.reRender(); + }; - if (dynamicExp) { - assignRecords(scope.$eval(dynamicExp)); - scope.$watchCollection(dynamicExp, assignRecords); - } else { - assignRecords(staticExp); + if (dynamicExp) { + assignRecords(scope.$eval(dynamicExp)); + scope.$watchCollection(dynamicExp, assignRecords); + } else { + assignRecords(staticExp); + } } var currentElement, messageCtrl; @@ -701,7 +783,7 @@ function ngMessageDirectiveFactory() { // If the message element was removed via a call to `detach` then `currentElement` will be null // So this handler only handles cases where something else removed the message element. if (currentElement && currentElement.$$attachId === $$attachId) { - ngMessagesCtrl.deregister(commentNode); + ngMessagesCtrl.deregister(commentNode, isDefault); messageCtrl.detach(); } newScope.$destroy(); @@ -716,14 +798,14 @@ function ngMessageDirectiveFactory() { $animate.leave(elm); } } - }); + }, isDefault); // We need to ensure that this directive deregisters itself when it no longer exists // Normally this is done when the attached element is destroyed; but if this directive // gets removed before we attach the message to the DOM there is nothing to watch // in which case we must deregister when the containing scope is destroyed. scope.$on('$destroy', function() { - ngMessagesCtrl.deregister(commentNode); + ngMessagesCtrl.deregister(commentNode, isDefault); }); } }; diff --git a/test/helpers/matchers.js b/test/helpers/matchers.js index ac297609e579..5010b212f6d2 100644 --- a/test/helpers/matchers.js +++ b/test/helpers/matchers.js @@ -313,6 +313,7 @@ beforeEach(function() { function generateCompare(isNot) { return function(actual, namespace, code, content) { + var matcher = new MinErrMatcher(isNot, namespace, code, content, { inputType: 'error', expectedAction: 'equal', diff --git a/test/minErrSpec.js b/test/minErrSpec.js index 4319fd88d569..66e018b077c8 100644 --- a/test/minErrSpec.js +++ b/test/minErrSpec.js @@ -2,32 +2,57 @@ describe('errors', function() { var originalObjectMaxDepthInErrorMessage = minErrConfig.objectMaxDepth; + var originalUrlErrorParamsEnabled = minErrConfig.urlErrorParamsEnabled; afterEach(function() { minErrConfig.objectMaxDepth = originalObjectMaxDepthInErrorMessage; + minErrConfig.urlErrorParamsEnabled = originalUrlErrorParamsEnabled; }); describe('errorHandlingConfig', function() { - it('should get default objectMaxDepth', function() { - expect(errorHandlingConfig().objectMaxDepth).toBe(5); - }); + describe('objectMaxDepth',function() { + it('should get default objectMaxDepth', function() { + expect(errorHandlingConfig().objectMaxDepth).toBe(5); + }); + + it('should set objectMaxDepth', function() { + errorHandlingConfig({objectMaxDepth: 3}); + expect(errorHandlingConfig().objectMaxDepth).toBe(3); + }); - it('should set objectMaxDepth', function() { - errorHandlingConfig({objectMaxDepth: 3}); - expect(errorHandlingConfig().objectMaxDepth).toBe(3); + it('should not change objectMaxDepth when undefined is supplied', function() { + errorHandlingConfig({objectMaxDepth: undefined}); + expect(errorHandlingConfig().objectMaxDepth).toBe(originalObjectMaxDepthInErrorMessage); + }); + + they('should set objectMaxDepth to NaN when $prop is supplied', + [NaN, null, true, false, -1, 0], function(maxDepth) { + errorHandlingConfig({objectMaxDepth: maxDepth}); + expect(errorHandlingConfig().objectMaxDepth).toBeNaN(); + } + ); }); - it('should not change objectMaxDepth when undefined is supplied', function() { - errorHandlingConfig({objectMaxDepth: undefined}); - expect(errorHandlingConfig().objectMaxDepth).toBe(originalObjectMaxDepthInErrorMessage); + + describe('urlErrorParamsEnabled',function() { + + it('should get default urlErrorParamsEnabled', function() { + expect(errorHandlingConfig().urlErrorParamsEnabled).toBe(true); + }); + + it('should set urlErrorParamsEnabled', function() { + errorHandlingConfig({urlErrorParamsEnabled: false}); + expect(errorHandlingConfig().urlErrorParamsEnabled).toBe(false); + errorHandlingConfig({urlErrorParamsEnabled: true}); + expect(errorHandlingConfig().urlErrorParamsEnabled).toBe(true); + }); + + it('should not change its value when non-boolean is supplied', function() { + errorHandlingConfig({urlErrorParamsEnabled: 123}); + expect(errorHandlingConfig().urlErrorParamsEnabled).toBe(originalUrlErrorParamsEnabled); + }); }); - they('should set objectMaxDepth to NaN when $prop is supplied', - [NaN, null, true, false, -1, 0], function(maxDepth) { - errorHandlingConfig({objectMaxDepth: maxDepth}); - expect(errorHandlingConfig().objectMaxDepth).toBeNaN(); - } - ); }); describe('minErr', function() { @@ -165,7 +190,6 @@ describe('errors', function() { .toMatch(/^[\s\S]*\?p0=a&p1=b&p2=value%20with%20space$/); }); - it('should strip error reference urls from the error message parameters', function() { var firstError = testError('firstcode', 'longer string and so on'); @@ -177,5 +201,13 @@ describe('errors', function() { '%3A%2F%2Ferrors.angularjs.org%2F%22NG_VERSION_FULL%22%2Ftest%2Ffirstcode'); }); + it('should not generate URL query parameters when urlErrorParamsEnabled is false', function() { + + errorHandlingConfig({urlErrorParamsEnabled: false}); + + expect(testError('acode', 'aproblem', 'a', 'b', 'c').message).toBe('[test:acode] aproblem\n' + + 'https://errors.angularjs.org/"NG_VERSION_FULL"/test/acode'); + }); + }); }); diff --git a/test/ng/directive/ngRefSpec.js b/test/ng/directive/ngRefSpec.js new file mode 100644 index 000000000000..ef62fae99cad --- /dev/null +++ b/test/ng/directive/ngRefSpec.js @@ -0,0 +1,561 @@ +'use strict'; + +describe('ngRef', function() { + + beforeEach(function() { + jasmine.addMatchers({ + toEqualJq: function(util) { + return { + compare: function(actual, expected) { + // Jquery <= 2.2 objects add a context property that is irrelevant for equality + if (actual && actual.hasOwnProperty('context')) { + delete actual.context; + } + + if (expected && expected.hasOwnProperty('context')) { + delete expected.context; + } + + return { + pass: util.equals(actual, expected) + }; + } + }; + } + }); + }); + + describe('on a component', function() { + + var myComponentController, attributeDirectiveController, $rootScope, $compile; + + beforeEach(module(function($compileProvider) { + $compileProvider.component('myComponent', { + template: 'foo', + controller: function() { + myComponentController = this; + } + }); + + $compileProvider.directive('attributeDirective', function() { + return { + restrict: 'A', + controller: function() { + attributeDirectiveController = this; + } + }; + }); + + })); + + beforeEach(inject(function(_$compile_, _$rootScope_) { + $rootScope = _$rootScope_; + $compile = _$compile_; + })); + + it('should bind in the current scope the controller of a component', function() { + $rootScope.$ctrl = 'undamaged'; + + $compile('')($rootScope); + expect($rootScope.$ctrl).toBe('undamaged'); + expect($rootScope.myComponentRef).toBe(myComponentController); + }); + + it('should throw if the expression is not assignable', function() { + expect(function() { + $compile('')($rootScope); + }).toThrowMinErr('ngRef', 'nonassign', 'Expression in ngRef="\'hello\'" is non-assignable!'); + }); + + it('should work with non:normalized entity name', function() { + $compile('')($rootScope); + expect($rootScope.myComponent1).toBe(myComponentController); + }); + + it('should work with data-non-normalized entity name', function() { + $compile('')($rootScope); + expect($rootScope.myComponent2).toBe(myComponentController); + }); + + it('should work with x-non-normalized entity name', function() { + $compile('')($rootScope); + expect($rootScope.myComponent3).toBe(myComponentController); + }); + + it('should work with data-non-normalized attribute name', function() { + $compile('')($rootScope); + expect($rootScope.myComponent1).toBe(myComponentController); + }); + + it('should work with x-non-normalized attribute name', function() { + $compile('')($rootScope); + expect($rootScope.myComponent2).toBe(myComponentController); + }); + + it('should not bind the controller of an attribute directive', function() { + $compile('')($rootScope); + expect($rootScope.myComponentRef).toBe(myComponentController); + }); + + it('should not leak to parent scopes', function() { + var template = + '
' + + '' + + '
'; + $compile(template)($rootScope); + expect($rootScope.myComponent).toBe(undefined); + }); + + it('should nullify the variable once the component is destroyed', function() { + var template = '
'; + + var element = $compile(template)($rootScope); + expect($rootScope.myComponent).toBe(myComponentController); + + var componentElement = element.children(); + var isolateScope = componentElement.isolateScope(); + componentElement.remove(); + isolateScope.$destroy(); + expect($rootScope.myComponent).toBe(null); + }); + + it('should be compatible with entering/leaving components', inject(function($animate) { + var template = ''; + $rootScope.$ctrl = {}; + var parent = $compile('
')($rootScope); + + var leaving = $compile(template)($rootScope); + var leavingController = myComponentController; + + $animate.enter(leaving, parent); + expect($rootScope.myComponent).toBe(leavingController); + + var entering = $compile(template)($rootScope); + var enteringController = myComponentController; + + $animate.enter(entering, parent); + $animate.leave(leaving, parent); + expect($rootScope.myComponent).toBe(enteringController); + })); + + it('should allow binding to a nested property', function() { + $rootScope.obj = {}; + + $compile('')($rootScope); + expect($rootScope.obj.myComponent).toBe(myComponentController); + }); + + }); + + it('should bind the jqlite wrapped DOM element if there is no component', inject(function($compile, $rootScope) { + + var el = $compile('my text')($rootScope); + + expect($rootScope.mySpan).toEqualJq(el); + expect($rootScope.mySpan[0].textContent).toBe('my text'); + })); + + it('should nullify the expression value if the DOM element is destroyed', inject(function($compile, $rootScope) { + var element = $compile('
my text
')($rootScope); + element.children().remove(); + expect($rootScope.mySpan).toBe(null); + })); + + it('should bind the controller of an element directive', function() { + var myDirectiveController; + + module(function($compileProvider) { + $compileProvider.directive('myDirective', function() { + return { + controller: function() { + myDirectiveController = this; + } + }; + }); + }); + + inject(function($compile, $rootScope) { + $compile('')($rootScope); + + expect($rootScope.myDirective).toBe(myDirectiveController); + }); + }); + + describe('ngRefRead', function() { + + it('should bind the element instead of the controller of a component if ngRefRead="$element" is set', function() { + + module(function($compileProvider) { + + $compileProvider.component('myComponent', { + template: 'my text', + controller: function() {} + }); + }); + + inject(function($compile, $rootScope) { + + var el = $compile('')($rootScope); + expect($rootScope.myEl).toEqualJq(el); + expect($rootScope.myEl[0].textContent).toBe('my text'); + }); + }); + + + it('should bind the element instead an element-directive controller if ngRefRead="$element" is set', function() { + + module(function($compileProvider) { + $compileProvider.directive('myDirective', function() { + return { + restrict: 'E', + template: 'my text', + controller: function() {} + }; + }); + }); + + inject(function($compile, $rootScope) { + var el = $compile('')($rootScope); + + expect($rootScope.myEl).toEqualJq(el); + expect($rootScope.myEl[0].textContent).toBe('my text'); + }); + }); + + + it('should bind an attribute-directive controller if ngRefRead="controllerName" is set', function() { + var attrDirective1Controller; + + module(function($compileProvider) { + $compileProvider.directive('elementDirective', function() { + return { + restrict: 'E', + template: 'my text', + controller: function() {} + }; + }); + + $compileProvider.directive('attributeDirective1', function() { + return { + restrict: 'A', + controller: function() { + attrDirective1Controller = this; + } + }; + }); + + $compileProvider.directive('attributeDirective2', function() { + return { + restrict: 'A', + controller: function() {} + }; + }); + + }); + + inject(function($compile, $rootScope) { + var el = $compile('')($rootScope); + + expect($rootScope.myController).toBe(attrDirective1Controller); + }); + }); + + it('should throw if no controller is found for the ngRefRead value', function() { + + module(function($compileProvider) { + $compileProvider.directive('elementDirective', function() { + return { + restrict: 'E', + template: 'my text', + controller: function() {} + }; + }); + }); + + inject(function($compile, $rootScope) { + + expect(function() { + $compile('')($rootScope); + }).toThrowMinErr('ngRef', 'noctrl', 'The controller for ngRefRead="attribute" could not be found on ngRef="myController"'); + + }); + }); + + }); + + + it('should bind the jqlite element if the controller is on an attribute-directive', function() { + var myDirectiveController; + + module(function($compileProvider) { + $compileProvider.directive('myDirective', function() { + return { + restrict: 'A', + template: 'my text', + controller: function() { + myDirectiveController = this; + } + }; + }); + }); + + inject(function($compile, $rootScope) { + var el = $compile('
')($rootScope); + + expect(myDirectiveController).toBeDefined(); + expect($rootScope.myEl).toEqualJq(el); + expect($rootScope.myEl[0].textContent).toBe('my text'); + }); + }); + + + it('should bind the jqlite element if the controller is on an class-directive', function() { + var myDirectiveController; + + module(function($compileProvider) { + $compileProvider.directive('myDirective', function() { + return { + restrict: 'C', + template: 'my text', + controller: function() { + myDirectiveController = this; + } + }; + }); + }); + + inject(function($compile, $rootScope) { + var el = $compile('
')($rootScope); + + expect(myDirectiveController).toBeDefined(); + expect($rootScope.myEl).toEqualJq(el); + expect($rootScope.myEl[0].textContent).toBe('my text'); + }); + }); + + describe('transclusion', function() { + + it('should work with simple transclusion', function() { + module(function($compileProvider) { + $compileProvider + .component('myComponent', { + transclude: true, + template: '', + controller: function() { + this.text = 'SUCCESS'; + } + }); + }); + + inject(function($compile, $rootScope) { + var template = '{{myComponent.text}}'; + var element = $compile(template)($rootScope); + $rootScope.$apply(); + expect(element.text()).toBe('SUCCESS'); + dealoc(element); + }); + }); + + it('should be compatible with element transclude components', function() { + + module(function($compileProvider) { + $compileProvider + .component('myComponent', { + transclude: 'element', + controller: function($animate, $element, $transclude) { + this.text = 'SUCCESS'; + this.$postLink = function() { + $transclude(function(clone, newScope) { + $animate.enter(clone, $element.parent(), $element); + }); + }; + } + }); + }); + + inject(function($compile, $rootScope) { + var template = + '
' + + '' + + '{{myComponent.text}}' + + '' + + '
'; + var element = $compile(template)($rootScope); + $rootScope.$apply(); + expect(element.text()).toBe('SUCCESS'); + dealoc(element); + }); + }); + + it('should be compatible with ngIf and transclusion on same element', function() { + module(function($compileProvider) { + $compileProvider.component('myComponent', { + template: '', + transclude: true, + controller: function($scope) { + this.text = 'SUCCESS'; + } + }); + }); + + inject(function($compile, $rootScope) { + var template = + '
' + + '' + + '{{myComponent.text}}' + + '' + + '
'; + var element = $compile(template)($rootScope); + + $rootScope.$apply('present = false'); + expect(element.text()).toBe(''); + $rootScope.$apply('present = true'); + expect(element.text()).toBe('SUCCESS'); + $rootScope.$apply('present = false'); + expect(element.text()).toBe(''); + $rootScope.$apply('present = true'); + expect(element.text()).toBe('SUCCESS'); + dealoc(element); + }); + }); + + it('should be compatible with element transclude & destroy components', function() { + var myComponentController; + module(function($compileProvider) { + $compileProvider + .component('myTranscludingComponent', { + transclude: 'element', + controller: function($animate, $element, $transclude) { + myComponentController = this; + + var currentClone, currentScope; + this.transclude = function(text) { + this.text = text; + $transclude(function(clone, newScope) { + currentClone = clone; + currentScope = newScope; + $animate.enter(clone, $element.parent(), $element); + }); + }; + this.destroy = function() { + currentClone.remove(); + currentScope.$destroy(); + }; + } + }); + }); + + inject(function($compile, $rootScope) { + var template = + '
' + + '' + + '{{myComponent.text}}' + + '' + + '
'; + var element = $compile(template)($rootScope); + $rootScope.$apply(); + expect(element.text()).toBe(''); + + myComponentController.transclude('transcludedOk'); + $rootScope.$apply(); + expect(element.text()).toBe('transcludedOk'); + + myComponentController.destroy(); + $rootScope.$apply(); + expect(element.text()).toBe(''); + }); + }); + + it('should be compatible with element transclude directives', function() { + module(function($compileProvider) { + $compileProvider + .directive('myDirective', function($animate) { + return { + transclude: 'element', + controller: function() { + this.text = 'SUCCESS'; + }, + link: function(scope, element, attrs, ctrl, $transclude) { + $transclude(function(clone, newScope) { + $animate.enter(clone, element.parent(), element); + }); + } + }; + }); + }); + + inject(function($compile, $rootScope) { + var template = + '
' + + '' + + '{{myDirective.text}}' + + '' + + '
'; + var element = $compile(template)($rootScope); + $rootScope.$apply(); + expect(element.text()).toBe('SUCCESS'); + dealoc(element); + }); + }); + + }); + + it('should work with components with templates via $http', function() { + module(function($compileProvider) { + $compileProvider.component('httpComponent', { + templateUrl: 'template.html', + controller: function() { + this.me = true; + } + }); + }); + + inject(function($compile, $httpBackend, $rootScope) { + var template = '
'; + var element = $compile(template)($rootScope); + $httpBackend.expect('GET', 'template.html').respond('ok'); + $rootScope.$apply(); + expect($rootScope.controller).toBeUndefined(); + $httpBackend.flush(); + expect($rootScope.controller.me).toBe(true); + dealoc(element); + }); + }); + + + it('should work with ngRepeat-ed components', function() { + var controllers = []; + + module(function($compileProvider) { + $compileProvider.component('myComponent', { + template: 'foo', + controller: function() { + controllers.push(this); + } + }); + }); + + + inject(function($compile, $rootScope) { + $rootScope.elements = [0,1,2,3,4]; + $rootScope.controllers = []; // Initialize the array because ngRepeat creates a child scope + + var template = '
'; + var element = $compile(template)($rootScope); + $rootScope.$apply(); + + expect($rootScope.controllers).toEqual(controllers); + + $rootScope.$apply('elements = []'); + + expect($rootScope.controllers).toEqual([null, null, null, null, null]); + }); + }); + +}); diff --git a/test/ngMessages/messagesSpec.js b/test/ngMessages/messagesSpec.js index b86f764f37d0..527a577b1f18 100644 --- a/test/ngMessages/messagesSpec.js +++ b/test/ngMessages/messagesSpec.js @@ -661,6 +661,100 @@ describe('ngMessages', function() { ); + describe('default message', function() { + it('should render a default message when no message matches', inject(function($rootScope, $compile) { + element = $compile('
' + + '
Message is set
' + + '
Default message is set
' + + '
')($rootScope); + $rootScope.$apply(function() { + $rootScope.col = { unexpected: false }; + }); + + $rootScope.$digest(); + + expect(element.text().trim()).toBe(''); + expect(element).not.toHaveClass('ng-active'); + + $rootScope.$apply(function() { + $rootScope.col = { unexpected: true }; + }); + + expect(element.text().trim()).toBe('Default message is set'); + expect(element).toHaveClass('ng-active'); + + $rootScope.$apply(function() { + $rootScope.col = { unexpected: false }; + }); + + expect(element.text().trim()).toBe(''); + expect(element).not.toHaveClass('ng-active'); + + $rootScope.$apply(function() { + $rootScope.col = { val: true, unexpected: true }; + }); + + expect(element.text().trim()).toBe('Message is set'); + expect(element).toHaveClass('ng-active'); + })); + + it('should not render a default message with ng-messages-multiple if another error matches', + inject(function($rootScope, $compile) { + element = $compile('
' + + '
Message is set
' + + '
Other message is set
' + + '
Default message is set
' + + '
')($rootScope); + + expect(element.text().trim()).toBe(''); + + $rootScope.$apply(function() { + $rootScope.col = { val: true, other: false, unexpected: false }; + }); + + expect(element.text().trim()).toBe('Message is set'); + + $rootScope.$apply(function() { + $rootScope.col = { val: true, other: true, unexpected: true }; + }); + + expect(element.text().trim()).toBe('Message is set Other message is set'); + + $rootScope.$apply(function() { + $rootScope.col = { val: false, other: false, unexpected: true }; + }); + + expect(element.text().trim()).toBe('Default message is set'); + }) + ); + + it('should handle a default message with ngIf', inject(function($rootScope, $compile) { + element = $compile('
' + + '
Message is set
' + + '
Default message is set
' + + '
')($rootScope); + $rootScope.default = true; + $rootScope.col = {unexpected: true}; + $rootScope.$digest(); + + expect(element.text().trim()).toBe('Default message is set'); + + $rootScope.$apply('default = false'); + + expect(element.text().trim()).toBe(''); + + $rootScope.$apply('default = true'); + + expect(element.text().trim()).toBe('Default message is set'); + + $rootScope.$apply(function() { + $rootScope.col = { val: true }; + }); + + expect(element.text().trim()).toBe('Message is set'); + })); + }); + describe('when including templates', function() { they('should work with a dynamic collection model which is managed by ngRepeat', {'
': '
' +