fixed select with ng:format

select (one/multiple) could not chose from a list of objects, since DOM requires string ids.
Solved by adding index formatter, which exposed incorrect handling of formatters in select
widgets.
This commit is contained in:
Misko Hevery 2011-01-13 10:35:26 -08:00
parent 934f44f69e
commit 347be5ae9a
13 changed files with 433 additions and 171 deletions

View file

@ -1,5 +1,7 @@
# <angular/> 0.9.10 flea-whisperer (in-progress) # # <angular/> 0.9.10 flea-whisperer (in-progress) #
### Bug Fixes
- html select (one/multiple) could not chose from a list of objects, since DOM requires string ids.
# <angular/> 0.9.9 time-shift (2011-01-13) # # <angular/> 0.9.9 time-shift (2011-01-13) #
@ -99,9 +101,9 @@
- small docs improvements (mainly docs for the $resource service) - small docs improvements (mainly docs for the $resource service)
### Breaking changes ### Breaking changes
- Angular expressions in the view used to support regular expressions. This feature was rarely - Angular expressions in the view used to support regular expressions. This feature was rarely
used and added unnecessary complexity. It not a good idea to have regexps in the view anyway, used and added unnecessary complexity. It not a good idea to have regexps in the view anyway,
so we removed this support. If you had any regexp in your views, you will have to move them to so we removed this support. If you had any regexp in your views, you will have to move them to
your controllers. (commit e5e69d9b90850eb653883f52c76e28dd870ee067) your controllers. (commit e5e69d9b90850eb653883f52c76e28dd870ee067)
@ -120,7 +122,7 @@
- docs app UI polishing with dual scrolling and other improvements - docs app UI polishing with dual scrolling and other improvements
### Bug Fixes ### Bug Fixes
- `select` widget now behaves correctly when it's `option` items are created via `ng:repeat` - `select` widget now behaves correctly when it's `option` items are created via `ng:repeat`
(issue #170) (issue #170)
- fix for async xhr cache issue #152 by adding `$browser.defer` and `$defer` service - fix for async xhr cache issue #152 by adding `$browser.defer` and `$defer` service

View file

@ -1,4 +1,5 @@
var ngdoc = require('ngdoc.js'); var ngdoc = require('ngdoc.js');
var DOM = require('dom.js').DOM;
describe('ngdoc', function(){ describe('ngdoc', function(){
var Doc = ngdoc.Doc; var Doc = ngdoc.Doc;
@ -253,5 +254,67 @@ describe('ngdoc', function(){
}); });
}); });
}); });
describe('usage', function(){
var dom;
beforeEach(function(){
dom = new DOM();
this.addMatchers({
toContain: function(text) {
this.actual = this.actual.toString();
return this.actual.indexOf(text) > -1;
}
});
});
describe('filter', function(){
it('should format', function(){
var doc = new Doc({
ngdoc:'formatter',
shortName:'myFilter',
param: [
{name:'a'},
{name:'b'}
]
});
doc.html_usage_filter(dom);
expect(dom).toContain('myFilter_expression | myFilter:b');
expect(dom).toContain('angular.filter.myFilter(a, b)');
});
});
describe('validator', function(){
it('should format', function(){
var doc = new Doc({
ngdoc:'validator',
shortName:'myValidator',
param: [
{name:'a'},
{name:'b'}
]
});
doc.html_usage_validator(dom);
expect(dom).toContain('ng:validate="myValidator:b"');
expect(dom).toContain('angular.validator.myValidator(a, b)');
});
});
describe('formatter', function(){
it('should format', function(){
var doc = new Doc({
ngdoc:'formatter',
shortName:'myFormatter',
param: [
{name:'a'},
]
});
doc.html_usage_formatter(dom);
expect(dom).toContain('ng:format="myFormatter:a"');
expect(dom).toContain('var userInputString = angular.formatter.myFormatter.format(modelValue, a);');
expect(dom).toContain('var modelValue = angular.formatter.myFormatter.parse(userInputString, a);');
});
});
});
}); });

View file

@ -231,15 +231,7 @@ Doc.prototype = {
dom.code(function(){ dom.code(function(){
dom.text(self.name); dom.text(self.name);
dom.text('('); dom.text('(');
var first = true; self.parameters(dom, ', ');
(self.param || []).forEach(function(param){
if (first) {
first = false;
} else {
dom.text(', ');
}
dom.text(param.name);
});
dom.text(');'); dom.text(');');
}); });
@ -273,44 +265,17 @@ Doc.prototype = {
dom.text(self.shortName); dom.text(self.shortName);
dom.text('_expression | '); dom.text('_expression | ');
dom.text(self.shortName); dom.text(self.shortName);
var first = true; self.parameters(dom, ':', true);
(self.param||[]).forEach(function(param){
if (first) {
first = false;
} else {
if (param.optional) {
dom.tag('i', function(){
dom.text('[:' + param.name + ']');
});
} else {
dom.text(':' + param.name);
}
}
});
dom.text(' }}'); dom.text(' }}');
}); });
}); });
dom.h3('In JavaScript', function(){ dom.h('In JavaScript', function(){
dom.tag('code', function(){ dom.tag('code', function(){
dom.text('angular.filter.'); dom.text('angular.filter.');
dom.text(self.shortName); dom.text(self.shortName);
dom.text('('); dom.text('(');
var first = true; self.parameters(dom, ', ');
(self.param||[]).forEach(function(param){
if (first) {
first = false;
dom.text(param.name);
} else {
if (param.optional) {
dom.tag('i', function(){
dom.text('[, ' + param.name + ']');
});
} else {
dom.text(', ' + param.name);
}
}
});
dom.text(')'); dom.text(')');
}); });
}); });
@ -319,32 +284,40 @@ Doc.prototype = {
self.html_usage_returns(dom); self.html_usage_returns(dom);
}); });
}, },
html_usage_formatter: function(dom){ html_usage_formatter: function(dom){
var self = this; var self = this;
dom.h('Usage', function(){ dom.h('Usage', function(){
dom.h('In HTML Template Binding', function(){ dom.h('In HTML Template Binding', function(){
dom.code(function(){ dom.code(function(){
dom.text('<input type="text" ng:format="'); if (self.inputType=='select')
dom.text('<select name="bindExpression"');
else
dom.text('<input type="text" name="bindExpression"');
dom.text(' ng:format="');
dom.text(self.shortName); dom.text(self.shortName);
self.parameters(dom, ':', false, true);
dom.text('">'); dom.text('">');
}); });
}); });
dom.h3('In JavaScript', function(){ dom.h('In JavaScript', function(){
dom.code(function(){ dom.code(function(){
dom.text('var userInputString = angular.formatter.'); dom.text('var userInputString = angular.formatter.');
dom.text(self.shortName); dom.text(self.shortName);
dom.text('.format(modelValue);'); dom.text('.format(modelValue');
}); self.parameters(dom, ', ', false, true);
dom.html('<br/>'); dom.text(');');
dom.code(function(){ dom.text('\n');
dom.text('var modelValue = angular.formatter.'); dom.text('var modelValue = angular.formatter.');
dom.text(self.shortName); dom.text(self.shortName);
dom.text('.parse(userInputString);'); dom.text('.parse(userInputString');
self.parameters(dom, ', ', false, true);
dom.text(');');
}); });
}); });
self.html_usage_parameters(dom);
self.html_usage_returns(dom); self.html_usage_returns(dom);
}); });
}, },
@ -356,18 +329,7 @@ Doc.prototype = {
dom.code(function(){ dom.code(function(){
dom.text('<input type="text" ng:validate="'); dom.text('<input type="text" ng:validate="');
dom.text(self.shortName); dom.text(self.shortName);
var first = true; self.parameters(dom, ':', true);
(self.param||[]).forEach(function(param){
if (first) {
first = false;
} else {
if (param.optional) {
dom.text('[:' + param.name + ']');
} else {
dom.text(':' + param.name);
}
}
});
dom.text('"/>'); dom.text('"/>');
}); });
}); });
@ -377,19 +339,7 @@ Doc.prototype = {
dom.text('angular.validator.'); dom.text('angular.validator.');
dom.text(self.shortName); dom.text(self.shortName);
dom.text('('); dom.text('(');
var first = true; self.parameters(dom, ', ');
(self.param||[]).forEach(function(param){
if (first) {
first = false;
dom.text(param.name);
} else {
if (param.optional) {
dom.text('[, ' + param.name + ']');
} else {
dom.text(', ' + param.name);
}
}
});
dom.text(')'); dom.text(')');
}); });
}); });
@ -443,8 +393,22 @@ Doc.prototype = {
}, },
html_usage_service: function(dom){ html_usage_service: function(dom){
} },
parameters: function(dom, separator, skipFirst, prefix) {
var sep = prefix ? separator : '';
(this.param||[]).forEach(function(param, i){
if (!(skipFirst && i==0)) {
if (param.optional) {
dom.text('[' + sep + param.name + ']');
} else {
dom.text(sep + param.name);
}
}
sep = separator;
});
}
}; };
////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////

View file

@ -33,7 +33,7 @@ function toJson(obj, pretty) {
* @returns {Object|Array|Date|string|number} Deserialized thingy. * @returns {Object|Array|Date|string|number} Deserialized thingy.
*/ */
function fromJson(json, useNative) { function fromJson(json, useNative) {
if (!json) return json; if (!isString(json)) return json;
var obj, p, expression; var obj, p, expression;

View file

@ -197,7 +197,7 @@ angularDirective("ng:bind", function(expression, element){
if (lastValue === value && lastError == error) return; if (lastValue === value && lastError == error) return;
isDomElement = isElement(value); isDomElement = isElement(value);
if (!isHtml && !isDomElement && isObject(value)) { if (!isHtml && !isDomElement && isObject(value)) {
value = toJson(value); value = toJson(value, true);
} }
if (value != lastValue || error != lastError) { if (value != lastValue || error != lastError) {
lastValue = value; lastValue = value;
@ -234,7 +234,7 @@ function compileBindTemplate(template){
return text; return text;
}); });
}); });
bindTemplateCache[template] = fn = function(element){ bindTemplateCache[template] = fn = function(element, prettyPrintJson){
var parts = [], self = this, var parts = [], self = this,
oldElement = this.hasOwnProperty($$element) ? self.$element : _undefined; oldElement = this.hasOwnProperty($$element) ? self.$element : _undefined;
self.$element = element; self.$element = element;
@ -243,7 +243,7 @@ function compileBindTemplate(template){
if (isElement(value)) if (isElement(value))
value = ''; value = '';
else if (isObject(value)) else if (isObject(value))
value = toJson(value, true); value = toJson(value, prettyPrintJson);
parts.push(value); parts.push(value);
} }
self.$element = oldElement; self.$element = oldElement;
@ -292,7 +292,7 @@ angularDirective("ng:bind-template", function(expression, element){
return function(element) { return function(element) {
var lastValue; var lastValue;
this.$onEval(function() { this.$onEval(function() {
var value = templateFn.call(this, element); var value = templateFn.call(this, element, true);
if (value != lastValue) { if (value != lastValue) {
element.text(value); element.text(value);
lastValue = value; lastValue = value;

View file

@ -15,7 +15,7 @@ angularFormatter.noop = formatter(identity, identity);
* @description * @description
* Formats the user input as JSON text. * Formats the user input as JSON text.
* *
* @returns {string} A JSON string representation of the model. * @returns {?string} A JSON string representation of the model.
* *
* @example * @example
* <div ng:init="data={name:'misko', project:'angular'}"> * <div ng:init="data={name:'misko', project:'angular'}">
@ -30,7 +30,9 @@ angularFormatter.noop = formatter(identity, identity);
* expect(binding('data')).toEqual('data={\n }'); * expect(binding('data')).toEqual('data={\n }');
* }); * });
*/ */
angularFormatter.json = formatter(toJson, fromJson); angularFormatter.json = formatter(toJson, function(value){
return fromJson(value || 'null');
});
/** /**
* @workInProgress * @workInProgress
@ -154,3 +156,59 @@ angularFormatter.list = formatter(
angularFormatter.trim = formatter( angularFormatter.trim = formatter(
function(obj) { return obj ? trim("" + obj) : ""; } function(obj) { return obj ? trim("" + obj) : ""; }
); );
/**
* @workInProgress
* @ngdoc formatter
* @name angular.formatter.index
* @description
* Index formatter is meant to be used with `select` input widget. It is useful when one needs
* to select from a set of objects. To create pull-down one can iterate over the array of object
* to build the UI. However the value of the pull-down must be a string. This means that when on
* object is selected form the pull-down, the pull-down value is a string which needs to be
* converted back to an object. This conversion from string to on object is not possible, at best
* the converted object is a copy of the original object. To solve this issue we create a pull-down
* where the value strings are an index of the object in the array. When pull-down is selected the
* index can be used to look up the original user object.
*
* @inputType select
* @param {array} array to be used for selecting an object.
* @returns {object} object which is located at the selected position.
*
* @example
* <script>
* function DemoCntl(){
* this.users = [
* {name:'guest', password:'guest'},
* {name:'user', password:'123'},
* {name:'admin', password:'abc'}
* ];
* }
* </script>
* <div ng:controller="DemoCntl">
* User:
* <select name="currentUser" ng:format="index:users">
* <option ng:repeat="user in users" value="{{$index}}">{{user.name}}</option>
* </select>
* <select name="currentUser" ng:format="index:users">
* <option ng:repeat="user in users" value="{{$index}}">{{user.name}}</option>
* </select>
* user={{currentUser.name}}<br/>
* password={{currentUser.password}}<br/>
* </div>
*
* @scenario
* it('should format trim', function(){
* expect(binding('currentUser.password')).toEqual('guest');
* select('currentUser').option('2');
* expect(binding('currentUser.password')).toEqual('abc');
* });
*/
angularFormatter.index = formatter(
function(object, array){
return '' + indexOf(array || [], object);
},
function(index, array){
return (array||[])[index];
}
);

View file

@ -218,6 +218,7 @@ function parser(text, json){
var ZERO = valueFn(0), var ZERO = valueFn(0),
tokens = lex(text, json), tokens = lex(text, json),
assignment = _assignment, assignment = _assignment,
assignable = logicalOR,
functionCall = _functionCall, functionCall = _functionCall,
fieldAccess = _fieldAccess, fieldAccess = _fieldAccess,
objectIndex = _objectIndex, objectIndex = _objectIndex,
@ -231,6 +232,7 @@ function parser(text, json){
functionCall = functionCall =
fieldAccess = fieldAccess =
objectIndex = objectIndex =
assignable =
filterChain = filterChain =
functionIdent = functionIdent =
pipeFunction = pipeFunction =
@ -238,9 +240,11 @@ function parser(text, json){
} }
return { return {
assertAllConsumed: assertAllConsumed, assertAllConsumed: assertAllConsumed,
assignable: assignable,
primary: primary, primary: primary,
statements: statements, statements: statements,
validator: validator, validator: validator,
formatter: formatter,
filter: filter, filter: filter,
//TODO: delete me, since having watch in UI is logic in UI. (leftover form getangular) //TODO: delete me, since having watch in UI is logic in UI. (leftover form getangular)
watch: watch watch: watch
@ -353,6 +357,33 @@ function parser(text, json){
return pipeFunction(angularValidator); return pipeFunction(angularValidator);
} }
function formatter(){
var token = expect();
var formatter = angularFormatter[token.text];
var argFns = [];
var token;
if (!formatter) throwError('is not a valid formatter.', token);
while(true) {
if ((token = expect(':'))) {
argFns.push(expression());
} else {
return valueFn({
format:invokeFn(formatter.format),
parse:invokeFn(formatter.parse)
});
}
}
function invokeFn(fn){
return function(self, input){
var args = [input];
for ( var i = 0; i < argFns.length; i++) {
args.push(argFns[i](self));
}
return fn.apply(self, args);
};
}
}
function _pipeFunction(fnScope){ function _pipeFunction(fnScope){
var fn = functionIdent(fnScope); var fn = functionIdent(fnScope);
var argsFn = []; var argsFn = [];

View file

@ -8,16 +8,16 @@
* standard HTML set. These widgets are bound using the name attribute * standard HTML set. These widgets are bound using the name attribute
* to an expression. In addition they can have `ng:validate`, `ng:required`, * to an expression. In addition they can have `ng:validate`, `ng:required`,
* `ng:format`, `ng:change` attribute to further control their behavior. * `ng:format`, `ng:change` attribute to further control their behavior.
* *
* @usageContent * @usageContent
* see example below for usage * see example below for usage
* *
* <input type="text|checkbox|..." ... /> * <input type="text|checkbox|..." ... />
* <textarea ... /> * <textarea ... />
* <select ...> * <select ...>
* <option>...</option> * <option>...</option>
* </select> * </select>
* *
* @example * @example
<table style="font-size:.9em;"> <table style="font-size:.9em;">
<tr> <tr>
@ -96,7 +96,7 @@
<td><tt>{{input6|json}}</tt></td> <td><tt>{{input6|json}}</tt></td>
</tr> </tr>
</table> </table>
* @scenario * @scenario
* it('should exercise text', function(){ * it('should exercise text', function(){
* input('input1').enter('Carlos'); * input('input1').enter('Carlos');
@ -134,14 +134,19 @@
function modelAccessor(scope, element) { function modelAccessor(scope, element) {
var expr = element.attr('name'); var expr = element.attr('name');
var assign;
if (expr) { if (expr) {
assign = parser(expr).assignable().assign;
if (!assign) throw new Error("Expression '" + expr + "' is not assignable.");
return { return {
get: function() { get: function() {
return scope.$eval(expr); return scope.$eval(expr);
}, },
set: function(value) { set: function(value) {
if (value !== _undefined) { if (value !== _undefined) {
return scope.$tryEval(expr + '=' + toJson(value), element); return scope.$tryEval(function(){
assign(scope, value);
}, element);
} }
} }
}; };
@ -151,15 +156,14 @@ function modelAccessor(scope, element) {
function modelFormattedAccessor(scope, element) { function modelFormattedAccessor(scope, element) {
var accessor = modelAccessor(scope, element), var accessor = modelAccessor(scope, element),
formatterName = element.attr('ng:format') || NOOP, formatterName = element.attr('ng:format') || NOOP,
formatter = angularFormatter(formatterName); formatter = compileFormatter(formatterName);
if (!formatter) throw "Formatter named '" + formatterName + "' not found.";
if (accessor) { if (accessor) {
return { return {
get: function() { get: function() {
return formatter.format(accessor.get()); return formatter.format(scope, accessor.get());
}, },
set: function(value) { set: function(value) {
return accessor.set(formatter.parse(value)); return accessor.set(formatter.parse(scope, value));
} }
}; };
} }
@ -169,6 +173,10 @@ function compileValidator(expr) {
return parser(expr).validator()(); return parser(expr).validator()();
} }
function compileFormatter(expr) {
return parser(expr).formatter()();
}
/** /**
* @workInProgress * @workInProgress
* @ngdoc widget * @ngdoc widget
@ -195,7 +203,7 @@ function compileValidator(expr) {
I need an integer or nothing: I need an integer or nothing:
<input type="text" name="value" ng:validate="integer"><br/> <input type="text" name="value" ng:validate="integer"><br/>
* *
* @scenario * @scenario
it('should check ng:validate', function(){ it('should check ng:validate', function(){
expect(element('.doc-example-live :input:last').attr('className')). expect(element('.doc-example-live :input:last').attr('className')).
@ -214,7 +222,7 @@ function compileValidator(expr) {
* @description * @description
* The `ng:required` attribute widget validates that the user input is present. It is a special case * The `ng:required` attribute widget validates that the user input is present. It is a special case
* of the {@link angular.widget.@ng:validate ng:validate} attribute widget. * of the {@link angular.widget.@ng:validate ng:validate} attribute widget.
* *
* @element INPUT * @element INPUT
* @css ng-validation-error * @css ng-validation-error
* *
@ -253,10 +261,10 @@ function compileValidator(expr) {
* array. * array.
* *
* @example * @example
Enter a comma separated list of items: Enter a comma separated list of items:
<input type="text" name="list" ng:format="list" value="table, chairs, plate"> <input type="text" name="list" ng:format="list" value="table, chairs, plate">
<pre>list={{list}}</pre> <pre>list={{list}}</pre>
* *
* @scenario * @scenario
it('should check ng:format', function(){ it('should check ng:format', function(){
expect(binding('list')).toBe('list=["table","chairs","plate"]'); expect(binding('list')).toBe('list=["table","chairs","plate"]');
@ -269,11 +277,10 @@ function valueAccessor(scope, element) {
validator = compileValidator(validatorName), validator = compileValidator(validatorName),
requiredExpr = element.attr('ng:required'), requiredExpr = element.attr('ng:required'),
formatterName = element.attr('ng:format') || NOOP, formatterName = element.attr('ng:format') || NOOP,
formatter = angularFormatter(formatterName), formatter = compileFormatter(formatterName),
format, parse, lastError, required, format, parse, lastError, required,
invalidWidgets = scope.$service('$invalidWidgets') || {markValid:noop, markInvalid:noop}; invalidWidgets = scope.$service('$invalidWidgets') || {markValid:noop, markInvalid:noop};
if (!validator) throw "Validator named '" + validatorName + "' not found."; if (!validator) throw "Validator named '" + validatorName + "' not found.";
if (!formatter) throw "Formatter named '" + formatterName + "' not found.";
format = formatter.format; format = formatter.format;
parse = formatter.parse; parse = formatter.parse;
if (requiredExpr) { if (requiredExpr) {
@ -291,7 +298,7 @@ function valueAccessor(scope, element) {
if (lastError) if (lastError)
elementError(element, NG_VALIDATION_ERROR, _null); elementError(element, NG_VALIDATION_ERROR, _null);
try { try {
var value = parse(element.val()); var value = parse(scope, element.val());
validate(); validate();
return value; return value;
} catch (e) { } catch (e) {
@ -301,7 +308,7 @@ function valueAccessor(scope, element) {
}, },
set: function(value) { set: function(value) {
var oldValue = element.val(), var oldValue = element.val(),
newValue = format(value); newValue = format(scope, value);
if (oldValue != newValue) { if (oldValue != newValue) {
element.val(newValue || ''); // needed for ie element.val(newValue || ''); // needed for ie
} }
@ -355,19 +362,22 @@ function radioAccessor(scope, element) {
} }
function optionsAccessor(scope, element) { function optionsAccessor(scope, element) {
var options = element[0].options; var formatterName = element.attr('ng:format') || NOOP,
formatter = compileFormatter(formatterName);
return { return {
get: function(){ get: function(){
var values = []; var values = [];
forEach(options, function(option){ forEach(element[0].options, function(option){
if (option.selected) values.push(option.value); if (option.selected) values.push(formatter.parse(scope, option.value));
}); });
return values; return values;
}, },
set: function(values){ set: function(values){
var keys = {}; var keys = {};
forEach(values, function(value){ keys[value] = true; }); forEach(values, function(value){
forEach(options, function(option){ keys[formatter.format(scope, value)] = true;
});
forEach(element[0].options, function(option){
option.selected = keys[option.value]; option.selected = keys[option.value];
}); });
} }
@ -376,6 +386,18 @@ function optionsAccessor(scope, element) {
function noopAccessor() { return { get: noop, set: noop }; } function noopAccessor() { return { get: noop, set: noop }; }
/*
* TODO: refactor
*
* The table bellow is not quite right. In some cases the formatter is on the model side
* and in some cases it is on the view side. This is a historical artifact
*
* The concept of model/view accessor is useful for anyone who is trying to develop UI, and
* so it should be exposed to others. There should be a form object which keeps track of the
* accessors and also acts as their factory. It should expose it as an object and allow
* the validator to publish errors to it, so that the the error messages can be bound to it.
*
*/
var textWidget = inputWidget('keydown change', modelAccessor, valueAccessor, initWidgetValue(), true), var textWidget = inputWidget('keydown change', modelAccessor, valueAccessor, initWidgetValue(), true),
buttonWidget = inputWidget('click', noopAccessor, noopAccessor, noop), buttonWidget = inputWidget('click', noopAccessor, noopAccessor, noop),
INPUT_TYPE = { INPUT_TYPE = {
@ -389,8 +411,8 @@ var textWidget = inputWidget('keydown change', modelAccessor, valueAccessor, ini
'image': buttonWidget, 'image': buttonWidget,
'checkbox': inputWidget('click', modelFormattedAccessor, checkedAccessor, initWidgetValue(false)), 'checkbox': inputWidget('click', modelFormattedAccessor, checkedAccessor, initWidgetValue(false)),
'radio': inputWidget('click', modelFormattedAccessor, radioAccessor, radioInit), 'radio': inputWidget('click', modelFormattedAccessor, radioAccessor, radioInit),
'select-one': inputWidget('change', modelFormattedAccessor, valueAccessor, initWidgetValue(_null)), 'select-one': inputWidget('change', modelAccessor, valueAccessor, initWidgetValue(_null)),
'select-multiple': inputWidget('change', modelFormattedAccessor, optionsAccessor, initWidgetValue([])) 'select-multiple': inputWidget('change', modelAccessor, optionsAccessor, initWidgetValue([]))
// 'file': fileWidget??? // 'file': fileWidget???
}; };
@ -427,7 +449,7 @@ function radioInit(model, view, element) {
* *
* @description * @description
* The directive executes an expression whenever the input widget changes. * The directive executes an expression whenever the input widget changes.
* *
* @element INPUT * @element INPUT
* @param {expression} expression to execute. * @param {expression} expression to execute.
* *
@ -438,17 +460,17 @@ function radioInit(model, view, element) {
changeCount {{textCount}}<br/> changeCount {{textCount}}<br/>
<input type="checkbox" name="checkbox" ng:change="checkboxCount = 1 + checkboxCount"> <input type="checkbox" name="checkbox" ng:change="checkboxCount = 1 + checkboxCount">
changeCount {{checkboxCount}}<br/> changeCount {{checkboxCount}}<br/>
* *
* @scenario * @scenario
it('should check ng:change', function(){ it('should check ng:change', function(){
expect(binding('textCount')).toBe('0'); expect(binding('textCount')).toBe('0');
expect(binding('checkboxCount')).toBe('0'); expect(binding('checkboxCount')).toBe('0');
using('.doc-example-live').input('text').enter('abc'); using('.doc-example-live').input('text').enter('abc');
expect(binding('textCount')).toBe('1'); expect(binding('textCount')).toBe('1');
expect(binding('checkboxCount')).toBe('0'); expect(binding('checkboxCount')).toBe('0');
using('.doc-example-live').input('checkbox').check(); using('.doc-example-live').input('checkbox').check();
expect(binding('textCount')).toBe('1'); expect(binding('textCount')).toBe('1');
expect(binding('checkboxCount')).toBe('1'); expect(binding('checkboxCount')).toBe('1');
@ -504,41 +526,49 @@ angularWidget('select', function(element){
* <select name="selection"> * <select name="selection">
* <option ng:repeat="x in [1,2]">{{x}}</option> * <option ng:repeat="x in [1,2]">{{x}}</option>
* </select> * </select>
* *
* The issue is that the select gets evaluated before option is unrolled. * The issue is that the select gets evaluated before option is unrolled.
* This means that the selection is undefined, but the browser * This means that the selection is undefined, but the browser
* default behavior is to show the top selection in the list. * default behavior is to show the top selection in the list.
* To fix that we register a $update function on the select element * To fix that we register a $update function on the select element
* and the option creation then calls the $update function when it is * and the option creation then calls the $update function when it is
* unrolled. The $update function then calls this update function, which * unrolled. The $update function then calls this update function, which
* then tries to determine if the model is unassigned, and if so it tries to * then tries to determine if the model is unassigned, and if so it tries to
* chose one of the options from the list. * chose one of the options from the list.
*/ */
angularWidget('option', function(){ angularWidget('option', function(){
this.descend(true); this.descend(true);
this.directives(true); this.directives(true);
return function(element) { return function(option) {
var select = element.parent(); var select = option.parent();
var isMultiple = select.attr('multiple') == '';
var scope = retrieveScope(select); var scope = retrieveScope(select);
var model = modelFormattedAccessor(scope, select); var model = modelAccessor(scope, select);
var view = valueAccessor(scope, select); var formattedModel = modelFormattedAccessor(scope, select);
var option = element; var view = isMultiple
? optionsAccessor(scope, select)
: valueAccessor(scope, select);
var lastValue = option.attr($value); var lastValue = option.attr($value);
var lastSelected = option.attr('ng-' + $selected); var wasSelected = option.attr('ng-' + $selected);
element.data($$update, function(){ option.data($$update, isMultiple
var value = option.attr($value); ? function(){
var selected = option.attr('ng-' + $selected); view.set(model.get());
var modelValue = model.get();
if (lastSelected != selected || lastValue != value) {
lastSelected = selected;
lastValue = value;
if (selected || modelValue == _null || modelValue == _undefined)
model.set(value);
if (value == modelValue) {
view.set(lastValue);
} }
} : function(){
}); var currentValue = option.attr($value);
var isSelected = option.attr('ng-' + $selected);
var modelValue = model.get();
if (wasSelected != isSelected || lastValue != currentValue) {
wasSelected = isSelected;
lastValue = currentValue;
if (isSelected || !modelValue == null || modelValue == undefined )
formattedModel.set(currentValue);
if (currentValue == modelValue) {
view.set(lastValue);
}
}
}
);
}; };
}); });
@ -549,12 +579,12 @@ angularWidget('option', function(){
* *
* @description * @description
* Include external HTML fragment. * Include external HTML fragment.
* *
* Keep in mind that Same Origin Policy applies to included resources * Keep in mind that Same Origin Policy applies to included resources
* (e.g. ng:include won't work for file:// access). * (e.g. ng:include won't work for file:// access).
* *
* @param {string} src expression evaluating to URL. * @param {string} src expression evaluating to URL.
* @param {Scope=} [scope=new_child_scope] optional expression which evaluates to an * @param {Scope=} [scope=new_child_scope] optional expression which evaluates to an
* instance of angular.scope to set the HTML fragment to. * instance of angular.scope to set the HTML fragment to.
* @param {string=} onload Expression to evaluate when a new partial is loaded. * @param {string=} onload Expression to evaluate when a new partial is loaded.
* *
@ -636,17 +666,17 @@ angularWidget('ng:include', function(element){
* *
* @description * @description
* Conditionally change the DOM structure. * Conditionally change the DOM structure.
* *
* @usageContent * @usageContent
* <any ng:switch-when="matchValue1">...</any> * <any ng:switch-when="matchValue1">...</any>
* <any ng:switch-when="matchValue2">...</any> * <any ng:switch-when="matchValue2">...</any>
* ... * ...
* <any ng:switch-default>...</any> * <any ng:switch-default>...</any>
* *
* @param {*} on expression to match against <tt>ng:switch-when</tt>. * @param {*} on expression to match against <tt>ng:switch-when</tt>.
* @paramDescription * @paramDescription
* On child elments add: * On child elments add:
* *
* * `ng:switch-when`: the case statement to match against. If match then this * * `ng:switch-when`: the case statement to match against. If match then this
* case will be displayed. * case will be displayed.
* * `ng:switch-default`: the default case when no other casses match. * * `ng:switch-default`: the default case when no other casses match.

View file

@ -33,5 +33,20 @@ describe("formatter", function(){
assertEquals('a', angular.formatter.trim.format(" a ")); assertEquals('a', angular.formatter.trim.format(" a "));
assertEquals('a', angular.formatter.trim.parse(' a ')); assertEquals('a', angular.formatter.trim.parse(' a '));
}); });
describe('json', function(){
it('should treat empty string as null', function(){
expect(angular.formatter.json.parse('')).toEqual(null);
});
});
describe('index', function(){
it('should parse an object from array', function(){
expect(angular.formatter.index.parse('1', ['A', 'B', 'C'])).toEqual('B');
});
it('should format an index from array', function(){
expect(angular.formatter.index.format('B', ['A', 'B', 'C'])).toEqual('1');
});
});
}); });

View file

@ -92,11 +92,11 @@ describe('json', function(){
it('should not serialize undefined values', function() { it('should not serialize undefined values', function() {
expect(angular.toJson({A:undefined})).toEqual('{}'); expect(angular.toJson({A:undefined})).toEqual('{}');
}); });
it('should not serialize $window object', function() { it('should not serialize $window object', function() {
expect(toJson(window)).toEqual('WINDOW'); expect(toJson(window)).toEqual('WINDOW');
}); });
it('should not serialize $document object', function() { it('should not serialize $document object', function() {
expect(toJson(document)).toEqual('DOCUMENT'); expect(toJson(document)).toEqual('DOCUMENT');
}); });
@ -116,6 +116,13 @@ describe('json', function(){
expect(fromJson("{exp:1.2e-10}")).toEqual({exp:1.2E-10}); expect(fromJson("{exp:1.2e-10}")).toEqual({exp:1.2E-10});
}); });
it('should ignore non-strings', function(){
expect(fromJson([])).toEqual([]);
expect(fromJson({})).toEqual({});
expect(fromJson(null)).toEqual(null);
expect(fromJson(undefined)).toEqual(undefined);
});
//run these tests only in browsers that have native JSON parser //run these tests only in browsers that have native JSON parser
if (JSON && JSON.parse) { if (JSON && JSON.parse) {
@ -187,18 +194,18 @@ describe('json', function(){
expect(function(){fromJson('[].constructor');}). expect(function(){fromJson('[].constructor');}).
toThrow(new Error("Parse Error: Token '.' is not valid json at column 3 of expression [[].constructor] starting at [.constructor].")); toThrow(new Error("Parse Error: Token '.' is not valid json at column 3 of expression [[].constructor] starting at [.constructor]."));
}); });
it('should not allow object dereference', function(){ it('should not allow object dereference', function(){
expect(function(){fromJson('{a:1, b: $location, c:1}');}).toThrow(); expect(function(){fromJson('{a:1, b: $location, c:1}');}).toThrow();
expect(function(){fromJson("{a:1, b:[1]['__parent__']['location'], c:1}");}).toThrow(); expect(function(){fromJson("{a:1, b:[1]['__parent__']['location'], c:1}");}).toThrow();
}); });
it('should not allow assignments', function(){ it('should not allow assignments', function(){
expect(function(){fromJson("{a:1, b:[1]=1, c:1}");}).toThrow(); expect(function(){fromJson("{a:1, b:[1]=1, c:1}");}).toThrow();
expect(function(){fromJson("{a:1, b:=1, c:1}");}).toThrow(); expect(function(){fromJson("{a:1, b:=1, c:1}");}).toThrow();
expect(function(){fromJson("{a:1, b:x=1, c:1}");}).toThrow(); expect(function(){fromJson("{a:1, b:x=1, c:1}");}).toThrow();
}); });
}); });
}); });

View file

@ -396,4 +396,29 @@ describe('parser', function() {
expect(scope.obj.name).toBeUndefined(); expect(scope.obj.name).toBeUndefined();
expect(scope.obj[0].name).toEqual(1); expect(scope.obj[0].name).toEqual(1);
}); });
describe('formatter', function(){
it('should return no argument function', function() {
var noop = parser('noop').formatter()();
expect(noop.format(null, 'abc')).toEqual('abc');
expect(noop.parse(null, '123')).toEqual('123');
});
it('should delegate arguments', function(){
var index = parser('index:objs').formatter()();
expect(index.format({objs:['A','B']}, 'B')).toEqual('1');
expect(index.parse({objs:['A','B']}, '1')).toEqual('B');
});
});
describe('assignable', function(){
it('should expose assignment function', function(){
var fn = parser('a').assignable();
expect(fn.assign).toBeTruthy();
var scope = {};
fn.assign(scope, 123);
expect(scope).toEqual({a:123});
});
});
}); });

View file

@ -104,10 +104,17 @@ describe("directive", function(){
}); });
it('should ng:bind-attr', function(){ describe('ng:bind-attr', function(){
var scope = compile('<img ng:bind-attr="{src:\'http://localhost/mysrc\', alt:\'myalt\'}"/>'); it('should bind attributes', function(){
expect(element.attr('src')).toEqual('http://localhost/mysrc'); var scope = compile('<img ng:bind-attr="{src:\'http://localhost/mysrc\', alt:\'myalt\'}"/>');
expect(element.attr('alt')).toEqual('myalt'); expect(element.attr('src')).toEqual('http://localhost/mysrc');
expect(element.attr('alt')).toEqual('myalt');
});
it('should not pretty print JSON in attributes', function(){
var scope = compile('<img alt="{{ {a:1} }}"/>');
expect(element.attr('alt')).toEqual('{"a":1}');
});
}); });
it('should remove special attributes on false', function(){ it('should remove special attributes on false', function(){

View file

@ -44,7 +44,7 @@ describe("widget", function(){
expect(scope.$get('name')).toEqual('Kai'); expect(scope.$get('name')).toEqual('Kai');
expect(scope.$get('count')).toEqual(2); expect(scope.$get('count')).toEqual(2);
}); });
it('should not trigger eval if value does not change', function(){ it('should not trigger eval if value does not change', function(){
compile('<input type="Text" name="name" value="Misko" ng:change="count = count + 1" ng:init="count=0"/>'); compile('<input type="Text" name="name" value="Misko" ng:change="count = count + 1" ng:init="count=0"/>');
expect(scope.name).toEqual("Misko"); expect(scope.name).toEqual("Misko");
@ -53,7 +53,7 @@ describe("widget", function(){
expect(scope.name).toEqual("Misko"); expect(scope.name).toEqual("Misko");
expect(scope.count).toEqual(0); expect(scope.count).toEqual(0);
}); });
it('should allow complex refernce binding', function(){ it('should allow complex refernce binding', function(){
compile('<div ng:init="obj={abc:{}}">'+ compile('<div ng:init="obj={abc:{}}">'+
'<input type="Text" name="obj[\'abc\'].name" value="Misko""/>'+ '<input type="Text" name="obj[\'abc\'].name" value="Misko""/>'+
@ -416,7 +416,7 @@ describe("widget", function(){
scope.$eval(); scope.$eval();
expect(element[0].childNodes[1].selected).toEqual(true); expect(element[0].childNodes[1].selected).toEqual(true);
}); });
it('should select default option on repeater', function(){ it('should select default option on repeater', function(){
compile( compile(
'<select name="selection">' + '<select name="selection">' +
@ -424,7 +424,7 @@ describe("widget", function(){
'</select>'); '</select>');
expect(scope.selection).toEqual('1'); expect(scope.selection).toEqual('1');
}); });
it('should select selected option on repeater', function(){ it('should select selected option on repeater', function(){
compile( compile(
'<select name="selection">' + '<select name="selection">' +
@ -433,7 +433,7 @@ describe("widget", function(){
'</select>'); '</select>');
expect(scope.selection).toEqual('ABC'); expect(scope.selection).toEqual('ABC');
}); });
it('should select dynamically selected option on repeater', function(){ it('should select dynamically selected option on repeater', function(){
compile( compile(
'<select name="selection">' + '<select name="selection">' +
@ -441,21 +441,81 @@ describe("widget", function(){
'</select>'); '</select>');
expect(scope.selection).toEqual('2'); expect(scope.selection).toEqual('2');
}); });
it('should allow binding to objects through JSON', function(){
compile(
'<select name="selection" ng:format="json">' +
'<option ng:repeat="obj in objs" value="{{obj}}">{{obj.name}}</option>' +
'</select>');
scope.objs = [{name:'A'}, {name:'B'}];
scope.$eval();
expect(scope.selection).toEqual({name:'A'});
});
it('should allow binding to objects through index', function(){
compile(
'<select name="selection" ng:format="index:objs">' +
'<option ng:repeat="obj in objs" value="{{$index}}">{{obj.name}}</option>' +
'</select>');
scope.objs = [{name:'A'}, {name:'B'}];
scope.$eval();
expect(scope.selection).toBe(scope.objs[0]);
});
}); });
it('should support type="select-multiple"', function(){ describe('select-multiple', function(){
compile( it('should support type="select-multiple"', function(){
'<select name="selection" multiple>' + compile('<select name="selection" multiple>' +
'<option>A</option>' + '<option>A</option>' +
'<option selected>B</option>' + '<option selected>B</option>' +
'</select>'); '</select>');
expect(scope.selection).toEqual(['B']); expect(scope.selection).toEqual(['B']);
scope.selection = ['A']; scope.selection = ['A'];
scope.$eval(); scope.$eval();
expect(element[0].childNodes[0].selected).toEqual(true); expect(element[0].childNodes[0].selected).toEqual(true);
});
it('should allow binding to objects through index', function(){
compile('<select name="selection" multiple ng:format="index:list">' +
'<option selected value="0">A</option>' +
'<option selected value="1">B</option>' +
'<option value="2">C</option>' +
'</select>',
function(){
scope.list = [{name:'A'}, {name:'B'}, {name:'C'}];
});
scope.$eval();
expect(scope.selection).toEqual([{name:'A'}, {name:'B'}]);
});
it('should be empty array when no items are selected', function(){
compile(
'<select name="selection" multiple ng:format="index:list">' +
'<option value="0">A</option>' +
'<option value="1">B</option>' +
'<option value="2">C</option>' +
'</select>');
scope.list = [{name:'A'}, {name:'B'}, {name:'C'}];
scope.$eval();
expect(scope.selection).toEqual([]);
});
it('should be contain the selected object', function(){
compile('<select name="selection" multiple ng:format="index:list">' +
'<option value="0">A</option>' +
'<option value="1" selected>B</option>' +
'<option value="2">C</option>' +
'</select>',
function(){
scope.list = [{name:'A'}, {name:'B'}, {name:'C'}];
});
scope.$eval();
expect(scope.selection).toEqual([{name:'B'}]);
});
}); });
it('should ignore text widget which have no name', function(){ it('should ignore text widget which have no name', function(){
compile('<input type="text"/>'); compile('<input type="text"/>');
expect(scope.$element.attr('ng-exception')).toBeFalsy(); expect(scope.$element.attr('ng-exception')).toBeFalsy();
@ -504,7 +564,7 @@ describe("widget", function(){
scope.$eval(); scope.$eval();
expect(element.text()).toEqual('true:misko'); expect(element.text()).toEqual('true:misko');
}); });
it("should compare stringified versions", function(){ it("should compare stringified versions", function(){
var switchWidget = angular.widget('ng:switch'); var switchWidget = angular.widget('ng:switch');
expect(switchWidget.equals(true, 'true')).toEqual(true); expect(switchWidget.equals(true, 'true')).toEqual(true);
@ -521,7 +581,7 @@ describe("widget", function(){
scope.$eval(); scope.$eval();
expect(element.text()).toEqual('one'); expect(element.text()).toEqual('one');
}); });
it("should match urls", function(){ it("should match urls", function(){
var scope = angular.compile('<ng:switch on="url" using="route:params"><div ng:switch-when="/Book/:name">{{params.name}}</div></ng:switch>'); var scope = angular.compile('<ng:switch on="url" using="route:params"><div ng:switch-when="/Book/:name">{{params.name}}</div></ng:switch>');
scope.url = '/Book/Moby'; scope.url = '/Book/Moby';