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/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/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/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 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'; 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}}!');