From a5fd2e4c0376676fa317e09a8d8be4966b82cbfe Mon Sep 17 00:00:00 2001 From: Martin Staffa Date: Wed, 22 Jun 2016 15:25:37 +0200 Subject: [PATCH 1/3] feat($interpolate): use custom toString() function if present Except on Numbers, Dates and Arrays. Thanks to @danielkrainas for the initial implementation of this feature. This behavior is consistent with implementations found in other languages such as Ruby, Python, and CoffeeScript. http://rubymonk.com/learning/books/1-ruby-primer/chapters/5-strings/lessons/31-string-basics https://docs.python.org/2/library/stdtypes.html#string-formatting-operations http://coffeescriptcookbook.com/chapters/strings/interpolation The commit also exposes a private $$stringify method on the angular global, so that ngMessageFormat can use the same logic without duplicating it. Fixes #7317 Closes #8350 Fixes #11406 BREAKING CHANGE: When converting values to strings, interpolation now uses a custom toString() function on objects that are not Number, Array or Date (custom means that the `toString` function is not the same as `Object.prototype.toString`). Otherwise, interpolation uses JSON.stringify() as usual. Should you have a custom toString() function but still want the output of JSON.stringify(), migrate as shown in the following examples: Before: ```html {{myObject}} ``` After - use the `json` filter to stringify the object: ```html {{myObject | json}} ``` --- docs/content/guide/interpolation.ngdoc | 9 ++++++++ src/.jshintrc | 1 + src/Angular.js | 22 +++++++++++++++++++ src/AngularPublic.js | 3 ++- src/ng/interpolate.js | 17 --------------- src/ngMessageFormat/messageFormatCommon.js | 10 +-------- src/ngMessageFormat/messageFormatService.js | 5 +++-- test/ng/interpolateSpec.js | 23 ++++++++++++++++++++ test/ngMessageFormat/messageFormatSpec.js | 24 +++++++++++++++++++++ 9 files changed, 85 insertions(+), 29 deletions(-) diff --git a/docs/content/guide/interpolation.ngdoc b/docs/content/guide/interpolation.ngdoc index f1d07df1ca69..6aa1d66b316c 100644 --- a/docs/content/guide/interpolation.ngdoc +++ b/docs/content/guide/interpolation.ngdoc @@ -26,6 +26,15 @@ normal {@link ng.$rootScope.Scope#$digest digest} cycle. Note that the interpolateDirective has a priority of 100 and sets up the watch in the preLink function. +### How the string representation is computed + +If the interpolated value is not a `String`, it is computed as follows: +- `undefined` and `null` are converted to `''` +- if the value is an object that is not a `Number`, `Date` or `Array`, $interpolate looks for +a custom `toString()` function on the object, and uses that. Custom means that +`myObject.toString !== `Object.prototype.toString`. +- if the above doesn't apply, `JSON.stringify` is used. + ### Binding to boolean attributes Attributes such as `disabled` are called `boolean` attributes, because their presence means `true` and diff --git a/src/.jshintrc b/src/.jshintrc index 8c32819a6fa9..b77462c5b0d9 100644 --- a/src/.jshintrc +++ b/src/.jshintrc @@ -96,6 +96,7 @@ "createMap": false, "VALIDITY_STATE_PROPERTY": false, "reloadWithDebugInfo": false, + "stringify": false, "NODE_TYPE_ELEMENT": false, "NODE_TYPE_ATTRIBUTE": false, diff --git a/src/Angular.js b/src/Angular.js index 0b7bd75ea3ba..a5180712dfa1 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -86,6 +86,7 @@ getBlockNodes: true, hasOwnProperty: true, createMap: true, + stringify: true, NODE_TYPE_ELEMENT: true, NODE_TYPE_ATTRIBUTE: true, @@ -1903,6 +1904,27 @@ function createMap() { return Object.create(null); } +function stringify(value) { + if (value == null) { // null || undefined + return ''; + } + switch (typeof value) { + case 'string': + break; + case 'number': + value = '' + value; + break; + default: + if (hasCustomToString(value) && !isArray(value) && !isDate(value)) { + value = value.toString(); + } else { + value = toJson(value); + } + } + + return value; +} + var NODE_TYPE_ELEMENT = 1; var NODE_TYPE_ATTRIBUTE = 2; var NODE_TYPE_TEXT = 3; diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 8404ac091ab5..89b9afd29a9a 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -156,7 +156,8 @@ function publishExternalAPI(angular) { '$$minErr': minErr, '$$csp': csp, '$$encodeUriSegment': encodeUriSegment, - '$$encodeUriQuery': encodeUriQuery + '$$encodeUriQuery': encodeUriQuery, + '$$stringify': stringify }); angularModule = setupModuleLoader(window); diff --git a/src/ng/interpolate.js b/src/ng/interpolate.js index a8c93319761c..7edf0af277e8 100644 --- a/src/ng/interpolate.js +++ b/src/ng/interpolate.js @@ -111,23 +111,6 @@ function $InterpolateProvider() { replace(escapedEndRegexp, endSymbol); } - function stringify(value) { - if (value == null) { // null || undefined - return ''; - } - switch (typeof value) { - case 'string': - break; - case 'number': - value = '' + value; - break; - default: - value = toJson(value); - } - - return value; - } - //TODO: this is the same as the constantWatchDelegate in parse.js function constantWatchDelegate(scope, listener, objectEquality, constantInterp) { var unwatch; diff --git a/src/ngMessageFormat/messageFormatCommon.js b/src/ngMessageFormat/messageFormatCommon.js index 3082bef30978..3991fac368e8 100644 --- a/src/ngMessageFormat/messageFormatCommon.js +++ b/src/ngMessageFormat/messageFormatCommon.js @@ -8,15 +8,7 @@ /* global isFunction: false */ /* global noop: false */ /* global toJson: false */ - -function stringify(value) { - if (value == null /* null/undefined */) { return ''; } - switch (typeof value) { - case 'string': return value; - case 'number': return '' + value; - default: return toJson(value); - } -} +/* global $$stringify: false */ // Convert an index into the string into line/column for use in error messages // As such, this doesn't have to be efficient. diff --git a/src/ngMessageFormat/messageFormatService.js b/src/ngMessageFormat/messageFormatService.js index b6b7999c44e2..e7df48401600 100644 --- a/src/ngMessageFormat/messageFormatService.js +++ b/src/ngMessageFormat/messageFormatService.js @@ -10,7 +10,6 @@ /* global noop: true */ /* global toJson: true */ /* global MessageFormatParser: false */ -/* global stringify: false */ /** * @ngdoc module @@ -180,7 +179,7 @@ var $$MessageFormatFactory = ['$parse', '$locale', '$sce', '$exceptionHandler', return function stringifier(value) { try { value = trustedContext ? $sce['getTrusted'](trustedContext, value) : $sce['valueOf'](value); - return allOrNothing && (value === void 0) ? value : stringify(value); + return allOrNothing && (value === void 0) ? value : $$stringify(value); } catch (err) { $exceptionHandler($interpolateMinErr['interr'](text, err)); } @@ -214,6 +213,7 @@ var $interpolateMinErr; var isFunction; var noop; var toJson; +var $$stringify; var module = window['angular']['module']('ngMessageFormat', ['ng']); module['factory']('$$messageFormat', $$MessageFormatFactory); @@ -222,6 +222,7 @@ module['config'](['$provide', function($provide) { isFunction = window['angular']['isFunction']; noop = window['angular']['noop']; toJson = window['angular']['toJson']; + $$stringify = window['angular']['$$stringify']; $provide['decorator']('$interpolate', $$interpolateDecorator); }]); diff --git a/test/ng/interpolateSpec.js b/test/ng/interpolateSpec.js index 1605866ed907..2aae67b34fa1 100644 --- a/test/ng/interpolateSpec.js +++ b/test/ng/interpolateSpec.js @@ -35,6 +35,29 @@ describe('$interpolate', function() { expect($interpolate('{{ false }}')({})).toEqual('false'); })); + it('should use custom toString when present', inject(function($interpolate, $rootScope) { + var context = { + a: { + toString: function() { + return 'foo'; + } + } + }; + + expect($interpolate('{{ a }}')(context)).toEqual('foo'); + })); + + it('should NOT use toString on array objects', inject(function($interpolate) { + expect($interpolate('{{a}}')({ a: [] })).toEqual('[]'); + })); + + + it('should NOT use toString on Date objects', inject(function($interpolate) { + var date = new Date(2014, 10, 10); + expect($interpolate('{{a}}')({ a: date })).toBe(JSON.stringify(date)); + expect($interpolate('{{a}}')({ a: date })).not.toEqual(date.toString()); + })); + it('should return interpolation function', inject(function($interpolate, $rootScope) { var interpolateFn = $interpolate('Hello {{name}}!'); diff --git a/test/ngMessageFormat/messageFormatSpec.js b/test/ngMessageFormat/messageFormatSpec.js index e65d5a16401a..41958180d19f 100644 --- a/test/ngMessageFormat/messageFormatSpec.js +++ b/test/ngMessageFormat/messageFormatSpec.js @@ -311,6 +311,30 @@ describe('$$ngMessageFormat', function() { })); + it('should use custom toString when present', inject(function($interpolate, $rootScope) { + var context = { + a: { + toString: function() { + return 'foo'; + } + } + }; + + expect($interpolate('{{ a }}')(context)).toEqual('foo'); + })); + + it('should NOT use toString on array objects', inject(function($interpolate) { + expect($interpolate('{{a}}')({ a: [] })).toEqual('[]'); + })); + + + it('should NOT use toString on Date objects', inject(function($interpolate) { + var date = new Date(2014, 10, 10); + expect($interpolate('{{a}}')({ a: date })).toBe(JSON.stringify(date)); + expect($interpolate('{{a}}')({ a: date })).not.toEqual(date.toString()); + })); + + it('should return interpolation function', inject(function($interpolate, $rootScope) { var interpolateFn = $interpolate('Hello {{name}}!'); From fa80a61a05a3b49a2c770d5544cb8480907a18d3 Mon Sep 17 00:00:00 2001 From: Martin Staffa Date: Sun, 5 Jun 2016 14:52:18 +0200 Subject: [PATCH 2/3] fix(ngBind): use same string representation as $interpolate Fixes #11716 BREAKING CHANGE: `ngBind` now uses the same logic as $interpolate (i.e. {{myString}}) when binding, which means values other than strings are now transformed as following: - null / undefined become empty string - with an object's custom toString() function, except if the object is a Date, Array, or Number - otherwise with JSON.stringify Previously, ngBind would use always use toString(). The following examples show the different output: ```js $scope.myPlainObject = {a: 1, b: 2}; $scope.myCustomObject = {a: 1, b: 2, toString: function() {return 'a+b';}}; ``` Plain Object: ```html [object Object] {"a":1,"b":2} ``` Object with custom toString(): ```html [object Object] a+b ``` If you want the output of `toString()`, you can use it directly on the value in ngBind: ```html [object Object] ``` --- src/ng/directive/ngBind.js | 2 +- test/ng/directive/ngBindSpec.js | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/ng/directive/ngBind.js b/src/ng/directive/ngBind.js index 807f6ab7b3c5..0444227e1d7b 100644 --- a/src/ng/directive/ngBind.js +++ b/src/ng/directive/ngBind.js @@ -60,7 +60,7 @@ var ngBindDirective = ['$compile', function($compile) { $compile.$$addBindingInfo(element, attr.ngBind); element = element[0]; scope.$watch(attr.ngBind, function ngBindWatchAction(value) { - element.textContent = isUndefined(value) ? '' : value; + element.textContent = stringify(value); }); }; } diff --git a/test/ng/directive/ngBindSpec.js b/test/ng/directive/ngBindSpec.js index 39a35c15723a..424d5cd758fb 100644 --- a/test/ng/directive/ngBindSpec.js +++ b/test/ng/directive/ngBindSpec.js @@ -46,6 +46,43 @@ describe('ngBind*', function() { expect(element.text()).toEqual('-0false'); })); + they('should jsonify $prop', [[{a: 1}, '{"a":1}'], [true, 'true'], [false, 'false']], function(prop) { + inject(function($rootScope, $compile) { + $rootScope.value = prop[0]; + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.text()).toEqual(prop[1]); + }); + }); + + it('should use custom toString when present', inject(function($rootScope, $compile) { + $rootScope.value = { + toString: function() { + return 'foo'; + } + }; + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.text()).toEqual('foo'); + })); + + it('should NOT use toString on array objects', inject(function($rootScope, $compile) { + $rootScope.value = []; + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.text()).toEqual('[]'); + })); + + + it('should NOT use toString on Date objects', inject(function($rootScope, $compile) { + $rootScope.value = new Date(2014, 10, 10, 0, 0, 0); + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.text()).toBe(JSON.stringify($rootScope.value)); + expect(element.text()).not.toEqual($rootScope.value.toString()); + })); + + it('should one-time bind if the expression starts with two colons', inject(function($rootScope, $compile) { element = $compile('
')($rootScope); $rootScope.a = 'lucas'; From 7ce7e09c51819814af796b41a456312acb5d9332 Mon Sep 17 00:00:00 2001 From: Martin Staffa Date: Wed, 22 Jun 2016 14:49:39 +0200 Subject: [PATCH 3/3] test(input): ensure Date objects work for min/max in date input types Tests that - interpolated Date objects work for min/max - Date objects work for ng-min/ng-max --- test/ng/directive/inputSpec.js | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 8ac6cf257a20..6b80cce7c1b3 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -1857,6 +1857,16 @@ describe('input', function() { it('should parse ISO-based date strings as a valid min date value', function() { var inputElm = helper.compileInput(''); + $rootScope.value = new Date(2010, 1, 1, 0, 0, 0); + $rootScope.min = new Date(2014, 10, 10, 0, 0, 0).toISOString(); + $rootScope.$digest(); + + expect($rootScope.form.myControl.$error.min).toBeTruthy(); + }); + + it('should parse interpolated Date objects as a valid min date value', function() { + var inputElm = helper.compileInput(''); + $rootScope.value = new Date(2010, 1, 1, 0, 0, 0); $rootScope.min = new Date(2014, 10, 10, 0, 0, 0); $rootScope.$digest(); @@ -1896,6 +1906,16 @@ describe('input', function() { it('should parse ISO-based date strings as a valid max date value', function() { var inputElm = helper.compileInput(''); + $rootScope.value = new Date(2020, 1, 1, 0, 0, 0); + $rootScope.max = new Date(2014, 10, 10, 0, 0, 0).toISOString(); + $rootScope.$digest(); + + expect($rootScope.form.myControl.$error.max).toBeTruthy(); + }); + + it('should parse interpolated Date objects as a valid max date value', function() { + var inputElm = helper.compileInput(''); + $rootScope.value = new Date(2020, 1, 1, 0, 0, 0); $rootScope.max = new Date(2014, 10, 10, 0, 0, 0); $rootScope.$digest(); @@ -1990,6 +2010,44 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + + it('should allow Date objects as valid ng-max values', function() { + $rootScope.max = new Date(2012, 1, 1, 1, 2, 0); + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('2014-01-01T12:34:00'); + expect(inputElm).toBeInvalid(); + + $rootScope.max = new Date(2013, 1, 1, 1, 2, 0); + $rootScope.$digest(); + + expect(inputElm).toBeInvalid(); + + $rootScope.max = new Date(2014, 1, 1, 1, 2, 0); + $rootScope.$digest(); + + expect(inputElm).toBeValid(); + }); + + + it('should allow Date objects as valid ng-min values', function() { + $rootScope.min = new Date(2013, 1, 1, 1, 2, 0); + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('2010-01-01T12:34:00'); + expect(inputElm).toBeInvalid(); + + $rootScope.min = new Date(2014, 1, 1, 1, 2, 0); + $rootScope.$digest(); + + expect(inputElm).toBeInvalid(); + + $rootScope.min = new Date(2009, 1, 1, 1, 2, 0); + $rootScope.$digest(); + + expect(inputElm).toBeValid(); + }); + describe('ISO_DATE_REGEXP', function() { var dates = [ // Validate date