Dojorama Part 4: Input Form, Input Validation, Data Model And Data Bindings

This article is part of the series Dojorama: Building a Dojo Single Page Application

If you followed this series of articles up to this point, you should by now have a pretty good feel for the way our code is organized. Let's brake with the copy/paste-tutorial and we'll look at some of the techniques implemented in the final application:

Data Bindings

Data binding is a technique that binds together two components and maintains synchronization of data between them. A binding can be established from source to target, or it can be bidirectional. With dojo/Stateful, we already have most of what we need to establish such a connection. Any object can be turned into a stateful object which allows us to be notified whenever a property change occurs:

var obj = new Stateful({
    someProperty: ""
});

obj.watch('someProperty', function (name, old, val) {
    alert(name + ' has changed from ' + old + ' to ' + val);
});

By the way, all the widgets in our application are stateful objects because they are descendants of mijit/_WidgetBase. Here's an example of a bidirectional binding between an input element and a simple object:

// input element (dojo-form-controls/Textbox)
var i = new Textbox({});

// data model (dojo/Stateful)
var m = new Stateful({
    bopsi: "Bopsi"
});

// bind input element to data model
i.watch('value', function (name, old, val) {
    console.log('Input element has a new value');
    m.set('bopsi', val); // update data model
});

// bind data model to input element
m.watch('bopsi', function (name, old, val) {
    console.log('Data model has received an update');
    i.set('value', val); // update input element
});

setTimeout(function () {
    i.set('value', 'Bops Bops');
}, 2000);

setTimeout(function () {
    m.set('bopsi', 'Mau Mau');
}, 4000);

Outlining The Data Flow

Let's look at the data flow from user input all the way to persistence and user notification. For this scenario we'll have an input form, a release data model, an error model and an object store:

  • User enters some data
    • Whenever the user changes a value in a field, the data model is immediately updated
  • User submits form
    • Data model checks the input
      • If there is an error, the data model updates the error model
        • Error model updates the view to notify the user of the error(s)
  • User corrects data and re-submits the form
    • Data model thinks that input is ok
      • Data model serializes data and calls the add() method on the object store
        • Object store posts data to API
          • API verifies input and returns an error
      • Data model updates the error model
        • Error model updates the view to notify the user of the error(s)
  • User corrects data and re-submits the form

Oook, please show me those components...

The Error Data Model

Nothing too fancy here, just a stateful object to be loaded with client-side or server-side error messages:

define([
    "dojo/_base/declare",
    "dojo/Stateful"
], function (
    declare,
    Stateful
) {
    return declare([Stateful], {
        load: function (errors) {
            if (errors.title) {
                this.set('title', errors.title);
            }
        },

        reset: function () {
            this.set('title', null);
        }
    });
});

The Release Data Model

A lot of the work happens in the stateful release data model. For the sake of this example, we are only concerned with the create() flow but eventually, this model will also provide for update() and remove() handling.

Note how the store is passed as an argument to the constructor. This means that we can very easily replace the dojo/store/JsonRest store with a LocalStorage-based store during development until the API is available. This situation requires a little bit of preparation: while dojo/store/JsonRest operates asynchronously, returning a promise for callback management, a local store will operate synchronously and directly return data. dojo/when transparently handles this for us, it returns a promise if the initial value is a promise, or the result of the callback otherwise.

define([
    "dojo/_base/declare",
    "dojo/_base/array",
    "dojo/_base/lang",
    "dojo/Stateful",
    "dojo/Deferred",
    "dojo/when",
    "dojo/json"
], function (
    declare,
    array,
    lang,
    Stateful,
    Deferred,
    when,
    json
) {
    return declare([Stateful], {
        store: null,
        errorModel: null,

        constructor: function (params) {
            this.store = params.store;
            this.errorModel = params.errorModel;
        },

        create: function () {
            var deferred = new Deferred(), errors = null, promiseOrData = null;
            this.errorModel.reset();

            try {
                this.isValid();
            } catch (e) {
                errors = e.errors;
            }

            if (errors) {
                // client-side validation error
                this.errorModel.set(errors);
                deferred.reject({ code: 'invalid-input' });
            } else {
                promiseOrData = this.store.add(this.serialize());

                when(
                    promiseOrData,
                    lang.hitch(this, function () {
                        deferred.resolve();
                    }),
                    lang.hitch(this, function (error) {
                        // server-side error
                        if(error.response.status === 422) {
                            this.normalizeServerError(json.parse(error.response.data));
                        }
                        deferred.reject({ code: 'invalid-input' });
                    })
                );
            }

            return deferred.promise;
        },

        isValid: function () {
            if (!this.get('title')) {
                throw {
                    errors: {
                        title: 'Invalid title'
                    }
                };
            }
        },

        serialize: function () {
            return {
                title: this.get('title')
            };
        },

        normalizeServerError: function (data) {
            array.forEach(data.errors, lang.hitch(this, function (error) {
                if (error.field && error.field === 'title') {
                    this.errorModel.set('title', 'Server says that something is wrong with your title');
                }
            }));
        }
    });
});

Establish Data Bindings

Here we're setting up the form and data models to finally establish the data bindings:

define([
    "dojo/_base/declare",
    "mijit/_WidgetBase",
    "mijit/_TemplatedMixin",
    "dojo-form-controls/Textbox",
    "dojo/_base/lang",
    "dojo/dom-style",
    "dojo/on",
    "./model/ErrorModel",
    "./model/ReleaseModel",
    "dojo/text!./templates/FormWidget.html"
], function (
    declare,
    _WidgetBase,
    _TemplatedMixin,
    Textbox,
    lang,
    domStyle,
    on,
    ErrorModel,
    ReleaseModel,
    template
) {
    return declare([_WidgetBase, _TemplatedMixin], {
        templateString: template,
        store: null,
        titleInput: null,
        errorModel: null,
        releaseModel: null,

        constructor: function (params) {
            this.store = params.store;
        },

        postCreate: function () {
            this.inherited(arguments);

            // data models
            this.errorModel = new ErrorModel();

            this.releaseModel = new ReleaseModel({
                store: this.store,
                errorModel: this.errorModel
            });

            // input element
            this.titleInput = new Textbox({}, this.titleNode);

            // data bindings
            this.titleInput.watch('value', lang.hitch(this, function (name, old, val) {
                // update the data model's title property when the input element is updated
                this.releaseModel.set('title', val);
            }));

            this.errorModel.watch('title', lang.hitch(this, function (name, old, val) {
                // display an error message when the error model's title propery gets updated
                this.errorNode.innerHTML = val;
                domStyle.set(this.errorNode, { display: (val) ? 'block' : 'none' });
            }));

            // form submit listener
            on(this.formNode, 'submit', lang.hitch(this, function (ev) {
                ev.preventDefault();
                this.releaseModel.create().then(
                    function () {
                        alert('Release has been successfully created');
                    },
                    function () {
                        alert('Release could not be created');
                    }
                );
            }));
        },

        startup: function () {
            this.inherited(arguments);
            this.titleInput.startup();
        }
    });
});

The Form Template

<form data-dojo-attach-point="formNode">
    <fieldset>
        <label>
            <div data-dojo-attach-point="errorNode" style="background:red;color:white"></div>
            <input data-dojo-attach-point="titleNode"/> Title<br />
        </label>
        <button type="submit">Submit</button>
    </fieldset>
</form>

Conclusion

We've looked at the overall flow and the steps involved in accepting user input and persistence via an API. The examples have been simplified to demonstrate the concepts in a hopefully understandable way. In a real application we'd also have to properly deal with connection handles to avoid memory leaking upon object destruction. In the final application we'll be using a small library called dojo-data-model which provides much of the plumbing involved when creating data models. Please have a look at the final application to see the complete solution.

We've talked about object store substitution for local development (and testing) but how do you actually do that? Enter dependency injection container and configuration