Wednesday, 22 May 2013

Automatic Backbone model validation

Today I'm going to dig into Backbone.js validations and how to automate them. Backbone itself doesn't provide any proper validation. Fortunately, there is quite good plugin: Backbone.validation. It provides an easy way to define own constraints and error messages for models.

However, Backbone.validation plugin has one disadvantage. It doesn't provide any good way to transfer validation messages across multiple Backbone views nor provide events for single attribute validations. In simple applications this is not a problem, but huge user interface must be divided into multiple views and validation must work automatically for all of those, without generating too much processing overhead.

The solution?

First step was to implement per-attribute. Actually, Backbone.validation provides per-attribute validation callbacks but those are always bound to a view. In order to make our validations more modular, per-attribute validation events must be view-independent. We can create our own events "valid:<attr>" and "invalid:<attr>" by overriding the Backbone.Validation.mixin.validate function:

# PART 1: tweak backbone validation a little bit
# ===============================================
# first, allow errorous inputs to update our model
Backbone.Validation.configure
forceUpdate: true
# then disable default callbacks for backbone validation events
_.extend Backbone.Validation.callbacks,
valid: (view, attr, selector) ->
# these are not used
invalid: (view, attr, error, selector) ->
# these are not used
# we are using our custom model events "valid:<attr>" and "invalid:<attr>" to indicate
# when model field is validated
attrValid = (view, attr, selector) ->
# 'view' is not used, views use our implementation ;)
@trigger "valid:#{attr}", attr, selector
attrInvalid = (view, attr, errorMsg, selector) ->
# 'view' is not used, views use our implementation ;)
@trigger "invalid:#{attr}", attr, errorMsg, selector
origValidate = Backbone.Validation.mixin.validate
Backbone.Validation.mixin.validate = (attrs, setOptions) ->
options = setOptions || {}
# "this" refers now to model so we apply it to our validation
# functions, thus can trigger validation events for right model
options.valid = => attrValid.apply(@, arguments)
options.invalid = => attrInvalid.apply(@, arguments)
origValidate.call(@, attrs, options)
# add customized validation to our models, when validations occur, they raise
# model events valid:<attr> and invalid:<attr> to our models. Views may be interested
# in those validation callbacks for showing error messages.
_.extend(Backbone.Model.prototype, Backbone.Validation.mixin)

Ok. Now we have view independent validation events for attributes. Next step is to bind those events into views so that each input listens valid and invalid events from associated models. The problem is that there is no pre-defined information about available events because they are generated dynamically from model constraints. Thus, I've looped every input element from the view after render and used their name attribute for event binding:

# PART 2: bind our backbone view to validation events and also for
# input changes so that model changes automatically when input changes
# =======================================================================
# This defines which element's attribute will be used for linking
inputSelector = 'name'
# model update function, which is called when input changes
updateModel = (model, attr, value) ->
model.set(attr, value || null)
# This is called when input element is marked as valid
# It basically just removes Bootstrap error classes and
# custom error messages from input's control-group
setValid = ($input, attr, selector) ->
$group = $input.closest('.control-group')
return if $group.length == 0
$group.find('.help-inline.error').remove()
$group.removeClass('error')
return
# Helper function to check if input has already same an error message
containsError = ($err, $group) ->
errText = $err.text()
for help in $group.find('.help-inline.error')
return true if $(help).text() == errText
return false
# This is called when input is marked as valid
setInvalid = ($input, attr, errMsg, selector) ->
$group = $input.closest('.control-group')
if $group.length == 0
console.log "Invalid input '#{attr}' has no control group. No message will be shown..", $input
return
$controls = $group.find('.controls')
$err = $("<span class='help-inline error'>#{_.escape(errMsg)}</span>")
if !containsError($err, $group)
if $controls.length > 0
$err.insertAfter($controls).addClass('next-line')
else
$group.append($err)
# finally add error class
$group.addClass('error')
# This function is called when new stuff is rendered to the document
# It searches all inputs and binds update and validation events to
# those inputs so that changes/validation results are reflected automatically
# between model <-> view <-> template
addInputBindings = (view, model) ->
return if not model
view.$(':input:not(.btn)').each ->
$this = $(this)
return if not $this.attr(inputSelector)
# if we have backbone relational / nested plugin then we may have want
# to validate and update also nested attributes
attr = $this.attr(inputSelector).replace(/\-/g, '.')
# input change to model bindings
if !$this.data('ignore-model')
# when input changes, then models changes too
# we could add also events from model changes to change
# input value but we won't do it in this example
$this.on 'change', -> updateModel(model, attr, $this.val())
# validation bindings
if !$this.data('ignore-validation')
# remove previous
model.off "valid:#{attr}", null, view
model.off "invalid:#{attr}", null, view
# add new
model.on "valid:#{attr}", ((attr, selector) -> setValid($this, attr, selector)), view
model.on "invalid:#{attr}", ((attr, errMsg, selector) -> setInvalid($this, attr, errMsg, selector)), view
# This is base class for all our items
class window.ItemView extends Backbone.Marionette.ItemView
constructor: (options) ->
super
@on 'render', => addInputBindings(@, @model)
In the above code, I've added also an automatic input-to-model binding so that every time when user changes the input, the changed value will be stored into associated model attribute. Validation binding and input-to-model bindings can be disabled from individual inputs by defining HTML5 data-attributes ignore-model="true" and ignore-validation="true".

Because these kind of validation and input-to-model bindings are kind of boilerplate code, I wanted to automate them by using CoffeeScript inheritance and Backbone.marionette plugin: all that developers must do is to inherit their new view classes from ItemView class and after that all validations and input-to-model synchronizations are working automagically! Cool, I think!

And usage...

Here is a little example code about the usage of our brand new "framework":

jQuery ->
# Simple person model with two attributes
class Person extends Backbone.Model
validation:
age:
required: true
min: 10
name:
required: true
msg: "All persons have a name..."
class PersonView extends ItemView
el: $("#person-form")
template: Handlebars.compile($("#person-form-template").html())
events:
"click #send-btn" : 'send'
"click #cancel-btn" : 'cancel'
send: ->
@model.validate()
if @model.isValid()
alert "OK!"
# do some save stuff here
cancel: ->
@model.clear()
@render()
view = new PersonView(model: new Person())
view.render()
Of course our framework requires that our markup follows some specific conventions. I'm using Twitter Bootstrap so the framework expects that every validated input is inside control-group div. However, it shouldn't be non-trivial to tweak setValid and setInvalid methods form part 2 in order to make your own markup implementation. ;)

A working example can be found from jsFiddle: http://jsfiddle.net/RfcMK/4/

No comments:

Post a Comment