From 1aaa43fdc04c0cc115740ee233140f7d61725cd9 Mon Sep 17 00:00:00 2001 From: Jorge del Casar Date: Tue, 26 Aug 2014 21:42:05 +0200 Subject: [PATCH 001/180] Added local to SanitizationWhitelist HTML5 apps on BlackBerry 10 devices use "local" as protocol for local files. --- src/ng/sanitizeUri.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ng/sanitizeUri.js b/src/ng/sanitizeUri.js index faceda8f56de..eb7a4dee79bf 100644 --- a/src/ng/sanitizeUri.js +++ b/src/ng/sanitizeUri.js @@ -5,8 +5,8 @@ * Private service to sanitize uris for links and images. Used by $compile and $sanitize. */ function $$SanitizeUriProvider() { - var aHrefSanitizationWhitelist = /^\s*(https?|ftp|mailto|tel|file):/, - imgSrcSanitizationWhitelist = /^\s*((https?|ftp|file|blob):|data:image\/)/; + var aHrefSanitizationWhitelist = /^\s*(https?|ftp|mailto|tel|file|local):/, + imgSrcSanitizationWhitelist = /^\s*((https?|ftp|file|blob|local):|data:image\/)/; /** * @description From 9f38de64a210f9c25402f6de4b1584749c6279a1 Mon Sep 17 00:00:00 2001 From: Jorge del Casar Date: Wed, 27 Aug 2014 14:19:16 +0200 Subject: [PATCH 002/180] Updated sanitizeUriSpec for local urls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added test for ´local´'s protocol to be sure `local:///` urls are not sanitized neither `src` nor `href`. --- test/ng/sanitizeUriSpec.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/ng/sanitizeUriSpec.js b/test/ng/sanitizeUriSpec.js index c36ec48a2535..11766191e7ec 100644 --- a/test/ng/sanitizeUriSpec.js +++ b/test/ng/sanitizeUriSpec.js @@ -110,6 +110,9 @@ describe('sanitizeUri', function() { testUrl = "file:///foo/bar.html"; expect(sanitizeImg(testUrl)).toBe('file:///foo/bar.html'); + + testUrl = "local:///foo/bar.html"; + expect(sanitizeImg(testUrl)).toBe('local:///foo/bar.html'); }); it('should not sanitize blob urls', function() { @@ -220,6 +223,9 @@ describe('sanitizeUri', function() { testUrl = "file:///foo/bar.html"; expect(sanitizeHref(testUrl)).toBe('file:///foo/bar.html'); + + testUrl = "local:///foo/bar.html"; + expect(sanitizeHref(testUrl)).toBe('local:///foo/bar.html'); })); it('should allow reconfiguration of the href whitelist', function() { From a02f0567663485a5b3c4e7505eb2420c42b0fb3f Mon Sep 17 00:00:00 2001 From: Jeff Cross Date: Thu, 21 Aug 2014 12:23:24 -0700 Subject: [PATCH 003/180] feat(minErr): allow specifying ErrorConstructor in minErr constructor In some cases, the type of Error thrown by minErr is meaningful, such as in $q where a TypeError is sometimes required. This fix allows providing an error constructor as the second argument to minErr, which will be used to construct the error that gets returned by the factory function. --- lib/promises-aplus/promises-aplus-test-adapter.js | 6 ++++++ src/minErr.js | 8 +++++--- test/minErrSpec.js | 8 ++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/promises-aplus/promises-aplus-test-adapter.js b/lib/promises-aplus/promises-aplus-test-adapter.js index b5a4a9f385ae..ba1ad125902b 100644 --- a/lib/promises-aplus/promises-aplus-test-adapter.js +++ b/lib/promises-aplus/promises-aplus-test-adapter.js @@ -4,6 +4,12 @@ var isFunction = function isFunction(value){return typeof value == 'function';}; var isPromiseLike = function isPromiseLike(obj) {return obj && isFunction(obj.then);}; var isObject = function isObject(value){return value != null && typeof value === 'object';}; +var minErr = function minErr (module, constructor) { + return function (){ + var ErrorConstructor = constructor || Error; + throw new ErrorConstructor(module + arguments[0] + arguments[1]); + }; +}; var $q = qFactory(process.nextTick, function noopExceptionHandler() {}); diff --git a/src/minErr.js b/src/minErr.js index 7df5fdffd4b1..489608582d29 100644 --- a/src/minErr.js +++ b/src/minErr.js @@ -25,10 +25,13 @@ * should all be static strings, not variables or general expressions. * * @param {string} module The namespace to use for the new minErr instance. + * @param {function} ErrorConstructor Custom error constructor to be instantiated when returning + * error from returned function, for cases when a particular type of error is useful. * @returns {function(code:string, template:string, ...templateArgs): Error} minErr instance */ -function minErr(module) { +function minErr(module, ErrorConstructor) { + ErrorConstructor = ErrorConstructor || Error; return function () { var code = arguments[0], prefix = '[' + (module ? module + ':' : '') + code + '] ', @@ -69,7 +72,6 @@ function minErr(module) { message = message + (i == 2 ? '?' : '&') + 'p' + (i-2) + '=' + encodeURIComponent(stringify(arguments[i])); } - - return new Error(message); + return new ErrorConstructor(message); }; } diff --git a/test/minErrSpec.js b/test/minErrSpec.js index 6b7d93b894e4..da387f525ada 100644 --- a/test/minErrSpec.js +++ b/test/minErrSpec.js @@ -82,4 +82,12 @@ describe('minErr', function () { expect(myError.message).toMatch(/^\[26\] This is a Foo/); expect(myNamespacedError.message).toMatch(/^\[test:26\] That is a Bar/); }); + + + it('should accept an optional 2nd argument to construct custom errors', function() { + var normalMinErr = minErr('normal'); + expect(normalMinErr('acode', 'aproblem') instanceof TypeError).toBe(false); + var typeMinErr = minErr('type', TypeError); + expect(typeMinErr('acode', 'aproblem') instanceof TypeError).toBe(true); + }); }); From fb96c1702b314c8055f45016d48e19d0ff555aa0 Mon Sep 17 00:00:00 2001 From: Jeff Cross Date: Thu, 21 Aug 2014 11:31:31 -0700 Subject: [PATCH 004/180] chore($q): convert thrown Error to $minErr when calling $q constructor without resolver --- docs/content/error/$q/norslvr.ngdoc | 17 +++++++++++++++++ src/ng/q.js | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 docs/content/error/$q/norslvr.ngdoc diff --git a/docs/content/error/$q/norslvr.ngdoc b/docs/content/error/$q/norslvr.ngdoc new file mode 100644 index 000000000000..174433aedbae --- /dev/null +++ b/docs/content/error/$q/norslvr.ngdoc @@ -0,0 +1,17 @@ +@ngdoc error +@name $q:norslvr +@fullName No resolver function passed to $Q +@description + +Occurs when calling creating a promise using {@link $q} as a constructor, without providing the +required `resolver` function. + +``` +//bad +var promise = $q().then(doSomething); + +//good +var promise = $q(function(resolve, reject) { + waitForSomethingAsync.then(resolve); +}).then(doSomething); +``` diff --git a/src/ng/q.js b/src/ng/q.js index 3f46e5cdd7a3..ee80b00f35d9 100644 --- a/src/ng/q.js +++ b/src/ng/q.js @@ -235,6 +235,7 @@ function $$QProvider() { * @returns {object} Promise manager. */ function qFactory(nextTick, exceptionHandler) { + var $qMinErr = minErr('$q'); function callOnce(self, resolveFn, rejectFn) { var called = false; function wrap(fn) { @@ -522,8 +523,7 @@ function qFactory(nextTick, exceptionHandler) { var $Q = function Q(resolver) { if (!isFunction(resolver)) { - // TODO(@caitp): minErr this - throw new TypeError('Expected resolverFn'); + throw $qMinErr('norslvr', "Expected resolverFn, got '{0}'", resolver); } if (!(this instanceof Q)) { From e5b328e3317ef1f959e3f30d2b6a886d8a3018fd Mon Sep 17 00:00:00 2001 From: Jeff Cross Date: Thu, 21 Aug 2014 16:02:04 -0700 Subject: [PATCH 005/180] chore($q): replace plain TypeError with minErr+TypeError in cyclical resolve check --- docs/content/error/$q/qcycle.ngdoc | 23 +++++++++++++++++++++++ src/ng/q.js | 14 +++++++++++--- test/ng/qSpec.js | 14 ++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 docs/content/error/$q/qcycle.ngdoc diff --git a/docs/content/error/$q/qcycle.ngdoc b/docs/content/error/$q/qcycle.ngdoc new file mode 100644 index 000000000000..7b0bba204c30 --- /dev/null +++ b/docs/content/error/$q/qcycle.ngdoc @@ -0,0 +1,23 @@ +@ngdoc error +@name $q:qcycle +@fullName Cannot resolve a promise with itself +@description + +Occurs when resolving a promise with itself as the value, including returning the promise in a +function passed to `then`. The A+ 1.1 spec mandates that this behavior throw a TypeError. +https://github.com/promises-aplus/promises-spec#the-promise-resolution-procedure + +``` +var promise = $q.defer().promise; + +//bad +promise.then(function (val) { + //Cannot return self + return promise; +}); + +//good +promise.then(function (val) { + return 'some other value'; +}); +``` diff --git a/src/ng/q.js b/src/ng/q.js index ee80b00f35d9..1f4d79b89cbb 100644 --- a/src/ng/q.js +++ b/src/ng/q.js @@ -235,7 +235,7 @@ function $$QProvider() { * @returns {object} Promise manager. */ function qFactory(nextTick, exceptionHandler) { - var $qMinErr = minErr('$q'); + var $qMinErr = minErr('$q', TypeError); function callOnce(self, resolveFn, rejectFn) { var called = false; function wrap(fn) { @@ -338,8 +338,16 @@ function qFactory(nextTick, exceptionHandler) { Deferred.prototype = { resolve: function(val) { if (this.promise.$$state.status) return; - if (val === this.promise) throw new TypeError('Cycle detected'); - this.$$resolve(val); + if (val === this.promise) { + this.$$reject($qMinErr( + 'qcycle', + "Expected promise to be resolved with value other than itself '{0}'", + val)); + } + else { + this.$$resolve(val); + } + }, $$resolve: function(val) { diff --git a/test/ng/qSpec.js b/test/ng/qSpec.js index 0f6e93af58be..801a52789d67 100644 --- a/test/ng/qSpec.js +++ b/test/ng/qSpec.js @@ -815,6 +815,20 @@ describe('q', function() { }); + it('should complain if promise fulfilled with itself', function() { + var resolveSpy = jasmine.createSpy('resolve'); + var rejectSpy = jasmine.createSpy('reject'); + promise.then(resolveSpy, rejectSpy); + deferred.resolve(deferred.promise); + mockNextTick.flush(); + + expect(resolveSpy).not.toHaveBeenCalled(); + expect(rejectSpy).toHaveBeenCalled(); + expect(rejectSpy.calls[0].args[0].message). + toMatch(/\[\$q\:qcycle\] Expected promise to be resolved with value other than itself/); + }); + + it('should do nothing if a promise was previously resolved', function() { promise.then(success(), error()); expect(logStr()).toBe(''); From c5a3be46021aefdc3ab890ff5320ba864dc292d3 Mon Sep 17 00:00:00 2001 From: Caitlin Potter Date: Tue, 26 Aug 2014 16:55:15 -0400 Subject: [PATCH 006/180] docs($q): correct @ngdoc annotations for methods of $q Closes #8782 Closes #8784 --- src/ng/q.js | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/ng/q.js b/src/ng/q.js index 1f4d79b89cbb..5d9dea239254 100644 --- a/src/ng/q.js +++ b/src/ng/q.js @@ -250,9 +250,10 @@ function qFactory(nextTick, exceptionHandler) { } /** - * @ngdoc + * @ngdoc method * @name ng.$q#defer - * @methodOf ng.$q + * @kind function + * * @description * Creates a `Deferred` object which represents a task which will finish in the future. * @@ -402,9 +403,10 @@ function qFactory(nextTick, exceptionHandler) { }; /** - * @ngdoc - * @name ng.$q#reject - * @methodOf ng.$q + * @ngdoc method + * @name $q#reject + * @kind function + * * @description * Creates a promise that is resolved as rejected with the specified `reason`. This api should be * used to forward rejection in a chain of promises. If you are dealing with the last promise in @@ -471,9 +473,10 @@ function qFactory(nextTick, exceptionHandler) { }; /** - * @ngdoc - * @name ng.$q#when - * @methodOf ng.$q + * @ngdoc method + * @name $q#when + * @kind function + * * @description * Wraps an object that might be a value or a (3rd party) then-able promise into a $q promise. * This is useful when you are dealing with an object that might or might not be a promise, or if @@ -491,9 +494,10 @@ function qFactory(nextTick, exceptionHandler) { }; /** - * @ngdoc - * @name ng.$q#all - * @methodOf ng.$q + * @ngdoc method + * @name $q#all + * @kind function + * * @description * Combines multiple promises into a single promise that is resolved when all of the input * promises are resolved. From 32cb1914735c69ab58f51df1085105bde1efa12f Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Tue, 26 Aug 2014 13:47:34 -0700 Subject: [PATCH 007/180] chore(tests): use jquery again in e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jQuery was not included in e2e tests, but we did not notice it as Angular fell back to jqlite… --- docs/gulpfile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gulpfile.js b/docs/gulpfile.js index ea518d8e0d21..e90cf872ca6f 100644 --- a/docs/gulpfile.js +++ b/docs/gulpfile.js @@ -42,7 +42,7 @@ gulp.task('assets', ['bower'], function() { copyComponent('open-sans-fontface'), copyComponent('lunr.js','/*.js'), copyComponent('google-code-prettify'), - copyComponent('jquery', '/jquery.*'), + copyComponent('jquery', '/dist/*.js'), copyComponent('marked', '/**/*.js', '../node_modules', 'package.json') ); }); From 331d5452039fabf7784eb4fbb2ddc718594aa847 Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Mon, 25 Aug 2014 11:09:58 -0700 Subject: [PATCH 008/180] feat(filter): allow to define the timezone for formatting dates Angular used to always use the browser timezone for `dateFilter`. An additional parameter was added to allow to use `UTC` timezone instead. Related to #8447. --- src/ng/filter/filters.js | 8 +++++++- test/ng/filter/filtersSpec.js | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/ng/filter/filters.js b/src/ng/filter/filters.js index 4b028dd661e2..65ea0e1ab0f4 100644 --- a/src/ng/filter/filters.js +++ b/src/ng/filter/filters.js @@ -362,6 +362,8 @@ var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZEw']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d * specified in the string input, the time is considered to be in the local timezone. * @param {string=} format Formatting rules (see Description). If not specified, * `mediumDate` is used. + * @param {string=} timezone Timezone to be used for formatting. Right now, only `'UTC'` is supported. + * If not specified, the timezone of the browser will be used. * @returns {string} Formatted string or the input if input is not recognized as date/millis. * * @example @@ -421,7 +423,7 @@ function dateFilter($locale) { } - return function(date, format) { + return function(date, format, timezone) { var text = '', parts = [], fn, match; @@ -451,6 +453,10 @@ function dateFilter($locale) { } } + if (timezone && timezone === 'UTC') { + date = new Date(date.getTime()); + date.setMinutes(date.getMinutes() + date.getTimezoneOffset()); + } forEach(parts, function(value){ fn = DATE_FORMATS[value]; text += fn ? fn(date, $locale.DATETIME_FORMATS) diff --git a/test/ng/filter/filtersSpec.js b/test/ng/filter/filtersSpec.js index af0cdba6d96d..7d2acef42601 100644 --- a/test/ng/filter/filtersSpec.js +++ b/test/ng/filter/filtersSpec.js @@ -390,5 +390,10 @@ describe('filters', function() { expect(date('2003-09-10T13:02:03.12Z', format)).toEqual('2003-09-' + localDay + ' 03'); expect(date('2003-09-10T13:02:03.1Z', format)).toEqual('2003-09-' + localDay + ' 03'); }); + + it('should use UTC if the timezone is set to "UTC"', function() { + expect(date(new Date(2003, 8, 10, 3, 2, 4), 'yyyy-MM-dd HH-mm-ss')).toEqual('2003-09-10 03-02-04'); + expect(date(new Date(Date.UTC(2003, 8, 10, 3, 2, 4)), 'yyyy-MM-dd HH-mm-ss', 'UTC')).toEqual('2003-09-10 03-02-04'); + }); }); }); From 0c78edc182076f1ce00306f8543cc89fda8db289 Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Tue, 26 Aug 2014 10:38:28 -0700 Subject: [PATCH 009/180] fix(input): use year 1970 instead of 1900 for `input[time]` BREAKING CHANGE: According to the HTML5 spec `input[time]` should create dates based on the year 1970 (used to be based on the year 1900). Related to #8447. --- src/ng/directive/input.js | 6 +++--- test/ng/directive/inputSpec.js | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index c2cf92332081..45f41bd39996 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -282,7 +282,7 @@ var inputType = { * Input with time validation and transformation. In browsers that do not yet support * the HTML5 date input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 * local time format (HH:mm), for example: `14:57`. Model must be a Date object. This binding will always output a - * Date object to the model of January 1, 1900, or local date `new Date(0, 0, 1, HH, mm)`. + * Date object to the model of January 1, 1970, or local date `new Date(1970, 0, 1, HH, mm)`. * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. @@ -303,7 +303,7 @@ var inputType = {
@@ -1042,7 +1042,7 @@ function createDateParser(regexp, mapping) { if(parts) { parts.shift(); - map = { yyyy: 0, MM: 1, dd: 1, HH: 0, mm: 0 }; + map = { yyyy: 1970, MM: 1, dd: 1, HH: 0, mm: 0 }; forEach(parts, function(part, index) { if(index < mapping.length) { diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index a569dc5f3e6a..37b35dfc1414 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -1947,7 +1947,7 @@ describe('input', function() { compileInput(''); scope.$apply(function (){ - scope.threeFortyOnePm = new Date(0, 0, 1, 15, 41); + scope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41); }); expect(inputElm.val()).toBe('15:41'); @@ -1957,7 +1957,7 @@ describe('input', function() { compileInput(''); scope.$apply(function (){ - scope.breakMe = new Date(0, 0, 1, 16, 25); + scope.breakMe = new Date(1970, 0, 1, 16, 25); }); expect(inputElm.val()).toBe('16:25'); @@ -2023,7 +2023,7 @@ describe('input', function() { it('should validate', function (){ changeInputValueTo('23:02'); expect(inputElm).toBeValid(); - expect(+scope.value).toBe(+new Date(0, 0, 1, 23, 2)); + expect(+scope.value).toBe(+new Date(1970, 0, 1, 23, 2)); expect(scope.form.alias.$error.min).toBeFalsy(); }); }); @@ -2043,7 +2043,7 @@ describe('input', function() { it('should validate', function() { changeInputValueTo('05:30'); expect(inputElm).toBeValid(); - expect(+scope.value).toBe(+new Date(0, 0, 1, 5, 30)); + expect(+scope.value).toBe(+new Date(1970, 0, 1, 5, 30)); expect(scope.form.alias.$error.max).toBeFalsy(); }); }); From eecc1301c4748e7eedb5469448d0ca030341f774 Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Mon, 25 Aug 2014 11:10:59 -0700 Subject: [PATCH 010/180] feat(input): allow to define the timezone for parsing dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Angular used to always use the browser timezone when parsing `input[date]`, `input[time]`, … The timezone can now be changed to `UTC` via `ngModelOptions`. Closes #8447. --- src/ng/directive/input.js | 27 +++++++++++++-- test/ng/directive/inputSpec.js | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 45f41bd39996..b6a267b2f94c 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -113,6 +113,9 @@ var inputType = { * modern browsers do not yet support this input type, it is important to provide cues to users on the * expected input format via a placeholder or label. The model must always be a Date object. * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a @@ -198,6 +201,9 @@ var inputType = { * the HTML5 date input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 * local datetime format (yyyy-MM-ddTHH:mm), for example: `2010-12-28T14:57`. The model must be a Date object. * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a @@ -284,6 +290,9 @@ var inputType = { * local time format (HH:mm), for example: `14:57`. Model must be a Date object. This binding will always output a * Date object to the model of January 1, 1970, or local date `new Date(1970, 0, 1, HH, mm)`. * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a @@ -369,6 +378,9 @@ var inputType = { * the HTML5 week input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 * week format (yyyy-W##), for example: `2013-W02`. The model must always be a Date object. * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a @@ -453,6 +465,9 @@ var inputType = { * month format (yyyy-MM), for example: `2009-01`. The model must always be a Date object. In the event the model is * not set to the first of the month, the first of that model's month is assumed. * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be @@ -1061,6 +1076,7 @@ function createDateParser(regexp, mapping) { function createDateInputType(type, regexp, parseDate, format) { return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { textInputType(scope, element, attr, ctrl, $sniffer, $browser); + var timezone = ctrl && ctrl.$options && ctrl.$options.timezone; ctrl.$parsers.push(function(value) { if(ctrl.$isEmpty(value)) { @@ -1070,7 +1086,11 @@ function createDateInputType(type, regexp, parseDate, format) { if(regexp.test(value)) { ctrl.$setValidity(type, true); - return parseDate(value); + var parsedDate = parseDate(value); + if (timezone === 'UTC') { + parsedDate.setMinutes(parsedDate.getMinutes() - parsedDate.getTimezoneOffset()); + } + return parsedDate; } ctrl.$setValidity(type, false); @@ -1079,7 +1099,7 @@ function createDateInputType(type, regexp, parseDate, format) { ctrl.$formatters.push(function(value) { if(isDate(value)) { - return $filter('date')(value, format); + return $filter('date')(value, format, timezone); } return ''; }); @@ -2604,6 +2624,9 @@ var ngValueDirective = function() { * `ngModelOptions="{ updateOn: 'default blur', debounce: {'default': 500, 'blur': 0} }"` * - `getterSetter`: boolean value which determines whether or not to treat functions bound to `ngModel` as getters/setters. + * - `timezone`: Defines the timezone to be used to read/write the `Date` instance in the model for + * ``, ``, ... . Right now, the only supported value is `'UTC'`, + * otherwise the default timezone of the browser will be used. * * @example diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 37b35dfc1414..a35b85b081cc 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -1627,6 +1627,18 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + it('should use UTC if specified in the options', function() { + compileInput(''); + + changeInputValueTo('2013-07'); + expect(+scope.value).toBe(Date.UTC(2013, 6, 1)); + + scope.$apply(function() { + scope.value = new Date(Date.UTC(2014, 6, 1)); + }); + expect(inputElm.val()).toBe('2014-07'); + }); + describe('min', function (){ beforeEach(function (){ @@ -1746,6 +1758,18 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + it('should use UTC if specified in the options', function() { + compileInput(''); + + changeInputValueTo('2013-W03'); + expect(+scope.value).toBe(Date.UTC(2013, 0, 17)); + + scope.$apply(function() { + scope.value = new Date(Date.UTC(2014, 0, 17)); + }); + expect(inputElm.val()).toBe('2014-W03'); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -1863,6 +1887,18 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + it('should use UTC if specified in the options', function() { + compileInput(''); + + changeInputValueTo('2000-01-01T01:02'); + expect(+scope.value).toBe(Date.UTC(2000, 0, 1, 1, 2)); + + scope.$apply(function() { + scope.value = new Date(Date.UTC(2001, 0, 1, 1, 2)); + }); + expect(inputElm.val()).toBe('2001-01-01T01:02'); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -2008,6 +2044,18 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + it('should use UTC if specified in the options', function() { + compileInput(''); + + changeInputValueTo('23:02'); + expect(+scope.value).toBe(Date.UTC(1970, 0, 1, 23, 2)); + + scope.$apply(function() { + scope.value = new Date(Date.UTC(1971, 0, 1, 23, 2)); + }); + expect(inputElm.val()).toBe('23:02'); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -2153,6 +2201,18 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + it('should use UTC if specified in the options', function() { + compileInput(''); + + changeInputValueTo('2000-01-01'); + expect(+scope.value).toBe(Date.UTC(2000, 0, 1)); + + scope.$apply(function() { + scope.value = new Date(Date.UTC(2001, 0, 1)); + }); + expect(inputElm.val()).toBe('2001-01-01'); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); From afe7bfc909fdbeee6431b0910cf292dcf04a2b48 Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Tue, 26 Aug 2014 13:15:28 -0700 Subject: [PATCH 011/180] fix(input): allow to use seconds in `input[time]` and `input[datetime-local]` The HTML5 spec allows to use seconds for `input[time]` and `input[datetime-local]`, even though they are not displayed by all browsers. Related to #8447. --- src/ng/directive/input.js | 53 +++++++------- test/ng/directive/inputSpec.js | 128 +++++++++++++++++++++------------ 2 files changed, 109 insertions(+), 72 deletions(-) diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index b6a267b2f94c..630f4c15fc7c 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -12,10 +12,10 @@ var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\ var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/; var DATE_REGEXP = /^(\d{4})-(\d{2})-(\d{2})$/; -var DATETIMELOCAL_REGEXP = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)$/; +var DATETIMELOCAL_REGEXP = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d))?$/; var WEEK_REGEXP = /^(\d{4})-W(\d\d)$/; var MONTH_REGEXP = /^(\d{4})-(\d\d)$/; -var TIME_REGEXP = /^(\d\d):(\d\d)$/; +var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d))?$/; var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; var inputType = { @@ -199,7 +199,7 @@ var inputType = { * @description * Input with datetime validation and transformation. In browsers that do not yet support * the HTML5 date input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 - * local datetime format (yyyy-MM-ddTHH:mm), for example: `2010-12-28T14:57`. The model must be a Date object. + * local datetime format (yyyy-MM-ddTHH:mm:ss), for example: `2010-12-28T14:57:00`. The model must be a Date object. * * The timezone to be used to read/write the `Date` instance in the model can be defined using * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. @@ -207,9 +207,9 @@ var inputType = { * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a - * valid ISO datetime format (yyyy-MM-ddTHH:mm). + * valid ISO datetime format (yyyy-MM-ddTHH:mm:ss). * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be - * a valid ISO datetime format (yyyy-MM-ddTHH:mm). + * a valid ISO datetime format (yyyy-MM-ddTHH:mm:ss). * @param {string=} required Sets `required` validation error key if the value is not entered. * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of @@ -229,12 +229,12 @@ var inputType = { Pick a date between in 2013: + placeholder="yyyy-MM-ddTHH:mm:ss" min="2001-01-01T00:00:00" max="2013-12-31T00:00:00" required /> Required! Not a valid date! - value = {{value | date: "yyyy-MM-ddTHH:mm"}}
+ value = {{value | date: "yyyy-MM-ddTHH:mm:ss"}}
myForm.input.$valid = {{myForm.input.$valid}}
myForm.input.$error = {{myForm.input.$error}}
myForm.$valid = {{myForm.$valid}}
@@ -242,7 +242,7 @@ var inputType = { - var value = element(by.binding('value | date: "yyyy-MM-ddTHH:mm"')); + var value = element(by.binding('value | date: "yyyy-MM-ddTHH:mm:ss"')); var valid = element(by.binding('myForm.input.$valid')); var input = element(by.model('value')); @@ -258,7 +258,7 @@ var inputType = { } it('should initialize to model', function() { - expect(value.getText()).toContain('2010-12-28T14:57'); + expect(value.getText()).toContain('2010-12-28T14:57:00'); expect(valid.getText()).toContain('myForm.input.$valid = true'); }); @@ -269,7 +269,7 @@ var inputType = { }); it('should be invalid if over max', function() { - setInput('2015-01-01T23:59'); + setInput('2015-01-01T23:59:00'); expect(value.getText()).toContain(''); expect(valid.getText()).toContain('myForm.input.$valid = false'); }); @@ -277,8 +277,8 @@ var inputType = { */ 'datetime-local': createDateInputType('datetimelocal', DATETIMELOCAL_REGEXP, - createDateParser(DATETIMELOCAL_REGEXP, ['yyyy', 'MM', 'dd', 'HH', 'mm']), - 'yyyy-MM-ddTHH:mm'), + createDateParser(DATETIMELOCAL_REGEXP, ['yyyy', 'MM', 'dd', 'HH', 'mm', 'ss']), + 'yyyy-MM-ddTHH:mm:ss'), /** * @ngdoc input @@ -287,8 +287,8 @@ var inputType = { * @description * Input with time validation and transformation. In browsers that do not yet support * the HTML5 date input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 - * local time format (HH:mm), for example: `14:57`. Model must be a Date object. This binding will always output a - * Date object to the model of January 1, 1970, or local date `new Date(1970, 0, 1, HH, mm)`. + * local time format (HH:mm:ss), for example: `14:57:00`. Model must be a Date object. This binding will always output a + * Date object to the model of January 1, 1970, or local date `new Date(1970, 0, 1, HH, mm, ss)`. * * The timezone to be used to read/write the `Date` instance in the model can be defined using * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. @@ -296,9 +296,9 @@ var inputType = { * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a - * valid ISO time format (HH:mm). + * valid ISO time format (HH:mm:ss). * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be a - * valid ISO time format (HH:mm). + * valid ISO time format (HH:mm:ss). * @param {string=} required Sets `required` validation error key if the value is not entered. * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of @@ -312,18 +312,18 @@ var inputType = {
Pick a between 8am and 5pm: + placeholder="HH:mm:ss" min="08:00:00" max="17:00:00" required /> Required! Not a valid date! - value = {{value | date: "HH:mm"}}
+ value = {{value | date: "HH:mm:ss"}}
myForm.input.$valid = {{myForm.input.$valid}}
myForm.input.$error = {{myForm.input.$error}}
myForm.$valid = {{myForm.$valid}}
@@ -331,7 +331,7 @@ var inputType = {
- var value = element(by.binding('value | date: "HH:mm"')); + var value = element(by.binding('value | date: "HH:mm:ss"')); var valid = element(by.binding('myForm.input.$valid')); var input = element(by.model('value')); @@ -347,7 +347,7 @@ var inputType = { } it('should initialize to model', function() { - expect(value.getText()).toContain('14:57'); + expect(value.getText()).toContain('14:57:00'); expect(valid.getText()).toContain('myForm.input.$valid = true'); }); @@ -358,7 +358,7 @@ var inputType = { }); it('should be invalid if over max', function() { - setInput('23:59'); + setInput('23:59:00'); expect(value.getText()).toContain(''); expect(valid.getText()).toContain('myForm.input.$valid = false'); }); @@ -366,8 +366,8 @@ var inputType = { */ 'time': createDateInputType('time', TIME_REGEXP, - createDateParser(TIME_REGEXP, ['HH', 'mm']), - 'HH:mm'), + createDateParser(TIME_REGEXP, ['HH', 'mm', 'ss']), + 'HH:mm:ss'), /** * @ngdoc input @@ -1057,15 +1057,14 @@ function createDateParser(regexp, mapping) { if(parts) { parts.shift(); - map = { yyyy: 1970, MM: 1, dd: 1, HH: 0, mm: 0 }; + map = { yyyy: 1970, MM: 1, dd: 1, HH: 0, mm: 0, ss: 0 }; forEach(parts, function(part, index) { if(index < mapping.length) { map[mapping[index]] = +part; } }); - - return new Date(map.yyyy, map.MM - 1, map.dd, map.HH, map.mm); + return new Date(map.yyyy, map.MM - 1, map.dd, map.HH, map.mm, map.ss || 0); } } diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index a35b85b081cc..abd8dc11e4c2 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -1816,7 +1816,7 @@ describe('input', function() { compileInput(''); scope.$apply(function(){ - scope.lunchtime = '2013-12-16T11:30'; + scope.lunchtime = '2013-12-16T11:30:00'; }); expect(inputElm.val()).toBe(''); @@ -1826,20 +1826,20 @@ describe('input', function() { compileInput(''); scope.$apply(function (){ - scope.tenSecondsToNextYear = new Date(2013, 11, 31, 23, 59); + scope.tenSecondsToNextYear = new Date(2013, 11, 31, 23, 59, 0); }); - expect(inputElm.val()).toBe('2013-12-31T23:59'); + expect(inputElm.val()).toBe('2013-12-31T23:59:00'); }); it('should set the model undefined if the view is invalid', function (){ compileInput(''); scope.$apply(function (){ - scope.breakMe = new Date(2009, 0, 6, 16, 25); + scope.breakMe = new Date(2009, 0, 6, 16, 25, 0); }); - expect(inputElm.val()).toBe('2009-01-06T16:25'); + expect(inputElm.val()).toBe('2009-01-06T16:25:00'); try { //set to text for browsers with datetime-local validation. @@ -1891,62 +1891,81 @@ describe('input', function() { compileInput(''); changeInputValueTo('2000-01-01T01:02'); - expect(+scope.value).toBe(Date.UTC(2000, 0, 1, 1, 2)); + expect(+scope.value).toBe(Date.UTC(2000, 0, 1, 1, 2, 0)); scope.$apply(function() { - scope.value = new Date(Date.UTC(2001, 0, 1, 1, 2)); + scope.value = new Date(Date.UTC(2001, 0, 1, 1, 2, 0)); }); - expect(inputElm.val()).toBe('2001-01-01T01:02'); + expect(inputElm.val()).toBe('2001-01-01T01:02:00'); + }); + + it('should allow to specify the seconds', function() { + compileInput(''); + + changeInputValueTo('2000-01-01T01:02:03'); + expect(+scope.value).toBe(+new Date(2000, 0, 1, 1, 2, 3)); + + scope.$apply(function() { + scope.value = new Date(2001, 0, 1, 1, 2, 3); + }); + expect(inputElm.val()).toBe('2001-01-01T01:02:03'); + }); + + it('should allow to skip the seconds', function() { + compileInput(''); + + changeInputValueTo('2000-01-01T01:02'); + expect(+scope.value).toBe(+new Date(2000, 0, 1, 1, 2, 0)); }); describe('min', function (){ beforeEach(function (){ - compileInput(''); + compileInput(''); }); it('should invalidate', function (){ - changeInputValueTo('1999-12-31T01:02'); + changeInputValueTo('1999-12-31T01:02:00'); expect(inputElm).toBeInvalid(); expect(scope.value).toBeFalsy(); expect(scope.form.alias.$error.min).toBeTruthy(); }); it('should validate', function (){ - changeInputValueTo('2000-01-01T23:02'); + changeInputValueTo('2000-01-01T23:02:00'); expect(inputElm).toBeValid(); - expect(+scope.value).toBe(+new Date(2000, 0, 1, 23, 2)); + expect(+scope.value).toBe(+new Date(2000, 0, 1, 23, 2, 0)); expect(scope.form.alias.$error.min).toBeFalsy(); }); }); describe('max', function (){ beforeEach(function (){ - compileInput(''); + compileInput(''); }); it('should invalidate', function (){ - changeInputValueTo('2019-12-31T01:02'); + changeInputValueTo('2019-12-31T01:02:00'); expect(inputElm).toBeInvalid(); expect(scope.value).toBeFalsy(); expect(scope.form.alias.$error.max).toBeTruthy(); }); it('should validate', function() { - changeInputValueTo('2000-01-01T01:02'); + changeInputValueTo('2000-01-01T01:02:00'); expect(inputElm).toBeValid(); - expect(+scope.value).toBe(+new Date(2000, 0, 1, 1, 2)); + expect(+scope.value).toBe(+new Date(2000, 0, 1, 1, 2, 0)); expect(scope.form.alias.$error.max).toBeFalsy(); }); }); it('should validate even if max value changes on-the-fly', function(done) { - scope.max = '2013-01-01T01:02'; + scope.max = '2013-01-01T01:02:00'; compileInput(''); - changeInputValueTo('2014-01-01T12:34'); + changeInputValueTo('2014-01-01T12:34:00'); expect(inputElm).toBeInvalid(); - scope.max = '2001-01-01T01:02'; + scope.max = '2001-01-01T01:02:00'; scope.$digest(function () { expect(inputElm).toBeValid(); done(); @@ -1954,13 +1973,13 @@ describe('input', function() { }); it('should validate even if min value changes on-the-fly', function(done) { - scope.min = '2013-01-01T01:02'; + scope.min = '2013-01-01T01:02:00'; compileInput(''); - changeInputValueTo('2010-01-01T12:34'); + changeInputValueTo('2010-01-01T12:34:00'); expect(inputElm).toBeInvalid(); - scope.min = '2014-01-01T01:02'; + scope.min = '2014-01-01T01:02:00'; scope.$digest(function () { expect(inputElm).toBeValid(); done(); @@ -1973,7 +1992,7 @@ describe('input', function() { compileInput(''); scope.$apply(function(){ - scope.lunchtime = '11:30'; + scope.lunchtime = '11:30:00'; }); expect(inputElm.val()).toBe(''); @@ -1983,20 +2002,20 @@ describe('input', function() { compileInput(''); scope.$apply(function (){ - scope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41); + scope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 0); }); - expect(inputElm.val()).toBe('15:41'); + expect(inputElm.val()).toBe('15:41:00'); }); it('should set the model undefined if the view is invalid', function (){ compileInput(''); scope.$apply(function (){ - scope.breakMe = new Date(1970, 0, 1, 16, 25); + scope.breakMe = new Date(1970, 0, 1, 16, 25, 0); }); - expect(inputElm.val()).toBe('16:25'); + expect(inputElm.val()).toBe('16:25:00'); try { //set to text for browsers with time validation. @@ -2047,63 +2066,82 @@ describe('input', function() { it('should use UTC if specified in the options', function() { compileInput(''); - changeInputValueTo('23:02'); - expect(+scope.value).toBe(Date.UTC(1970, 0, 1, 23, 2)); + changeInputValueTo('23:02:00'); + expect(+scope.value).toBe(Date.UTC(1970, 0, 1, 23, 2, 0)); scope.$apply(function() { - scope.value = new Date(Date.UTC(1971, 0, 1, 23, 2)); + scope.value = new Date(Date.UTC(1971, 0, 1, 23, 2, 0)); }); - expect(inputElm.val()).toBe('23:02'); + expect(inputElm.val()).toBe('23:02:00'); + }); + + it('should allow to specify the seconds', function() { + compileInput(''); + + changeInputValueTo('01:02:03'); + expect(+scope.value).toBe(+new Date(1970, 0, 1, 1, 2, 3)); + + scope.$apply(function() { + scope.value = new Date(1970, 0, 1, 1, 2, 3); + }); + expect(inputElm.val()).toBe('01:02:03'); + }); + + it('should allow to skip the seconds', function() { + compileInput(''); + + changeInputValueTo('01:02'); + expect(+scope.value).toBe(+new Date(1970, 0, 1, 1, 2, 0)); }); describe('min', function (){ beforeEach(function (){ - compileInput(''); + compileInput(''); }); it('should invalidate', function (){ - changeInputValueTo('01:02'); + changeInputValueTo('01:02:00'); expect(inputElm).toBeInvalid(); expect(scope.value).toBeFalsy(); expect(scope.form.alias.$error.min).toBeTruthy(); }); it('should validate', function (){ - changeInputValueTo('23:02'); + changeInputValueTo('23:02:00'); expect(inputElm).toBeValid(); - expect(+scope.value).toBe(+new Date(1970, 0, 1, 23, 2)); + expect(+scope.value).toBe(+new Date(1970, 0, 1, 23, 2, 0)); expect(scope.form.alias.$error.min).toBeFalsy(); }); }); describe('max', function (){ beforeEach(function (){ - compileInput(''); + compileInput(''); }); it('should invalidate', function (){ - changeInputValueTo('23:00'); + changeInputValueTo('23:00:00'); expect(inputElm).toBeInvalid(); expect(scope.value).toBeFalsy(); expect(scope.form.alias.$error.max).toBeTruthy(); }); it('should validate', function() { - changeInputValueTo('05:30'); + changeInputValueTo('05:30:00'); expect(inputElm).toBeValid(); - expect(+scope.value).toBe(+new Date(1970, 0, 1, 5, 30)); + expect(+scope.value).toBe(+new Date(1970, 0, 1, 5, 30, 0)); expect(scope.form.alias.$error.max).toBeFalsy(); }); }); it('should validate even if max value changes on-the-fly', function(done) { - scope.max = '21:02'; + scope.max = '21:02:00'; compileInput(''); - changeInputValueTo('22:34'); + changeInputValueTo('22:34:00'); expect(inputElm).toBeInvalid(); - scope.max = '12:34'; + scope.max = '12:34:00'; scope.$digest(function () { expect(inputElm).toBeValid(); done(); @@ -2111,13 +2149,13 @@ describe('input', function() { }); it('should validate even if min value changes on-the-fly', function(done) { - scope.min = '08:45'; + scope.min = '08:45:00'; compileInput(''); - changeInputValueTo('06:15'); + changeInputValueTo('06:15:00'); expect(inputElm).toBeInvalid(); - scope.min = '13:50'; + scope.min = '13:50:00'; scope.$digest(function () { expect(inputElm).toBeValid(); done(); From c7921e7d7e01cebb08d01b9a632676b4624314d1 Mon Sep 17 00:00:00 2001 From: Igor Minar Date: Tue, 26 Aug 2014 09:54:16 -0700 Subject: [PATCH 012/180] refactor(nodeName_): remove IE8 specific branch --- src/Angular.js | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/Angular.js b/src/Angular.js index 6b7bd00c28a7..8949ca381a1b 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -610,18 +610,8 @@ function makeMap(str) { } -if (msie < 9) { - nodeName_ = function(element) { - element = element.nodeName ? element : element[0]; - return lowercase( - (element.scopeName && element.scopeName != 'HTML') - ? element.scopeName + ':' + element.nodeName : element.nodeName - ); - }; -} else { - nodeName_ = function(element) { - return lowercase(element.nodeName ? element.nodeName : element[0].nodeName); - }; +function nodeName_(element) { + return lowercase(element.nodeName ? element.nodeName : element[0].nodeName); } From c5671f463a851c56b2ef6d1296ba509fe3a18605 Mon Sep 17 00:00:00 2001 From: Igor Minar Date: Tue, 26 Aug 2014 09:55:48 -0700 Subject: [PATCH 013/180] perf(nodeName_): simplify the code and reduce the number of DOM calls --- src/Angular.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Angular.js b/src/Angular.js index 8949ca381a1b..841b5d30b528 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -611,7 +611,7 @@ function makeMap(str) { function nodeName_(element) { - return lowercase(element.nodeName ? element.nodeName : element[0].nodeName); + return lowercase(element.nodeName || element[0].nodeName); } From 718d5cf90ff3a96a602cc8ba83c83f284b7a2158 Mon Sep 17 00:00:00 2001 From: Igor Minar Date: Tue, 26 Aug 2014 13:40:44 -0700 Subject: [PATCH 014/180] refactor(hashKey): don't generate memory garbage we now store both the object type and the id as the hashkey and return it for all objects. for primitives we still have to do string concatination because we can't use expandos on them to store the hashkey --- src/apis.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/apis.js b/src/apis.js index 3b0a22ab3191..36e0a103ff82 100644 --- a/src/apis.js +++ b/src/apis.js @@ -14,21 +14,23 @@ * The resulting string key is in 'type:hashKey' format. */ function hashKey(obj, nextUidFn) { - var objType = typeof obj, - key; + var key = obj && obj.$$hashKey; - if (objType == 'function' || (objType == 'object' && obj !== null)) { - if (typeof (key = obj.$$hashKey) == 'function') { - // must invoke on object to keep the right this + if (key) { + if (typeof key === 'function') { key = obj.$$hashKey(); - } else if (key === undefined) { - key = obj.$$hashKey = (nextUidFn || nextUid)(); } + return key; + } + + var objType = typeof obj; + if (objType == 'function' || (objType == 'object' && obj !== null)) { + key = obj.$$hashKey = objType + ':' + (nextUidFn || nextUid)(); } else { - key = obj; + key = objType + ':' + obj; } - return objType + ':' + key; + return key; } /** From f0a49cabaf7f5e3a6e4f5067073f534e42eb0e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Tue, 19 Aug 2014 00:04:59 -0400 Subject: [PATCH 015/180] fix(ngModel): treat undefined parse responses as parse errors With this commit, ngModel will now handle parsing first and then validation afterwards once the parsing is successful. If any parser along the way returns `undefined` then ngModel will break the chain of parsing and register a a parser error represented by the type of input that is being collected (e.g. number, date, datetime, url, etc...). If a parser fails for a standard text input field then an error of `parse` will be placed on `model.$error`. BREAKING CHANGE Any parser code from before that returned an `undefined` value (or nothing at all) will now cause a parser failure. When this occurs none of the validators present in `$validators` will run until the parser error is gone. --- src/ng/directive/form.js | 11 +- src/ng/directive/input.js | 166 +++++++++++++--------------- test/ng/directive/inputSpec.js | 191 +++++++++++++++++++++++++++++++-- 3 files changed, 264 insertions(+), 104 deletions(-) diff --git a/src/ng/directive/form.js b/src/ng/directive/form.js index 43041c47e708..a499e1fc8119 100644 --- a/src/ng/directive/form.js +++ b/src/ng/directive/form.js @@ -7,7 +7,8 @@ var nullFormCtrl = { $setValidity: noop, $setDirty: noop, $setPristine: noop, - $setSubmitted: noop + $setSubmitted: noop, + $$clearControlValidity: noop }, SUBMITTED_CLASS = 'ng-submitted'; @@ -144,11 +145,15 @@ function FormController(element, attrs, $scope, $animate) { if (control.$name && form[control.$name] === control) { delete form[control.$name]; } + + form.$$clearControlValidity(control); + arrayRemove(controls, control); + }; + + form.$$clearControlValidity = function(control) { forEach(errors, function(queue, validationToken) { form.$setValidity(validationToken, true, control); }); - - arrayRemove(controls, control); }; /** diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 630f4c15fc7c..8a8d4b870ceb 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -18,6 +18,7 @@ var MONTH_REGEXP = /^(\d{4})-(\d\d)$/; var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d))?$/; var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; +var $ngModelMinErr = new minErr('ngModel'); var inputType = { /** @@ -885,13 +886,6 @@ var inputType = { 'file': noop }; -// A helper function to call $setValidity and return the value / undefined, -// a pattern that is repeated a lot in the input validation logic. -function validate(ctrl, validatorName, validity, value){ - ctrl.$setValidity(validatorName, validity); - return validity ? value : undefined; -} - function testFlags(validity, flags) { var i, flag; if (flags) { @@ -905,25 +899,6 @@ function testFlags(validity, flags) { return false; } -// Pass validity so that behaviour can be mocked easier. -function addNativeHtml5Validators(ctrl, validatorName, badFlags, ignoreFlags, validity) { - if (isObject(validity)) { - ctrl.$$hasNativeValidators = true; - var validator = function(value) { - // Don't overwrite previous validation, don't consider valueMissing to apply (ng-required can - // perform the required validation) - if (!ctrl.$error[validatorName] && - !testFlags(validity, ignoreFlags) && - testFlags(validity, badFlags)) { - ctrl.$setValidity(validatorName, false); - return; - } - return value; - }; - ctrl.$parsers.push(validator); - } -} - function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { var validity = element.prop(VALIDITY_STATE_PROPERTY); var placeholder = element[0].placeholder, noevent = {}; @@ -1074,25 +1049,20 @@ function createDateParser(regexp, mapping) { function createDateInputType(type, regexp, parseDate, format) { return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { + badInputChecker(scope, element, attr, ctrl); textInputType(scope, element, attr, ctrl, $sniffer, $browser); var timezone = ctrl && ctrl.$options && ctrl.$options.timezone; + ctrl.$$parserName = type; ctrl.$parsers.push(function(value) { - if(ctrl.$isEmpty(value)) { - ctrl.$setValidity(type, true); - return null; - } - - if(regexp.test(value)) { - ctrl.$setValidity(type, true); + if (ctrl.$isEmpty(value)) return null; + if (regexp.test(value)) { var parsedDate = parseDate(value); if (timezone === 'UTC') { parsedDate.setMinutes(parsedDate.getMinutes() - parsedDate.getTimezoneOffset()); } return parsedDate; } - - ctrl.$setValidity(type, false); return undefined; }); @@ -1104,81 +1074,69 @@ function createDateInputType(type, regexp, parseDate, format) { }); if(attr.min) { - var minValidator = function(value) { - var valid = ctrl.$isEmpty(value) || - (parseDate(value) >= parseDate(attr.min)); - ctrl.$setValidity('min', valid); - return valid ? value : undefined; - }; - - ctrl.$parsers.push(minValidator); - ctrl.$formatters.push(minValidator); + ctrl.$validators.min = function(value) { + return ctrl.$isEmpty(value) || isUndefined(attr.min) || parseDate(value) >= parseDate(attr.min); + }; } if(attr.max) { - var maxValidator = function(value) { - var valid = ctrl.$isEmpty(value) || - (parseDate(value) <= parseDate(attr.max)); - ctrl.$setValidity('max', valid); - return valid ? value : undefined; - }; - - ctrl.$parsers.push(maxValidator); - ctrl.$formatters.push(maxValidator); + ctrl.$validators.max = function(value) { + return ctrl.$isEmpty(value) || isUndefined(attr.max) || parseDate(value) <= parseDate(attr.max); + }; } }; } -var numberBadFlags = ['badInput']; +function badInputChecker(scope, element, attr, ctrl) { + var node = element[0]; + var nativeValidation = ctrl.$$hasNativeValidators = isObject(node.validity); + if (nativeValidation) { + ctrl.$parsers.push(function(value) { + var validity = element.prop(VALIDITY_STATE_PROPERTY) || {}; + return validity.badInput || validity.typeMismatch ? undefined : value; + }); + } +} function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); textInputType(scope, element, attr, ctrl, $sniffer, $browser); + ctrl.$$parserName = 'number'; ctrl.$parsers.push(function(value) { - var empty = ctrl.$isEmpty(value); - if (empty || NUMBER_REGEXP.test(value)) { - ctrl.$setValidity('number', true); - return value === '' ? null : (empty ? value : parseFloat(value)); - } else { - ctrl.$setValidity('number', false); - return undefined; - } + if(ctrl.$isEmpty(value)) return null; + if(NUMBER_REGEXP.test(value)) return parseFloat(value); + return undefined; }); - addNativeHtml5Validators(ctrl, 'number', numberBadFlags, null, ctrl.$$validityState); - ctrl.$formatters.push(function(value) { - return ctrl.$isEmpty(value) ? '' : '' + value; + if (!ctrl.$isEmpty(value)) { + if (!isNumber(value)) { + throw $ngModelMinErr('numfmt', 'Expected `{0}` to be a number', value); + } + value = value.toString(); + } + return value; }); if (attr.min) { - var minValidator = function(value) { - var min = parseFloat(attr.min); - return validate(ctrl, 'min', ctrl.$isEmpty(value) || value >= min, value); + ctrl.$validators.min = function(value) { + return ctrl.$isEmpty(value) || isUndefined(attr.min) || value >= parseFloat(attr.min); }; - - ctrl.$parsers.push(minValidator); - ctrl.$formatters.push(minValidator); } if (attr.max) { - var maxValidator = function(value) { - var max = parseFloat(attr.max); - return validate(ctrl, 'max', ctrl.$isEmpty(value) || value <= max, value); + ctrl.$validators.max = function(value) { + return ctrl.$isEmpty(value) || isUndefined(attr.max) || value <= parseFloat(attr.max); }; - - ctrl.$parsers.push(maxValidator); - ctrl.$formatters.push(maxValidator); } - - ctrl.$formatters.push(function(value) { - return validate(ctrl, 'number', ctrl.$isEmpty(value) || isNumber(value), value); - }); } function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); textInputType(scope, element, attr, ctrl, $sniffer, $browser); + ctrl.$$parserName = 'url'; ctrl.$validators.url = function(modelValue, viewValue) { var value = modelValue || viewValue; return ctrl.$isEmpty(value) || URL_REGEXP.test(value); @@ -1186,8 +1144,10 @@ function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { } function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); textInputType(scope, element, attr, ctrl, $sniffer, $browser); + ctrl.$$parserName = 'email'; ctrl.$validators.email = function(modelValue, viewValue) { var value = modelValue || viewValue; return ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value); @@ -1223,7 +1183,7 @@ function parseConstantExpr($parse, context, name, expression, fallback) { if (isDefined(expression)) { parseFn = $parse(expression); if (!parseFn.constant) { - throw new minErr('ngModel')('constexpr', 'Expected constant expression for `{0}`, but saw ' + + throw minErr('ngModel')('constexpr', 'Expected constant expression for `{0}`, but saw ' + '`{1}`.', name, expression); } return parseFn(context); @@ -1598,7 +1558,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ ctrl = this; if (!ngModelSet) { - throw minErr('ngModel')('nonassign', "Expression '{0}' is non-assignable. Element: {1}", + throw $ngModelMinErr('nonassign', "Expression '{0}' is non-assignable. Element: {1}", $attr.ngModel, startingTag($element)); } @@ -1663,6 +1623,19 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ $animate.addClass($element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); } + this.$$clearValidity = function() { + forEach(ctrl.$error, function(val, key) { + var validationKey = snake_case(key, '-'); + $animate.removeClass($element, VALID_CLASS + validationKey); + $animate.removeClass($element, INVALID_CLASS + validationKey); + }); + + invalidCount = 0; + $error = ctrl.$error = {}; + + parentForm.$$clearControlValidity(ctrl); + }; + /** * @ngdoc method * @name ngModel.NgModelController#$setValidity @@ -1694,7 +1667,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ ctrl.$valid = true; ctrl.$invalid = false; } - } else { + } else if(!$error[validationErrorKey]) { toggleValidCss(false); ctrl.$invalid = true; ctrl.$valid = false; @@ -1883,16 +1856,27 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ parentForm.$setDirty(); } - var modelValue = viewValue; - forEach(ctrl.$parsers, function(fn) { - modelValue = fn(modelValue); - }); + var hasBadInput, modelValue = viewValue; + for(var i = 0; i < ctrl.$parsers.length; i++) { + modelValue = ctrl.$parsers[i](modelValue); + if(isUndefined(modelValue)) { + hasBadInput = true; + break; + } + } - if (ctrl.$modelValue !== modelValue && - (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) { + var parserName = ctrl.$$parserName || 'parse'; + if (hasBadInput) { + ctrl.$$invalidModelValue = ctrl.$modelValue = undefined; + ctrl.$$clearValidity(); + ctrl.$setValidity(parserName, false); + } else if (ctrl.$modelValue !== modelValue && + (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) { + ctrl.$setValidity(parserName, true); ctrl.$$runValidators(modelValue, viewValue); - ctrl.$$writeModelToScope(); } + + ctrl.$$writeModelToScope(); }; this.$$writeModelToScope = function() { diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index abd8dc11e4c2..b574bd0e1871 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -9,7 +9,8 @@ describe('NgModelController', function() { parentFormCtrl = { $setValidity: jasmine.createSpy('$setValidity'), - $setDirty: jasmine.createSpy('$setDirty') + $setDirty: jasmine.createSpy('$setDirty'), + $$clearControlValidity: noop }; element = jqLite('
'); @@ -223,6 +224,106 @@ describe('NgModelController', function() { expect(ctrl.$dirty).toBe(true); expect(parentFormCtrl.$setDirty).not.toHaveBeenCalled(); }); + + it('should remove all other errors when any parser returns undefined', function() { + var a, b, val = function(val, x) { + return x ? val : x; + }; + + ctrl.$parsers.push(function(v) { return val(v, a); }); + ctrl.$parsers.push(function(v) { return val(v, b); }); + + ctrl.$validators.high = function(value) { + return !isDefined(value) || value > 5; + }; + + ctrl.$validators.even = function(value) { + return !isDefined(value) || value % 2 === 0; + }; + + a = b = true; + + ctrl.$setViewValue('3'); + expect(ctrl.$error).toEqual({ parse: false, high : true, even : true }); + + ctrl.$setViewValue('10'); + expect(ctrl.$error).toEqual({ parse: false, high : false, even : false }); + + a = undefined; + + ctrl.$setViewValue('12'); + expect(ctrl.$error).toEqual({ parse: true }); + + a = true; + b = undefined; + + ctrl.$setViewValue('14'); + expect(ctrl.$error).toEqual({ parse: true }); + + a = undefined; + b = undefined; + + ctrl.$setViewValue('16'); + expect(ctrl.$error).toEqual({ parse: true }); + + a = b = false; //not undefined + + ctrl.$setViewValue('2'); + expect(ctrl.$error).toEqual({ parse: false, high : true, even : false }); + }); + + it('should remove all non-parse-related CSS classes from the form when a parser fails', + inject(function($compile, $rootScope) { + + var element = $compile('
' + + '' + + '
')($rootScope); + var inputElm = element.find('input'); + var ctrl = $rootScope.myForm.myControl; + + var parserIsFailing = false; + ctrl.$parsers.push(function(value) { + return parserIsFailing ? undefined : value; + }); + + ctrl.$validators.alwaysFail = function() { + return false; + }; + + ctrl.$setViewValue('123'); + scope.$digest(); + + expect(element).not.toHaveClass('ng-valid-parse'); + expect(element).toHaveClass('ng-invalid-always-fail'); + + parserIsFailing = true; + ctrl.$setViewValue('12345'); + scope.$digest(); + + expect(element).toHaveClass('ng-invalid-parse'); + expect(element).not.toHaveClass('ng-invalid-always-fail'); + + dealoc(element); + })); + + it('should set the ng-invalid-parse and ng-valid-parse CSS class when parsers fail and pass', function() { + var pass = true; + ctrl.$parsers.push(function(v) { + return pass ? v : undefined; + }); + + var input = element.find('input'); + + ctrl.$setViewValue('1'); + expect(input).toHaveClass('ng-valid-parse'); + expect(input).not.toHaveClass('ng-invalid-parse'); + + pass = undefined; + + ctrl.$setViewValue('2'); + expect(input).not.toHaveClass('ng-valid-parse'); + expect(input).toHaveClass('ng-invalid-parse'); + }); }); @@ -1639,6 +1740,16 @@ describe('input', function() { expect(inputElm.val()).toBe('2014-07'); }); + it('should label parse errors as `month`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('xxx'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.month).toBeTruthy(); + }); describe('min', function (){ beforeEach(function (){ @@ -1770,6 +1881,17 @@ describe('input', function() { expect(inputElm.val()).toBe('2014-W03'); }); + it('should label parse errors as `week`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('yyy'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.week).toBeTruthy(); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -1918,6 +2040,17 @@ describe('input', function() { expect(+scope.value).toBe(+new Date(2000, 0, 1, 1, 2, 0)); }); + it('should label parse errors as `datetimelocal`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('zzz'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.datetimelocal).toBeTruthy(); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -2094,6 +2227,17 @@ describe('input', function() { expect(+scope.value).toBe(+new Date(1970, 0, 1, 1, 2, 0)); }); + it('should label parse errors as `time`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('mmm'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.time).toBeTruthy(); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -2251,6 +2395,17 @@ describe('input', function() { expect(inputElm.val()).toBe('2001-01-01'); }); + it('should label parse errors as `date`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('nnn'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.date).toBeTruthy(); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -2374,13 +2529,17 @@ describe('input', function() { }); - it('should invalidate number if suffering from bad input', function() { + it('should only invalidate the model if suffering from bad input when the data is parsed', function() { compileInput('', { valid: false, badInput: true }); - changeInputValueTo('10a'); + expect(scope.age).toBeUndefined(); + expect(inputElm).toBeValid(); + + changeInputValueTo('this-will-fail-because-of-the-badInput-flag'); + expect(scope.age).toBeUndefined(); expect(inputElm).toBeInvalid(); }); @@ -2400,6 +2559,13 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + it('should throw if the model value is not a number', function() { + expect(function() { + scope.value = 'one'; + compileInput(''); + }).toThrowMinErr('ngModel', 'numfmt', "Expected `one` to be a number"); + }); + describe('min', function() { @@ -2440,7 +2606,7 @@ describe('input', function() { changeInputValueTo('20'); expect(inputElm).toBeInvalid(); - expect(scope.value).toBeFalsy(); + expect(scope.value).toBeUndefined(); expect(scope.form.alias.$error.max).toBeTruthy(); changeInputValueTo('0'); @@ -2914,13 +3080,18 @@ describe('input', function() { }); - it('should set $valid even if model fails other validators', function() { - compileInput(''); - changeInputValueTo('bademail'); + it('should consider bad input as an error before any other errors are considered', function() { + compileInput('', { badInput : true }); + var ctrl = inputElm.controller('ngModel'); + ctrl.$parsers.push(function() { + return undefined; + }); + + changeInputValueTo('abc123'); - expect(inputElm).toHaveClass('ng-valid-required'); - expect(inputElm.controller('ngModel').$error.required).toBe(false); - expect(inputElm).toBeInvalid(); // invalid because of the email validator + expect(ctrl.$error.parse).toBe(true); + expect(inputElm).toHaveClass('ng-invalid-parse'); + expect(inputElm).toBeInvalid(); // invalid because of the number validator }); From 51f807d7b914e329857cd02a05ee4148ea181143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Sat, 19 Jul 2014 11:21:31 -0400 Subject: [PATCH 016/180] feat(ngModel): provide validation API functions for sync and async validations This commit introduces a 2nd validation queue called `$asyncValidators`. Each time a value is processed by the validation pipeline, if all synchronous `$validators` succeed, the value is then passed through the `$asyncValidators` validation queue. These validators should return a promise. Rejection of a validation promise indicates a failed validation. --- src/ng/directive/form.js | 81 +++++++++-- src/ng/directive/input.js | 151 ++++++++++++++++++-- test/ng/directive/formSpec.js | 29 ++++ test/ng/directive/inputSpec.js | 253 ++++++++++++++++++++++++++++++++- 4 files changed, 483 insertions(+), 31 deletions(-) diff --git a/src/ng/directive/form.js b/src/ng/directive/form.js index a499e1fc8119..335ac606582f 100644 --- a/src/ng/directive/form.js +++ b/src/ng/directive/form.js @@ -5,6 +5,7 @@ var nullFormCtrl = { $addControl: noop, $removeControl: noop, $setValidity: noop, + $$setPending: noop, $setDirty: noop, $setPristine: noop, $setSubmitted: noop, @@ -54,8 +55,9 @@ function FormController(element, attrs, $scope, $animate) { var form = this, parentForm = element.parent().controller('form') || nullFormCtrl, invalidCount = 0, // used to easily determine if we are valid - errors = form.$error = {}, - controls = []; + pendingCount = 0, + controls = [], + errors = form.$error = {}; // init state form.$name = attrs.name || attrs.ngForm; @@ -151,9 +153,29 @@ function FormController(element, attrs, $scope, $animate) { }; form.$$clearControlValidity = function(control) { - forEach(errors, function(queue, validationToken) { + forEach(form.$pending, clear); + forEach(errors, clear); + + function clear(queue, validationToken) { form.$setValidity(validationToken, true, control); - }); + } + + parentForm.$$clearControlValidity(form); + }; + + form.$$setPending = function(validationToken, control) { + var pending = form.$pending && form.$pending[validationToken]; + + if (!pending || !includes(pending, control)) { + pendingCount++; + form.$valid = form.$invalid = undefined; + form.$pending = form.$pending || {}; + if (!pending) { + pending = form.$pending[validationToken] = []; + } + pending.push(control); + parentForm.$$setPending(validationToken, form); + } }; /** @@ -167,24 +189,56 @@ function FormController(element, attrs, $scope, $animate) { */ form.$setValidity = function(validationToken, isValid, control) { var queue = errors[validationToken]; + var pendingChange, pending = form.$pending && form.$pending[validationToken]; + + if (pending) { + pendingChange = indexOf(pending, control) >= 0; + if (pendingChange) { + arrayRemove(pending, control); + pendingCount--; + + if (pending.length === 0) { + delete form.$pending[validationToken]; + } + } + } + + var pendingNoMore = form.$pending && pendingCount === 0; + if (pendingNoMore) { + form.$pending = undefined; + } if (isValid) { - if (queue) { - arrayRemove(queue, control); - if (!queue.length) { - invalidCount--; + if (queue || pendingChange) { + if (queue) { + arrayRemove(queue, control); + } + if (!queue || !queue.length) { + if (errors[validationToken]) { + invalidCount--; + } if (!invalidCount) { - toggleValidCss(isValid); - form.$valid = true; - form.$invalid = false; + if (!form.$pending) { + toggleValidCss(isValid); + form.$valid = true; + form.$invalid = false; + } + } else if(pendingNoMore) { + toggleValidCss(false); + form.$valid = false; + form.$invalid = true; } errors[validationToken] = false; toggleValidCss(true, validationToken); parentForm.$setValidity(validationToken, true, form); } } - } else { + if (!form.$pending) { + form.$valid = false; + form.$invalid = true; + } + if (!invalidCount) { toggleValidCss(isValid); } @@ -197,9 +251,6 @@ function FormController(element, attrs, $scope, $animate) { parentForm.$setValidity(validationToken, false, form); } queue.push(control); - - form.$valid = false; - form.$invalid = true; } }; diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 8a8d4b870ceb..911ec30f0cd7 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1386,7 +1386,8 @@ var VALID_CLASS = 'ng-valid', PRISTINE_CLASS = 'ng-pristine', DIRTY_CLASS = 'ng-dirty', UNTOUCHED_CLASS = 'ng-untouched', - TOUCHED_CLASS = 'ng-touched'; + TOUCHED_CLASS = 'ng-touched', + PENDING_CLASS = 'ng-pending'; /** * @ngdoc type @@ -1421,6 +1422,44 @@ var VALID_CLASS = 'ng-valid', * provided with the model value as an argument and must return a true or false value depending * on the response of that validation. * + * ```js + * ngModel.$validators.validCharacters = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * return /[0-9]+/.test(value) && + * /[a-z]+/.test(value) && + * /[A-Z]+/.test(value) && + * /\W+/.test(value); + * }; + * ``` + * + * @property {Object.} $asyncValidators A collection of validations that are expected to + * perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided + * is expected to return a promise when it is run during the model validation process. Once the promise + * is delivered then the validation status will be set to true when fulfilled and false when rejected. + * When the asynchronous validators are trigged, each of the validators will run in parallel and the model + * value will only be updated once all validators have been fulfilled. Also, keep in mind that all + * asynchronous validators will only run once all synchronous validators have passed. + * + * Please note that if $http is used then it is important that the server returns a success HTTP response code + * in order to fulfill the validation and a status level of `4xx` in order to reject the validation. + * + * ```js + * ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * return $http.get('/api/users/' + value). + * then(function() { + * //username exists, this means the validator fails + * return false; + * }, function() { + * //username does not exist, therefore this validation is true + * return true; + * }); + * }; + * ``` + * + * @param {string} name The name of the validator. + * @param {Function} validationFn The validation function that will be run. + * * @property {Array.} $viewChangeListeners Array of functions to execute whenever the * view value has changed. It is called with no arguments, and its return value is ignored. * This can be used in place of additional $watches against the model value. @@ -1433,6 +1472,7 @@ var VALID_CLASS = 'ng-valid', * @property {boolean} $dirty True if user has already interacted with the control. * @property {boolean} $valid True if there is no error. * @property {boolean} $invalid True if at least one error on the control. + * @property {Object.} $pending True if one or more asynchronous validators is still yet to be delivered. * * @description * @@ -1540,6 +1580,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ this.$viewValue = Number.NaN; this.$modelValue = Number.NaN; this.$validators = {}; + this.$asyncValidators = {}; + this.$validators = {}; this.$parsers = []; this.$formatters = []; this.$viewChangeListeners = []; @@ -1607,6 +1649,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ var parentForm = $element.inheritedData('$formController') || nullFormCtrl, invalidCount = 0, // used to easily determine if we are valid + pendingCount = 0, // used to easily determine if there are any pending validations $error = this.$error = {}; // keep invalid keys here @@ -1624,18 +1667,67 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ } this.$$clearValidity = function() { + $animate.removeClass($element, PENDING_CLASS); forEach(ctrl.$error, function(val, key) { var validationKey = snake_case(key, '-'); $animate.removeClass($element, VALID_CLASS + validationKey); $animate.removeClass($element, INVALID_CLASS + validationKey); }); + // just incase an asnyc validator is still running while + // the parser fails + if(ctrl.$pending) { + ctrl.$$clearPending(); + } + invalidCount = 0; $error = ctrl.$error = {}; parentForm.$$clearControlValidity(ctrl); }; + this.$$clearPending = function() { + pendingCount = 0; + ctrl.$pending = undefined; + $animate.removeClass($element, PENDING_CLASS); + }; + + this.$$setPending = function(validationErrorKey, promise, currentValue) { + ctrl.$pending = ctrl.$pending || {}; + if (angular.isUndefined(ctrl.$pending[validationErrorKey])) { + ctrl.$pending[validationErrorKey] = true; + pendingCount++; + } + + ctrl.$valid = ctrl.$invalid = undefined; + parentForm.$$setPending(validationErrorKey, ctrl); + + $animate.addClass($element, PENDING_CLASS); + $animate.removeClass($element, INVALID_CLASS); + $animate.removeClass($element, VALID_CLASS); + + //Special-case for (undefined|null|false|NaN) values to avoid + //having to compare each of them with each other + currentValue = currentValue || ''; + promise.then(resolve(true), resolve(false)); + + function resolve(bool) { + return function() { + var value = ctrl.$viewValue || ''; + if (ctrl.$pending && ctrl.$pending[validationErrorKey] && currentValue === value) { + pendingCount--; + delete ctrl.$pending[validationErrorKey]; + ctrl.$setValidity(validationErrorKey, bool); + if (pendingCount === 0) { + ctrl.$$clearPending(); + ctrl.$$updateValidModelValue(value); + ctrl.$$writeModelToScope(); + } + } + }; + } + }; + /** * @ngdoc method * @name ngModel.NgModelController#$setValidity @@ -1655,28 +1747,30 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * @param {boolean} isValid Whether the current state is valid (true) or invalid (false). */ this.$setValidity = function(validationErrorKey, isValid) { - // Purposeful use of ! here to cast isValid to boolean in case it is undefined + + // avoid doing anything if the validation value has not changed // jshint -W018 - if ($error[validationErrorKey] === !isValid) return; + if (!ctrl.$pending && $error[validationErrorKey] === !isValid) return; // jshint +W018 if (isValid) { if ($error[validationErrorKey]) invalidCount--; - if (!invalidCount) { + if (!invalidCount && !pendingCount) { toggleValidCss(true); ctrl.$valid = true; ctrl.$invalid = false; } } else if(!$error[validationErrorKey]) { - toggleValidCss(false); - ctrl.$invalid = true; - ctrl.$valid = false; invalidCount++; + if (!pendingCount) { + toggleValidCss(false); + ctrl.$invalid = true; + ctrl.$valid = false; + } } $error[validationErrorKey] = !isValid; toggleValidCss(isValid, validationErrorKey); - parentForm.$setValidity(validationErrorKey, isValid, ctrl); }; @@ -1804,7 +1898,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * @name ngModel.NgModelController#$validate * * @description - * Runs each of the registered validations set on the $validators object. + * Runs each of the registered validators (first synchronous validators and then asynchronous validators). */ this.$validate = function() { // ignore $validate before model initialized @@ -1820,9 +1914,40 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ }; this.$$runValidators = function(modelValue, viewValue) { - forEach(ctrl.$validators, function(fn, name) { - ctrl.$setValidity(name, fn(modelValue, viewValue)); + // this is called in the event if incase the input value changes + // while a former asynchronous validator is still doing its thing + if(ctrl.$pending) { + ctrl.$$clearPending(); + } + + var continueValidation = validate(ctrl.$validators, function(validator, result) { + ctrl.$setValidity(validator, result); }); + + if (continueValidation) { + validate(ctrl.$asyncValidators, function(validator, result) { + if (!isPromiseLike(result)) { + throw $ngModelMinErr("$asyncValidators", + "Expected asynchronous validator to return a promise but got '{0}' instead.", result); + } + ctrl.$$setPending(validator, result, modelValue); + }); + } + + ctrl.$$updateValidModelValue(modelValue); + + function validate(validators, callback) { + var status = true; + forEach(validators, function(fn, name) { + var result = fn(modelValue, viewValue); + callback(name, result); + status = status && result; + }); + return status; + } + }; + + this.$$updateValidModelValue = function(modelValue) { ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue; }; @@ -1870,13 +1995,13 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ ctrl.$$invalidModelValue = ctrl.$modelValue = undefined; ctrl.$$clearValidity(); ctrl.$setValidity(parserName, false); + ctrl.$$writeModelToScope(); } else if (ctrl.$modelValue !== modelValue && (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) { ctrl.$setValidity(parserName, true); ctrl.$$runValidators(modelValue, viewValue); + ctrl.$$writeModelToScope(); } - - ctrl.$$writeModelToScope(); }; this.$$writeModelToScope = function() { diff --git a/test/ng/directive/formSpec.js b/test/ng/directive/formSpec.js index 21e21f3234dd..2a9657955e74 100644 --- a/test/ng/directive/formSpec.js +++ b/test/ng/directive/formSpec.js @@ -579,6 +579,35 @@ describe('form', function() { }); }); + describe('$pending', function() { + beforeEach(function() { + doc = $compile('
')(scope); + scope.$digest(); + }); + + it('should set valid and invalid to undefined when a validation error state is set as pending', inject(function($q, $rootScope) { + var defer, form = doc.data('$formController'); + + var ctrl = {}; + form.$$setPending('matias', ctrl); + + expect(form.$valid).toBeUndefined(); + expect(form.$invalid).toBeUndefined(); + expect(form.$pending.matias).toEqual([ctrl]); + + form.$setValidity('matias', true, ctrl); + + expect(form.$valid).toBe(true); + expect(form.$invalid).toBe(false); + expect(form.$pending).toBeUndefined(); + + form.$setValidity('matias', false, ctrl); + + expect(form.$valid).toBe(false); + expect(form.$invalid).toBe(true); + expect(form.$pending).toBeUndefined(); + })); + }); describe('$setPristine', function() { diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index b574bd0e1871..98bc3537e754 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -8,6 +8,7 @@ describe('NgModelController', function() { var attrs = {name: 'testAlias', ngModel: 'value'}; parentFormCtrl = { + $$setPending: jasmine.createSpy('$$setPending'), $setValidity: jasmine.createSpy('$setValidity'), $setDirty: jasmine.createSpy('$setDirty'), $$clearControlValidity: noop @@ -377,7 +378,7 @@ describe('NgModelController', function() { }); }); - describe('$validators', function() { + describe('validations pipeline', function() { it('should perform validations when $validate() is called', function() { ctrl.$validators.uppercase = function(value) { @@ -504,6 +505,251 @@ describe('NgModelController', function() { expect(ctrl.$error.tooLong).toBe(true); expect(ctrl.$error.notNumeric).not.toBe(true); }); + + it('should render a validator asynchronously when a promise is returned', inject(function($q) { + var defer; + ctrl.$asyncValidators.promiseValidator = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + scope.$apply('value = ""'); + + expect(ctrl.$valid).toBeUndefined(); + expect(ctrl.$invalid).toBeUndefined(); + expect(ctrl.$pending.promiseValidator).toBe(true); + + defer.resolve(); + scope.$digest(); + + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + expect(ctrl.$pending).toBeUndefined(); + + scope.$apply('value = "123"'); + + defer.reject(); + scope.$digest(); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(ctrl.$pending).toBeUndefined(); + })); + + it('should throw an error when a promise is not returned for an asynchronous validator', inject(function($q) { + ctrl.$asyncValidators.async = function(value) { + return true; + }; + + expect(function() { + scope.$apply('value = "123"'); + }).toThrowMinErr("ngModel", "$asyncValidators", + "Expected asynchronous validator to return a promise but got 'true' instead."); + })); + + it('should only run the async validators once all the sync validators have passed', + inject(function($q) { + + var stages = {}; + + stages.sync = { status1 : false, status2: false, count : 0 }; + ctrl.$validators.syncValidator1 = function(modelValue, viewValue) { + stages.sync.count++; + return stages.sync.status1; + }; + + ctrl.$validators.syncValidator2 = function(modelValue, viewValue) { + stages.sync.count++; + return stages.sync.status2; + }; + + stages.async = { defer : null, count : 0 }; + ctrl.$asyncValidators.asyncValidator = function(modelValue, viewValue) { + stages.async.defer = $q.defer(); + stages.async.count++; + return stages.async.defer.promise; + }; + + scope.$apply('value = "123"'); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + + expect(stages.sync.count).toBe(2); + expect(stages.async.count).toBe(0); + + stages.sync.status1 = true; + + scope.$apply('value = "456"'); + + expect(stages.sync.count).toBe(4); + expect(stages.async.count).toBe(0); + + stages.sync.status2 = true; + + scope.$apply('value = "789"'); + + expect(stages.sync.count).toBe(6); + expect(stages.async.count).toBe(1); + + stages.async.defer.resolve(); + scope.$apply(); + + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + })); + + it('should ignore expired async validation promises once delivered', inject(function($q) { + var defer, oldDefer, newDefer; + ctrl.$asyncValidators.async = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + scope.$apply('value = ""'); + oldDefer = defer; + scope.$apply('value = "123"'); + newDefer = defer; + + newDefer.reject(); + scope.$digest(); + oldDefer.resolve(); + scope.$digest(); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(ctrl.$pending).toBeUndefined(); + })); + + it('should clear and ignore all pending promises when the input values changes', inject(function($q) { + var isPending = false; + ctrl.$validators.sync = function(value) { + isPending = isObject(ctrl.$pending); + return true; + }; + + var defers = []; + ctrl.$asyncValidators.async = function(value) { + var defer = $q.defer(); + defers.push(defer); + return defer.promise; + }; + + scope.$apply('value = "123"'); + expect(isPending).toBe(false); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + expect(defers.length).toBe(1); + expect(isObject(ctrl.$pending)).toBe(true); + + scope.$apply('value = "456"'); + expect(isPending).toBe(false); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + expect(defers.length).toBe(2); + expect(isObject(ctrl.$pending)).toBe(true); + + defers[1].resolve(); + scope.$digest(); + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + expect(isObject(ctrl.$pending)).toBe(false); + })); + + it('should clear and ignore all pending promises when a parser fails', inject(function($q) { + var failParser = false; + ctrl.$parsers.push(function(value) { + return failParser ? undefined : value; + }); + + var defer; + ctrl.$asyncValidators.async = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + ctrl.$setViewValue('x..y..z'); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + + failParser = true; + + ctrl.$setViewValue('1..2..3'); + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(isObject(ctrl.$pending)).toBe(false); + + defer.resolve(); + scope.$digest(); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(isObject(ctrl.$pending)).toBe(false); + })); + + it('should re-evaluate the form validity state once the asynchronous promise has been delivered', + inject(function($compile, $rootScope, $q) { + + var element = $compile('
' + + '' + + '' + + '
')($rootScope); + var inputElm = element.find('input'); + + var formCtrl = $rootScope.myForm; + var usernameCtrl = formCtrl.username; + var ageCtrl = formCtrl.age; + + var usernameDefer; + usernameCtrl.$asyncValidators.usernameAvailability = function() { + usernameDefer = $q.defer(); + return usernameDefer.promise; + }; + + $rootScope.$digest(); + expect(usernameCtrl.$invalid).toBe(true); + expect(formCtrl.$invalid).toBe(true); + + usernameCtrl.$setViewValue('valid-username'); + $rootScope.$digest(); + + expect(formCtrl.$pending.usernameAvailability).toBeTruthy(); + expect(usernameCtrl.$invalid).toBe(undefined); + expect(formCtrl.$invalid).toBe(undefined); + + usernameDefer.resolve(); + $rootScope.$digest(); + expect(usernameCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(true); + + ageCtrl.$setViewValue(22); + $rootScope.$digest(); + + expect(usernameCtrl.$invalid).toBe(false); + expect(ageCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(false); + + usernameCtrl.$setViewValue('valid'); + $rootScope.$digest(); + + expect(usernameCtrl.$invalid).toBe(true); + expect(ageCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(true); + + usernameCtrl.$setViewValue('another-valid-username'); + $rootScope.$digest(); + + usernameDefer.resolve(); + $rootScope.$digest(); + + expect(usernameCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(false); + expect(formCtrl.$pending).toBeFalsy(); + expect(ageCtrl.$invalid).toBe(false); + + dealoc(element); + })); + }); }); @@ -3294,9 +3540,10 @@ describe('NgModel animations', function() { return animations; } - function assertValidAnimation(animation, event, className) { + function assertValidAnimation(animation, event, classNameA, classNameB) { expect(animation.event).toBe(event); - expect(animation.args[1]).toBe(className); + expect(animation.args[1]).toBe(classNameA); + if(classNameB) expect(animation.args[2]).toBe(classNameB); } var doc, input, scope, model; From 520b9416f97ee93daced1b1633891e1803879dea Mon Sep 17 00:00:00 2001 From: Caitlin Potter Date: Fri, 22 Aug 2014 16:38:21 -0400 Subject: [PATCH 017/180] feat($rootScope): implement $applyAsync to support combining calls to $apply into a single digest. It is now possible to queue up multiple expressions to be evaluated in a single digest using $applyAsync. The asynchronous expressions will be evaluated either 1) the next time $apply or $rootScope.$digest is called, or 2) after after the queue flushing scheduled for the next turn occurs (roughly ~10ms depending on browser and application). --- src/ng/rootScope.js | 56 ++++++++++++++++++++++++++ test/ng/rootScopeSpec.js | 85 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index 9938240a60e6..0ea968f16cbc 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -71,6 +71,7 @@ function $RootScopeProvider(){ var TTL = 10; var $rootScopeMinErr = minErr('$rootScope'); var lastDirtyWatch = null; + var applyAsyncId = null; this.digestTtl = function(value) { if (arguments.length) { @@ -134,6 +135,7 @@ function $RootScopeProvider(){ this.$$listeners = {}; this.$$listenerCount = {}; this.$$isolateBindings = {}; + this.$$applyAsyncQueue = []; } /** @@ -688,6 +690,13 @@ function $RootScopeProvider(){ beginPhase('$digest'); + if (this === $rootScope && applyAsyncId !== null) { + // If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then + // cancel the scheduled $apply and flush the queue of expressions to be evaluated. + $browser.defer.cancel(applyAsyncId); + flushApplyAsync(); + } + lastDirtyWatch = null; do { // "while dirty" loop @@ -997,6 +1006,33 @@ function $RootScopeProvider(){ } }, + /** + * @ngdoc method + * @name $rootScope.Scope#$applyAsync + * @kind function + * + * @description + * Schedule the invokation of $apply to occur at a later time. The actual time difference + * varies across browsers, but is typically around ~10 milliseconds. + * + * This can be used to queue up multiple expressions which need to be evaluated in the same + * digest. + * + * @param {(string|function())=} exp An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with current `scope` parameter. + */ + $applyAsync: function(expr) { + var scope = this; + expr && $rootScope.$$applyAsyncQueue.push($applyAsyncExpression); + scheduleApplyAsync(); + + function $applyAsyncExpression() { + scope.$eval(expr); + } + }, + /** * @ngdoc method * @name $rootScope.Scope#$on @@ -1229,5 +1265,25 @@ function $RootScopeProvider(){ * because it's unique we can easily tell it apart from other values */ function initWatchVal() {} + + function flushApplyAsync() { + var queue = $rootScope.$$applyAsyncQueue; + while (queue.length) { + try { + queue.shift()(); + } catch(e) { + $exceptionHandler(e); + } + } + applyAsyncId = null; + } + + function scheduleApplyAsync() { + if (applyAsyncId === null) { + applyAsyncId = $browser.defer(function() { + $rootScope.$apply(flushApplyAsync); + }); + } + } }]; } diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index 74eec39fc528..e3168ec10e86 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -1399,6 +1399,91 @@ describe('Scope', function() { }); + describe('$applyAsync', function() { + beforeEach(module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + })); + + + it('should evaluate in the context of specific $scope', inject(function($rootScope, $browser) { + var scope = $rootScope.$new(); + scope.$applyAsync('x = "CODE ORANGE"'); + + $browser.defer.flush(); + expect(scope.x).toBe('CODE ORANGE'); + expect($rootScope.x).toBeUndefined(); + })); + + + it('should evaluate queued expressions in order', inject(function($rootScope, $browser) { + $rootScope.x = []; + $rootScope.$applyAsync('x.push("expr1")'); + $rootScope.$applyAsync('x.push("expr2")'); + + $browser.defer.flush(); + expect($rootScope.x).toEqual(['expr1', 'expr2']); + })); + + + it('should evaluate subsequently queued items in same turn', inject(function($rootScope, $browser) { + $rootScope.x = []; + $rootScope.$applyAsync(function() { + $rootScope.x.push('expr1'); + $rootScope.$applyAsync('x.push("expr2")'); + expect($browser.deferredFns.length).toBe(0); + }); + + $browser.defer.flush(); + expect($rootScope.x).toEqual(['expr1', 'expr2']); + })); + + + it('should pass thrown exceptions to $exceptionHandler', inject(function($rootScope, $browser, $exceptionHandler) { + $rootScope.$applyAsync(function() { + throw 'OOPS'; + }); + + $browser.defer.flush(); + expect($exceptionHandler.errors).toEqual([ + 'OOPS' + ]); + })); + + + it('should evaluate subsequent expressions after an exception is thrown', inject(function($rootScope, $browser) { + $rootScope.$applyAsync(function() { + throw 'OOPS'; + }); + $rootScope.$applyAsync('x = "All good!"'); + + $browser.defer.flush(); + expect($rootScope.x).toBe('All good!'); + })); + + + it('should be cancelled if a $rootScope digest occurs before the next tick', inject(function($rootScope, $browser) { + var apply = spyOn($rootScope, '$apply').andCallThrough(); + var cancel = spyOn($browser.defer, 'cancel').andCallThrough(); + var expression = jasmine.createSpy('expr'); + + $rootScope.$applyAsync(expression); + $rootScope.$digest(); + expect(expression).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + expression.reset(); + cancel.reset(); + + // assert that we no longer are waiting to execute + expect($browser.deferredFns.length).toBe(0); + + // assert that another digest won't call the function again + $rootScope.$digest(); + expect(expression).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + })); + }); + + describe('events', function() { describe('$on', function() { From f0e209a26d6309e06a29664a9fc87998f0e3dabe Mon Sep 17 00:00:00 2001 From: Caitlin Potter Date: Fri, 22 Aug 2014 18:17:55 -0400 Subject: [PATCH 018/180] feat($http): implement mechanism for coalescing calls to $apply in $http When multiple responses are received within a short window from each other, it can be wasteful to perform full dirty-checking cycles for each individual response. In order to prevent this, it is now possible to coalesce calls to $apply for responses which occur close together. This behaviour is opt-in, and the default is disabled, in order to avoid breaking tests or applications. In order to activate coalesced apply in tests or in an application, simply perform the following steps during configuration. angular.module('myFancyApp', []). config(function($httpProvider) { $httpProvider.useApplyAsync(true); }); OR: angular.mock.module(function($httpProvider) { $httpProvider.useApplyAsync(true); }); Closes #8736 Closes #7634 Closes #5297 --- src/ng/http.js | 40 ++++++++++++++++++- src/ngMock/angular-mocks.js | 12 +++--- test/ng/httpSpec.js | 77 +++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/ng/http.js b/src/ng/http.js index 9017fe85292d..1fdc615f2c21 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -143,6 +143,34 @@ function $HttpProvider() { xsrfHeaderName: 'X-XSRF-TOKEN' }; + var useApplyAsync = false; + /** + * @ngdoc method + * @name $httpProvider#useApplyAsync + * @description + * + * Configure $http service to combine processing of multiple http responses received at around + * the same time via {@link ng.$rootScope#applyAsync $rootScope.$applyAsync}. This can result in + * significant performance improvement for bigger applications that make many HTTP requests + * concurrently (common during application bootstrap). + * + * Defaults to false. If no value is specifed, returns the current configured value. + * + * @param {boolean=} value If true, when requests are loaded, they will schedule a deferred + * "apply" on the next tick, giving time for subsequent requests in a roughly ~10ms window + * to load and share the same digest cycle. + * + * @returns {boolean|Object} If a value is specified, returns the $httpProvider for chaining. + * otherwise, returns the current configured value. + **/ + this.useApplyAsync = function(value) { + if (isDefined(value)) { + useApplyAsync = !!value; + return this; + } + return useApplyAsync; + }; + /** * Are ordered by request, i.e. they are applied in the same order as the * array, on request, but reverse order, on response. @@ -949,8 +977,16 @@ function $HttpProvider() { } } - resolvePromise(response, status, headersString, statusText); - if (!$rootScope.$$phase) $rootScope.$apply(); + function resolveHttpPromise() { + resolvePromise(response, status, headersString, statusText); + } + + if (useApplyAsync) { + $rootScope.$applyAsync(resolveHttpPromise); + } else { + resolveHttpPromise(); + if (!$rootScope.$$phase) $rootScope.$apply(); + } } diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index ba9790539ff8..073dadc0295b 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -1488,11 +1488,11 @@ function createHttpBackendMock($rootScope, $delegate, $browser) { * all pending requests will be flushed. If there are no pending requests when the flush method * is called an exception is thrown (as this typically a sign of programming error). */ - $httpBackend.flush = function(count) { - $rootScope.$digest(); + $httpBackend.flush = function(count, digest) { + if (digest !== false) $rootScope.$digest(); if (!responses.length) throw new Error('No pending request to flush !'); - if (angular.isDefined(count)) { + if (angular.isDefined(count) && count !== null) { while (count--) { if (!responses.length) throw new Error('No more pending request to flush !'); responses.shift()(); @@ -1502,7 +1502,7 @@ function createHttpBackendMock($rootScope, $delegate, $browser) { responses.shift()(); } } - $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingExpectation(digest); }; @@ -1520,8 +1520,8 @@ function createHttpBackendMock($rootScope, $delegate, $browser) { * afterEach($httpBackend.verifyNoOutstandingExpectation); * ``` */ - $httpBackend.verifyNoOutstandingExpectation = function() { - $rootScope.$digest(); + $httpBackend.verifyNoOutstandingExpectation = function(digest) { + if (digest !== false) $rootScope.$digest(); if (expectations.length) { throw new Error('Unsatisfied requests: ' + expectations.join(', ')); } diff --git a/test/ng/httpSpec.js b/test/ng/httpSpec.js index c1c33ffb8788..c2ad25f0ce82 100644 --- a/test/ng/httpSpec.js +++ b/test/ng/httpSpec.js @@ -1526,3 +1526,80 @@ describe('$http', function() { $httpBackend.verifyNoOutstandingExpectation = noop; }); }); + + +describe('$http with $applyAapply', function() { + var $http, $httpBackend, $rootScope, $browser, log; + beforeEach(module(function($httpProvider) { + $httpProvider.useApplyAsync(true); + }, provideLog)); + + + beforeEach(inject(['$http', '$httpBackend', '$rootScope', '$browser', 'log', function(http, backend, scope, browser, logger) { + $http = http; + $httpBackend = backend; + $rootScope = scope; + $browser = browser; + spyOn($rootScope, '$apply').andCallThrough(); + spyOn($rootScope, '$applyAsync').andCallThrough(); + spyOn($rootScope, '$digest').andCallThrough(); + spyOn($browser.defer, 'cancel').andCallThrough(); + log = logger; + }])); + + + it('should schedule coalesced apply on response', function() { + var handler = jasmine.createSpy('handler'); + $httpBackend.expect('GET', '/template1.html').respond(200, '

Header!

', {}); + $http.get('/template1.html').then(handler); + // Ensure requests are sent + $rootScope.$digest(); + + $httpBackend.flush(null, false); + expect($rootScope.$applyAsync).toHaveBeenCalledOnce(); + expect(handler).not.toHaveBeenCalled(); + + $browser.defer.flush(); + expect(handler).toHaveBeenCalledOnce(); + }); + + + it('should combine multiple responses within short time frame into a single $apply', function() { + $httpBackend.expect('GET', '/template1.html').respond(200, '

Header!

', {}); + $httpBackend.expect('GET', '/template2.html').respond(200, '

Body!

', {}); + + $http.get('/template1.html').then(log.fn('response 1')); + $http.get('/template2.html').then(log.fn('response 2')); + // Ensure requests are sent + $rootScope.$digest(); + + $httpBackend.flush(null, false); + expect(log).toEqual([]); + + $browser.defer.flush(); + expect(log).toEqual(['response 1', 'response 2']); + }); + + + it('should handle pending responses immediately if a digest occurs on $rootScope', function() { + $httpBackend.expect('GET', '/template1.html').respond(200, '

Header!

', {}); + $httpBackend.expect('GET', '/template2.html').respond(200, '

Body!

', {}); + $httpBackend.expect('GET', '/template3.html').respond(200, '

Body!

', {}); + + $http.get('/template1.html').then(log.fn('response 1')); + $http.get('/template2.html').then(log.fn('response 2')); + $http.get('/template3.html').then(log.fn('response 3')); + // Ensure requests are sent + $rootScope.$digest(); + + // Intermediate $digest occurs before 3rd response is received, assert that pending responses + /// are handled + $httpBackend.flush(2); + expect(log).toEqual(['response 1', 'response 2']); + + // Finally, third response is received, and a second coalesced $apply is started + $httpBackend.flush(null, false); + $browser.defer.flush(); + expect(log).toEqual(['response 1', 'response 2', 'response 3']); + }); +}); From c305fdda8305c0fd4a9c631a434c1e738ca65926 Mon Sep 17 00:00:00 2001 From: Richard Harrington Date: Wed, 27 Aug 2014 08:35:31 -0400 Subject: [PATCH 019/180] docs(shallowCopy): add missing word 'are' and period. Closes #8794 --- src/Angular.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Angular.js b/src/Angular.js index 841b5d30b528..9a0f3c296f84 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -817,7 +817,7 @@ function copy(source, destination, stackSource, stackDest) { /** * Creates a shallow copy of an object, an array or a primitive. * - * Assumes that there no proto properties for objects + * Assumes that there are no proto properties for objects. */ function shallowCopy(src, dst) { if (isArray(src)) { From 8ba14c6fd1107df7a4adf6de9bc6d18c8f924443 Mon Sep 17 00:00:00 2001 From: Igor Minar Date: Wed, 27 Aug 2014 11:15:39 -0700 Subject: [PATCH 020/180] fix(Angular): remove duplicate nodeName_ references I forgot to remove the variable declaration in previous nodeName_ commit. --- src/Angular.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Angular.js b/src/Angular.js index 9a0f3c296f84..189e2e45378b 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -10,7 +10,6 @@ toString: true, ngMinErr: true, angularModule: true, - nodeName_: true, uid: true, REGEX_STRING_REGEXP: true, VALIDITY_STATE_PROPERTY: true, @@ -170,7 +169,6 @@ var /** holds major version number for IE or NaN for real browsers */ /** @name angular */ angular = window.angular || (window.angular = {}), angularModule, - nodeName_, uid = 0; /** From 97325bb50ddebe7f0db8ed45887829071be85208 Mon Sep 17 00:00:00 2001 From: Igor Minar Date: Wed, 27 Aug 2014 13:57:36 -0700 Subject: [PATCH 021/180] chore(build): uprade closure-compiler to v20140814 no significant change in code size --- bower.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bower.json b/bower.json index a99f4bf3d71c..62f25d85851c 100644 --- a/bower.json +++ b/bower.json @@ -5,7 +5,7 @@ "lunr.js": "0.4.3", "open-sans-fontface": "1.0.4", "google-code-prettify": "1.0.1", - "closure-compiler": "https://closure-compiler.googlecode.com/files/compiler-20130603.zip", + "closure-compiler": "https://dl.google.com/closure-compiler/compiler-20140814.zip", "ng-closure-runner": "https://raw.github.com/angular/ng-closure-runner/v0.2.3/assets/ng-closure-runner.zip", "bootstrap": "3.1.1" } From 0e2ec3091d0fa32c9545050ba5e3c55db1b6fa20 Mon Sep 17 00:00:00 2001 From: Zach Pomerantz Date: Wed, 27 Aug 2014 14:07:18 -0700 Subject: [PATCH 022/180] docs(compile): `translcuded` -> `transcluded` Oops. Closes #8799 --- src/ng/compile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ng/compile.js b/src/ng/compile.js index 5caa41452b22..ac0eab395690 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -188,7 +188,7 @@ * * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope: * `function([scope], cloneLinkingFn, futureParentElement)`. * * `scope`: optional argument to override the scope. - * * `cloneLinkingFn`: optional argument to create clones of the original translcuded content. + * * `cloneLinkingFn`: optional argument to create clones of the original transcluded content. * * `futureParentElement`: * * defines the parent to which the `cloneLinkingFn` will add the cloned elements. * * default: `$element.parent()` resp. `$element` for `transclude:'element'` resp. `transclude:true`. From 711ee0dfbebf082c64c7c8cfe86eb2cdd7b9ab99 Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Tue, 26 Aug 2014 15:23:47 -0700 Subject: [PATCH 023/180] fix($browser): detect changes to the browser url that happened in sync Closes #6976. --- src/ng/browser.js | 7 +++++++ src/ng/rootScope.js | 2 ++ src/ngMock/angular-mocks.js | 2 ++ test/ng/browserSpecs.js | 36 ++++++++++++++++++++++++++++++++---- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/ng/browser.js b/src/ng/browser.js index 3ca4a7c0a86c..e769c4535f15 100644 --- a/src/ng/browser.js +++ b/src/ng/browser.js @@ -234,6 +234,13 @@ function Browser(window, document, $log, $sniffer) { return callback; }; + /** + * Checks whether the url has changed outside of Angular. + * Needs to be exported to be able to check for changes that have been done in sync, + * as hashchange/popstate events fire in async. + */ + self.$$checkUrlChange = fireUrlChange; + ////////////////////////////////////////////////////////////// // Misc API ////////////////////////////////////////////////////////////// diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index 0ea968f16cbc..8e36208ed3a7 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -689,6 +689,8 @@ function $RootScopeProvider(){ logIdx, logMsg, asyncTask; beginPhase('$digest'); + // Check for changes to browser url that happened in sync before the call to $digest + $browser.$$checkUrlChange(); if (this === $rootScope && applyAsyncId !== null) { // If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 073dadc0295b..a8c29e7c7d6c 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -56,6 +56,8 @@ angular.mock.$Browser = function() { return listener; }; + self.$$checkUrlChange = angular.noop; + self.cookieHash = {}; self.lastCookieHash = {}; self.deferredFns = []; diff --git a/test/ng/browserSpecs.js b/test/ng/browserSpecs.js index fc50795b4ec5..f5a4359625c2 100755 --- a/test/ng/browserSpecs.js +++ b/test/ng/browserSpecs.js @@ -36,7 +36,7 @@ function MockWindow() { }; this.location = { - href: 'http://server', + href: 'http://server/', replace: noop }; @@ -419,7 +419,7 @@ describe('browser', function() { expect(replaceState).not.toHaveBeenCalled(); expect(locationReplace).not.toHaveBeenCalled(); - expect(fakeWindow.location.href).toEqual('http://server'); + expect(fakeWindow.location.href).toEqual('http://server/'); }); it('should use history.replaceState when available', function() { @@ -431,7 +431,7 @@ describe('browser', function() { expect(pushState).not.toHaveBeenCalled(); expect(locationReplace).not.toHaveBeenCalled(); - expect(fakeWindow.location.href).toEqual('http://server'); + expect(fakeWindow.location.href).toEqual('http://server/'); }); it('should set location.href when pushState not available', function() { @@ -453,7 +453,7 @@ describe('browser', function() { expect(pushState).not.toHaveBeenCalled(); expect(replaceState).not.toHaveBeenCalled(); - expect(fakeWindow.location.href).toEqual('http://server'); + expect(fakeWindow.location.href).toEqual('http://server/'); }); it('should return $browser to allow chaining', function() { @@ -620,4 +620,32 @@ describe('browser', function() { expect(browser.baseHref()).toEqual('/base/path/'); }); }); + + describe('integration tests with $location', function() { + + beforeEach(module(function($provide, $locationProvider) { + spyOn(fakeWindow.history, 'pushState').andCallFake(function(stateObj, title, newUrl) { + fakeWindow.location.href = newUrl; + }); + $provide.value('$browser', browser); + browser.pollFns = []; + + $locationProvider.html5Mode(true); + })); + + it('should update $location when it was changed outside of Angular in sync '+ + 'before $digest was called', function() { + inject(function($rootScope, $location) { + fakeWindow.history.pushState(null, '', 'http://server/someTestHash'); + + // Verify that infinite digest reported in #6976 no longer occurs + expect(function() { + $rootScope.$digest(); + }).not.toThrow(); + + expect($location.path()).toBe('/someTestHash'); + }); + }); + }); + }); From bbc0f90409d6858f4e87aee27004a383b248cbb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Tue, 12 Aug 2014 12:40:17 -0400 Subject: [PATCH 024/180] feat($templateRequest): introduce the $templateRequest service This handy service is designed to download and cache template contents and to throw an error when a template request fails. BREAKING CHANGE Angular will now throw a $compile minErr each a template fails to download for ngView, directives and ngMessage template requests. This changes the former behavior of silently ignoring failed HTTP requests--or when the template itself is empty. Please ensure that all directive, ngView and ngMessage code now properly addresses this scenario. NgInclude is uneffected from this change. --- angularFiles.js | 1 + src/AngularPublic.js | 2 + src/ng/compile.js | 11 ++-- src/ng/directive/ngInclude.js | 10 ++-- src/ng/templateRequest.js | 53 +++++++++++++++++ src/ngMessages/messages.js | 8 +-- src/ngRoute/route.js | 8 +-- test/ng/directive/ngIncludeSpec.js | 1 + test/ng/templateRequestSpec.js | 89 ++++++++++++++++++++++++++++ test/ngRoute/directive/ngViewSpec.js | 11 ++-- test/ngRoute/routeSpec.js | 35 +++++------ 11 files changed, 187 insertions(+), 42 deletions(-) create mode 100644 src/ng/templateRequest.js create mode 100644 test/ng/templateRequestSpec.js diff --git a/angularFiles.js b/angularFiles.js index 6c6dc1e59af5..924fcd487fb2 100755 --- a/angularFiles.js +++ b/angularFiles.js @@ -34,6 +34,7 @@ var angularFiles = { 'src/ng/sanitizeUri.js', 'src/ng/sce.js', 'src/ng/sniffer.js', + 'src/ng/templateRequest.js', 'src/ng/timeout.js', 'src/ng/urlUtils.js', 'src/ng/window.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index e859a0da7577..14c8383a9b99 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -78,6 +78,7 @@ $SceDelegateProvider, $SnifferProvider, $TemplateCacheProvider, + $TemplateRequestProvider, $TimeoutProvider, $$RAFProvider, $$AsyncCallbackProvider, @@ -227,6 +228,7 @@ function publishExternalAPI(angular){ $sceDelegate: $SceDelegateProvider, $sniffer: $SnifferProvider, $templateCache: $TemplateCacheProvider, + $templateRequest: $TemplateRequestProvider, $timeout: $TimeoutProvider, $window: $WindowProvider, $$rAF: $$RAFProvider, diff --git a/src/ng/compile.js b/src/ng/compile.js index ac0eab395690..6c40d9b0db7a 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -669,9 +669,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { }; this.$get = [ - '$injector', '$interpolate', '$exceptionHandler', '$http', '$templateCache', '$parse', + '$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse', '$controller', '$rootScope', '$document', '$sce', '$animate', '$$sanitizeUri', - function($injector, $interpolate, $exceptionHandler, $http, $templateCache, $parse, + function($injector, $interpolate, $exceptionHandler, $templateRequest, $parse, $controller, $rootScope, $document, $sce, $animate, $$sanitizeUri) { var Attributes = function(element, attributesToCopy) { @@ -1827,8 +1827,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { $compileNode.empty(); - $http.get($sce.getTrustedResourceUrl(templateUrl), {cache: $templateCache}). - success(function(content) { + $templateRequest($sce.getTrustedResourceUrl(templateUrl)) + .then(function(content) { var compileNode, tempTemplateAttrs, $template, childBoundTranscludeFn; content = denormalizeTemplate(content); @@ -1903,9 +1903,6 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { childBoundTranscludeFn); } linkQueue = null; - }). - error(function(response, code, headers, config) { - throw $compileMinErr('tpload', 'Failed to load template: {0}', config.url); }); return function delayedNodeLinkFn(ignoreChildLinkFn, scope, node, rootElement, boundTranscludeFn) { diff --git a/src/ng/directive/ngInclude.js b/src/ng/directive/ngInclude.js index 8aea896b2dd8..e78e72bc1910 100644 --- a/src/ng/directive/ngInclude.js +++ b/src/ng/directive/ngInclude.js @@ -169,8 +169,8 @@ * @description * Emitted when a template HTTP request yields an erronous response (status < 200 || status > 299) */ -var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate', '$sce', - function($http, $templateCache, $anchorScroll, $animate, $sce) { +var ngIncludeDirective = ['$templateRequest', '$anchorScroll', '$animate', '$sce', + function($templateRequest, $anchorScroll, $animate, $sce) { return { restrict: 'ECA', priority: 400, @@ -215,7 +215,9 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' var thisChangeId = ++changeCounter; if (src) { - $http.get(src, {cache: $templateCache}).success(function(response) { + //set the 2nd param to true to ignore the template request error so that the inner + //contents and scope can be cleaned up. + $templateRequest(src, true).then(function(response) { if (thisChangeId !== changeCounter) return; var newScope = scope.$new(); ctrl.template = response; @@ -236,7 +238,7 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' currentScope.$emit('$includeContentLoaded'); scope.$eval(onloadExp); - }).error(function() { + }, function() { if (thisChangeId === changeCounter) { cleanupLastIncludeContent(); scope.$emit('$includeContentError'); diff --git a/src/ng/templateRequest.js b/src/ng/templateRequest.js new file mode 100644 index 000000000000..7155718e89a4 --- /dev/null +++ b/src/ng/templateRequest.js @@ -0,0 +1,53 @@ +'use strict'; + +var $compileMinErr = minErr('$compile'); + +/** + * @ngdoc service + * @name $templateRequest + * + * @description + * The `$templateRequest` service downloads the provided template using `$http` and, upon success, + * stores the contents inside of `$templateCache`. If the HTTP request fails or the response data + * of the HTTP request is empty then a `$compile` error will be thrown (the exception can be thwarted + * by setting the 2nd parameter of the function to true). + * + * @param {string} tpl The HTTP request template URL + * @param {boolean=} ignoreRequestError Whether or not to ignore the exception when the request fails or the template is empty + * + * @return {Promise} the HTTP Promise for the given. + * + * @property {number} totalPendingRequests total amount of pending template requests being downloaded. + */ +function $TemplateRequestProvider() { + this.$get = ['$templateCache', '$http', '$q', function($templateCache, $http, $q) { + function handleRequestFn(tpl, ignoreRequestError) { + var self = handleRequestFn; + self.totalPendingRequests++; + + return $http.get(tpl, { cache : $templateCache }) + .then(function(response) { + var html = response.data; + if(!html || html.length === 0) { + return handleError(); + } + + self.totalPendingRequests--; + $templateCache.put(tpl, html); + return html; + }, handleError); + + function handleError() { + self.totalPendingRequests--; + if (!ignoreRequestError) { + throw $compileMinErr('tpload', 'Failed to load template: {0}', tpl); + } + return $q.reject(); + } + } + + handleRequestFn.totalPendingRequests = 0; + + return handleRequestFn; + }]; +} diff --git a/src/ngMessages/messages.js b/src/ngMessages/messages.js index ecf5bc21f435..3254390a5a51 100644 --- a/src/ngMessages/messages.js +++ b/src/ngMessages/messages.js @@ -228,8 +228,8 @@ angular.module('ngMessages', []) *
* */ - .directive('ngMessages', ['$compile', '$animate', '$http', '$templateCache', - function($compile, $animate, $http, $templateCache) { + .directive('ngMessages', ['$compile', '$animate', '$templateRequest', + function($compile, $animate, $templateRequest) { var ACTIVE_CLASS = 'ng-active'; var INACTIVE_CLASS = 'ng-inactive'; @@ -296,8 +296,8 @@ angular.module('ngMessages', []) var tpl = $attrs.ngMessagesInclude || $attrs.include; if(tpl) { - $http.get(tpl, { cache: $templateCache }) - .success(function processTemplate(html) { + $templateRequest(tpl) + .then(function processTemplate(html) { var after, container = angular.element('
').html(html); angular.forEach(container.children(), function(elm) { elm = angular.element(elm); diff --git a/src/ngRoute/route.js b/src/ngRoute/route.js index 53b1927d6ad5..b140ddfbc1b8 100644 --- a/src/ngRoute/route.js +++ b/src/ngRoute/route.js @@ -226,10 +226,9 @@ function $RouteProvider(){ '$routeParams', '$q', '$injector', - '$http', - '$templateCache', + '$templateRequest', '$sce', - function($rootScope, $location, $routeParams, $q, $injector, $http, $templateCache, $sce) { + function($rootScope, $location, $routeParams, $q, $injector, $templateRequest, $sce) { /** * @ngdoc service @@ -556,8 +555,7 @@ function $RouteProvider(){ templateUrl = $sce.getTrustedResourceUrl(templateUrl); if (angular.isDefined(templateUrl)) { next.loadedTemplateUrl = templateUrl; - template = $http.get(templateUrl, {cache: $templateCache}). - then(function(response) { return response.data; }); + template = $templateRequest(templateUrl); } } if (angular.isDefined(template)) { diff --git a/test/ng/directive/ngIncludeSpec.js b/test/ng/directive/ngIncludeSpec.js index 1d2f1ec4c89a..4dca3f803dc8 100644 --- a/test/ng/directive/ngIncludeSpec.js +++ b/test/ng/directive/ngIncludeSpec.js @@ -199,6 +199,7 @@ describe('ngInclude', function() { $rootScope.url = 'url2'; $rootScope.$digest(); $httpBackend.flush(); + expect($rootScope.$$childHead).toBeFalsy(); expect(element.text()).toBe(''); diff --git a/test/ng/templateRequestSpec.js b/test/ng/templateRequestSpec.js new file mode 100644 index 000000000000..efc6a182116c --- /dev/null +++ b/test/ng/templateRequestSpec.js @@ -0,0 +1,89 @@ +'use strict'; + +describe('$templateRequest', function() { + + it('should download the provided template file', + inject(function($rootScope, $templateRequest, $httpBackend) { + + $httpBackend.expectGET('tpl.html').respond('
abc
'); + + var content; + $templateRequest('tpl.html').then(function(html) { content = html; }); + + $rootScope.$digest(); + $httpBackend.flush(); + + expect(content).toBe('
abc
'); + })); + + it('should cache the request using $templateCache to prevent extra downloads', + inject(function($rootScope, $templateRequest, $templateCache) { + + $templateCache.put('tpl.html', 'matias'); + + var content; + $templateRequest('tpl.html').then(function(html) { content = html; }); + + $rootScope.$digest(); + expect(content).toBe('matias'); + })); + + it('should throw an error when the template is not found', + inject(function($rootScope, $templateRequest, $httpBackend) { + + $httpBackend.expectGET('tpl.html').respond(404); + + $templateRequest('tpl.html'); + + $rootScope.$digest(); + + expect(function() { + $rootScope.$digest(); + $httpBackend.flush(); + }).toThrowMinErr('$compile', 'tpload', 'Failed to load template: tpl.html'); + })); + + it('should throw an error when the template is empty', + inject(function($rootScope, $templateRequest, $httpBackend) { + + $httpBackend.expectGET('tpl.html').respond(''); + + $templateRequest('tpl.html'); + + $rootScope.$digest(); + + expect(function() { + $rootScope.$digest(); + $httpBackend.flush(); + }).toThrowMinErr('$compile', 'tpload', 'Failed to load template: tpl.html'); + })); + + it('should keep track of how many requests are going on', + inject(function($rootScope, $templateRequest, $httpBackend) { + + $httpBackend.expectGET('a.html').respond('a'); + $httpBackend.expectGET('b.html').respond('c'); + $templateRequest('a.html'); + $templateRequest('b.html'); + + expect($templateRequest.totalPendingRequests).toBe(2); + + $rootScope.$digest(); + $httpBackend.flush(); + + expect($templateRequest.totalPendingRequests).toBe(0); + + $httpBackend.expectGET('c.html').respond(404); + $templateRequest('c.html'); + + expect($templateRequest.totalPendingRequests).toBe(1); + $rootScope.$digest(); + + try { + $httpBackend.flush(); + } catch(e) {} + + expect($templateRequest.totalPendingRequests).toBe(0); + })); + +}); diff --git a/test/ngRoute/directive/ngViewSpec.js b/test/ngRoute/directive/ngViewSpec.js index a832a7b32cc8..6113a2ca2ec8 100644 --- a/test/ngRoute/directive/ngViewSpec.js +++ b/test/ngRoute/directive/ngViewSpec.js @@ -56,7 +56,7 @@ describe('ngView', function() { }); - it('should instantiate controller for empty template', function() { + it('should not instantiate the associated controller when an empty template is downloaded', function() { var log = [], controllerScope, Ctrl = function($scope) { controllerScope = $scope; @@ -70,11 +70,12 @@ describe('ngView', function() { inject(function($route, $rootScope, $templateCache, $location) { $templateCache.put('/tpl.html', [200, '', {}]); $location.path('/some'); - $rootScope.$digest(); - expect(controllerScope.$parent).toBe($rootScope); - expect(controllerScope).toBe($route.current.scope); - expect(log).toEqual(['ctrl-init']); + expect(function() { + $rootScope.$digest(); + }).toThrowMinErr('$compile', 'tpload', 'Failed to load template: /tpl.html'); + + expect(controllerScope).toBeUndefined(); }); }); diff --git a/test/ngRoute/routeSpec.js b/test/ngRoute/routeSpec.js index 5dcf96edcb32..220d4f47b631 100644 --- a/test/ngRoute/routeSpec.js +++ b/test/ngRoute/routeSpec.js @@ -671,35 +671,36 @@ describe('$route', function() { }); - it('should drop in progress route change when new route change occurs and old fails', function() { - module(function($routeProvider) { + it('should throw an error when a template is empty or not found', function() { + module(function($routeProvider, $exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); $routeProvider. when('/r1', { templateUrl: 'r1.html' }). - when('/r2', { templateUrl: 'r2.html' }); + when('/r2', { templateUrl: 'r2.html' }). + when('/r3', { templateUrl: 'r3.html' }); }); - inject(function($route, $httpBackend, $location, $rootScope) { - var log = ''; - $rootScope.$on('$routeChangeError', function(e, next, last, error) { - log += '$failed(' + next.templateUrl + ', ' + error.status + ');'; - }); - $rootScope.$on('$routeChangeStart', function(e, next) { log += '$before(' + next.templateUrl + ');'; }); - $rootScope.$on('$routeChangeSuccess', function(e, next) { log += '$after(' + next.templateUrl + ');'; }); - + inject(function($route, $httpBackend, $location, $rootScope, $exceptionHandler) { $httpBackend.expectGET('r1.html').respond(404, 'R1'); - $httpBackend.expectGET('r2.html').respond('R2'); - $location.path('/r1'); $rootScope.$digest(); - expect(log).toBe('$before(r1.html);'); + $httpBackend.flush(); + expect($exceptionHandler.errors.pop().message).toContain("[$compile:tpload] Failed to load template: r1.html"); + + $httpBackend.expectGET('r2.html').respond(''); $location.path('/r2'); $rootScope.$digest(); - expect(log).toBe('$before(r1.html);$before(r2.html);'); $httpBackend.flush(); - expect(log).toBe('$before(r1.html);$before(r2.html);$after(r2.html);'); - expect(log).not.toContain('$after(r1.html);'); + expect($exceptionHandler.errors.pop().message).toContain("[$compile:tpload] Failed to load template: r2.html"); + + $httpBackend.expectGET('r3.html').respond('abc'); + $location.path('/r3'); + $rootScope.$digest(); + + $httpBackend.flush(); + expect($exceptionHandler.errors.length).toBe(0); }); }); From cb8364c617acc8f2a88a79188f8cc8e2fc93ac51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Wed, 27 Aug 2014 20:31:07 -0400 Subject: [PATCH 025/180] fix($animate): ensure guarded animations consider AJAX requests upon bootstrap Prior to this fix when an Angular application is bootstrapped it would only place an animation guard to prevent animations from running when the application starts for the first two digest cycles. However, if any controllers or directives, that are executed upon boostrap, trigger any remote code to be downloaded (via $http) then the guard does not put that into consideration. This fix now properly addresses that circumstance and removes the guard once all outbound HTTP requests are complete when an Angular application is bootstrapped. Closes #8275 Closes #5262 --- src/ngAnimate/animate.js | 52 ++++++++++++++++++++++++-------- test/ng/directive/ngClassSpec.js | 5 +-- test/ngAnimate/animateSpec.js | 42 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/ngAnimate/animate.js b/src/ngAnimate/animate.js index 2b2de1abe367..d8c65712ca79 100644 --- a/src/ngAnimate/animate.js +++ b/src/ngAnimate/animate.js @@ -73,6 +73,16 @@ * When the `on` expression value changes and an animation is triggered then each of the elements within * will all animate without the block being applied to child elements. * + * ## Are animations run when the application starts? + * No they are not. When an application is bootstrapped Angular will disable animations from running to avoid + * a frenzy of animations from being triggered as soon as the browser has rendered the screen. For this to work, + * Angular will wait for two digest cycles until enabling animations. From there on, any animation-triggering + * layout changes in the application will trigger animations as normal. + * + * In addition, upon bootstrap, if the routing system or any directives or load remote data (via $http) then Angular + * will automatically extend the wait time to enable animations once **all** of the outbound HTTP requests + * are complete. + * *

CSS-defined Animations

* The animate service will automatically apply two CSS classes to the animated element and these two CSS classes * are designed to contain the start and end CSS styling. Both CSS transitions and keyframe animations are supported @@ -396,24 +406,40 @@ angular.module('ngAnimate', ['ng']) } $provide.decorator('$animate', - ['$delegate', '$$q', '$injector', '$sniffer', '$rootElement', '$$asyncCallback', '$rootScope', '$document', - function($delegate, $$q, $injector, $sniffer, $rootElement, $$asyncCallback, $rootScope, $document) { + ['$delegate', '$$q', '$injector', '$sniffer', '$rootElement', '$$asyncCallback', '$rootScope', '$document', '$templateRequest', + function($delegate, $$q, $injector, $sniffer, $rootElement, $$asyncCallback, $rootScope, $document, $templateRequest) { - var globalAnimationCounter = 0; $rootElement.data(NG_ANIMATE_STATE, rootAnimateState); // disable animations during bootstrap, but once we bootstrapped, wait again - // for another digest until enabling animations. The reason why we digest twice - // is because all structural animations (enter, leave and move) all perform a - // post digest operation before animating. If we only wait for a single digest - // to pass then the structural animation would render its animation on page load. - // (which is what we're trying to avoid when the application first boots up.) - $rootScope.$$postDigest(function() { - $rootScope.$$postDigest(function() { - rootAnimateState.running = false; - }); - }); + // for another digest until enabling animations. Enter, leave and move require + // a follow-up digest so having a watcher here is enough to let both digests pass. + // However, when any directive or view templates are downloaded then we need to + // handle postpone enabling animations until they are fully completed and then... + var watchFn = $rootScope.$watch( + function() { return $templateRequest.totalPendingRequests; }, + function(val, oldVal) { + if (oldVal === 0) { + if (val === 0) { + $rootScope.$$postDigest(onApplicationReady); + } + } else if(val === 0) { + // ...when the template has been downloaded we digest twice again until the + // animations are set to enabled (since enter, leave and move require a + // follow-up). + $rootScope.$$postDigest(function() { + $rootScope.$$postDigest(onApplicationReady); + }); + } + } + ); + function onApplicationReady() { + rootAnimateState.running = false; + watchFn(); + } + + var globalAnimationCounter = 0; var classNameFilter = $animateProvider.classNameFilter(); var isAnimatableClassName = !classNameFilter ? function() { return true; } diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index a00708f18d5d..2d4f28cce701 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -427,10 +427,7 @@ describe('ngClass animations', function() { }); inject(function($compile, $rootScope, $browser, $rootElement, $animate, $timeout, $document) { - // Enable animations by triggering the first item in the postDigest queue - digestQueue.shift()(); - - // wait for the 2nd animation bootstrap digest to pass + // Animations are enabled right away since there are no remote HTTP template requests $rootScope.$digest(); digestQueue.shift()(); diff --git a/test/ngAnimate/animateSpec.js b/test/ngAnimate/animateSpec.js index a8f67b1087e4..ef1e0fecab0b 100644 --- a/test/ngAnimate/animateSpec.js +++ b/test/ngAnimate/animateSpec.js @@ -50,6 +50,48 @@ describe("ngAnimate", function() { }); }); + it("should disable animations for two digests until all pending HTTP requests are complete during bootstrap", function() { + var animateSpy = jasmine.createSpy(); + module(function($animateProvider, $compileProvider) { + $compileProvider.directive('myRemoteDirective', function() { + return { + templateUrl : 'remote.html' + }; + }); + $animateProvider.register('.my-structrual-animation', function() { + return { + enter : animateSpy, + leave : animateSpy + }; + }); + }); + inject(function($rootScope, $compile, $animate, $rootElement, $document, $httpBackend) { + + $httpBackend.whenGET('remote.html').respond(200, 'content'); + + var element = $compile('
...
')($rootScope); + $rootElement.append(element); + jqLite($document[0].body).append($rootElement); + + // running this twice just to prove that the dual post digest is run + $rootScope.$digest(); + $rootScope.$digest(); + + $animate.enter(element, $rootElement); + $rootScope.$digest(); + + expect(animateSpy).not.toHaveBeenCalled(); + + $httpBackend.flush(); + $rootScope.$digest(); + + $animate.leave(element); + $rootScope.$digest(); + + expect(animateSpy).toHaveBeenCalled(); + }); + }); + //we use another describe block because the before/after operations below //are used across all animations tests and we don't want that same behavior From 5e9d270c61523d43d93909f0cd5b98d301168aa9 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sat, 23 Aug 2014 07:37:34 +0100 Subject: [PATCH 026/180] feat($compile/ngBind): allow disabling binding info The compiler and ngBind directives add binding information (`ng-binding` CSS class and `$binding` data property) to elements when they are bound to the scope. This is only to aid testing and debugging for tools such as Protractor and Batarang. In production this is unnecessary and add a performance penalty. This can be now disabled by calling `$compileProvider.debugInfoEnabled(false)` in a module `config` block: ``` someModule.config(['$compileProvider', function($compileProvider) { $compileProvider.debugInfoEnabled(false); }]); ``` In the bench/apps/largetable-bp benchmark this change, with debug info disabled, improved by ~140ms, that is 10%. Measuring the "create" phase, 25 loops, mean time ~1340ms -> ~1200ms. We were storing the whole `interpolationFn` in the `$binding` data on elements but this function was bringing a lot of closure variables with it and so was consuming unwanted amounts of memory. Now we are only storing the parsed interpolation expressions from the binding (i.e. the values of `interpolationFn.expressions`). BREAKING CHANGE: The value of `$binding` data property on an element is always an array now and the expressions do not include the curly braces `{{ ... }}`. --- src/ng/compile.js | 47 ++++++++++++++++++++++++++------- src/ng/directive/ngBind.js | 47 +++++++++++++++------------------ src/ngScenario/Scenario.js | 3 --- test/ng/compileSpec.js | 30 +++++++++++++++++---- test/ng/directive/ngBindSpec.js | 7 ----- 5 files changed, 84 insertions(+), 50 deletions(-) diff --git a/src/ng/compile.js b/src/ng/compile.js index 6c40d9b0db7a..4fdeff7f8997 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -668,6 +668,34 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } }; + /** + * @ngdoc method + * @name $compileProvider#debugInfoEnabled + * + * @param {boolean=} enabled update the debugInfoEnabled state if provided, otherwise just return the + * current debugInfoEnabled state + * @returns {*} current value if used as getter or itself (chaining) if used as setter + * + * @kind function + * + * @description + * Call this method to enable/disable various debug runtime information in the compiler such as adding + * binding information and a reference to the current scope on to DOM elements. + * If enabled, the compiler will add the following to DOM elements that have been bound to the scope + * * `ng-binding` CSS class + * * `$binding` data property containing an array of the binding expressions + * + * The default value is true. + */ + var debugInfoEnabled = true; + this.debugInfoEnabled = function(enabled) { + if(isDefined(enabled)) { + debugInfoEnabled = enabled; + return this; + } + return debugInfoEnabled; + }; + this.$get = [ '$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse', '$controller', '$rootScope', '$document', '$sce', '$animate', '$$sanitizeUri', @@ -867,6 +895,13 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { }, NG_ATTR_BINDING = /^ngAttr[A-Z]/; + compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo(element, binding) { + element + .addClass('ng-binding') + .data('$binding', + (element.data('$binding') || []).concat(binding.expressions || [binding]) + ); + } : noop; return compile; @@ -1947,17 +1982,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { directives.push({ priority: 0, compile: function textInterpolateCompileFn(templateNode) { - // when transcluding a template that has bindings in the root - // then we don't have a parent and should do this in the linkFn - var parent = templateNode.parent(), hasCompileParent = parent.length; - if (hasCompileParent) safeAddClass(templateNode.parent(), 'ng-binding'); - return function textInterpolateLinkFn(scope, node) { - var parent = node.parent(), - bindings = parent.data('$binding') || []; - bindings.push(interpolateFn); - parent.data('$binding', bindings); - if (!hasCompileParent) safeAddClass(parent, 'ng-binding'); + var parent = node.parent(); + compile.$$addBindingInfo(parent, interpolateFn); scope.$watch(interpolateFn, function interpolateFnWatchAction(value) { node[0].nodeValue = value; }); diff --git a/src/ng/directive/ngBind.js b/src/ng/directive/ngBind.js index d4dd81fc9121..6be2dfd05fd2 100644 --- a/src/ng/directive/ngBind.js +++ b/src/ng/directive/ngBind.js @@ -51,23 +51,22 @@ */ -var ngBindDirective = ngDirective({ - compile: function ngBindCompile(templateElement) { - templateElement.addClass('ng-binding'); - - return function ngBindLink(scope, element, attr) { - element.data('$binding', attr.ngBind); - element = element[0]; - - scope.$watch(attr.ngBind, function ngBindWatchAction(value) { - // We are purposefully using == here rather than === because we want to - // catch when value is "null or undefined" - // jshint -W041 - element.textContent = (value == undefined ? '' : value); - }); - }; - } -}); +var ngBindDirective = ['$compile', function($compile) { + return { + restrict: 'AC', + compile: function(templateElement) { + return function (scope, element, attr) { + $compile.$$addBindingInfo(element, attr.ngBind); + scope.$watch(attr.ngBind, function ngBindWatchAction(value) { + // We are purposefully using == here rather than === because we want to + // catch when value is "null or undefined" + // jshint -W041 + element.text(value == undefined ? '' : value); + }); + }; + } + }; +}]; /** @@ -121,11 +120,10 @@ var ngBindDirective = ngDirective({ */ -var ngBindTemplateDirective = ['$interpolate', function($interpolate) { +var ngBindTemplateDirective = ['$interpolate', '$compile', function($interpolate, $compile) { return function(scope, element, attr) { - // TODO: move this to scenario runner var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); - element.addClass('ng-binding').data('$binding', interpolateFn); + $compile.$$addBindingInfo(element, interpolateFn); attr.$observe('ngBindTemplate', function(value) { element.text(value); }); @@ -178,14 +176,13 @@ var ngBindTemplateDirective = ['$interpolate', function($interpolate) { */ -var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) { +var ngBindHtmlDirective = ['$sce', '$parse', '$compile', function($sce, $parse, $compile) { return { restrict: 'A', - compile: function ngBindCompile(tElement, tAttrs) { - tElement.addClass('ng-binding'); + compile: function (tElement, tAttrs) { - return function ngBindLink(scope, element, attr) { - element.data('$binding', attr.ngBindHtml); + return function (scope, element, attr) { + $compile.$$addBindingInfo(element, attr.ngBindHtml); var ngBindHtmlGetter = $parse(attr.ngBindHtml); var ngBindHtmlWatch = $parse(attr.ngBindHtml, function getStringValue(value) { return (value || '').toString(); diff --git a/src/ngScenario/Scenario.js b/src/ngScenario/Scenario.js index 6cf69d84ce79..7ee8030d404c 100644 --- a/src/ngScenario/Scenario.js +++ b/src/ngScenario/Scenario.js @@ -304,9 +304,6 @@ _jQuery.fn.bindings = function(windowJquery, bindExp) { var element = windowJquery(this), bindings; if (bindings = element.data('$binding')) { - if (!angular.isArray(bindings)) { - bindings = [bindings]; - } for(var expressions = [], binding, j=0, jj=bindings.length; j{{1+2}}
')($rootScope); - expect(element.hasClass('ng-binding')).toBe(true); - expect(element.data('$binding')[0].exp).toEqual('{{1+2}}'); - })); + expect(element.hasClass('ng-binding')).toBe(false); + expect(element.data('$binding')).toBeUndefined(); + }); + }); + it('should occur if `debugInfoEnabled` is true', function() { + module(function($compileProvider) { + $compileProvider.debugInfoEnabled(true); + }); + + inject(function($compile, $rootScope) { + element = $compile('
{{1+2}}
')($rootScope); + expect(element.hasClass('ng-binding')).toBe(true); + expect(element.data('$binding')).toEqual(['1+2']); + }); + }); + }); + it('should observe interpolated attrs', inject(function($rootScope, $compile) { $compile('
')($rootScope); diff --git a/test/ng/directive/ngBindSpec.js b/test/ng/directive/ngBindSpec.js index 704932b09085..2a1f00637044 100644 --- a/test/ng/directive/ngBindSpec.js +++ b/test/ng/directive/ngBindSpec.js @@ -122,13 +122,6 @@ describe('ngBind*', function() { describe('ngBindHtml', function() { - it('should add ng-binding class to the element in compile phase', inject(function($compile) { - var element = jqLite('
'); - $compile(element); - expect(element.hasClass('ng-binding')).toBe(true); - })); - - describe('SCE disabled', function() { beforeEach(function() { module(function($sceProvider) { $sceProvider.enabled(false); }); From e528ed8b37f56dd089138ca3685b765c734f691d Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sat, 23 Aug 2014 07:37:34 +0100 Subject: [PATCH 027/180] feat($compile): allow disabling scope info The compiler adds scope information (`ng-scope` CSS class and `$scope` data property) to elements when the are bound to the scope. This is mostly to aid debugging tools such as Batarang. In production this should be unnecesary and adds a performance penalty. In the bench/apps/largetable-bp this change caused an improvement of ~100ms (7%). This can be now disabled by calling `$compileProvider.debugInfoEnabled(false)` in a module `config` block: ``` someModule.config(['$compileProvider', function($compileProvider) { $compileProvider.debugInfoEnabled(false); }]); ``` In the bench/apps/largetable-bp benchmark this change, with debug info disabled, improved by ~120ms, that is ~10%. Measuring the "create" phase, 25 loops, mean time ~1200ms -> ~1080ms. --- src/ng/compile.js | 55 +++++++++++++++++++----------------------- test/ng/compileSpec.js | 49 ++++++++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 33 deletions(-) diff --git a/src/ng/compile.js b/src/ng/compile.js index 4fdeff7f8997..49b44bb9b9fe 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -886,6 +886,17 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } }; + + function safeAddClass($element, className) { + try { + $element.addClass(className); + } catch(e) { + // ignore, since it means that we are trying to set class on + // SVG element, where class name is read-only. + } + } + + var startSymbol = $interpolate.startSymbol(), endSymbol = $interpolate.endSymbol(), denormalizeTemplate = (startSymbol == '{{' || endSymbol == '}}') @@ -896,11 +907,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { NG_ATTR_BINDING = /^ngAttr[A-Z]/; compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo(element, binding) { - element - .addClass('ng-binding') - .data('$binding', - (element.data('$binding') || []).concat(binding.expressions || [binding]) - ); + safeAddClass(element, 'ng-binding'); + element.data('$binding', (element.data('$binding') || []).concat(binding.expressions || [binding])); + } : noop; + + compile.$$addScopeInfo = debugInfoEnabled ? function $$addScopeInfo(element, scope, isolated, noTemplate) { + safeAddClass(jqLite(element), isolated ? 'ng-isolate-scope' : 'ng-scope'); + var dataName = isolated ? (noTemplate ? '$isolateScopeNoTemplate' : '$isolateScope') : '$scope'; + element.data ? element.data(dataName, scope) : jqLite.data(element, dataName, scope); } : noop; return compile; @@ -924,9 +938,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { var compositeLinkFn = compileNodes($compileNodes, transcludeFn, $compileNodes, maxPriority, ignoreDirective, previousCompileContext); - safeAddClass($compileNodes, 'ng-scope'); - var namespace = null; + return function publicLinkFn(scope, cloneConnectFn, transcludeControllers, parentBoundTranscludeFn, futureParentElement){ + var namespace = null; assertArg(scope, 'scope'); if (!namespace) { namespace = detectNamespaceForChildElements(futureParentElement); @@ -949,7 +963,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } } - $linkNode.data('$scope', scope); + compile.$$addScopeInfo($linkNode, scope); if (cloneConnectFn) cloneConnectFn($linkNode, scope); if (compositeLinkFn) compositeLinkFn(scope, $linkNode, $linkNode, parentBoundTranscludeFn); @@ -967,15 +981,6 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } } - function safeAddClass($element, className) { - try { - $element.addClass(className); - } catch(e) { - // ignore, since it means that we are trying to set class on - // SVG element, where class name is read-only. - } - } - /** * Compile function matches each node in nodeList against the directives. Once all directives * for a particular node are collected their compile functions are executed. The compile @@ -1008,10 +1013,6 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { null, [], [], previousCompileContext) : null; - if (nodeLinkFn && nodeLinkFn.scope) { - safeAddClass(attrs.$$element, 'ng-scope'); - } - childLinkFn = (nodeLinkFn && nodeLinkFn.terminal || !(childNodes = nodeList[i].childNodes) || !childNodes.length) @@ -1062,7 +1063,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (nodeLinkFn) { if (nodeLinkFn.scope) { childScope = scope.$new(); - jqLite.data(node, '$scope', childScope); + compile.$$addScopeInfo(node, childScope); } else { childScope = scope; } @@ -1563,14 +1564,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { isolateScope = scope.$new(true); - if (templateDirective && (templateDirective === newIsolateScopeDirective || - templateDirective === newIsolateScopeDirective.$$originalDirective)) { - $element.data('$isolateScope', isolateScope); - } else { - $element.data('$isolateScopeNoTemplate', isolateScope); - } - - + compile.$$addScopeInfo($element, isolateScope, true, !(templateDirective && (templateDirective === newIsolateScopeDirective || + templateDirective === newIsolateScopeDirective.$$originalDirective))); safeAddClass($element, 'ng-isolate-scope'); diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index d7b438837ca9..41808b2351a5 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -4250,12 +4250,17 @@ describe('$compile', function() { - it('should not leak if two "element" transclusions are on the same element', function () { + it('should not leak if two "element" transclusions are on the same element (with debug info)', function () { if (jQuery) { // jQuery 2.x doesn't expose the cache storage. return; } + + module(function($compileProvider) { + $compileProvider.debugInfoEnabled(true); + }); + inject(function($compile, $rootScope) { expect(jqLiteCacheSize()).toEqual(0); @@ -4277,12 +4282,48 @@ describe('$compile', function() { }); - it('should not leak if two "element" transclusions are on the same element', function () { + it('should not leak if two "element" transclusions are on the same element (without debug info)', function () { if (jQuery) { // jQuery 2.x doesn't expose the cache storage. return; } + + module(function($compileProvider) { + $compileProvider.debugInfoEnabled(false); + }); + + inject(function($compile, $rootScope) { + expect(jqLiteCacheSize()).toEqual(0); + + element = $compile('
{{x}}
')($rootScope); + expect(jqLiteCacheSize()).toEqual(0); + + $rootScope.$apply('xs = [0,1]'); + expect(jqLiteCacheSize()).toEqual(0); + + $rootScope.$apply('xs = [0]'); + expect(jqLiteCacheSize()).toEqual(0); + + $rootScope.$apply('xs = []'); + expect(jqLiteCacheSize()).toEqual(0); + + element.remove(); + expect(jqLiteCacheSize()).toEqual(0); + }); + }); + + + it('should not leak if two "element" transclusions are on the same element (with debug info)', function () { + if (jQuery) { + // jQuery 2.x doesn't expose the cache storage. + return; + } + + module(function($compileProvider) { + $compileProvider.debugInfoEnabled(true); + }); + inject(function($compile, $rootScope) { expect(jqLiteCacheSize()).toEqual(0); element = $compile('
{{x}}
')($rootScope); @@ -5220,7 +5261,9 @@ describe('$compile', function() { })); }); inject(function($compile) { - element = $compile('
')($rootScope); + // We need to wrap the transclude directive's element in a parent element so that the + // cloned element gets deallocated/cleaned up correctly + element = $compile('
')($rootScope); expect(capturedTranscludeCtrl).toBeTruthy(); }); }); From 716e65a6e49f47e83f45cfaecab9901874f2c966 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 26 Aug 2014 17:55:04 -0700 Subject: [PATCH 028/180] test(input): dealoc elements --- test/ng/directive/inputSpec.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 98bc3537e754..ec09d76e07d8 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -799,6 +799,8 @@ describe('ngModel', function() { expect(element).toBeInvalid(); expect(element).toHaveClass('ng-invalid-required'); + + dealoc(element); })); From 8b413f8a507e74ecb65fc0a7669c0554c9455320 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 26 Aug 2014 17:55:46 -0700 Subject: [PATCH 029/180] test(ngClass): dealoc elements --- test/ng/directive/ngClassSpec.js | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index 2d4f28cce701..b2c2e70016f1 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -3,6 +3,9 @@ describe('ngClass', function() { var element; + beforeEach(module(function($compileProvider) { + $compileProvider.debugInfoEnabled(false); + })); afterEach(function() { dealoc(element); @@ -39,9 +42,9 @@ describe('ngClass', function() { })); - it('should support adding multiple classes conditionally via a map of class names to boolean' + + it('should support adding multiple classes conditionally via a map of class names to boolean ' + 'expressions', inject(function($rootScope, $compile) { - var element = $compile( + element = $compile( '
' + '
')($rootScope); @@ -63,7 +66,7 @@ describe('ngClass', function() { it('should remove classes when the referenced object is the same but its property is changed', inject(function($rootScope, $compile) { - var element = $compile('
')($rootScope); + element = $compile('
')($rootScope); $rootScope.classes = { A: true, B: true }; $rootScope.$digest(); expect(element.hasClass('A')).toBeTruthy(); @@ -124,7 +127,7 @@ describe('ngClass', function() { $rootScope.$digest(); $rootScope.dynCls = 'foo'; $rootScope.$digest(); - expect(element[0].className).toBe('ui-panel ui-selected ng-scope foo'); + expect(element[0].className).toBe('ui-panel ui-selected foo'); })); @@ -132,7 +135,7 @@ describe('ngClass', function() { element = $compile('
')($rootScope); $rootScope.dynCls = 'panel'; $rootScope.$digest(); - expect(element[0].className).toBe('panel bar ng-scope'); + expect(element[0].className).toBe('panel bar'); })); @@ -142,7 +145,7 @@ describe('ngClass', function() { $rootScope.$digest(); $rootScope.dynCls = 'window'; $rootScope.$digest(); - expect(element[0].className).toBe('bar ng-scope window'); + expect(element[0].className).toBe('bar window'); })); @@ -153,7 +156,7 @@ describe('ngClass', function() { element.addClass('foo'); $rootScope.dynCls = ''; $rootScope.$digest(); - expect(element[0].className).toBe('ng-scope'); + expect(element[0].className).toBe(''); })); @@ -161,7 +164,7 @@ describe('ngClass', function() { element = $compile('
')($rootScope); $rootScope.dynCls = [undefined, null]; $rootScope.$digest(); - expect(element[0].className).toBe('ng-scope'); + expect(element[0].className).toBe(''); })); @@ -364,10 +367,14 @@ describe('ngClass', function() { describe('ngClass animations', function() { var body, element, $rootElement; + afterEach(function() { + dealoc(element); + }); + it("should avoid calling addClass accidentally when removeClass is going on", function() { module('ngAnimateMock'); inject(function($compile, $rootScope, $animate, $timeout) { - var element = angular.element('
'); + element = angular.element('
'); var body = jqLite(document.body); body.append(element); $compile(element)($rootScope); @@ -432,7 +439,7 @@ describe('ngClass animations', function() { digestQueue.shift()(); $rootScope.val = 'crazy'; - var element = angular.element('
'); + element = angular.element('
'); jqLite($document[0].body).append($rootElement); $compile(element)($rootScope); @@ -479,7 +486,7 @@ describe('ngClass animations', function() { $rootScope.two = true; $rootScope.three = true; - var element = angular.element('
'); + element = angular.element('
'); $compile(element)($rootScope); $rootScope.$digest(); From f7f4e34c6034bddd09ef6d5d7ca1b1ee94192d91 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sat, 23 Aug 2014 07:37:34 +0100 Subject: [PATCH 030/180] feat: add angular.reloadWithDebugInfo() --- src/.jshintrc | 1 + src/Angular.js | 40 ++++++++++++++++++++++++++++++++++++++++ src/AngularPublic.js | 3 ++- test/AngularSpec.js | 20 ++++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/.jshintrc b/src/.jshintrc index 87dd0638cf94..6b7190b12ff8 100644 --- a/src/.jshintrc +++ b/src/.jshintrc @@ -88,6 +88,7 @@ "getBlockNodes": false, "createMap": false, "VALIDITY_STATE_PROPERTY": false, + "reloadWithDebugInfo": false, "skipDestroyOnNextJQueryCleanData": true, diff --git a/src/Angular.js b/src/Angular.js index 189e2e45378b..000e49412543 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -1402,6 +1402,14 @@ function bootstrap(element, modules, config) { modules.unshift(['$provide', function($provide) { $provide.value('$rootElement', element); }]); + + if (config.debugInfoEnabled) { + // Pushing so that this overrides `debugInfoEnabled` setting defined in user's `modules`. + modules.push(['$compileProvider', function($compileProvider) { + $compileProvider.debugInfoEnabled(true); + }]); + } + modules.unshift('ng'); var injector = createInjector(modules, config.strictDi); injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', @@ -1415,8 +1423,14 @@ function bootstrap(element, modules, config) { return injector; }; + var NG_ENABLE_DEBUG_INFO = /^NG_ENABLE_DEBUG_INFO!/; var NG_DEFER_BOOTSTRAP = /^NG_DEFER_BOOTSTRAP!/; + if (window && NG_ENABLE_DEBUG_INFO.test(window.name)) { + config.debugInfoEnabled = true; + window.name = window.name.replace(NG_ENABLE_DEBUG_INFO, ''); + } + if (window && !NG_DEFER_BOOTSTRAP.test(window.name)) { return doBootstrap(); } @@ -1430,6 +1444,32 @@ function bootstrap(element, modules, config) { }; } +/** + * @ngdoc function + * @name angular.reloadWithDebugInfo + * @module ng + * @description + * Use this function to reload the current application with debug information turned on. + * + * To improve performance adding various debugging information can be disabled. + * See {@link $compileProvider#debugInfoEnabled}. + * + * This overrides any setting of `$compileProvider.debugInfoEnabled()` that you defined in your + * modules. If you wish to debug an application via this information then you should open up a debug + * console in the browser then call this method directly in this console: + * + * ```js + * angular.reloadWithDebugInfo(); + * ``` + * + * The page should reload and the debug information should now be available. + * + */ +function reloadWithDebugInfo(doReload) { + window.name = 'NG_ENABLE_DEBUG_INFO!' + window.name; + if ( doReload !== false ) window.location.reload(); +} + var SNAKE_CASE_REGEXP = /[A-Z]/g; function snake_case(name, separator) { separator = separator || '_'; diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 14c8383a9b99..f81b613f644c 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -137,7 +137,8 @@ function publishExternalAPI(angular){ 'uppercase': uppercase, 'callbacks': {counter: 0}, '$$minErr': minErr, - '$$csp': csp + '$$csp': csp, + 'reloadWithDebugInfo': reloadWithDebugInfo }); angularModule = setupModuleLoader(window); diff --git a/test/AngularSpec.js b/test/AngularSpec.js index 42b2bbc48d8b..b03590dbda5e 100644 --- a/test/AngularSpec.js +++ b/test/AngularSpec.js @@ -1099,6 +1099,26 @@ describe('angular', function() { }); + describe("reloadWithDebugInfo", function() { + + it("should reload the current page with debugInfo turned on", function() { + + element = jqLite('
{{1+2}}
'); + angular.bootstrap(element); + expect(element.hasClass('ng-scope')).toBe(false); + dealoc(element); + + // We pass the false to prevent the page actually reloading + angular.reloadWithDebugInfo(false); + + element = jqLite('
{{1+2}}
'); + angular.bootstrap(element); + expect(element.hasClass('ng-scope')).toBe(true); + dealoc(element); + }); + }); + + describe('startingElementHtml', function(){ it('should show starting element tag only', function(){ expect(startingTag('
text
')). From 6fd26116bf8215b53f9babea62c73ee0f5bf986a Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sat, 23 Aug 2014 20:45:44 +0100 Subject: [PATCH 031/180] chore(clean-shrinkwrap): chokidar is fixed since 0.8.2 --- scripts/clean-shrinkwrap.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/clean-shrinkwrap.js b/scripts/clean-shrinkwrap.js index 66326f4f2fca..c46fe295da6b 100755 --- a/scripts/clean-shrinkwrap.js +++ b/scripts/clean-shrinkwrap.js @@ -23,9 +23,6 @@ function cleanModule(module, name) { if (name === 'chokidar') { if (module.version === '0.8.1') { delete module.dependencies; - } else if ( module.version !== '0.8.2') { - throw new Error("Unfamiliar chokidar version (v" + module.version + - ") , please check status of https://github.com/paulmillr/chokidar/pull/106"); } } From b864ca51593c1cba3f08eea1f9aeae1f694b9859 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 26 Aug 2014 14:39:22 -0700 Subject: [PATCH 032/180] chore(deps): update protractor to 1.1.1 --- npm-shrinkwrap.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index d057b17689ef..cc3cf7043cd4 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -3230,7 +3230,7 @@ } }, "protractor": { - "version": "1.0.0", + "version": "1.1.1", "dependencies": { "request": { "version": "2.36.0", @@ -3254,7 +3254,7 @@ "version": "0.12.1", "dependencies": { "punycode": { - "version": "1.3.0" + "version": "1.3.1" } } }, @@ -3361,6 +3361,9 @@ } } }, + "q": { + "version": "1.0.0" + }, "lodash": { "version": "2.4.1" }, From 3d1151cfeb977ac371f6eaeacb106278c0d54ac5 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Mon, 25 Aug 2014 08:28:51 +0100 Subject: [PATCH 033/180] test(e2e): fix by.binding() locators After upgrading, Protractor requires exact string that is used in the binding. --- docs/content/guide/$location.ngdoc | 72 +++++++++++++++--------------- docs/content/guide/module.ngdoc | 4 +- src/ng/directive/input.js | 8 ++-- src/ng/directive/select.js | 8 ++-- src/ng/filter/filters.js | 2 +- src/ng/filter/limitTo.js | 4 +- 6 files changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/content/guide/$location.ngdoc b/docs/content/guide/$location.ngdoc index 81a7d85ad299..f7a1557a90f8 100644 --- a/docs/content/guide/$location.ngdoc +++ b/docs/content/guide/$location.ngdoc @@ -469,12 +469,12 @@ In these examples we use `` it("should show fake browser info on load", function(){ expect(addressBar.getAttribute('value')).toBe(url); - expect(element(by.binding('$location.protocol')).getText()).toBe('http'); - expect(element(by.binding('$location.host')).getText()).toBe('www.example.com'); - expect(element(by.binding('$location.port')).getText()).toBe('80'); - expect(element(by.binding('$location.path')).getText()).toBe('/path'); - expect(element(by.binding('$location.search')).getText()).toBe('{"a":"b"}'); - expect(element(by.binding('$location.hash')).getText()).toBe('h'); + expect(element(by.binding('$location.protocol()')).getText()).toBe('http'); + expect(element(by.binding('$location.host()')).getText()).toBe('www.example.com'); + expect(element(by.binding('$location.port()')).getText()).toBe('80'); + expect(element(by.binding('$location.path()')).getText()).toBe('/path'); + expect(element(by.binding('$location.search()')).getText()).toBe('{"a":"b"}'); + expect(element(by.binding('$location.hash()')).getText()).toBe('h'); }); @@ -485,24 +485,24 @@ In these examples we use `` expect(addressBar.getAttribute('value')).toBe("http://www.example.com/base/first?a=b"); - expect(element(by.binding('$location.protocol')).getText()).toBe('http'); - expect(element(by.binding('$location.host')).getText()).toBe('www.example.com'); - expect(element(by.binding('$location.port')).getText()).toBe('80'); - expect(element(by.binding('$location.path')).getText()).toBe('/first'); - expect(element(by.binding('$location.search')).getText()).toBe('{"a":"b"}'); - expect(element(by.binding('$location.hash')).getText()).toBe(''); + expect(element(by.binding('$location.protocol()')).getText()).toBe('http'); + expect(element(by.binding('$location.host()')).getText()).toBe('www.example.com'); + expect(element(by.binding('$location.port()')).getText()).toBe('80'); + expect(element(by.binding('$location.path()')).getText()).toBe('/first'); + expect(element(by.binding('$location.search()')).getText()).toBe('{"a":"b"}'); + expect(element(by.binding('$location.hash()')).getText()).toBe(''); navigation.get(1).click(); expect(addressBar.getAttribute('value')).toBe("http://www.example.com/base/sec/ond?flag#hash"); - expect(element(by.binding('$location.protocol')).getText()).toBe('http'); - expect(element(by.binding('$location.host')).getText()).toBe('www.example.com'); - expect(element(by.binding('$location.port')).getText()).toBe('80'); - expect(element(by.binding('$location.path')).getText()).toBe('/sec/ond'); - expect(element(by.binding('$location.search')).getText()).toBe('{"flag":true}'); - expect(element(by.binding('$location.hash')).getText()).toBe('hash'); + expect(element(by.binding('$location.protocol()')).getText()).toBe('http'); + expect(element(by.binding('$location.host()')).getText()).toBe('www.example.com'); + expect(element(by.binding('$location.port()')).getText()).toBe('80'); + expect(element(by.binding('$location.path()')).getText()).toBe('/sec/ond'); + expect(element(by.binding('$location.search()')).getText()).toBe('{"flag":true}'); + expect(element(by.binding('$location.hash()')).getText()).toBe('hash'); }); @@ -621,12 +621,12 @@ In these examples we use `` it("should show fake browser info on load", function(){ expect(addressBar.getAttribute('value')).toBe(url); - expect(element(by.binding('$location.protocol')).getText()).toBe('http'); - expect(element(by.binding('$location.host')).getText()).toBe('www.example.com'); - expect(element(by.binding('$location.port')).getText()).toBe('80'); - expect(element(by.binding('$location.path')).getText()).toBe('/path'); - expect(element(by.binding('$location.search')).getText()).toBe('{"a":"b"}'); - expect(element(by.binding('$location.hash')).getText()).toBe('h'); + expect(element(by.binding('$location.protocol()')).getText()).toBe('http'); + expect(element(by.binding('$location.host()')).getText()).toBe('www.example.com'); + expect(element(by.binding('$location.port()')).getText()).toBe('80'); + expect(element(by.binding('$location.path()')).getText()).toBe('/path'); + expect(element(by.binding('$location.search()')).getText()).toBe('{"a":"b"}'); + expect(element(by.binding('$location.hash()')).getText()).toBe('h'); }); @@ -637,24 +637,24 @@ In these examples we use `` expect(addressBar.getAttribute('value')).toBe("http://www.example.com/base/index.html#!/first?a=b"); - expect(element(by.binding('$location.protocol')).getText()).toBe('http'); - expect(element(by.binding('$location.host')).getText()).toBe('www.example.com'); - expect(element(by.binding('$location.port')).getText()).toBe('80'); - expect(element(by.binding('$location.path')).getText()).toBe('/first'); - expect(element(by.binding('$location.search')).getText()).toBe('{"a":"b"}'); - expect(element(by.binding('$location.hash')).getText()).toBe(''); + expect(element(by.binding('$location.protocol()')).getText()).toBe('http'); + expect(element(by.binding('$location.host()')).getText()).toBe('www.example.com'); + expect(element(by.binding('$location.port()')).getText()).toBe('80'); + expect(element(by.binding('$location.path()')).getText()).toBe('/first'); + expect(element(by.binding('$location.search()')).getText()).toBe('{"a":"b"}'); + expect(element(by.binding('$location.hash()')).getText()).toBe(''); navigation.get(1).click(); expect(addressBar.getAttribute('value')).toBe("http://www.example.com/base/index.html#!/sec/ond?flag#hash"); - expect(element(by.binding('$location.protocol')).getText()).toBe('http'); - expect(element(by.binding('$location.host')).getText()).toBe('www.example.com'); - expect(element(by.binding('$location.port')).getText()).toBe('80'); - expect(element(by.binding('$location.path')).getText()).toBe('/sec/ond'); - expect(element(by.binding('$location.search')).getText()).toBe('{"flag":true}'); - expect(element(by.binding('$location.hash')).getText()).toBe('hash'); + expect(element(by.binding('$location.protocol()')).getText()).toBe('http'); + expect(element(by.binding('$location.host()')).getText()).toBe('www.example.com'); + expect(element(by.binding('$location.port()')).getText()).toBe('80'); + expect(element(by.binding('$location.path()')).getText()).toBe('/sec/ond'); + expect(element(by.binding('$location.search()')).getText()).toBe('{"flag":true}'); + expect(element(by.binding('$location.hash()')).getText()).toBe('hash'); }); diff --git a/docs/content/guide/module.ngdoc b/docs/content/guide/module.ngdoc index 5315da44ba91..1ef968347a05 100644 --- a/docs/content/guide/module.ngdoc +++ b/docs/content/guide/module.ngdoc @@ -50,7 +50,7 @@ I'm in a hurry. How do I get a Hello World module working? it('should add Hello to the name', function() { - expect(element(by.binding("{{ 'World' | greet }}")).getText()).toEqual('Hello, World!'); + expect(element(by.binding(" 'World' | greet ")).getText()).toEqual('Hello, World!'); }); @@ -128,7 +128,7 @@ The above is a suggestion. Tailor it to your needs. it('should add Hello to the name', function() { - expect(element(by.binding("{{ greeting }}")).getText()).toEqual('Bonjour World!'); + expect(element(by.binding(" greeting ")).getText()).toEqual('Bonjour World!'); }); diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 911ec30f0cd7..068079fb1e51 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -814,7 +814,7 @@ var inputType = { it('should change state', function() { - var color = element(by.binding('color')); + var color = element(by.binding('color | json')); expect(color.getText()).toContain('blue'); @@ -1313,7 +1313,7 @@ function checkboxInputType(scope, element, attr, ctrl, $sniffer, $browser, $filt - var user = element(by.binding('{{user}}')); + var user = element(by.binding('user')); var userNameValid = element(by.binding('myForm.userName.$valid')); var lastNameValid = element(by.binding('myForm.lastName.$valid')); var lastNameError = element(by.binding('myForm.lastName.$error')); @@ -2542,7 +2542,7 @@ var minlengthDirective = function() { * * * var listInput = element(by.model('names')); - * var names = element(by.binding('{{names}}')); + * var names = element(by.binding('names')); * var valid = element(by.binding('myForm.namesInput.$valid')); * var error = element(by.css('span.error')); * @@ -2572,7 +2572,7 @@ var minlengthDirective = function() { * * it("should split the text by newlines", function() { * var listInput = element(by.model('list')); - * var output = element(by.binding('{{ list | json }}')); + * var output = element(by.binding(' list | json ')); * listInput.sendKeys('abc\ndef\nghi'); * expect(output.getText()).toContain('[\n "abc",\n "def",\n "ghi"\n]'); * }); diff --git a/src/ng/directive/select.js b/src/ng/directive/select.js index 8bb93c56ceec..285791e64727 100644 --- a/src/ng/directive/select.js +++ b/src/ng/directive/select.js @@ -115,7 +115,7 @@ var ngOptionsMinErr = minErr('ngOptions'); Select bogus.

- Currently selected: {{ {selected_color:myColor} }} + Currently selected: {{ {selected_color:myColor} }}
@@ -123,13 +123,13 @@ var ngOptionsMinErr = minErr('ngOptions');
it('should check ng-options', function() { - expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('red'); + expect(element(by.binding(' {selected_color:myColor} ')).getText()).toMatch('red'); element.all(by.model('myColor')).first().click(); element.all(by.css('select[ng-model="myColor"] option')).first().click(); - expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('black'); + expect(element(by.binding(' {selected_color:myColor} ')).getText()).toMatch('black'); element(by.css('.nullable select[ng-model="myColor"]')).click(); element.all(by.css('.nullable select[ng-model="myColor"] option')).first().click(); - expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('null'); + expect(element(by.binding(' {selected_color:myColor} ')).getText()).toMatch('null'); }); diff --git a/src/ng/filter/filters.js b/src/ng/filter/filters.js index 65ea0e1ab0f4..0b69d7b4ba07 100644 --- a/src/ng/filter/filters.js +++ b/src/ng/filter/filters.js @@ -490,7 +490,7 @@ function dateFilter($locale) {
it('should jsonify filtered objects', function() { - expect(element(by.binding("{'name':'value'}")).getText()).toMatch(/\{\n "name": ?"value"\n}/); + expect(element(by.binding(" {'name':'value'} | json ")).getText()).toMatch(/\{\n "name": ?"value"\n}/); }); diff --git a/src/ng/filter/limitTo.js b/src/ng/filter/limitTo.js index 52abd260b9a2..6f01aa9c1f86 100644 --- a/src/ng/filter/limitTo.js +++ b/src/ng/filter/limitTo.js @@ -40,8 +40,8 @@ var numLimitInput = element(by.model('numLimit')); var letterLimitInput = element(by.model('letterLimit')); - var limitedNumbers = element(by.binding('numbers | limitTo:numLimit')); - var limitedLetters = element(by.binding('letters | limitTo:letterLimit')); + var limitedNumbers = element(by.binding(' numbers | limitTo:numLimit ')); + var limitedLetters = element(by.binding(' letters | limitTo:letterLimit ')); it('should limit the number array to first three items', function() { expect(numLimitInput.getAttribute('value')).toBe('3'); From 401e880be7ddf643a965cdda05b8460d7d13b81b Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 26 Aug 2014 10:59:39 -0700 Subject: [PATCH 034/180] refactor($compile): $$addBindingInfo accepts single expression or an array Instead of knowing about `.expressions` property, it just accepts a single expression or an array of expressions. --- src/ng/compile.js | 12 ++++++++++-- src/ng/directive/ngBind.js | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/ng/compile.js b/src/ng/compile.js index 49b44bb9b9fe..6994af57071a 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -907,8 +907,16 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { NG_ATTR_BINDING = /^ngAttr[A-Z]/; compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo(element, binding) { + var bindings = element.data('$binding') || []; + + if (isArray(binding)) { + bindings = bindings.concat(binding); + } else { + bindings.push(binding); + } + safeAddClass(element, 'ng-binding'); - element.data('$binding', (element.data('$binding') || []).concat(binding.expressions || [binding])); + element.data('$binding', bindings); } : noop; compile.$$addScopeInfo = debugInfoEnabled ? function $$addScopeInfo(element, scope, isolated, noTemplate) { @@ -1979,7 +1987,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { compile: function textInterpolateCompileFn(templateNode) { return function textInterpolateLinkFn(scope, node) { var parent = node.parent(); - compile.$$addBindingInfo(parent, interpolateFn); + compile.$$addBindingInfo(parent, interpolateFn.expressions); scope.$watch(interpolateFn, function interpolateFnWatchAction(value) { node[0].nodeValue = value; }); diff --git a/src/ng/directive/ngBind.js b/src/ng/directive/ngBind.js index 6be2dfd05fd2..7d6f8d30a11f 100644 --- a/src/ng/directive/ngBind.js +++ b/src/ng/directive/ngBind.js @@ -123,7 +123,7 @@ var ngBindDirective = ['$compile', function($compile) { var ngBindTemplateDirective = ['$interpolate', '$compile', function($interpolate, $compile) { return function(scope, element, attr) { var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); - $compile.$$addBindingInfo(element, interpolateFn); + $compile.$$addBindingInfo(element, interpolateFn.expressions); attr.$observe('ngBindTemplate', function(value) { element.text(value); }); From ab8bd91b85996e8fff6d33df3fa4a8f4e9c575fb Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 26 Aug 2014 12:15:50 -0700 Subject: [PATCH 035/180] refactor: remove doReload arg used only for testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We run unit tests in “strict” mode and thus can’t monkey-patch `window.location` nor `window.location.reload`. In order to avoid full page reload, we could pass location as argument, or another level of indirection, something like this: ```js var ourGlobalFunkyLocation = window.location; function reloadWithDebugInfo() { window.name = 'NG_ENABLE_DEBUG_INFO!' + window.name; ourGlobalFunkyLocation.reload(); } // in the test ourGlobalFunkyLocation = { reload: function() {} }; reloadWithDebugInfo(); ourGlobalFunkyLocation = window.location; ``` I don’t think any of these make sense, just so that we can test setting `window.name`. If the `reloadWithDebugInfo` function was more complicated, I would do it. I don’t think it’s worthy to confuse production code with extra logic which purpose was only to make testing possible. --- src/Angular.js | 4 ++-- test/AngularSpec.js | 20 -------------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/src/Angular.js b/src/Angular.js index 000e49412543..635314e77775 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -1465,9 +1465,9 @@ function bootstrap(element, modules, config) { * The page should reload and the debug information should now be available. * */ -function reloadWithDebugInfo(doReload) { +function reloadWithDebugInfo() { window.name = 'NG_ENABLE_DEBUG_INFO!' + window.name; - if ( doReload !== false ) window.location.reload(); + window.location.reload(); } var SNAKE_CASE_REGEXP = /[A-Z]/g; diff --git a/test/AngularSpec.js b/test/AngularSpec.js index b03590dbda5e..42b2bbc48d8b 100644 --- a/test/AngularSpec.js +++ b/test/AngularSpec.js @@ -1099,26 +1099,6 @@ describe('angular', function() { }); - describe("reloadWithDebugInfo", function() { - - it("should reload the current page with debugInfo turned on", function() { - - element = jqLite('
{{1+2}}
'); - angular.bootstrap(element); - expect(element.hasClass('ng-scope')).toBe(false); - dealoc(element); - - // We pass the false to prevent the page actually reloading - angular.reloadWithDebugInfo(false); - - element = jqLite('
{{1+2}}
'); - angular.bootstrap(element); - expect(element.hasClass('ng-scope')).toBe(true); - dealoc(element); - }); - }); - - describe('startingElementHtml', function(){ it('should show starting element tag only', function(){ expect(startingTag('
text
')). From d8b047a88d2e5e3665d37234202606f30de23711 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 26 Aug 2014 17:21:05 -0700 Subject: [PATCH 036/180] refactor($compile): $$addScopeInfo always expects jq wrapper `$$addScopeInfo` used to accept either DOM Node or jqLite/jQuery wrapper. This commit simplifies the method to always require jqLite/jQuery wrapper and thus remove the `element.data` condition which was wrong. If `element` was a raw comment element, the `data` property was a string (the value of the comment) and an exception was thrown. --- src/ng/compile.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ng/compile.js b/src/ng/compile.js index 6994af57071a..f117e8486ef1 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -920,9 +920,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } : noop; compile.$$addScopeInfo = debugInfoEnabled ? function $$addScopeInfo(element, scope, isolated, noTemplate) { - safeAddClass(jqLite(element), isolated ? 'ng-isolate-scope' : 'ng-scope'); + safeAddClass(element, isolated ? 'ng-isolate-scope' : 'ng-scope'); var dataName = isolated ? (noTemplate ? '$isolateScopeNoTemplate' : '$isolateScope') : '$scope'; - element.data ? element.data(dataName, scope) : jqLite.data(element, dataName, scope); + element.data(dataName, scope); } : noop; return compile; @@ -1071,7 +1071,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (nodeLinkFn) { if (nodeLinkFn.scope) { childScope = scope.$new(); - compile.$$addScopeInfo(node, childScope); + compile.$$addScopeInfo(jqLite(node), childScope); } else { childScope = scope; } From b826353c83913dd7dca03ff2683cad429f456eaf Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 26 Aug 2014 17:22:26 -0700 Subject: [PATCH 037/180] refactor($compile): rename element -> $element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To follow our convention (at least in this file): if it’s a jqLite/jQuery wrapper than the variable name starts with `$`. --- src/ng/compile.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ng/compile.js b/src/ng/compile.js index f117e8486ef1..a8d1e389e67d 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -906,8 +906,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { }, NG_ATTR_BINDING = /^ngAttr[A-Z]/; - compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo(element, binding) { - var bindings = element.data('$binding') || []; + compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo($element, binding) { + var bindings = $element.data('$binding') || []; if (isArray(binding)) { bindings = bindings.concat(binding); @@ -915,14 +915,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { bindings.push(binding); } - safeAddClass(element, 'ng-binding'); - element.data('$binding', bindings); + safeAddClass($element, 'ng-binding'); + $element.data('$binding', bindings); } : noop; - compile.$$addScopeInfo = debugInfoEnabled ? function $$addScopeInfo(element, scope, isolated, noTemplate) { - safeAddClass(element, isolated ? 'ng-isolate-scope' : 'ng-scope'); + compile.$$addScopeInfo = debugInfoEnabled ? function $$addScopeInfo($element, scope, isolated, noTemplate) { + safeAddClass($element, isolated ? 'ng-isolate-scope' : 'ng-scope'); var dataName = isolated ? (noTemplate ? '$isolateScopeNoTemplate' : '$isolateScope') : '$scope'; - element.data(dataName, scope); + $element.data(dataName, scope); } : noop; return compile; From 816e577027635a92c7bc7c8166206bef1026bad5 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Wed, 27 Aug 2014 14:06:27 -0700 Subject: [PATCH 038/180] perf($compile): add debug classes in compile phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a93f03d and d37f103 we changed the compiler and ngBind to add debugging CSS classes (i.e. ng-scope, ng-binding) in linking function. This simplified the code and made sense under the original assumptions that the debug info will be disabled by default. That is however not the case - debug info is enabled by default. When debug info is enabled, this change improves the largetable-bp benchmark by ~580ms, that is 30% faster. Measuring the “create” phase, 25 loops, meantime ~1920ms -> ~1340ms. This change does not affect performance when debug info is disabled. --- src/ng/compile.js | 27 +++++++++++++++++++++++---- src/ng/directive/ngBind.js | 21 ++++++++++++++------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/ng/compile.js b/src/ng/compile.js index a8d1e389e67d..0f8ebf8fbf18 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -915,16 +915,22 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { bindings.push(binding); } - safeAddClass($element, 'ng-binding'); $element.data('$binding', bindings); } : noop; + compile.$$addBindingClass = debugInfoEnabled ? function $$addBindingClass($element) { + safeAddClass($element, 'ng-binding'); + } : noop; + compile.$$addScopeInfo = debugInfoEnabled ? function $$addScopeInfo($element, scope, isolated, noTemplate) { - safeAddClass($element, isolated ? 'ng-isolate-scope' : 'ng-scope'); var dataName = isolated ? (noTemplate ? '$isolateScopeNoTemplate' : '$isolateScope') : '$scope'; $element.data(dataName, scope); } : noop; + compile.$$addScopeClass = debugInfoEnabled ? function $$addScopeClass($element, isolated) { + safeAddClass($element, isolated ? 'ng-isolate-scope' : 'ng-scope'); + } : noop; + return compile; //================================ @@ -947,6 +953,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { compileNodes($compileNodes, transcludeFn, $compileNodes, maxPriority, ignoreDirective, previousCompileContext); + compile.$$addScopeClass($compileNodes); + return function publicLinkFn(scope, cloneConnectFn, transcludeControllers, parentBoundTranscludeFn, futureParentElement){ var namespace = null; assertArg(scope, 'scope'); @@ -1021,6 +1029,10 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { null, [], [], previousCompileContext) : null; + if (nodeLinkFn && nodeLinkFn.scope) { + compile.$$addScopeClass(attrs.$$element); + } + childLinkFn = (nodeLinkFn && nodeLinkFn.terminal || !(childNodes = nodeList[i].childNodes) || !childNodes.length) @@ -1574,8 +1586,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { compile.$$addScopeInfo($element, isolateScope, true, !(templateDirective && (templateDirective === newIsolateScopeDirective || templateDirective === newIsolateScopeDirective.$$originalDirective))); - - safeAddClass($element, 'ng-isolate-scope'); + compile.$$addScopeClass($element, true); forEach(newIsolateScopeDirective.scope, function(definition, scopeName) { var match = definition.match(LOCAL_REGEXP) || [], @@ -1985,8 +1996,16 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { directives.push({ priority: 0, compile: function textInterpolateCompileFn(templateNode) { + var templateNodeParent = templateNode.parent(), + hasCompileParent = !!templateNodeParent.length; + + // When transcluding a template that has bindings in the root + // we don't have a parent and thus need to add the class during linking fn. + if (hasCompileParent) compile.$$addBindingClass(templateNodeParent); + return function textInterpolateLinkFn(scope, node) { var parent = node.parent(); + if (!hasCompileParent) compile.$$addBindingClass(parent); compile.$$addBindingInfo(parent, interpolateFn.expressions); scope.$watch(interpolateFn, function interpolateFnWatchAction(value) { node[0].nodeValue = value; diff --git a/src/ng/directive/ngBind.js b/src/ng/directive/ngBind.js index 7d6f8d30a11f..e8eadf323199 100644 --- a/src/ng/directive/ngBind.js +++ b/src/ng/directive/ngBind.js @@ -55,6 +55,7 @@ var ngBindDirective = ['$compile', function($compile) { return { restrict: 'AC', compile: function(templateElement) { + $compile.$$addBindingClass(templateElement); return function (scope, element, attr) { $compile.$$addBindingInfo(element, attr.ngBind); scope.$watch(attr.ngBind, function ngBindWatchAction(value) { @@ -121,13 +122,18 @@ var ngBindDirective = ['$compile', function($compile) { */ var ngBindTemplateDirective = ['$interpolate', '$compile', function($interpolate, $compile) { - return function(scope, element, attr) { - var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); - $compile.$$addBindingInfo(element, interpolateFn.expressions); - attr.$observe('ngBindTemplate', function(value) { - element.text(value); - }); - }; + return { + compile: function(templateElement) { + $compile.$$addBindingClass(templateElement); + return function(scope, element, attr) { + var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); + $compile.$$addBindingInfo(element, interpolateFn.expressions); + attr.$observe('ngBindTemplate', function(value) { + element.text(value); + }); + }; + } + } }]; @@ -180,6 +186,7 @@ var ngBindHtmlDirective = ['$sce', '$parse', '$compile', function($sce, $parse, return { restrict: 'A', compile: function (tElement, tAttrs) { + $compile.$$addBindingClass(tElement); return function (scope, element, attr) { $compile.$$addBindingInfo(element, attr.ngBindHtml); From 82725e58859d66cb95be7ccb0d2e61d771d2e590 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Wed, 27 Aug 2014 14:37:54 -0700 Subject: [PATCH 039/180] refactor(ngBind): name link and compile functions For easier debugging. --- src/ng/directive/ngBind.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ng/directive/ngBind.js b/src/ng/directive/ngBind.js index e8eadf323199..e1891a1a960a 100644 --- a/src/ng/directive/ngBind.js +++ b/src/ng/directive/ngBind.js @@ -54,9 +54,9 @@ var ngBindDirective = ['$compile', function($compile) { return { restrict: 'AC', - compile: function(templateElement) { + compile: function ngBindCompile(templateElement) { $compile.$$addBindingClass(templateElement); - return function (scope, element, attr) { + return function ngBindLink(scope, element, attr) { $compile.$$addBindingInfo(element, attr.ngBind); scope.$watch(attr.ngBind, function ngBindWatchAction(value) { // We are purposefully using == here rather than === because we want to @@ -123,9 +123,9 @@ var ngBindDirective = ['$compile', function($compile) { */ var ngBindTemplateDirective = ['$interpolate', '$compile', function($interpolate, $compile) { return { - compile: function(templateElement) { + compile: function ngBindTemplateCompile(templateElement) { $compile.$$addBindingClass(templateElement); - return function(scope, element, attr) { + return function ngBindTemplateLink(scope, element, attr) { var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); $compile.$$addBindingInfo(element, interpolateFn.expressions); attr.$observe('ngBindTemplate', function(value) { @@ -133,7 +133,7 @@ var ngBindTemplateDirective = ['$interpolate', '$compile', function($interpolate }); }; } - } + }; }]; @@ -185,10 +185,10 @@ var ngBindTemplateDirective = ['$interpolate', '$compile', function($interpolate var ngBindHtmlDirective = ['$sce', '$parse', '$compile', function($sce, $parse, $compile) { return { restrict: 'A', - compile: function (tElement, tAttrs) { + compile: function ngBindHtmlCompile(tElement, tAttrs) { $compile.$$addBindingClass(tElement); - return function (scope, element, attr) { + return function ngBindHtmlLink(scope, element, attr) { $compile.$$addBindingInfo(element, attr.ngBindHtml); var ngBindHtmlGetter = $parse(attr.ngBindHtml); var ngBindHtmlWatch = $parse(attr.ngBindHtml, function getStringValue(value) { From 636dbcaf824076b80dace38dea3addd406c497b9 Mon Sep 17 00:00:00 2001 From: Brian Ford Date: Wed, 27 Aug 2014 13:16:39 -0700 Subject: [PATCH 040/180] docs(debugInfo): add docs for $compileProvider.debugInfoEnabled() --- docs/content/guide/index.ngdoc | 1 + docs/content/guide/production.ngdoc | 42 +++++++++++++++++++++++++++++ src/Angular.js | 15 ++--------- src/ng/compile.js | 3 +++ 4 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 docs/content/guide/production.ngdoc diff --git a/docs/content/guide/index.ngdoc b/docs/content/guide/index.ngdoc index 0ca0abdcb54d..93bc7b42d062 100644 --- a/docs/content/guide/index.ngdoc +++ b/docs/content/guide/index.ngdoc @@ -81,6 +81,7 @@ This is a short list of libraries with specific support and documentation for wo ### General +* **Docs Page:** {@link guide/production Running an AngularJS App in Production} * **Javascript minification: **[Background](http://thegreenpizza.github.io/2013/05/25/building-minification-safe-angular.js-applications/), [ng-annotate automation tool](https://github.com/olov/ng-annotate) * **Analytics and Logging:** [Angularyitcs (Google Analytics)](http://ngmodules.org/modules/angularytics), [Angulartics (Analytics)](https://github.com/luisfarzati/angulartics), [Logging Client-Side Errors](http://www.bennadel.com/blog/2542-Logging-Client-Side-Errors-With-AngularJS-And-Stacktrace-js.htm) * **SEO:** [By hand](http://www.yearofmoo.com/2012/11/angularjs-and-seo.html), [prerender.io](http://prerender.io/), [Brombone](http://www.brombone.com/), [SEO.js](http://getseojs.com/), [SEO4Ajax](http://www.seo4ajax.com/) diff --git a/docs/content/guide/production.ngdoc b/docs/content/guide/production.ngdoc new file mode 100644 index 000000000000..996e0486f5fc --- /dev/null +++ b/docs/content/guide/production.ngdoc @@ -0,0 +1,42 @@ +@ngdoc overview +@name Running in Production +@description + +# Running an AngularJS App in Production + +There are a few things you might consider when running your AngularJS application in production. + + +## Disabling Debug Data + +By default AngularJS attaches information about scopes to DOM nodes, and adds CSS classes +to data-bound elements. The information that is not included is: + +As a result of `ngBind`, `ngBindHtml` or `{{...}}` interpolations, binding data and CSS class +`ng-class` is attached to the corresponding element. + +Where the compiler has created a new scope, the scope and either `ng-scope` or `ng-isolated-scope` +CSS class are attached to the corresponding element. These scope references can then be accessed via +`element.scope()` and `element.isolateScope()`. + +Tools like [Protractor](github.com/angular/protractor) and +[Batarang](https://github.com/angular/angularjs-batarang) need this information to run, +but you can disable this in production for a significant performance boost with: + +```js +myApp.config(['$compileProvider', function ($compileProvider) { + $compileProvider.debugInfoEnabled(false); +}]); +``` + +If you wish to debug an application with this information then you should open up a debug +console in the browser then call this method directly in this console: + +```js +angular.reloadWithDebugInfo(); +``` + +The page should reload and the debug information should now be available. + +For more see the docs pages on {@link ng.$compileProvider#debugInfoEnabled `$compileProvider`} +and {@link ng/function/angular.reloadWithDebugInfo `angular.reloadWithDebugInfo`}. diff --git a/src/Angular.js b/src/Angular.js index 635314e77775..d3c80e1cc841 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -1450,20 +1450,9 @@ function bootstrap(element, modules, config) { * @module ng * @description * Use this function to reload the current application with debug information turned on. + * This takes precedence over a call to `$compileProvider.debugInfoEnabled(false)`. * - * To improve performance adding various debugging information can be disabled. - * See {@link $compileProvider#debugInfoEnabled}. - * - * This overrides any setting of `$compileProvider.debugInfoEnabled()` that you defined in your - * modules. If you wish to debug an application via this information then you should open up a debug - * console in the browser then call this method directly in this console: - * - * ```js - * angular.reloadWithDebugInfo(); - * ``` - * - * The page should reload and the debug information should now be available. - * + * See {@link ng.$compileProvider#debugInfoEnabled} for more. */ function reloadWithDebugInfo() { window.name = 'NG_ENABLE_DEBUG_INFO!' + window.name; diff --git a/src/ng/compile.js b/src/ng/compile.js index 0f8ebf8fbf18..b6cfe5c372b2 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -685,6 +685,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { * * `ng-binding` CSS class * * `$binding` data property containing an array of the binding expressions * + * You may want to use this in production for a significant performance boost. See + * {@link guide/production#disabling-debug-data Disabling Debug Data} for more. + * * The default value is true. */ var debugInfoEnabled = true; From e5b58c04b7c3e849a383bcb3d604d18d95846fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Thu, 28 Aug 2014 11:25:15 -0400 Subject: [PATCH 041/180] test($animate): add tests for noop enaled and cancel methods --- src/ng/animate.js | 1 - test/ng/animateSpec.js | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ng/animate.js b/src/ng/animate.js index 81539bdad114..ababe39f96e9 100644 --- a/src/ng/animate.js +++ b/src/ng/animate.js @@ -88,7 +88,6 @@ var $AnimateProvider = ['$provide', function($provide) { // only serve one instance of a promise in order to save CPU cycles if (!currentDefer) { currentDefer = $$q.defer(); - currentDefer.promise.cancel = noop; //ngAnimate.$animate provides this $$asyncCallback(function() { currentDefer.resolve(); currentDefer = null; diff --git a/test/ng/animateSpec.js b/test/ng/animateSpec.js index 9e167dc35589..912012b160c4 100644 --- a/test/ng/animateSpec.js +++ b/test/ng/animateSpec.js @@ -71,6 +71,14 @@ describe("$animate", function() { expect($animate.leave(element)).toBeAPromise(); })); + it("should provide noop `enabled` and `cancel` methods", inject(function($animate) { + expect($animate.enabled).toBe(angular.noop); + expect($animate.enabled()).toBeUndefined(); + + expect($animate.cancel).toBe(angular.noop); + expect($animate.cancel()).toBeUndefined(); + })); + it("should add and remove classes on SVG elements", inject(function($animate) { if (!window.SVGElement) return; var svg = jqLite(''); From c74e1f4004189d6887a91aafb48ad401cf40a54f Mon Sep 17 00:00:00 2001 From: Igor Minar Date: Thu, 28 Aug 2014 09:30:29 -0700 Subject: [PATCH 042/180] chore(benchmarks): disable debugInfo in largetable benchmark --- benchmarks/largetable-bp/app.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/benchmarks/largetable-bp/app.js b/benchmarks/largetable-bp/app.js index 6135bccf8f58..10ddbe976218 100644 --- a/benchmarks/largetable-bp/app.js +++ b/benchmarks/largetable-bp/app.js @@ -1,5 +1,11 @@ var app = angular.module('largetableBenchmark', []); +app.config(function($compileProvider) { + if ($compileProvider.debugInfoEnabled) { + $compileProvider.debugInfoEnabled(false); + } +}); + app.filter('noop', function() { return function(input) { return input; From 9b940f90d1b4b43745f1d280158692fb889029d1 Mon Sep 17 00:00:00 2001 From: Shahar Talmi Date: Fri, 22 Aug 2014 04:18:06 +0300 Subject: [PATCH 043/180] fix(ngModel): allow non-assignable binding when getterSetter is used Closes #8704 --- src/ng/directive/input.js | 22 +++++++++++----------- test/ng/directive/inputSpec.js | 32 ++++++++++++-------------------- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 068079fb1e51..9017dd301adb 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1599,10 +1599,14 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ pendingDebounce = null, ctrl = this; - if (!ngModelSet) { - throw $ngModelMinErr('nonassign', "Expression '{0}' is non-assignable. Element: {1}", - $attr.ngModel, startingTag($element)); - } + this.$$setOptions = function(options) { + ctrl.$options = options; + + if (!ngModelSet && (!options || !options.getterSetter)) { + throw $ngModelMinErr('nonassign', "Expression '{0}' is non-assignable. Element: {1}", + $attr.ngModel, startingTag($element)); + } + }; /** * @ngdoc method @@ -2293,16 +2297,12 @@ var ngModelDirective = function() { controller: NgModelController, link: { pre: function(scope, element, attr, ctrls) { - // Pass the ng-model-options to the ng-model controller - if (ctrls[2]) { - ctrls[0].$options = ctrls[2].$options; - } - - // notify others, especially parent forms - var modelCtrl = ctrls[0], formCtrl = ctrls[1] || nullFormCtrl; + modelCtrl.$$setOptions(ctrls[2] && ctrls[2].$options); + + // notify others, especially parent forms formCtrl.$addControl(modelCtrl); scope.$on('$destroy', function() { diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index ec09d76e07d8..eb5ed857ed0e 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -32,26 +32,6 @@ describe('NgModelController', function() { }); - it('should fail on non-assignable model binding', inject(function($controller) { - var exception; - - try { - $controller(NgModelController, { - $scope: null, - $element: jqLite(''), - $attrs: { - ngModel: '1+2' - } - }); - } catch (e) { - exception = e; - } - - expect(exception.message). - toMatch(/^\[ngModel:nonassign\] Expression '1\+2' is non\-assignable\. Element: /); - })); - - it('should init the properties', function() { expect(ctrl.$untouched).toBe(true); expect(ctrl.$touched).toBe(false); @@ -1581,6 +1561,18 @@ describe('input', function() { expect(scope.name).toBe('d'); }); + it('should fail on non-assignable model binding if getterSetter is false', function() { + expect(function() { + compileInput(''); + }).toThrowMinErr('ngModel', 'nonassign', 'Expression \'accessor(user, \'name\')\' is non-assignable.'); + }); + + it('should not fail on non-assignable model binding if getterSetter is true', function() { + compileInput( + ''); + }); + }); it('should allow complex reference binding', function() { From 8ac8b5f06e7a8a5e78e82da68e39289d5e085c9a Mon Sep 17 00:00:00 2001 From: Tim Kindberg Date: Wed, 27 Aug 2014 23:37:02 -0400 Subject: [PATCH 044/180] docs(ngModelOptions): fix example --- src/ng/directive/input.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 9017dd301adb..bcec3dd1a4eb 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -2729,7 +2729,7 @@ var ngValueDirective = function() { * - `debounce`: integer value which contains the debounce model update value in milliseconds. A * value of 0 triggers an immediate update. If an object is supplied instead, you can specify a * custom value for each event. For example: - * `ngModelOptions="{ updateOn: 'default blur', debounce: {'default': 500, 'blur': 0} }"` + * `ng-model-options="{ updateOn: 'default blur', debounce: {'default': 500, 'blur': 0} }"` * - `getterSetter`: boolean value which determines whether or not to treat functions bound to `ngModel` as getters/setters. * - `timezone`: Defines the timezone to be used to read/write the `Date` instance in the model for From 544416ddbb0c8c6046036af5b6352750af5d7097 Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Wed, 27 Aug 2014 15:29:39 -0700 Subject: [PATCH 045/180] fix(ngEventDirs): execute `blur` and `focus` expression using `scope.$evalAsync` BREAKING CHANGE: The `blur` and `focus` event fire synchronously, also during DOM operations that remove elements. This lead to errors as the Angular model was not in a consistent state. See this [fiddle](http://jsfiddle.net/fq1dq5yb/) for a demo. This change executes the expression of those events using `scope.$evalAsync` if an `$apply` is in progress, otherwise keeps the old behavior. Fixes #4979 Fixes #5945 Closes #8803 Closes #6910 Closes #5402 --- src/ng/directive/ngEventDirs.js | 29 ++++++++++++-- test/ng/directive/ngEventDirsSpec.js | 60 ++++++++++++++++++++++++++++ test/ng/directive/ngKeySpec.js | 18 --------- 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/ng/directive/ngEventDirs.js b/src/ng/directive/ngEventDirs.js index d228c76077c0..c37ec695706c 100644 --- a/src/ng/directive/ngEventDirs.js +++ b/src/ng/directive/ngEventDirs.js @@ -37,6 +37,14 @@ * Events that are handled via these handler are always configured not to propagate further. */ var ngEventDirectives = {}; + +// For events that might fire synchronously during DOM manipulation +// we need to execute their event handlers asynchronously using $evalAsync, +// so that they are not executed in an inconsistent state. +var forceAsyncEvents = { + 'blur': true, + 'focus': true +}; forEach( 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '), function(name) { @@ -47,10 +55,16 @@ forEach( compile: function($element, attr) { var fn = $parse(attr[directiveName]); return function ngEventHandler(scope, element) { - element.on(lowercase(name), function(event) { - scope.$apply(function() { + var eventName = lowercase(name); + element.on(eventName, function(event) { + var callback = function() { fn(scope, {$event:event}); - }); + }; + if (forceAsyncEvents[eventName] && scope.$$phase) { + scope.$evalAsync(callback); + } else { + scope.$apply(callback); + } }); }; } @@ -367,6 +381,10 @@ forEach( * @description * Specify custom behavior on focus event. * + * Note: As the `focus` event is executed synchronously when calling `input.focus()` + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. + * * @element window, input, select, textarea, a * @priority 0 * @param {expression} ngFocus {@link guide/expression Expression} to evaluate upon @@ -383,6 +401,11 @@ forEach( * @description * Specify custom behavior on blur event. * + * Note: As the `blur` event is executed synchronously also during DOM manipulations + * (e.g. removing a focussed input), + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. + * * @element window, input, select, textarea, a * @priority 0 * @param {expression} ngBlur {@link guide/expression Expression} to evaluate upon diff --git a/test/ng/directive/ngEventDirsSpec.js b/test/ng/directive/ngEventDirsSpec.js index 5b73c2dd6a8b..1e1d5c92be55 100644 --- a/test/ng/directive/ngEventDirsSpec.js +++ b/test/ng/directive/ngEventDirsSpec.js @@ -39,4 +39,64 @@ describe('event directives', function() { expect($rootScope.formSubmitted).toEqual('foo'); })); }); + + describe('focus', function() { + + it('should call the listener asynchronously during $apply', + inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + $rootScope.focus = jasmine.createSpy('focus'); + + $rootScope.$apply(function() { + element.triggerHandler('focus'); + expect($rootScope.focus).not.toHaveBeenCalled(); + }); + + expect($rootScope.focus).toHaveBeenCalledOnce(); + })); + + it('should call the listener synchronously inside of $apply if outside of $apply', + inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + $rootScope.focus = jasmine.createSpy('focus').andCallFake(function() { + $rootScope.value = 'newValue'; + }); + + element.triggerHandler('focus'); + + expect($rootScope.focus).toHaveBeenCalledOnce(); + expect(element.val()).toBe('newValue'); + })); + + }); + + describe('blur', function() { + + it('should call the listener asynchronously during $apply', + inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + $rootScope.blur = jasmine.createSpy('blur'); + + $rootScope.$apply(function() { + element.triggerHandler('blur'); + expect($rootScope.blur).not.toHaveBeenCalled(); + }); + + expect($rootScope.blur).toHaveBeenCalledOnce(); + })); + + it('should call the listener synchronously inside of $apply if outside of $apply', + inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + $rootScope.blur = jasmine.createSpy('blur').andCallFake(function() { + $rootScope.value = 'newValue'; + }); + + element.triggerHandler('blur'); + + expect($rootScope.blur).toHaveBeenCalledOnce(); + expect(element.val()).toBe('newValue'); + })); + + }); }); diff --git a/test/ng/directive/ngKeySpec.js b/test/ng/directive/ngKeySpec.js index ef5addd507ed..c7b989a48b14 100644 --- a/test/ng/directive/ngKeySpec.js +++ b/test/ng/directive/ngKeySpec.js @@ -34,23 +34,5 @@ describe('ngKeyup and ngKeydown directives', function() { expect($rootScope.touched).toEqual(true); })); - it('should get called on focus', inject(function($rootScope, $compile) { - element = $compile('')($rootScope); - $rootScope.$digest(); - expect($rootScope.touched).toBeFalsy(); - - browserTrigger(element, 'focus'); - expect($rootScope.touched).toEqual(true); - })); - - it('should get called on blur', inject(function($rootScope, $compile) { - element = $compile('')($rootScope); - $rootScope.$digest(); - expect($rootScope.touched).toBeFalsy(); - - browserTrigger(element, 'blur'); - expect($rootScope.touched).toEqual(true); - })); - }); From efc5e20c5c1619253bd920a1a00a6dc08cad9213 Mon Sep 17 00:00:00 2001 From: Erin Altenhof-Long Date: Thu, 12 Jun 2014 12:01:29 -0700 Subject: [PATCH 046/180] feat(ngRoute): alias string as redirectTo property in .otherwise() Allow `.otherwise()` to interpret a string parameter as the `redirectTo` property Closes #7794 --- src/ngRoute/route.js | 6 +++++- test/ngRoute/routeSpec.js | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/ngRoute/route.js b/src/ngRoute/route.js index b140ddfbc1b8..9ffabc323ddc 100644 --- a/src/ngRoute/route.js +++ b/src/ngRoute/route.js @@ -212,10 +212,14 @@ function $RouteProvider(){ * Sets route definition that will be used on route change when no other route definition * is matched. * - * @param {Object} params Mapping information to be assigned to `$route.current`. + * @param {Object|string} params Mapping information to be assigned to `$route.current`. + * If called with a string, the value maps to `redirectTo`. * @returns {Object} self */ this.otherwise = function(params) { + if (typeof params === 'string') { + params = {redirectTo: params}; + } this.when(null, params); return this; }; diff --git a/test/ngRoute/routeSpec.js b/test/ngRoute/routeSpec.js index 220d4f47b631..8a0a370f0615 100644 --- a/test/ngRoute/routeSpec.js +++ b/test/ngRoute/routeSpec.js @@ -460,6 +460,23 @@ describe('$route', function() { expect(onChangeSpy).toHaveBeenCalled(); }); }); + + + it('should interpret a string as a redirect route', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'foo.html'}); + $routeProvider.when('/baz', {templateUrl: 'baz.html'}); + $routeProvider.otherwise('/foo'); + }); + + inject(function($route, $location, $rootScope) { + $location.path('/unknownRoute'); + $rootScope.$digest(); + + expect($location.path()).toBe('/foo'); + expect($route.current.templateUrl).toBe('foo.html'); + }); + }); }); From 50461aa4b63a1e38f0322ec90904be44323ecbc4 Mon Sep 17 00:00:00 2001 From: Colin Casey Date: Sat, 21 Sep 2013 16:42:12 -0300 Subject: [PATCH 047/180] feat(filterFilter): pass index to function predicate Closes #654 --- src/ng/filter/filter.js | 12 ++++++------ test/ng/filter/filterSpec.js | 10 ++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/ng/filter/filter.js b/src/ng/filter/filter.js index 9f0afeaa68b0..1ae95e404518 100644 --- a/src/ng/filter/filter.js +++ b/src/ng/filter/filter.js @@ -25,9 +25,9 @@ * property of the object. That's equivalent to the simple substring match with a `string` * as described above. * - * - `function(value)`: A predicate function can be used to write arbitrary filters. The function is - * called for each element of `array`. The final result is an array of those elements that - * the predicate returned true for. + * - `function(value, index)`: A predicate function can be used to write arbitrary filters. The + * function is called for each element of `array`. The final result is an array of those + * elements that the predicate returned true for. * * @param {function(actual, expected)|true|undefined} comparator Comparator which is used in * determining if the expected value (from the filter expression) and actual value (from @@ -120,9 +120,9 @@ function filterFilter() { var comparatorType = typeof(comparator), predicates = []; - predicates.check = function(value) { + predicates.check = function(value, index) { for (var j = 0; j < predicates.length; j++) { - if(!predicates[j](value)) { + if(!predicates[j](value, index)) { return false; } } @@ -211,7 +211,7 @@ function filterFilter() { var filtered = []; for ( var j = 0; j < array.length; j++) { var value = array[j]; - if (predicates.check(value)) { + if (predicates.check(value, j)) { filtered.push(value); } } diff --git a/test/ng/filter/filterSpec.js b/test/ng/filter/filterSpec.js index 083e198f438e..4249be8ef2c3 100644 --- a/test/ng/filter/filterSpec.js +++ b/test/ng/filter/filterSpec.js @@ -49,6 +49,16 @@ describe('Filter: filter', function() { expect(filter(items, function(i) {return i.done;}).length).toBe(1); }); + it('should pass the index to a function predicate', function() { + var items = [0, 1, 2, 3]; + + var result = filter(items, function(value, index) { + return index % 2 === 0; + }); + + expect(result).toEqual([0, 2]); + }); + it('should take object as predicate', function() { var items = [{first: 'misko', last: 'hevery'}, {first: 'adam', last: 'abrons'}]; From dae965d426ce9bf15602cd1c47647165ee31470e Mon Sep 17 00:00:00 2001 From: Julie Date: Mon, 9 Jun 2014 22:20:47 -0700 Subject: [PATCH 048/180] feat(testability): add $$testability service The $$testability service is a collection of methods for use when debugging or by automated testing tools. It is available globally through the function `angular.getTestability`. For reference, see the Angular.Dart version at https://github.com/angular/angular.dart/pull/1191 --- angularFiles.js | 1 + docs/content/guide/expression.ngdoc | 4 +- docs/content/guide/module.ngdoc | 4 +- npm-shrinkwrap.json | 2 +- package.json | 2 +- src/.jshintrc | 1 + src/Angular.js | 13 +++ src/AngularPublic.js | 3 + src/ng/directive/input.js | 8 +- src/ng/directive/ngEventDirs.js | 4 +- src/ng/directive/select.js | 6 +- src/ng/filter/filters.js | 2 +- src/ng/filter/limitTo.js | 4 +- src/ng/testability.js | 117 +++++++++++++++++++ test/ng/testabilitySpec.js | 172 ++++++++++++++++++++++++++++ 15 files changed, 327 insertions(+), 16 deletions(-) create mode 100644 src/ng/testability.js create mode 100644 test/ng/testabilitySpec.js diff --git a/angularFiles.js b/angularFiles.js index 924fcd487fb2..facb607af961 100755 --- a/angularFiles.js +++ b/angularFiles.js @@ -35,6 +35,7 @@ var angularFiles = { 'src/ng/sce.js', 'src/ng/sniffer.js', 'src/ng/templateRequest.js', + 'src/ng/testability.js', 'src/ng/timeout.js', 'src/ng/urlUtils.js', 'src/ng/window.js', diff --git a/docs/content/guide/expression.ngdoc b/docs/content/guide/expression.ngdoc index 1650a8286021..9c4069355709 100644 --- a/docs/content/guide/expression.ngdoc +++ b/docs/content/guide/expression.ngdoc @@ -38,7 +38,9 @@ the method from your view. If you want to `eval()` an Angular expression yoursel ## Example - 1+2={{1+2}} + + 1+2={{1+2}} + diff --git a/docs/content/guide/module.ngdoc b/docs/content/guide/module.ngdoc index 1ef968347a05..0b0297904bc6 100644 --- a/docs/content/guide/module.ngdoc +++ b/docs/content/guide/module.ngdoc @@ -50,7 +50,7 @@ I'm in a hurry. How do I get a Hello World module working? it('should add Hello to the name', function() { - expect(element(by.binding(" 'World' | greet ")).getText()).toEqual('Hello, World!'); + expect(element(by.binding("'World' | greet")).getText()).toEqual('Hello, World!'); }); @@ -128,7 +128,7 @@ The above is a suggestion. Tailor it to your needs. it('should add Hello to the name', function() { - expect(element(by.binding(" greeting ")).getText()).toEqual('Bonjour World!'); + expect(element(by.binding("greeting")).getText()).toEqual('Bonjour World!'); }); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index cc3cf7043cd4..c215feba8bb6 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -3230,7 +3230,7 @@ } }, "protractor": { - "version": "1.1.1", + "version": "1.2.0-beta1", "dependencies": { "request": { "version": "2.36.0", diff --git a/package.json b/package.json index e86ec044616d..75caf15aa889 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "karma-sauce-launcher": "0.2.0", "karma-script-launcher": "0.1.0", "karma-browserstack-launcher": "0.0.7", - "protractor": "1.0.0", + "protractor": "1.2.0-beta1", "yaml-js": "~0.0.8", "rewire": "1.1.3", "promises-aplus-tests": "~2.0.4", diff --git a/src/.jshintrc b/src/.jshintrc index 6b7190b12ff8..e26593342df5 100644 --- a/src/.jshintrc +++ b/src/.jshintrc @@ -79,6 +79,7 @@ "encodeUriQuery": false, "angularInit": false, "bootstrap": false, + "getTestability": false, "snake_case": false, "bindJQuery": false, "assertArg": false, diff --git a/src/Angular.js b/src/Angular.js index d3c80e1cc841..a90d7ee7b35b 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -74,6 +74,7 @@ encodeUriQuery: true, angularInit: true, bootstrap: true, + getTestability: true, snake_case: true, bindJQuery: true, assertArg: true, @@ -1459,6 +1460,18 @@ function reloadWithDebugInfo() { window.location.reload(); } +/* + * @name angular.getTestability + * @module ng + * @description + * Get the testability service for the instance of Angular on the given + * element. + * @param {DOMElement} element DOM element which is the root of angular application. + */ +function getTestability(rootElement) { + return angular.element(rootElement).injector().get('$$testability'); +} + var SNAKE_CASE_REGEXP = /[A-Z]/g; function snake_case(name, separator) { separator = separator || '_'; diff --git a/src/AngularPublic.js b/src/AngularPublic.js index f81b613f644c..c263e1a9c8cf 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -79,6 +79,7 @@ $SnifferProvider, $TemplateCacheProvider, $TemplateRequestProvider, + $$TestabilityProvider, $TimeoutProvider, $$RAFProvider, $$AsyncCallbackProvider, @@ -136,6 +137,7 @@ function publishExternalAPI(angular){ 'lowercase': lowercase, 'uppercase': uppercase, 'callbacks': {counter: 0}, + 'getTestability': getTestability, '$$minErr': minErr, '$$csp': csp, 'reloadWithDebugInfo': reloadWithDebugInfo @@ -230,6 +232,7 @@ function publishExternalAPI(angular){ $sniffer: $SnifferProvider, $templateCache: $TemplateCacheProvider, $templateRequest: $TemplateRequestProvider, + $$testability: $$TestabilityProvider, $timeout: $TimeoutProvider, $window: $WindowProvider, $$rAF: $$RAFProvider, diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index bcec3dd1a4eb..a306c11e282a 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -814,7 +814,7 @@ var inputType = {
it('should change state', function() { - var color = element(by.binding('color | json')); + var color = element(by.binding('color')); expect(color.getText()).toContain('blue'); @@ -1313,7 +1313,7 @@ function checkboxInputType(scope, element, attr, ctrl, $sniffer, $browser, $filt - var user = element(by.binding('user')); + var user = element(by.exactBinding('user')); var userNameValid = element(by.binding('myForm.userName.$valid')); var lastNameValid = element(by.binding('myForm.lastName.$valid')); var lastNameError = element(by.binding('myForm.lastName.$error')); @@ -2542,7 +2542,7 @@ var minlengthDirective = function() { * * * var listInput = element(by.model('names')); - * var names = element(by.binding('names')); + * var names = element(by.exactBinding('names')); * var valid = element(by.binding('myForm.namesInput.$valid')); * var error = element(by.css('span.error')); * @@ -2572,7 +2572,7 @@ var minlengthDirective = function() { * * it("should split the text by newlines", function() { * var listInput = element(by.model('list')); - * var output = element(by.binding(' list | json ')); + * var output = element(by.binding('list | json')); * listInput.sendKeys('abc\ndef\nghi'); * expect(output.getText()).toContain('[\n "abc",\n "def",\n "ghi"\n]'); * }); diff --git a/src/ng/directive/ngEventDirs.js b/src/ng/directive/ngEventDirs.js index c37ec695706c..ebcb0920a4ad 100644 --- a/src/ng/directive/ngEventDirs.js +++ b/src/ng/directive/ngEventDirs.js @@ -19,7 +19,9 @@ - count: {{count}} + + count: {{count}} + it('should check ng-click', function() { diff --git a/src/ng/directive/select.js b/src/ng/directive/select.js index 285791e64727..f8f2f0b81297 100644 --- a/src/ng/directive/select.js +++ b/src/ng/directive/select.js @@ -123,13 +123,13 @@ var ngOptionsMinErr = minErr('ngOptions'); it('should check ng-options', function() { - expect(element(by.binding(' {selected_color:myColor} ')).getText()).toMatch('red'); + expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('red'); element.all(by.model('myColor')).first().click(); element.all(by.css('select[ng-model="myColor"] option')).first().click(); - expect(element(by.binding(' {selected_color:myColor} ')).getText()).toMatch('black'); + expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('black'); element(by.css('.nullable select[ng-model="myColor"]')).click(); element.all(by.css('.nullable select[ng-model="myColor"] option')).first().click(); - expect(element(by.binding(' {selected_color:myColor} ')).getText()).toMatch('null'); + expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('null'); }); diff --git a/src/ng/filter/filters.js b/src/ng/filter/filters.js index 0b69d7b4ba07..65ea0e1ab0f4 100644 --- a/src/ng/filter/filters.js +++ b/src/ng/filter/filters.js @@ -490,7 +490,7 @@ function dateFilter($locale) { it('should jsonify filtered objects', function() { - expect(element(by.binding(" {'name':'value'} | json ")).getText()).toMatch(/\{\n "name": ?"value"\n}/); + expect(element(by.binding("{'name':'value'}")).getText()).toMatch(/\{\n "name": ?"value"\n}/); }); diff --git a/src/ng/filter/limitTo.js b/src/ng/filter/limitTo.js index 6f01aa9c1f86..52abd260b9a2 100644 --- a/src/ng/filter/limitTo.js +++ b/src/ng/filter/limitTo.js @@ -40,8 +40,8 @@ var numLimitInput = element(by.model('numLimit')); var letterLimitInput = element(by.model('letterLimit')); - var limitedNumbers = element(by.binding(' numbers | limitTo:numLimit ')); - var limitedLetters = element(by.binding(' letters | limitTo:letterLimit ')); + var limitedNumbers = element(by.binding('numbers | limitTo:numLimit')); + var limitedLetters = element(by.binding('letters | limitTo:letterLimit')); it('should limit the number array to first three items', function() { expect(numLimitInput.getAttribute('value')).toBe('3'); diff --git a/src/ng/testability.js b/src/ng/testability.js new file mode 100644 index 000000000000..299ce2f95b95 --- /dev/null +++ b/src/ng/testability.js @@ -0,0 +1,117 @@ +'use strict'; + + +function $$TestabilityProvider() { + this.$get = ['$rootScope', '$browser', '$location', + function($rootScope, $browser, $location) { + + /** + * @name $testability + * + * @description + * The private $$testability service provides a collection of methods for use when debugging + * or by automated test and debugging tools. + */ + var testability = {}; + + /** + * @name $$testability#findBindings + * + * @description + * Returns an array of elements that are bound (via ng-bind or {{}}) + * to expressions matching the input. + * + * @param {Element} element The element root to search from. + * @param {string} expression The binding expression to match. + * @param {boolean} opt_exactMatch If true, only returns exact matches + * for the expression. Filters and whitespace are ignored. + */ + testability.findBindings = function(element, expression, opt_exactMatch) { + var bindings = element.getElementsByClassName('ng-binding'); + var matches = []; + forEach(bindings, function(binding) { + var dataBinding = angular.element(binding).data('$binding'); + if (dataBinding) { + forEach(dataBinding, function(bindingName) { + if (opt_exactMatch) { + var matcher = new RegExp('(^|\\s)' + expression + '(\\s|\\||$)'); + if (matcher.test(bindingName)) { + matches.push(binding); + } + } else { + if (bindingName.indexOf(expression) != -1) { + matches.push(binding); + } + } + }); + } + }); + return matches; + }; + + /** + * @name $$testability#findModels + * + * @description + * Returns an array of elements that are two-way found via ng-model to + * expressions matching the input. + * + * @param {Element} element The element root to search from. + * @param {string} expression The model expression to match. + * @param {boolean} opt_exactMatch If true, only returns exact matches + * for the expression. + */ + testability.findModels = function(element, expression, opt_exactMatch) { + var prefixes = ['ng-', 'data-ng-', 'ng\\:']; + for (var p = 0; p < prefixes.length; ++p) { + var attributeEquals = opt_exactMatch ? '=' : '*='; + var selector = '[' + prefixes[p] + 'model' + attributeEquals + '"' + expression + '"]'; + var elements = element.querySelectorAll(selector); + if (elements.length) { + return elements; + } + } + }; + + /** + * @name $$testability#getLocation + * + * @description + * Shortcut for getting the location in a browser agnostic way. Returns + * the path, search, and hash. (e.g. /path?a=b#hash) + */ + testability.getLocation = function() { + return $location.url(); + }; + + /** + * @name $$testability#setLocation + * + * @description + * Shortcut for navigating to a location without doing a full page reload. + * + * @param {string} url The location url (path, search and hash, + * e.g. /path?a=b#hash) to go to. + */ + testability.setLocation = function(url) { + if (url !== $location.url()) { + $location.url(url); + $rootScope.$digest(); + } + }; + + /** + * @name $$testability#whenStable + * + * @description + * Calls the callback when $timeout and $http requests are completed. + * + * @param {function} callback + */ + testability.whenStable = function(callback) { + $browser.notifyWhenNoOutstandingRequests(callback); + }; + + return testability; + }]; +} diff --git a/test/ng/testabilitySpec.js b/test/ng/testabilitySpec.js new file mode 100644 index 000000000000..6454248531b2 --- /dev/null +++ b/test/ng/testabilitySpec.js @@ -0,0 +1,172 @@ +'use strict'; + +describe('$$testability', function() { + describe('finding elements', function() { + var $$testability, $compile, scope, element; + + beforeEach(inject(function(_$$testability_, _$compile_, $rootScope) { + $$testability = _$$testability_; + $compile = _$compile_; + scope = $rootScope.$new(); + })); + + afterEach(function() { + dealoc(element); + }); + + it('should find partial bindings', function() { + element = + '
' + + ' {{name}}' + + ' {{username}}' + + '
'; + element = $compile(element)(scope); + var names = $$testability.findBindings(element[0], 'name'); + expect(names.length).toBe(2); + expect(names[0]).toBe(element.find('span')[0]); + expect(names[1]).toBe(element.find('span')[1]); + }); + + it('should find exact bindings', function() { + element = + '
' + + ' {{name}}' + + ' {{username}}' + + '
'; + element = $compile(element)(scope); + var users = $$testability.findBindings(element[0], 'name', true); + expect(users.length).toBe(1); + expect(users[0]).toBe(element.find('span')[0]); + }); + + it('should ignore filters for exact bindings', function() { + element = + '
' + + ' {{name | uppercase}}' + + ' {{username}}' + + '
'; + element = $compile(element)(scope); + var users = $$testability.findBindings(element[0], 'name', true); + expect(users.length).toBe(1); + expect(users[0]).toBe(element.find('span')[0]); + }); + + it('should ignore whitespace for exact bindings', function() { + element = + '
' + + ' {{ name }}' + + ' {{username}}' + + '
'; + element = $compile(element)(scope); + var users = $$testability.findBindings(element[0], 'name', true); + expect(users.length).toBe(1); + expect(users[0]).toBe(element.find('span')[0]); + }); + + it('should find bindings by class', function() { + element = + '
' + + ' ' + + ' {{username}}' + + '
'; + element = $compile(element)(scope); + var names = $$testability.findBindings(element[0], 'name'); + expect(names.length).toBe(2); + expect(names[0]).toBe(element.find('span')[0]); + expect(names[1]).toBe(element.find('span')[1]); + }); + + it('should only search within the context element', function() { + element = + '
' + + '
  • {{name}}
' + + '
  • {{name}}
' + + '
'; + element = $compile(element)(scope); + var names = $$testability.findBindings(element.find('ul')[0], 'name'); + expect(names.length).toBe(1); + expect(names[0]).toBe(element.find('li')[0]); + }); + + it('should find partial models', function() { + element = + '
' + + ' ' + + ' ' + + '
'; + element = $compile(element)(scope); + var names = $$testability.findModels(element[0], 'name'); + expect(names.length).toBe(2); + expect(names[0]).toBe(element.find('input')[0]); + expect(names[1]).toBe(element.find('input')[1]); + }); + + it('should find exact models', function() { + element = + '
' + + ' ' + + ' ' + + '
'; + element = $compile(element)(scope); + var users = $$testability.findModels(element[0], 'name', true); + expect(users.length).toBe(1); + expect(users[0]).toBe(element.find('input')[0]); + }); + + it('should find models in different input types', function() { + element = + '
' + + ' ' + + '