diff --git a/.travis.yml b/.travis.yml index 6487ac8..6983520 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ +sudo: false language: node_js before_install: npm install -g grunt-cli node_js: diff --git a/SpecRunner.html b/SpecRunner.html index 0fc8494..cb75e2c 100644 --- a/SpecRunner.html +++ b/SpecRunner.html @@ -47,6 +47,7 @@ +
diff --git a/apidoc.md b/apidoc.md index 480f83a..c1a1f4c 100644 --- a/apidoc.md +++ b/apidoc.md @@ -167,6 +167,15 @@ You can register your own input readers, allowing you to change how the data is read. To do this register a callback function to an input type. +The default input reader for radio button is returning the value if button is checked or `null`. +```js +Backbone.Syphon.InputReaders.register('radio', function($el) { + return $el.prop('checked') ? $el.val() : null; +}); +``` + +You can override the default behavior to return the value of the radio button independently of its status. + ```js Backbone.Syphon.InputReaders.register('radio', function(el){ return el.val(); @@ -264,8 +273,9 @@ extracted from the element. This is the last opportunity to prevent bad data from getting serialized to your object. The most common use of this is to ensure radio button groups are only -serialized by the one radio button that is selected, within the group. This -behavior is built in by default (see below). +serialized by the one radio button that is selected, within the group, +defaulting to `null` value if no button is selected. This behavior is +built in by default (see below). ### Assigning Your Own Validator @@ -295,6 +305,16 @@ return a boolean (or truthy) value from the callback. Return values of `true` or truthy will be valid and the assignment will occur. Return values that are `false` or falsey will not be valid and the assignment will not occur. +### Overriding radio group output + +Default will serialize a null value for a radio group if no button is selected. If you want a different behavior like no output if no radio button selected, you should override radio key assignment validator, like this: + +```js +Backbone.Syphon.KeyAssignmentValidators.register("radio", function($el, key, value){ + return $el.prop('checked'); +}); +``` + ### Assign A Key Assignment Validation Set You can assign your own Key Validation Set by creating an instance of @@ -315,7 +335,7 @@ just register and remove validations as needed. There are two Key Assignment Validators built in to Syphon: * default: everything is valid -* radio: only radio buttons that are selected are valid +* radio: only radio buttons that are selected are valid, or if a value different than `undefined` is returned by input reader. This should work for most cases, with radio button groups set to a `null` in case no buttons are selected. ## Handling Non-"input" Elements diff --git a/readme.md b/readme.md index d607791..248899b 100644 --- a/readme.md +++ b/readme.md @@ -123,6 +123,7 @@ By default, a checkbox will return a boolean value signifying whether or not it ``` @@ -133,7 +134,8 @@ Backbone.Syphon.serialize(view); { a: false, - b: true + b: true, + c: null } ``` @@ -164,6 +166,13 @@ Backbone.Syphon.serialize(view); } ``` +Serializing a form with no radio button checked will be serialized to : +```js +{ + a: null +} +``` + This behavior can be changed by registering a different set of Key Extractors, Input Readers, and Key Assignment Validators. See the full [API Documentation](https://github.com/marionettejs/backbone.syphon/blob/master/apidoc.md). @@ -437,6 +446,7 @@ For more information on Key Assignment Validators, see the full There some known limitations in Backbone.Syphon, partially by design and partially implemented as default behaviors. * An input of type `checkbox` will return a boolean value. This can be overriden by replacing the Input Reader for checkboxes. +* Yo avoid circular references, care should be taken when using Backbone.Relational. See (#33)[https://github.com/marionettejs/backbone.syphon/issues/33]. ## Building Backbone.Syphon diff --git a/spec/javascripts/deserialize.spec.js b/spec/javascripts/deserialize.spec.js index f73914b..56992f6 100644 --- a/spec/javascripts/deserialize.spec.js +++ b/spec/javascripts/deserialize.spec.js @@ -137,6 +137,21 @@ describe('deserializing an object into a form', function() { expect(this.result).to.be.false; }); }); + + describe('and the corresponding value in the given object is null', function() { + beforeEach(function() { + this.view = new this.View(); + this.view.render(); + this.view.$('#the-checkbox').prop('checked', false); + + Backbone.Syphon.deserialize(this.view, {chk: null}); + this.result = this.view.$('#the-checkbox').prop('indeterminate'); + }); + + it('should add an indeterminate attribute', function() { + expect(this.result).to.be.true; + }); + }); }); describe('when deserializing into a button', function() { diff --git a/spec/javascripts/serialize.spec.js b/spec/javascripts/serialize.spec.js index 4931e9d..249be68 100644 --- a/spec/javascripts/serialize.spec.js +++ b/spec/javascripts/serialize.spec.js @@ -158,6 +158,20 @@ describe('serializing a form', function() { expect(this.result.chk).to.be.false; }); }); + + describe('and the checkbox is indeterminate', function() { + beforeEach(function() { + this.view = new this.View(); + this.view.render(); + this.view.$('#the-checkbox').prop('indeterminate', true); + + this.result = Backbone.Syphon.serialize(this.view); + }); + + it('should return an object with a value of null', function() { + expect(this.result.chk).to.be.null; + }); + }); }); describe('when serializing a button', function() { @@ -236,7 +250,7 @@ describe('serializing a form', function() { this.$el.html( '' ); @@ -246,11 +260,17 @@ describe('serializing a form', function() { this.view = new this.View(); this.view.render(); - this.result = Backbone.Syphon.serialize(this.view); + }); + + it('should return the value null when no selected radio button', function() { + var result = Backbone.Syphon.serialize(this.view); + expect(result.foo).to.equal(null); }); it('should only return the value of the selected radio button', function() { - expect(this.result.foo).to.equal('bar'); + this.view.$('[value=bar]').click(); + var result = Backbone.Syphon.serialize(this.view); + expect(result.foo).to.equal('bar'); }); }); @@ -292,6 +312,24 @@ describe('serializing a form', function() { }); }); + describe('when given a form element with nested inputs', function() { + beforeEach(function() { + this.form = $( + '' + )[0]; + + this.result = Backbone.Syphon.serialize(this.form); + }); + + it('retrieves the inputs\' values', function() { + expect(this.result.foo).to.equal('bar'); + }); + }); + describe('when given more than 1 form', function() { beforeEach(function() { this.View = Backbone.View.extend({ diff --git a/spec/javascripts/valueAssigners.spec.js b/spec/javascripts/valueAssigners.spec.js new file mode 100644 index 0000000..a2ef36b --- /dev/null +++ b/spec/javascripts/valueAssigners.spec.js @@ -0,0 +1,236 @@ +describe('value assigners', function() { + describe('by default', function() { + beforeEach(function() { + this.valueAssigners = Backbone.Syphon.ValueAssigners; + this.obj = { + foo: undefined, + bar: [] + }; + }); + + describe('for default type', function() { + beforeEach(function() { + var defaultValueAssigner = this.valueAssigners.get(); + this.valueAssignerFn = defaultValueAssigner(1); + }); + + describe('for a scalar', function() { + it('should set value', function() { + this.valueAssignerFn(this.obj, 'foo'); + expect(this.obj.foo).to.equal(1); + }); + + it('should override the value', function() { + this.obj.foo = 0; + this.valueAssignerFn(this.obj, 'foo'); + expect(this.obj.foo).to.equal(1); + }); + }); + + describe('for an array', function() { + it('should set value', function() { + this.valueAssignerFn(this.obj, 'bar'); + expect(this.obj.bar).to.have.length(1); + expect(this.obj.bar[0]).to.equal(1); + }); + + it('should override the value', function() { + this.obj.bar.push(0); + this.valueAssignerFn(this.obj, 'bar'); + expect(this.obj.bar[1]).to.equal(1); + }); + }); + }); + + describe('for radio type', function() { + beforeEach(function() { + this.radioValueAssigner = this.valueAssigners.get('radio'); + }); + + describe('for a scalar', function() { + it('should set value', function() { + var valueAssignerFn = this.radioValueAssigner(1); + valueAssignerFn(this.obj, 'foo'); + expect(this.obj.foo).to.equal(1); + }); + + it('should be possible to set a null value', function() { + var valueAssignerFn = this.radioValueAssigner(null); + valueAssignerFn(this.obj, 'foo'); + expect(this.obj.foo).to.equal(null); + }); + + it('should override existing null value', function() { + this.obj.foo = null; + + var valueAssignerFn = this.radioValueAssigner(1); + valueAssignerFn(this.obj, 'foo'); + expect(this.obj.foo).to.equal(1); + }); + + it('should override existing undefined value', function() { + this.obj.foo = undefined; + + var valueAssignerFn = this.radioValueAssigner(1); + valueAssignerFn(this.obj, 'foo'); + expect(this.obj.foo).to.equal(1); + }); + + it('should not override existing value different from undefined or null', function() { + this.obj.foo = 1; + + var valueAssignerFn = this.radioValueAssigner(2); + valueAssignerFn(this.obj, 'foo'); + expect(this.obj.foo).to.equal(1); + }); + }); + + describe('for an array', function() { + it('should set value', function() { + var valueAssignerFn = this.radioValueAssigner(1); + valueAssignerFn(this.obj, 'bar'); + expect(this.obj.bar).to.have.length(1); + expect(this.obj.bar[0]).to.equal(1); + }); + + it('should be possible to set a null value', function() { + var valueAssignerFn = this.radioValueAssigner(null); + valueAssignerFn(this.obj, 'bar'); + expect(this.obj.bar).to.have.length(1); + expect(this.obj.bar[0]).to.equal(null); + }); + + it('should override a null value', function() { + var valueAssignerFn = this.radioValueAssigner(1); + this.obj.bar.push(null); + valueAssignerFn(this.obj, 'bar'); + expect(this.obj.bar).to.have.length(1); + expect(this.obj.bar[0]).to.equal(1); + }); + + it('should override an undefined value', function() { + var valueAssignerFn = this.radioValueAssigner(1); + this.obj.bar.push(undefined); + valueAssignerFn(this.obj, 'bar'); + expect(this.obj.bar).to.have.length(1); + expect(this.obj.bar[0]).to.equal(1); + }); + + it('should not override a non null or undefined value', function() { + var valueAssignerFn = this.radioValueAssigner(1); + this.obj.bar.push(0); + valueAssignerFn(this.obj, 'bar'); + expect(this.obj.bar).to.have.length(1); + expect(this.obj.bar[0]).to.equal(0); + }); + }); + }); + }); + + describe('when serializing a form', function() { + beforeEach(function() { + this.View = Backbone.View.extend({ + render: function() { + this.$el.html( + '' + ); + } + }); + + this.view = new this.View(); + this.view.render(); + this.result = Backbone.Syphon.serialize(this.view); + }); + + it('should have a correct value for text input', function() { + expect(this.result).to.have.ownProperty('text'); + expect(this.result.text).to.equal('foo'); + }); + + it('should have a correct value for a non checked checkbox', function() { + expect(this.result).to.have.ownProperty('bar'); + expect(this.result.bar).to.equal(false); + }); + + it('should have a correct value for a checked checkbox', function() { + expect(this.result).to.have.ownProperty('bar2'); + expect(this.result.bar2).to.equal(true); + }); + + it('should set a null value for non checked radio', function() { + expect(this.result).to.have.ownProperty('foo'); + expect(this.result.foo).to.equal(null); + }); + + it('should set a null value for checked radio', function() { + expect(this.result).to.have.ownProperty('foo2'); + expect(this.result.foo2).to.equal('a'); + }); + + it('should have a correct value for radio array', function() { + expect(this.result).to.have.ownProperty('radio'); + expect(this.result.radio).to.be.an('array'); + expect(this.result.radio).to.have.length(1); + expect(this.result.radio[0]).to.equal('a'); + }); + }); + + describe('when specifying value assigners in the options for serialize', function() { + beforeEach(function() { + this.View = Backbone.View.extend({ + render: function() { + this.$el.html( + '' + ); + } + }); + + this.valueAssigners = new Backbone.Syphon.ValueAssignerSet(); + + // this value assigners add a prefix to all values. + this.valueAssigners.registerDefault(function(value) { + var prefixValue = function(value) { + return 'foo-' + value; + }; + + return function(obj, key) { + var v = prefixValue(value); + + if (_.isArray(obj[key])) { + obj[key].push(v); + } else { + obj[key] = v; + } + }; + }); + + this.view = new this.View(); + this.view.render(); + + this.result = Backbone.Syphon.serialize(this.view, { + valueAssigners: this.valueAssigners + }); + }); + + it('should use the specified value assigners', function() { + expect(this.result).to.have.ownProperty('bar'); + expect(this.result.bar).to.equal('foo-a'); + + expect(this.result).to.have.ownProperty('foo'); + expect(this.result.foo).to.equal('foo-b'); + }); + }); +}); diff --git a/src/backbone.syphon.inputreaders.js b/src/backbone.syphon.inputreaders.js index ccca818..c1b0993 100644 --- a/src/backbone.syphon.inputreaders.js +++ b/src/backbone.syphon.inputreaders.js @@ -17,5 +17,10 @@ InputReaders.registerDefault(function($el) { // Checkbox reader, returning a boolean value for // whether or not the checkbox is checked. InputReaders.register('checkbox', function($el) { - return $el.prop('checked'); + return ($el.prop('indeterminate')) ? null : $el.prop('checked'); +}); + +// Radio reader, returning the value if radio button is checked or null +InputReaders.register('radio', function($el) { + return $el.prop('checked') ? $el.val() : null; }); diff --git a/src/backbone.syphon.inputwriters.js b/src/backbone.syphon.inputwriters.js index 7affde5..a84b828 100644 --- a/src/backbone.syphon.inputwriters.js +++ b/src/backbone.syphon.inputwriters.js @@ -17,7 +17,11 @@ InputWriters.registerDefault(function($el, value) { // Checkbox writer, set whether or not the checkbox is checked // depending on the boolean value. InputWriters.register('checkbox', function($el, value) { - $el.prop('checked', value); + if (value === null) { + $el.prop('indeterminate', true); + } else { + $el.prop('checked', value); + } }); // Radio button writer, set whether or not the radio button is diff --git a/src/backbone.syphon.js b/src/backbone.syphon.js index f15d9e3..74d7e70 100644 --- a/src/backbone.syphon.js +++ b/src/backbone.syphon.js @@ -1,4 +1,4 @@ -/* jshint maxstatements: 13, maxlen: 102, maxcomplexity: 8, latedef: false */ +/* jshint maxstatements: 13, maxlen: 102, maxcomplexity: 9, latedef: false */ // Ignore Element Types // -------------------- @@ -41,7 +41,8 @@ Syphon.serialize = function(view, options) { var validKeyAssignment = config.keyAssignmentValidators.get(type); if (validKeyAssignment($el, key, value)) { var keychain = config.keySplitter(key); - data = assignKeyValue(data, keychain, value); + var valueAssigner = config.valueAssigners.get(type); + data = assignKeyValue(data, keychain, valueAssigner(value)); } }); @@ -147,7 +148,7 @@ var getElementType = function(el) { // Otherwise, get the form fields from the view. var getForm = function(viewOrForm) { if (_.isUndefined(viewOrForm.$el)) { - return $(viewOrForm).children(':input'); + return $(viewOrForm).find(':input'); } else { return viewOrForm.$(':input'); } @@ -165,6 +166,7 @@ var buildConfig = function(options) { config.keySplitter = config.keySplitter || Syphon.KeySplitter; config.keyJoiner = config.keyJoiner || Syphon.KeyJoiner; config.keyAssignmentValidators = config.keyAssignmentValidators || Syphon.KeyAssignmentValidators; + config.valueAssigners = config.valueAssigners || Syphon.ValueAssigners; return config; }; @@ -190,7 +192,7 @@ var buildConfig = function(options) { // becomes an array, and values are pushed in to the array, // allowing multiple fields with the same name to be // assigned to the array. -var assignKeyValue = function(obj, keychain, value) { +var assignKeyValue = function(obj, keychain, valueAssignerFn) { if (!keychain) { return obj; } var key = keychain.shift(); @@ -202,16 +204,12 @@ var assignKeyValue = function(obj, keychain, value) { // if it's the last key in the chain, assign the value directly if (keychain.length === 0) { - if (_.isArray(obj[key])) { - obj[key].push(value); - } else { - obj[key] = value; - } + valueAssignerFn(obj, key); } // recursive parsing of the array, depth-first if (keychain.length > 0) { - assignKeyValue(obj[key], keychain, value); + assignKeyValue(obj[key], keychain, valueAssignerFn); } return obj; diff --git a/src/backbone.syphon.keyassignmentvalidators.js b/src/backbone.syphon.keyassignmentvalidators.js index d393b88..f8255e3 100644 --- a/src/backbone.syphon.keyassignmentvalidators.js +++ b/src/backbone.syphon.keyassignmentvalidators.js @@ -19,5 +19,12 @@ KeyAssignmentValidators.registerDefault(function() { // But only the "checked" radio button for a given // radio button group is valid KeyAssignmentValidators.register('radio', function($el, key, value) { - return $el.prop('checked'); + if ($el.prop('checked')) { + return true; + } + + // in case radio button is not checked return true if a value different than undefined + // as been set. + // This behavior is useful to set a default value for a radio group + return value !== undefined; }); diff --git a/src/backbone.syphon.valueassigners.js b/src/backbone.syphon.valueassigners.js new file mode 100644 index 0000000..2a9e944 --- /dev/null +++ b/src/backbone.syphon.valueassigners.js @@ -0,0 +1,43 @@ +// Value Assigners +// ------------------------- + +// Value Assigners are used to whether or not a +// key should be assigned to a value, after the key and value have been +// extracted from the element. This is the last opportunity to prevent +// bad obj[key] from getting serialized to your object. + +var ValueAssignerSet = Syphon.ValueAssignerSet = TypeRegistry.extend(); + +// Build-in Key Assignment Values +var ValueAssigners = Syphon.ValueAssigners = new ValueAssignerSet(); + +// return value by default +ValueAssigners.registerDefault(function(value) { + return function(obj, key) { + if (_.isArray(obj[key])) { + obj[key].push(value); + } else { + obj[key] = value; + } + }; +}); + +// radio group button can have only one value assigned. +ValueAssigners.register('radio', function(value) { + var emptyValueFn = function(value) { + return _.isNull(value) || _.isUndefined(value) || _.isObject(value) && _.isEmpty(value); + }; + + return function(obj, key) { + if (_.isArray(obj[key])) { + if (!obj[key].length || (obj[key].length && emptyValueFn(obj[key][0]))) { + obj[key] = [value]; + } + } else { + // default is initialized to {} + if (emptyValueFn(obj[key])) { + obj[key] = value; + } + } + }; +}); diff --git a/src/build/backbone.syphon.js b/src/build/backbone.syphon.js index ca04c05..9260fa7 100644 --- a/src/build/backbone.syphon.js +++ b/src/build/backbone.syphon.js @@ -35,6 +35,7 @@ // @include ../backbone.syphon.keyassignmentvalidators.js // @include ../backbone.syphon.keysplitter.js // @include ../backbone.syphon.keyjoiner.js + // @include ../backbone.syphon.valueassigners.js return Backbone.Syphon; }));