htdocs/assets/js/view.js

Summary

Maintainability
B
6 hrs
Test Coverage
"use strict";
define(function(require) {
    var $ = require('jquery'),
        _ = require('underscore'),
        Backbone = require('backbone'),
        Autosize = require('autosize');


    var states = {UNLOADED:0, LOADED:1, RENDERED:2};
    /**
     * The base View class.
     * Extends Backbone Views with the following features:
     * - View setup/shutdown via render/unrender and load/unload.
     *      Load/Unload should request/destroy any data that is necessary to render the View.
     *      Render/Unrender should create/destroy any DOM elements associated with the View.
     *          This includes event listeners and timers.
     *          Note that unrender will call stopListening() on the View.
     * - A BeforeUnload hook via onExit
     *     This allows the View to indicate whether it wants the user to stay on the page.
     * - Automatic setup/cleanup of child elements/Views registered under the View.
     *     Utility methods are provided to register elements and child Views.
     */
    var View = Backbone.View.extend({
        /**
         * View constructor
         * Requires that an application object be passed in.
         */
        constructor: function(app, options) {
            if(_.isUndefined(app)) {
                throw 'No application object!';
            }
            this.App = app;
            Backbone.View.call(this, options);

            // Current state of the View.
            this.state = states.UNLOADED;

            // Child elements/Views to manage.
            this.childElements = {};
            this.childViews = {};
            this.childViewNamesToIDs = {};
            this.childViewIDsToNames = {};
        },
        /**
         * Loaded status
         * @return {boolean} Whether the View is loaded
         */
        loaded: function() {
            return this.state >= View.State.LOADED;
        },
        /**
         * Rendered status
         * @return {boolean} Whether the View is rendered
         */
        rendered: function() {
            return this.state >= View.State.RENDERED;
        },
        /**
         * Load wrapper
         * Calls _load() which implements the actual loading logic.
         */
        load: function() {
            if(this.state >= View.State.LOADED) {
                throw 'Calling load() on a loaded view';
            }

            this.state = View.State.LOADED;
            this._load.apply(this, arguments);
            this.trigger('load');
        },
        /**
         * Load method
         * Responsible for fetching data and eventually calling render().
         */
        _load: function() {
            this.render();
        },
        /**
         * Unload wrapper
         * Calls _unload() which implements the actual unloading logic.
         */
        unload: function() {
            if(this.state > View.State.LOADED) {
                this.unrender();
            }

            if(this.state < View.State.UNLOADED) {
                throw 'Calling unload() on an unloaded view';
            }

            this.state = View.State.UNLOADED;
            this._unload.apply(this, arguments);
            this.trigger('unload');
        },
        /**
         * Responsible for unloading data.
         */
        _unload: function() {},
        /**
         * Reload the View.
         */
        reload: function() {
            this.unload();
            this.load();
        },

        /**
         * Render wrapper
         * Calls _render() which implements the actual rendering logic.
         */
        render: function() {
            if(this.state < View.State.LOADED) {
                throw 'Calling render() on a unloaded view';
            }
            if(this.state >= View.State.RENDERED) {
                throw 'Calling render() on a rendered view';
            }

            this.state = View.State.RENDERED;
            this._render.apply(this, arguments);
            this.delegateEvents();
            this.trigger('render');
            return this;
        },
        /**
         * Responsible for rendering the View.
         */
        _render: function() {},
        /**
         * Unrender wrapper
         * Calls _unrender() which implements the actual unrendering logic.
         */
        unrender: function() {
            if(this.state < View.State.RENDERED) {
                throw 'Calling unrender() on an unrendered view';
            }

            // Clean up listeners, destroy managed children and clear the container.
            this.stopListening();
            this.undelegateEvents();
            this.destroyElements();
            this.destroyViews();
            this.$el.text('');

            this.state = View.State.LOADED;
            this._unrender.apply(this, arguments);
            this.trigger('unrender');
        },
        /**
         * Responsible for unrendering the View.
         */
        _unrender: function() {},
        rerender: function() {
            this.unrender();
            this.render();
        },

        /**
         * Partially re-render the View.
         */
        update: function() {},

        /**
         * Apply slightly transparency to the view to indicate that it's processing
         */
        dim: function() {
            this.$el.css({'opacity': 0.3});
        },
        /**
         * Disable transparency effect.
         */
        undim: function() {
            this.$el.css({'opacity': 1.0});
        },

        /**
         * Destroy the View.
         * @param {boolean} remove - Whether to also call remove().
         */
        destroy: function(remove) {
            if(this.state >= View.State.RENDERED) {
                this.unrender();
            }
            if(this.state >= View.State.LOADED) {
                this.unload();
            }
            if(remove) {
                this.remove();
            }
            this.trigger('destroy', this);
        },

        /**
         * Load collections.
         * A convenience method to automatically load several collections and
         * then call render on the View.
         * This method is NOT synchronous.
         * @param {Array} collections - An array of Backbone collections to update.
         * @param {Function} func - A callback to execute once all Deferred are resolved.
         * @param {Array} deferred - An array of additional Deferred to resolve.
         * @return {Deferred} The Deferred object.
         */
        loadCollections: function(collections, func, deferred) {
            if(_.isUndefined(collections)) collections = [];
            if(_.isUndefined(func)) func = this.render;
            if(_.isUndefined(deferred)) deferred = [];

            for(var i = 0; i < collections.length; ++i) {
                deferred.push(collections[i].update());
            }
            return $.when.apply($, deferred).then(
                this.cbLoaded(func),
                $.proxy(this.App.hideLoader(), this)
            );
        },

        /**
         * Check if the View can exit. Executes recursively on child Views.
         * @return {boolean|string} true to allow, false to deny or a string to display a prompt.
         */
        onExit: function() {
            // Make sure all our children are ok with this.
            for(var k in this.childViews) {
                var ret = this.childViews[k].onExit();
                if(!_.isBoolean(ret) || !ret) {
                    return ret;
                }
            }
            return true;
        },

        /**
         * Return a previously registered selector.
         * @param {string} str - A jQuery selector string.
         */
        getElement: function(str) {
            return this.childElements[str];
        },
        /**
         * Register and return the selector.
         * @param {string} str - A jQuery selector string.
         */
        registerElement: function(str) {
            var selector = this.$(str);
            this.childElements[str] = selector;
            return selector;
        },
        /**
         * Cleanup an element and remove it from the list.
         * @param {string} k - The key for this element.
         */
        destroyElement: function(k) {
            var elems = this.childElements[k];
            if(_.isUndefined(elems)) {
                throw 'Called destroyChild on unknown key: ' + k;
            }

            // Loop over all elements in the selector.
            for(var i = 0; i < elems.length; ++i) {
                var elem = $(elems[i]);
                var data = elem.data();
                if(!data) {
                    continue;
                }

                // Do element specific cleanup if necessary.
                if('select2' in data) {
                    elem.select2('destroy');
                    delete data['select2'];
                } else if('autosize' in data) {
                    Autosize.destroy(elem);
                    delete data['autosize'];
                } else if('DateTimePicker' in data) {
                    data['DateTimePicker'].destroy();
                    delete data['DateTimePicker'];
                } else if('tablesorter' in data) {
                    elem.trigger('destroy');
                    delete data['tablesorter'];
                } else if('ScrollToFixed' in data) {
                    elem.trigger('detach.ScrollToFixed');
                    delete data['ScrollToFixed'];
                } else if('codemirror' in data) {
                    data['codemirror'].toTextArea();
                    delete data['codemirror'];
                }
                // Remove any listeners, just in case.
                elem.off();
            }
            delete this.childElements[k];
        },
        /**
         * Cleanup all registered elements.
         */
        destroyElements: function() {
            for(var k in this.childElements) {
                this.destroyElement(k);
            }
        },

        /**
         * Return a previously registered View.
         * @param {string|View} k - A View key.
         */
        getView: function(k) {
            var arr = k.substr(-2) === '[]';
            var str = this.childViewNamesToIDs[k];
            if(!_.isObject(str) && !arr) {
                return this.childViews[k] || this.childViews[str];
            }

            var views = [];
            for(var x in str) {
                var view = this.childViews[x];
                if(view) {
                    views.push(view);
                }
            }
            return views;
        },
        /**
         * Register a child View. Additionally attaches the View to the DOM if init is true.
         * @param {View} view - A View object.
         * @param {boolean} init - Whether to init and attach the View.
         * @param {Selector} sel - jQuery selector to use as a parent.
         * @param {string} str - An optional View key.
         */
        registerView: function(view, init, sel, str) {
            if(_.isUndefined(str)) str = view.cid;
            if(_.isUndefined(sel)) sel = this.$el;
            var arr = str.substr(-2) === '[]';
            if(this.childViewNamesToIDs[str] && !arr) {
                throw 'Adding View with duplicate key: ' + str;
            }
            if(this.childViews[view.cid]) {
                throw 'Adding existing view: ' + view.cid;
            }
            if(view.cid == this.cid) {
                throw 'Registering self';
            }
            if(!this.childViewNamesToIDs[str] && arr) {
                this.childViewNamesToIDs[str] = {};
            }

            this.listenTo(view, 'destroy', this.destroyView);
            this.childViews[view.cid] = view;
            if(arr) {
                this.childViewNamesToIDs[str][view.cid] = null;
            } else {
                this.childViewNamesToIDs[str] = view.cid;
            }
            this.childViewIDsToNames[view.cid] = str;

            if(init) {
                view.load();
                sel.append(view.el);
            }
            return view;
        },
        /**
         * Cleanup a View and remove it from the list.
         * @param {string|View} k - The key for the View or the View itself.
         */
        destroyView: function(k) {
            if(_.isObject(k)) k = k.cid;
            var view = this.childViews[k] || this.childViews[this.childViewNamesToIDs[k]];
            if(view) {
                // Stop listening to the View and call destroy on it.
                // Don't bother calling destroy if the view is UNLOADED
                this.stopListening(view);
                if(view.state > View.State.UNLOADED) {
                    view.destroy();
                }

                var name = this.childViewIDsToNames[view.cid];
                delete this.childViews[view.cid];
                delete this.childViewIDsToNames[view.cid];
                if(name.substr(-2) === '[]' && _.size(this.childViewNamesToIDs[name]) > 1) {
                    delete this.childViewNamesToIDs[name][view.cid];
                } else {
                    delete this.childViewNamesToIDs[name];
                }
            } else {
                throw 'Called destroyChild on unknown key: ' + k;
            }
        },
        /**
         * Cleanup child Views.
         */
        destroyViews: function() {
            // Call destroy on all children.
            for(var k in this.childViews) {
                this.destroyView(k);
            }
        },

        /**
         * Rendered callback wrapper
         * Executes a callback only if the View is in the rendered (or higher) state.
         */
        cbRendered: function(cb) {
            return $.proxy(function() {
                if(this.rendered()) { cb.apply(this, arguments); }
            }, this);
        },
        /**
         * Loaded callback wrapper
         * Executes a callback only if the View is in the loaded (or higher) state.
         */
        cbLoaded: function(cb) {
            return $.proxy(function() {
                if(this.loaded()) { cb.apply(this, arguments); }
            }, this);
        },

        getSelectableCount: function() {
            return 0;
        },
        onSelectionAction: function(i, j) {}
    }, {
        State: states
    });

    return View;
});