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) #
### 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) #

View file

@ -1,4 +1,5 @@
var ngdoc = require('ngdoc.js');
var DOM = require('dom.js').DOM;
describe('ngdoc', function(){
var Doc = ngdoc.Doc;
@ -254,4 +255,66 @@ 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.text(self.name);
dom.text('(');
var first = true;
(self.param || []).forEach(function(param){
if (first) {
first = false;
} else {
dom.text(', ');
}
dom.text(param.name);
});
self.parameters(dom, ', ');
dom.text(');');
});
@ -273,44 +265,17 @@ Doc.prototype = {
dom.text(self.shortName);
dom.text('_expression | ');
dom.text(self.shortName);
var first = 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);
}
}
});
self.parameters(dom, ':', true);
dom.text(' }}');
});
});
dom.h3('In JavaScript', function(){
dom.h('In JavaScript', function(){
dom.tag('code', function(){
dom.text('angular.filter.');
dom.text(self.shortName);
dom.text('(');
var first = true;
(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);
}
}
});
self.parameters(dom, ', ');
dom.text(')');
});
});
@ -325,26 +290,34 @@ Doc.prototype = {
dom.h('Usage', function(){
dom.h('In HTML Template Binding', 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);
self.parameters(dom, ':', false, true);
dom.text('">');
});
});
dom.h3('In JavaScript', function(){
dom.h('In JavaScript', function(){
dom.code(function(){
dom.text('var userInputString = angular.formatter.');
dom.text(self.shortName);
dom.text('.format(modelValue);');
});
dom.html('<br/>');
dom.code(function(){
dom.text('.format(modelValue');
self.parameters(dom, ', ', false, true);
dom.text(');');
dom.text('\n');
dom.text('var modelValue = angular.formatter.');
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);
});
},
@ -356,18 +329,7 @@ Doc.prototype = {
dom.code(function(){
dom.text('<input type="text" ng:validate="');
dom.text(self.shortName);
var first = true;
(self.param||[]).forEach(function(param){
if (first) {
first = false;
} else {
if (param.optional) {
dom.text('[:' + param.name + ']');
} else {
dom.text(':' + param.name);
}
}
});
self.parameters(dom, ':', true);
dom.text('"/>');
});
});
@ -377,19 +339,7 @@ Doc.prototype = {
dom.text('angular.validator.');
dom.text(self.shortName);
dom.text('(');
var first = true;
(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);
}
}
});
self.parameters(dom, ', ');
dom.text(')');
});
});
@ -443,6 +393,20 @@ Doc.prototype = {
},
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.
*/
function fromJson(json, useNative) {
if (!json) return json;
if (!isString(json)) return json;
var obj, p, expression;

View file

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

View file

@ -15,7 +15,7 @@ angularFormatter.noop = formatter(identity, identity);
* @description
* 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
* <div ng:init="data={name:'misko', project:'angular'}">
@ -30,7 +30,9 @@ angularFormatter.noop = formatter(identity, identity);
* expect(binding('data')).toEqual('data={\n }');
* });
*/
angularFormatter.json = formatter(toJson, fromJson);
angularFormatter.json = formatter(toJson, function(value){
return fromJson(value || 'null');
});
/**
* @workInProgress
@ -154,3 +156,59 @@ angularFormatter.list = formatter(
angularFormatter.trim = formatter(
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),
tokens = lex(text, json),
assignment = _assignment,
assignable = logicalOR,
functionCall = _functionCall,
fieldAccess = _fieldAccess,
objectIndex = _objectIndex,
@ -231,6 +232,7 @@ function parser(text, json){
functionCall =
fieldAccess =
objectIndex =
assignable =
filterChain =
functionIdent =
pipeFunction =
@ -238,9 +240,11 @@ function parser(text, json){
}
return {
assertAllConsumed: assertAllConsumed,
assignable: assignable,
primary: primary,
statements: statements,
validator: validator,
formatter: formatter,
filter: filter,
//TODO: delete me, since having watch in UI is logic in UI. (leftover form getangular)
watch: watch
@ -353,6 +357,33 @@ function parser(text, json){
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){
var fn = functionIdent(fnScope);
var argsFn = [];

View file

@ -134,14 +134,19 @@
function modelAccessor(scope, element) {
var expr = element.attr('name');
var assign;
if (expr) {
assign = parser(expr).assignable().assign;
if (!assign) throw new Error("Expression '" + expr + "' is not assignable.");
return {
get: function() {
return scope.$eval(expr);
},
set: function(value) {
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) {
var accessor = modelAccessor(scope, element),
formatterName = element.attr('ng:format') || NOOP,
formatter = angularFormatter(formatterName);
if (!formatter) throw "Formatter named '" + formatterName + "' not found.";
formatter = compileFormatter(formatterName);
if (accessor) {
return {
get: function() {
return formatter.format(accessor.get());
return formatter.format(scope, accessor.get());
},
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()();
}
function compileFormatter(expr) {
return parser(expr).formatter()();
}
/**
* @workInProgress
* @ngdoc widget
@ -269,11 +277,10 @@ function valueAccessor(scope, element) {
validator = compileValidator(validatorName),
requiredExpr = element.attr('ng:required'),
formatterName = element.attr('ng:format') || NOOP,
formatter = angularFormatter(formatterName),
formatter = compileFormatter(formatterName),
format, parse, lastError, required,
invalidWidgets = scope.$service('$invalidWidgets') || {markValid:noop, markInvalid:noop};
if (!validator) throw "Validator named '" + validatorName + "' not found.";
if (!formatter) throw "Formatter named '" + formatterName + "' not found.";
format = formatter.format;
parse = formatter.parse;
if (requiredExpr) {
@ -291,7 +298,7 @@ function valueAccessor(scope, element) {
if (lastError)
elementError(element, NG_VALIDATION_ERROR, _null);
try {
var value = parse(element.val());
var value = parse(scope, element.val());
validate();
return value;
} catch (e) {
@ -301,7 +308,7 @@ function valueAccessor(scope, element) {
},
set: function(value) {
var oldValue = element.val(),
newValue = format(value);
newValue = format(scope, value);
if (oldValue != newValue) {
element.val(newValue || ''); // needed for ie
}
@ -355,19 +362,22 @@ function radioAccessor(scope, element) {
}
function optionsAccessor(scope, element) {
var options = element[0].options;
var formatterName = element.attr('ng:format') || NOOP,
formatter = compileFormatter(formatterName);
return {
get: function(){
var values = [];
forEach(options, function(option){
if (option.selected) values.push(option.value);
forEach(element[0].options, function(option){
if (option.selected) values.push(formatter.parse(scope, option.value));
});
return values;
},
set: function(values){
var keys = {};
forEach(values, function(value){ keys[value] = true; });
forEach(options, function(option){
forEach(values, function(value){
keys[formatter.format(scope, value)] = true;
});
forEach(element[0].options, function(option){
option.selected = keys[option.value];
});
}
@ -376,6 +386,18 @@ function optionsAccessor(scope, element) {
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),
buttonWidget = inputWidget('click', noopAccessor, noopAccessor, noop),
INPUT_TYPE = {
@ -389,8 +411,8 @@ var textWidget = inputWidget('keydown change', modelAccessor, valueAccessor, ini
'image': buttonWidget,
'checkbox': inputWidget('click', modelFormattedAccessor, checkedAccessor, initWidgetValue(false)),
'radio': inputWidget('click', modelFormattedAccessor, radioAccessor, radioInit),
'select-one': inputWidget('change', modelFormattedAccessor, valueAccessor, initWidgetValue(_null)),
'select-multiple': inputWidget('change', modelFormattedAccessor, optionsAccessor, initWidgetValue([]))
'select-one': inputWidget('change', modelAccessor, valueAccessor, initWidgetValue(_null)),
'select-multiple': inputWidget('change', modelAccessor, optionsAccessor, initWidgetValue([]))
// 'file': fileWidget???
};
@ -517,28 +539,36 @@ angularWidget('select', function(element){
angularWidget('option', function(){
this.descend(true);
this.directives(true);
return function(element) {
var select = element.parent();
return function(option) {
var select = option.parent();
var isMultiple = select.attr('multiple') == '';
var scope = retrieveScope(select);
var model = modelFormattedAccessor(scope, select);
var view = valueAccessor(scope, select);
var option = element;
var model = modelAccessor(scope, select);
var formattedModel = modelFormattedAccessor(scope, select);
var view = isMultiple
? optionsAccessor(scope, select)
: valueAccessor(scope, select);
var lastValue = option.attr($value);
var lastSelected = option.attr('ng-' + $selected);
element.data($$update, function(){
var value = option.attr($value);
var selected = option.attr('ng-' + $selected);
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);
var wasSelected = option.attr('ng-' + $selected);
option.data($$update, isMultiple
? function(){
view.set(model.get());
}
}
});
: 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);
}
}
}
);
};
});

View file

@ -34,4 +34,19 @@ describe("formatter", function(){
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

@ -116,6 +116,13 @@ describe('json', function(){
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
if (JSON && JSON.parse) {

View file

@ -396,4 +396,29 @@ describe('parser', function() {
expect(scope.obj.name).toBeUndefined();
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(){
var scope = compile('<img ng:bind-attr="{src:\'http://localhost/mysrc\', alt:\'myalt\'}"/>');
expect(element.attr('src')).toEqual('http://localhost/mysrc');
expect(element.attr('alt')).toEqual('myalt');
describe('ng:bind-attr', function(){
it('should bind attributes', function(){
var scope = compile('<img ng:bind-attr="{src:\'http://localhost/mysrc\', alt:\'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(){

View file

@ -442,18 +442,78 @@ describe("widget", function(){
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(){
compile(
'<select name="selection" multiple>' +
'<option>A</option>' +
'<option selected>B</option>' +
'</select>');
expect(scope.selection).toEqual(['B']);
scope.selection = ['A'];
scope.$eval();
expect(element[0].childNodes[0].selected).toEqual(true);
describe('select-multiple', function(){
it('should support type="select-multiple"', function(){
compile('<select name="selection" multiple>' +
'<option>A</option>' +
'<option selected>B</option>' +
'</select>');
expect(scope.selection).toEqual(['B']);
scope.selection = ['A'];
scope.$eval();
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(){