From bd8f8f35418026bb9f3e8f153b8d60bc19a443ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bachelier?= Date: Wed, 15 Apr 2015 09:36:00 +0200 Subject: [PATCH] Add support for default value for radio button group in serialize data: * Default is set to null but it's easy to override. * Add value assigners to control value assignment to data --- SpecRunner.html | 1 + apidoc.md | 26 +- readme.md | 7 + spec/javascripts/serialize.spec.js | 12 +- spec/javascripts/valueAssigners.spec.js | 236 ++++++++++++++++++ src/backbone.syphon.inputreaders.js | 5 + src/backbone.syphon.js | 16 +- ...backbone.syphon.keyassignmentvalidators.js | 9 +- src/backbone.syphon.valueassigners.js | 43 ++++ src/build/backbone.syphon.js | 1 + 10 files changed, 340 insertions(+), 16 deletions(-) create mode 100644 spec/javascripts/valueAssigners.spec.js create mode 100644 src/backbone.syphon.valueassigners.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 b506351..248899b 100644 --- a/readme.md +++ b/readme.md @@ -166,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). diff --git a/spec/javascripts/serialize.spec.js b/spec/javascripts/serialize.spec.js index 48c47c9..249be68 100644 --- a/spec/javascripts/serialize.spec.js +++ b/spec/javascripts/serialize.spec.js @@ -250,7 +250,7 @@ describe('serializing a form', function() { this.$el.html( '
' + '' + - '' + + '' + '' + '
' ); @@ -260,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'); }); }); 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 2636d28..c1b0993 100644 --- a/src/backbone.syphon.inputreaders.js +++ b/src/backbone.syphon.inputreaders.js @@ -19,3 +19,8 @@ InputReaders.registerDefault(function($el) { InputReaders.register('checkbox', function($el) { 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.js b/src/backbone.syphon.js index d5b6b95..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)); } }); @@ -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; }));