diff --git a/Gruntfile.js b/Gruntfile.js index 170f494a..6243c57f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -122,6 +122,9 @@ module.exports = function(grunt) { ngLocale: { files: { src: 'src/ngLocale/**/*.js' }, }, + ngMessages: { + files: { src: 'src/ngMessages/**/*.js' }, + }, ngMock: { files: { src: 'src/ngMock/**/*.js' }, }, @@ -190,6 +193,10 @@ module.exports = function(grunt) { dest: 'build/angular-resource.js', src: util.wrap(files['angularModules']['ngResource'], 'module') }, + messages: { + dest: 'build/angular-messages.js', + src: util.wrap(files['angularModules']['ngMessages'], 'module') + }, animate: { dest: 'build/angular-animate.js', src: util.wrap(files['angularModules']['ngAnimate'], 'module') diff --git a/angularFiles.js b/angularFiles.js index 1647ba48..80ed0f9e 100755 --- a/angularFiles.js +++ b/angularFiles.js @@ -80,6 +80,9 @@ angularFiles = { 'ngCookies': [ 'src/ngCookies/cookies.js' ], + 'ngMessages': [ + 'src/ngMessages/messages.js' + ], 'ngResource': [ 'src/ngResource/resource.js' ], @@ -128,6 +131,7 @@ angularFiles = { 'test/auto/*.js', 'test/ng/**/*.js', 'test/ngAnimate/*.js', + 'test/ngMessages/*.js', 'test/ngCookies/*.js', 'test/ngResource/*.js', 'test/ngRoute/**/*.js', @@ -189,6 +193,7 @@ angularFiles = { angularFiles['angularSrcModules'] = [].concat( angularFiles['angularModules']['ngAnimate'], + angularFiles['angularModules']['ngMessages'], angularFiles['angularModules']['ngCookies'], angularFiles['angularModules']['ngResource'], angularFiles['angularModules']['ngRoute'], diff --git a/src/ngAnimate/animate.js b/src/ngAnimate/animate.js index 3880715d..e48f38bc 100644 --- a/src/ngAnimate/animate.js +++ b/src/ngAnimate/animate.js @@ -29,6 +29,8 @@ * | {@link ng.directive:ngClass#usage_animations ngClass} | add and remove (the CSS class(es) present) | * | {@link ng.directive:ngShow#usage_animations ngShow} & {@link ng.directive:ngHide#usage_animations ngHide} | add and remove (the ng-hide class value) | * | {@link ng.directive:form#usage_animations form} & {@link ng.directive:ngModel#usage_animations ngModel} | add and remove (dirty, pristine, valid, invalid & all other validations) | + * | {@link ngMessages.directive:ngMessage#usage_animations ngMessages} | add and remove (ng-active & ng-inactive) | + * | {@link ngMessages.directive:ngMessage#usage_animations ngMessage} | enter and leave | * * You can find out more information about animations upon visiting each directive page. * diff --git a/src/ngMessages/messages.js b/src/ngMessages/messages.js new file mode 100644 index 00000000..bc5447c8 --- /dev/null +++ b/src/ngMessages/messages.js @@ -0,0 +1,394 @@ +'use strict'; + +/** + * @ngdoc module + * @name ngMessages + * @description + * + * The `ngMessages` module provides enhanced support for displaying messages within templates + * (typically within forms or when rendering message objects that return key/value data). + * Instead of relying on JavaScript code and/or complex ng-if statements within your form template to + * show and hide error messages specific to the state of an input field, the `ngMessages` and + * `ngMessage` directives are designed to handle the complexity, inheritance and priority + * 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` + * and `ngMessage` directives. + * + * # Usage + * The `ngMessages` directive listens on a key/value collection which is set on the ngMessages attribute. + * Since the {@link ngModel ngModel} directive exposes an `$error` object, this error object can be + * used with `ngMessages` to display control error messages in an easier way than with just regular angular + * template directives. + * + * ```html + *
+ * + *
+ *
You did not enter a field
+ *
The value entered is too short
+ *
+ *
+ * ``` + * + * Now whatever key/value entries are present within the provided object (in this case `$error`) then + * the ngMessages directive will render the inner first ngMessage directive (depending if the key values + * match the attribute value present on each ngMessage directive). In other words, if your errors + * object contains the following data: + * + * ```javascript + * + * myField.$error = { minlength : true, required : false }; + * ``` + * + * Then the `required` message will be displayed first. When required is false then the `minlength` message + * will be displayed right after (since these messages are ordered this way in the template HTML code). + * The prioritization of each message is determined by what order they're present in the DOM. + * Therefore, instead of having custom JavaScript code determine the priority of what errors are + * present before others, the presentation of the errors are handled within the template. + * + * By default, ngMessages will only display one error at a time. However, if you wish to display all + * messages then the `ng-messages-multiple` attribute flag can be used on the element containing the + * ngMessages directive to make this happen. + * + * ```html + *
...
+ * ``` + * + * ## Reusing and Overriding Messages + * In addition to prioritization, ngMessages also allows for including messages from a remote or an inline + * template. This allows for generic collection of messages to be reused across multiple parts of an + * application. + * + * ```html + * + *
+ * ``` + * + * However, including generic messages may not be useful enough to match all input fields, therefore, + * `ngMessages` provides the ability to override messages defined in the remote template by redefining + * then within the directive container. + * + * ```html + * + * + * + *
+ * + * + *
+ * + *
You did not enter your email address
+ * + * + *
Your email address is invalid
+ *
+ *
+ * ``` + * + * In the example HTML code above the message that is set on required will override the corresponding + * required message defined within the remote template. Therefore, with particular input fields (such + * email addresses, date fields, autocomplete inputs, etc...), specialized error messages can be applied + * while more generic messages can be used to handle other, more general input errors. + * + * ## Animations + * If the `ngAnimate` module is active within the application then both the `ngMessages` and + * `ngMessage` directives will trigger animations whenever any messages are added and removed + * from the DOM by the `ngMessages` directive. + * + * Whenever the `ngMessages` directive contains one or more visible messages then the `.ng-active` CSS + * class will be added to the element. The `.ng-inactive` CSS class will be applied when there are no + * animations present. Therefore, CSS transitions and keyframes as well as JavaScript animations can + * hook into the animations whenever these classes are added/removed. + * + * Let's say that our HTML code for our messages container looks like so: + * + * ```html + *
+ *
...
+ *
...
+ *
+ * ``` + * + * Then the CSS animation code for the message container looks like so: + * + * ```css + * .my-messages { + * transition:1s linear all; + * } + * .my-messages.ng-active { + * // messages are visible + * } + * .my-messages.ng-inactive { + * // messages are hidden + * } + * ``` + * + * Whenever an inner message is attached (becomes visible) or removed (becomes hidden) then the enter + * and leave animation is triggered for each particular element bound to the `ngMessage` directive. + * + * Therefore, the CSS code for the inner messages looks like so: + * + * ```css + * .some-message { + * transition:1s linear all; + * } + * + * .some-message.ng-enter {} + * .some-message.ng-enter.ng-enter-active {} + * + * .some-message.ng-leave {} + * .some-message.ng-leave.ng-leave-active {} + * ``` + * + * {@link ngAnimate Click here} to learn how to use JavaScript animations or to learn more about ngAnimate. + */ +angular.module('ngMessages', []) + + /** + * @ngdoc directive + * @module ngMessages + * @name ngMessages + * @restrict AE + * + * @description + * # Overview + * `ngMessages` is a directive that is designed to show and hide messages based on the state + * of a key/value object that is listens on. The directive itself compliments error message + * reporting with the `ngModel` $error object (which stores a key/value state of validation errors). + * + * `ngMessages` manages the state of internal messages within its container element. The internal + * messages use the `ngMessage` directive and will be inserted/removed from the page depending + * on if they're present within the key/value object. By default, only one message will be displayed + * 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 on the directive container.) + * + * A remote template can also be used to promote message reuseability and messages can also be + * overridden. + * + * {@link ngMessages.directive:ngMessages Click here} to learn more about `ngMessages` and `ngMessage`. + * + * @usage + * ```html + * + * + * ... + * ... + * ... + * + * + * + * + * ... + * ... + * ... + * + * ``` + * + * @param {string} ngMessages an angular expression evaluating to a key/value object + * (this is typically the $error object on an ngModel instance). + * @param {string=} ngMessagesMultiple|multiple when set, all messages will be displayed with true + * @param {string=} ngMessagesInclude|include when set, the specified template will be included into the ng-messages container + * + * @example + * + * + *
+ * + * + * + *
myForm.myName.$error = {{ myForm.myName.$error | json }}
+ * + *
+ *
You did not enter a field
+ *
Your field is too short
+ *
Your field is too long
+ *
+ *
+ *
+ * + * angular.module('ngMessagesExample', ['ngMessages']); + * + *
+ */ + .directive('ngMessages', ['$compile', '$animate', '$http', '$templateCache', + function($compile, $animate, $http, $templateCache) { + var ACTIVE_CLASS = 'ng-active'; + var INACTIVE_CLASS = 'ng-inactive'; + + return { + restrict: 'AE', + controller: function($scope) { + this.$renderNgMessageClasses = angular.noop; + + var messages = []; + this.registerMessage = function(index, message) { + for(var i = 0; i < messages.length; i++) { + if(messages[i].type == message.type) { + if(index != i) { + var temp = messages[index]; + messages[index] = messages[i]; + if(index < messages.length) { + messages[i] = temp; + } else { + messages.splice(0, i); //remove the old one (and shift left) + } + } + return; + } + } + messages.splice(index, 0, message); //add the new one (and shift right) + }; + + this.renderMessages = function(values, multiple) { + values = values || {}; + + var found; + angular.forEach(messages, function(message) { + if((!found || multiple) && truthyVal(values[message.type])) { + message.attach(); + found = true; + } else { + message.detach(); + } + }); + + this.renderElementClasses(found); + + function truthyVal(value) { + return value !== null && value !== false && value; + } + }; + }, + require: 'ngMessages', + link: function($scope, element, $attrs, ctrl) { + ctrl.renderElementClasses = function(bool) { + bool ? $animate.setClass(element, ACTIVE_CLASS, INACTIVE_CLASS) + : $animate.setClass(element, INACTIVE_CLASS, ACTIVE_CLASS); + }; + + //JavaScript treats empty strings as false, but ng-message-multiple by itself is an empty string + var multiple = angular.isString($attrs.ngMessagesMultiple) || + angular.isString($attrs.multiple); + + var cachedValues, watchAttr = $attrs.ngMessages || $attrs['for']; //for is a reserved keyword + $scope.$watchCollection(watchAttr, function(values) { + cachedValues = values; + ctrl.renderMessages(values, multiple); + }); + + var tpl = $attrs.ngMessagesInclude || $attrs.include; + if(tpl) { + $http.get(tpl, { cache: $templateCache }) + .success(function processTemplate(html) { + var after, container = angular.element('
').html(html); + angular.forEach(container.children(), function(elm) { + elm = angular.element(elm); + after ? after.after(elm) + : element.prepend(elm); //start of the container + after = elm; + $compile(elm)($scope); + }); + ctrl.renderMessages(cachedValues, multiple); + }); + } + } + }; + }]) + + + /** + * @ngdoc directive + * @name ngMessage + * @restrict AE + * @scope + * + * @description + * # Overview + * `ngMessage` is a directive with the purpose to show and hide a particular message. + * For `ngMessage` to operate, a parent `ngMessages` directive on a parent DOM element + * must be situated since it determines which messages are visible based on the state + * of the provided key/value map that `ngMessages` listens on. + * + * @usage + * ```html + * + * + * ... + * ... + * ... + * + * + * + * + * ... + * ... + * ... + * + * ``` + * + * {@link ngMessages.directive:ngMessages Click here} to learn more about `ngMessages` and `ngMessage`. + * + * @param {string} ngMessage a string value corresponding to the message key. + */ + .directive('ngMessage', ['$animate', function($animate) { + var COMMENT_NODE = 8; + return { + require: '^ngMessages', + transclude: 'element', + terminal: true, + restrict: 'AE', + link: function($scope, $element, $attrs, ngMessages, $transclude) { + var index, element; + + var commentNode = $element[0]; + var parentNode = commentNode.parentNode; + for(var i = 0, j = 0; i < parentNode.childNodes.length; i++) { + var node = parentNode.childNodes[i]; + if(node.nodeType == COMMENT_NODE && node.nodeValue.indexOf('ngMessage') >= 0) { + if(node === commentNode) { + index = j; + break; + } + j++; + } + } + + ngMessages.registerMessage(index, { + type : $attrs.ngMessage || $attrs.when, + attach : function() { + if(!element) { + $transclude($scope, function(clone) { + $animate.enter(clone, null, $element); + element = clone; + }); + } + }, + detach : function(now) { + if(element) { + $animate.leave(element); + element = null; + } + } + }); + } + }; + }]); diff --git a/test/ngMessages/messagesSpec.js b/test/ngMessages/messagesSpec.js new file mode 100644 index 00000000..0a026b46 --- /dev/null +++ b/test/ngMessages/messagesSpec.js @@ -0,0 +1,471 @@ +'use strict'; + +describe('ngMessages', function() { + + beforeEach(module('ngMessages')); + + function they(msg, vals, spec, focus) { + forEach(vals, function(val, key) { + var m = msg.replace('$prop', key); + (focus ? iit : it)(m, function() { + spec(val); + }); + }); + } + + function tthey(msg, vals, spec) { + they(msg, vals, spec, true); + } + + function s(str) { + return str.replace(/\s+/g,''); + } + + var element; + afterEach(function() { + dealoc(element); + }); + + it('should render based off of a hashmap collection', inject(function($rootScope, $compile) { + element = $compile('
' + + '
Message is set
' + + '
')($rootScope); + $rootScope.$digest(); + + expect(element.text()).not.toContain('Message is set'); + + $rootScope.$apply(function() { + $rootScope.col = { val : true }; + }); + + expect(element.text()).toContain('Message is set'); + })); + + it('should use the data attribute when an element directive is used', + inject(function($rootScope, $compile) { + + element = $compile('' + + ' Message is set
' + + '')($rootScope); + $rootScope.$digest(); + + expect(element.text()).not.toContain('Message is set'); + + $rootScope.$apply(function() { + $rootScope.col = { val : true }; + }); + + expect(element.text()).toContain('Message is set'); + })); + + they('should render empty when $prop is used as a collection value', + { 'null': null, + 'false': false, + '0': 0, + '[]': [], + '[{}]': [{}], + '': '', + '{ val2 : true }': { val2 : true } }, + function(prop) { + inject(function($rootScope, $compile) { + element = $compile('
' + + '
Message is set
' + + '
')($rootScope); + $rootScope.$digest(); + + $rootScope.$apply(function() { + $rootScope.col = prop; + }); + expect(element.text()).not.toContain('Message is set'); + }); + }); + + they('should insert and remove matching inner elements when $prop is used as a value', + { 'true': true, + '1': 1, + '{}': {}, + '[]': [], + '[null]': [null] }, + function(prop) { + inject(function($rootScope, $compile) { + + element = $compile('
' + + '
This message is blue
' + + '
This message is red
' + + '
')($rootScope); + + $rootScope.$apply(function() { + $rootScope.col = {}; + }); + + expect(element.children().length).toBe(0); + expect(trim(element.text())).toEqual(''); + + $rootScope.$apply(function() { + $rootScope.col = { + blue : true, + red : false + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual('This message is blue'); + + $rootScope.$apply(function() { + $rootScope.col = { + red : prop + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual('This message is red'); + + $rootScope.$apply(function() { + $rootScope.col = null; + }); + expect(element.children().length).toBe(0); + expect(trim(element.text())).toEqual(''); + + + $rootScope.$apply(function() { + $rootScope.col = { + blue : 0, + red : null + }; + }); + + expect(element.children().length).toBe(0); + expect(trim(element.text())).toEqual(''); + }); + }); + + it('should display the elements in the order defined in the DOM', + inject(function($rootScope, $compile) { + + element = $compile('
' + + '
Message#one
' + + '
Message#two
' + + '
Message#three
' + + '
')($rootScope); + + $rootScope.$apply(function() { + $rootScope.col = { + three : true, + one : true, + two : true + }; + }); + + angular.forEach(['one','two','three'], function(key) { + expect(s(element.text())).toEqual('Message#' + key); + + $rootScope.$apply(function() { + $rootScope.col[key] = false; + }); + }); + + expect(s(element.text())).toEqual(''); + })); + + it('should add ng-active/ng-inactive CSS classes to the element when errors are/aren\'t displayed', + inject(function($rootScope, $compile) { + + element = $compile('
' + + '
This message is ready
' + + '
')($rootScope); + + $rootScope.$apply(function() { + $rootScope.col = {}; + }); + + expect(element.hasClass('ng-active')).toBe(false); + expect(element.hasClass('ng-inactive')).toBe(true); + + $rootScope.$apply(function() { + $rootScope.col = { ready : true }; + }); + + expect(element.hasClass('ng-active')).toBe(true); + expect(element.hasClass('ng-inactive')).toBe(false); + })); + + it('should render animations when the active/inactive classes are added/removed', function() { + module('ngAnimate'); + module('ngAnimateMock'); + inject(function($rootScope, $compile, $animate) { + element = $compile('
' + + '
This message is ready
' + + '
')($rootScope); + + $rootScope.$apply(function() { + $rootScope.col = {}; + }); + + var event = $animate.queue.pop(); + expect(event.event).toBe('setClass'); + expect(event.args[1]).toBe('ng-inactive'); + expect(event.args[2]).toBe('ng-active'); + + $rootScope.$apply(function() { + $rootScope.col = { ready : true }; + }); + + event = $animate.queue.pop(); + expect(event.event).toBe('setClass'); + expect(event.args[1]).toBe('ng-active'); + expect(event.args[2]).toBe('ng-inactive'); + }); + }); + + describe('when including templates', function() { + they('should load a remote template using $prop', + {'
': + '
', + '' : + ''}, + function(html) { + inject(function($compile, $rootScope, $templateCache) { + $templateCache.put('abc.html', '
A
' + + '
B
' + + '
C
'); + + element = $compile(html)($rootScope); + $rootScope.$apply(function() { + $rootScope.data = { + 'a': 1, + 'b': 2, + 'c': 3 + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("A"); + + $rootScope.$apply(function() { + $rootScope.data = { + 'c': 3 + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("C"); + }); + }); + + it('should cache the template after download', + inject(function($rootScope, $compile, $templateCache, $httpBackend) { + + $httpBackend.expect('GET', 'tpl').respond(201, 'abc'); + + expect($templateCache.get('tpl')).toBeUndefined(); + + element = $compile('
')($rootScope); + + $rootScope.$digest(); + $httpBackend.flush(); + + expect($templateCache.get('tpl')).toBeDefined(); + })); + + it('should re-render the messages after download without an extra digest', + inject(function($rootScope, $compile, $httpBackend) { + + $httpBackend.expect('GET', 'my-messages').respond(201,'
You did not enter a value
'); + + element = $compile('
' + + '
Your value is that of failure
' + + '
')($rootScope); + + $rootScope.data = { + required : true, + failed : true + }; + + $rootScope.$digest(); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("Your value is that of failure"); + + $httpBackend.flush(); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("You did not enter a value"); + })); + + it('should allow for overriding the remote template messages within the element', + inject(function($compile, $rootScope, $templateCache) { + + $templateCache.put('abc.html', '
A
' + + '
B
' + + '
C
'); + + element = $compile('
' + + '
AAA
' + + '
CCC
' + + '
')($rootScope); + + $rootScope.$apply(function() { + $rootScope.data = { + 'a': 1, + 'b': 2, + 'c': 3 + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("AAA"); + + $rootScope.$apply(function() { + $rootScope.data = { + 'b': 2, + 'c': 3 + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("B"); + + $rootScope.$apply(function() { + $rootScope.data = { + 'c': 3 + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("CCC"); + })); + + it('should retain the order of the remote template\'s messages when overriding within the element', + inject(function($compile, $rootScope, $templateCache) { + + $templateCache.put('abc.html', '
C
' + + '
A
' + + '
B
'); + + element = $compile('
' + + '
AAA
' + + '
CCC
' + + '
')($rootScope); + + $rootScope.$apply(function() { + $rootScope.data = { + 'a': 1, + 'b': 2, + 'c': 3 + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("CCC"); + + $rootScope.$apply(function() { + $rootScope.data = { + 'a': 1, + 'b': 2, + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("AAA"); + + $rootScope.$apply(function() { + $rootScope.data = { + 'b': 3 + }; + }); + + expect(element.children().length).toBe(1); + expect(trim(element.text())).toEqual("B"); + })); + + }); + + describe('when multiple', function() { + they('should show all truthy messages when the $prop attr is present', + { 'multiple' : 'multiple', + 'ng-messages-multiple' : 'ng-messages-multiple' }, + function(prop) { + inject(function($rootScope, $compile) { + element = $compile('
' + + '
1
' + + '
2
' + + '
3
' + + '
')($rootScope); + + $rootScope.$apply(function() { + $rootScope.data = { + 'one': true, + 'two': false, + 'three': true + }; + }); + + expect(element.children().length).toBe(2); + expect(s(element.text())).toContain("13"); + }); + }); + + it('should render all truthy messages from a remote template', + inject(function($rootScope, $compile, $templateCache) { + + $templateCache.put('xyz.html', '
X
' + + '
Y
' + + '
Z
'); + + element = $compile('
')($rootScope); + + $rootScope.$apply(function() { + $rootScope.data = { + 'x': 'a', + 'y': null, + 'z': true + }; + }); + + expect(element.children().length).toBe(2); + expect(s(element.text())).toEqual("XZ"); + + $rootScope.$apply(function() { + $rootScope.data.y = {}; + }); + + expect(element.children().length).toBe(3); + expect(s(element.text())).toEqual("XYZ"); + })); + + it('should render and override all truthy messages from a remote template', + inject(function($rootScope, $compile, $templateCache) { + + $templateCache.put('xyz.html', '
X
' + + '
Y
' + + '
Z
'); + + element = $compile('
' + + '
YYY
' + + '
ZZZ
' + + '
')($rootScope); + + $rootScope.$apply(function() { + $rootScope.data = { + 'x': 'a', + 'y': null, + 'z': true + }; + }); + + expect(element.children().length).toBe(2); + expect(s(element.text())).toEqual("XZZZ"); + + $rootScope.$apply(function() { + $rootScope.data.y = {}; + }); + + expect(element.children().length).toBe(3); + expect(s(element.text())).toEqual("XYYYZZZ"); + })); + }); +});