view/component.js

/*
 * Your installation or use of this SugarCRM file is subject to the applicable
 * terms available at
 * http://support.sugarcrm.com/Resources/Master_Subscription_Agreements/.
 * If you do not agree to all of the applicable terms or do not have the
 * authority to bind the entity as an authorized representative, then do not
 * install or use this SugarCRM file.
 *
 * Copyright (C) SugarCRM Inc. All rights reserved.
 */

const BeforeEvent = require('../core/before-event');
const User = require('../core/user');

/**
 * Extends `Backbone.View`. Represents the base view class for layouts, views,
 * and fields.
 *
 * This is an abstract class.
 *
 * @module View/Component
 * @class
 * @mixes Core/BeforeEvent
 */
const Component = Backbone.View.extend({
    /*
     * FIXME SC-5690: This property will be removed when we have mixin
     * support with an ES6-style super method.
     */
    /**
     * Internal flag used to prevent {@link #events} from being delegated
     * prior to {@link #initialize}, in {@link #delegateEvents}. Do not use
     * this flag!
     *
     * @private
     * @type {boolean}
     * @memberOf View/Component
     * @instance
     */
    _isInitialized: false,

    /**
     * Constructor for sidecar components, currently used to define the
     * order of event delegation on {@link #events this component's events},
     * after Backbone changed the order in which events are delegated from
     * 0.9.10 to 1.2.0. Also temporarily defines {@link #options} on the
     * component, as Backbone no longer does this by default.
     *
     * @param {Object} options The `Backbone.View` initialization options.
     * @return {Backbone.View} The created `Backbone.View`.
     * @memberOf View/Component
     * @instance
     */
    constructor: function(options) {
        /**
         * Backbone view options.
         *
         * @deprecated Deprecated since 7.8.0 since this is no longer supported
         *   by Backbone.
         * @type {Object}
         * @memberOf View/Component
         * @name options
         * @instance
         */
        this.options = options || {}; // FIXME SC-5597: delete this

        this._wrapInitialize(options);

        return Backbone.View.apply(this, arguments);
    },

    /**
     * Wraps the initialize method to delegate the events on the element,
     * after it initializes.
     *
     * @param {Object} options The Backbone.View initialization options.
     * @protected
     * @memberOf View/Component
     * @instance
     */
    _wrapInitialize: function(options) {
        this.initialize = _.wrap(this.initialize, _.bind(function (init) {
            init.call(this, options);
            this.initialize = init;
            this._isInitialized = true;
            this.delegateEvents();
        }, this));
    },

    /**
     * Initializes this component.
     *
     * @param {Object} options The `Backbone.View` initialization options.
     * @param {Core/Context} options.context Reference to the context.
     * @param {Object} [options.meta] Component metadata.
     * @param {string} [options.module] Module name.
     * @param {Data/Bean} [options.model] Reference to the model this
     *   component is bound to.
     * @param {Data/BeanCollection} [options.collection] Reference to the
     *   collection this component is bound to.
     * @memberOf View/Component
     * @instance
     */
    initialize: function(options) {
        /**
         * Reference to the context (required).
         * @type {Core/Context}
         * @memberOf View/Component
         * @name context
         * @instance
         */
        this.context = options.context || SUGAR.App.controller && SUGAR.App.controller.context || new Backbone.Model();

        /**
         * Component metadata (optional).
         * @type {Object}
         * @memberOf View/Component
         * @name meta
         * @instance
         */
        this.meta = options.meta;

        /**
         * Module name (optional).
         * @type {string}
         * @memberOf View/Component
         * @name module
         * @instance
         */
        this.module = options.module || this.context.get('module');

        /**
         * Reference to the model this component is bound to.
         * @type {Data/Bean}
         * @memberOf View/Component
         * @name model
         * @instance
         */
        this.model = options.model || this.context.get('model');

        /**
         * Reference to the collection this component is bound to.
         * @type {Data/BeanCollection}
         * @memberOf View/Component
         * @name collection
         * @instance
         */
        this.collection = options.collection || this.context.get('collection');

        // Adds classes to the component based on the metadata.
        if (this.meta && this.meta.css_class) {
            this.$el.addClass(this.meta.css_class);
        }

        this.updateVisibleState(true);

        // Register last state defaults
        User.lastState.register(this);
    },

    /**
     * Renders this component.
     *
     * Override this method to provide custom logic.
     * The default implementation does nothing.
     * See `Backbone.View#render` for details.
     * The convention is for {@link #_render} to always return `this`.
     *
     * @return {View/Component} Instance of this component.
     * @protected
     * @memberOf View/Component
     * @instance
     */
    _render: function() {
        return this;
    },

    /**
     * Renders this component.
     *
     * **IMPORTANT**: Do not override this method.
     * Instead, override {@link View/Component#_render} to provide render logic.
     *
     * @return {View/Component} Instance of this component.
     * @memberOf View/Component
     * @instance
     */
    render: function() {
        if (this.disposed === true) {
            SUGAR.App.logger.error("Unable to render component because it's disposed " + this + '\n');
            return false;
        }
        if (!this.triggerBefore('render'))
            return false;
        this._render();

        this.trigger('render');

        return this;
    },

    /**
     * Proxies the parent method on `Backbone.View`, but only called after
     * {@link #initialize this view instance initializes}.
     *
     * @return {View/Component} Instance of this component.
     * @memberOf View/Component
     * @instance
     */
    delegateEvents: function () {
        if (!this._isInitialized) {
            return this;
        }

        // FIXME SC-5680: We can't call `_super` from View/Component - it
        // would just call this method again. SC-5680 will address fixing
        // the `_super` method.
        return Backbone.View.prototype.delegateEvents.apply(this, arguments);
    },

    /**
     * Sets template option.
     *
     * If the given option already exists it is augmented by the value of the
     * given `option` parameter.
     *
     * See the Handlebars.js documentation for details.
     *
     * @param {string} key Option key.
     * @param {Object} option Option value.
     * @memberOf View/Component
     * @instance
     */
    setTemplateOption: function(key, option) {
        this.options.templateOptions = this.options.templateOptions || {};
        this.options.templateOptions[key] = _.extend({}, this.options.templateOptions[key], option);
    },

    /**
     * Binds data changes to this component.
     *
     * @abstract
     * @memberOf View/Component
     * @instance
     */
    bindDataChange: function() {
        // Override this method to wire up model/collection events
    },

    /**
     * Removes this component's event handlers from model and collection.
     *
     * Performs the opposite of what {@link View/Component#bindDataChange}
     * method does.
     *
     * Override this method to provide custom logic.
     *
     * @memberOf View/Component
     * @instance
     */
    unbindData: function() {
        if (this.model) this.model.off(null, null, this);
        if (this.collection) this.collection.off(null, null, this);
    },

    /**
     * Removes all event callbacks registered within this component
     * and undelegates Backbone events.
     *
     * Override this method to provide custom logic.
     *
     * @memberOf View/Component
     * @instance
     */
    unbind: function() {
        this.off();
        this.offBefore();
        this.undelegateEvents();
        SUGAR.App.events.off(null, null, this);
        SUGAR.App.events.unregister(this);
        if (this.context) this.context.off(null, null, this);
        if (this.layout) this.layout.off(null, null, this);
    },

    /**
     * Fetches data for layout's model or collection.
     *
     * The default implementation does nothing.
     * See {@link View/Layout#loadData} and {@link View/View#loadData} methods.
     *
     * @method
     * @memberOf View/Component
     * @instance
     */
    loadData: _.noop,

    /**
     * Disposes this component.
     *
     * This method:
     *
     * * unbinds this component from model and collection
     * * removes all event callbacks registered within this component
     * * removes this component from the DOM
     *
     * Override this method to provide custom logic:
     * ```
     * const ViewManager = require('./view-manager');
     * ViewManager.views.MyView = ViewManager.View.extend({
     *      _dispose: function() {
     *          // Perform custom clean-up. For example, clear timeout handlers, etc.
     *          ...
     *          // Call super
     *          ViewManager.View.prototype._dispose.call(this);
     *      }
     * });
     * ```
     * @protected
     * @memberOf View/Component
     * @instance
     */
    _dispose: function() {
        this.unbindData();
        this.unbind();
        this.remove();
        this.model = null;
        this.collection = null;
        this.context = null;
        this.$el = null;
        this.el = null;
    },

    /**
     * Disposes a component.
     *
     * Once the component gets disposed it can not be rendered.
     * Do not override this method. Instead override
     * {@link View/Component#_dispose} method
     * if you need custom disposal logic.
     *
     * @memberOf View/Component
     * @instance
     */
    dispose: function() {
        if (this.disposed === true) return;
        this._dispose();
        this.disposed = true;
    },

    /**
     * Gets a string representation of this component.
     *
     * @return {string} String representation of this component.
     * @memberOf View/Component
     * @instance
     */
    toString: function() {
        return this.cid +
            '-' + (this.$el && this.$el.id ? this.$el.id : '<no-id>') +
            '/' + this.module +
            '/' + this.model +
            '/' + this.collection;
    },

    /**
     * Traverses upwards from the current component to find the first
     * component that matches the name.
     *
     * The default implementation does nothing.
     * See {@link View/Layout#closestComponent},
     * {@link View/View#closestComponent} and
     * {@link View/Field#closestComponent} methods.
     *
     * @param {string} name The name of the component to find.
     * @return {View/Component} The component or `undefined` if not found.
     * @method
     * @memberOf View/Component
     * @instance
     */
    closestComponent: _.noop,

    /**
     * Pass through function to jQuery's show to show view.
     *
     * @return {boolean|undefined} `false` if the BeforeEvent for `show` fails;
     *   `undefined` otherwise.
     * @memberOf View/Component
     * @instance
     */
    show: function() {
        if (!this.isVisible()) {
            if (!this.triggerBefore('show')) {
                return false;
            }

            this._show();
            this.trigger('show');
        }
    },

    /**
     * Pass through function to jQuery's hide to hide view.
     *
     * @return {boolean|undefined} `false` if the BeforeEvent for `hide` fails;
     *   `undefined` otherwise.
     * @memberOf View/Component
     * @instance
     */
    hide: function() {
        if (this.isVisible()) {
            if (!this.triggerBefore('hide')) {
                return false;
            }

            this._hide();
            this.trigger('hide');
        }
    },

    /**
     * Checks if this component is visible on the page.
     *
     * @return {boolean} `true` if this component is visible on the page;
     *   `false` otherwise.
     * @memberOf View/Component
     * @instance
     */
    isVisible: function() {
        return this._isVisible;
    },

    /**
     * Updates this component's visibility state.
     *
     * **Note:** This does not show/hide the component. Please use
     * {@link View/Component#show} and {@link View/Component#hide} to do this.
     *
     * @param {boolean} visible Visibility state of this component.
     * @memberOf View/Component
     * @instance
     */
    updateVisibleState: function(visible) {
        /**
         * Flag to indicate the visible state of the component.
         *
         * @type {boolean}
         * @private
         * @memberOf View/Component
         * @instance
         */
        this._isVisible = !!visible;
    },

    /**
     * Override this method to provide custom show logic.
     *
     * @protected
     * @memberOf View/Component
     * @instance
     */
    _show: function() {
        this.$el.removeClass('hide').show();
        this.updateVisibleState(true);
    },

    /**
     * Override this method to provide custom show logic.
     *
     * @protected
     * @memberOf View/Component
     * @instance
     */
    _hide: function() {
        this.$el.addClass('hide').hide();
        this.updateVisibleState(false);
    },

    /**
     *  Retrieves and invokes parent prototype functions.
     *
     *  Requires a method parameter to function. The method called should be
     *  named the same as the function being called from.
     *
     * Examples:
     *
     * * Good:
     * ```
     * ({
     *     initialize: function(options) {
     *         // extend the base meta with some custom meta
     *         options.meta = _.extend({}, myMeta, options.meta || {});
     *         // Only call parent initialize from initialize
     *         this._super('initialize', [options]);
     *         this.buildFoo(options);
     *     }
     * });
     * ```
     *
     * * Bad:
     * ```
     * ({
     *     initialize: function(options) {
     *         // extend the base meta with some custom meta
     *         options.meta = _.extend({}, myMeta, options.meta || {});
     *         // Calling a function like buildFoo from initialize is incorrect. Should call directly on this
     *         this._super('buildFoo',[options]);
     *     }
     * });
     * ```
     *
     * @param {string} method The name of the method to call (e.g.
     *   `initialize`, `_renderHtml`).
     * @param {Array} [args] Arguments to pass to the parent method.
     * @return {*} The result of invoking the parent method.
     * @protected
     * @memberOf View/Component
     * @instance
     */
    _super: function(method, args) {
        //Must be used to invoke parent methods
        if (!method || !_.isString(method)) {
            return SUGAR.App.logger.error('tried to call _super without specifying a parent method in ' + this.name);
        }

        var parent, thisProto = Object.getPrototypeOf(this);
        args = args || [];

        //_lastSuperClass is used to walk the prototype chain
        this._superStack = this._superStack || {};
        this._superStack[method] = this._superStack[method] || [];
        if (this._superStack[method].length > 0) {
            parent = Object.getPrototypeOf(_.last(this._superStack[method]));
            if (_.contains(this._superStack[method], parent)) {
                return SUGAR.App.logger.error('Loop detected calling ' + method + ' from ' + this.name);
            }
        } else {
            parent = Object.getPrototypeOf(thisProto);
        }

        //First verify that the method exists on the current object
        if (!thisProto[method]) {
            return SUGAR.App.logger.error('Unable to find method ' + method + ' on class ' + this.name);
        }

        //Walk up the chain until we find a parent that implements the method.
        while (!parent.hasOwnProperty(method) && parent !== Component.prototype) {
            parent = Object.getPrototypeOf(parent);
        }

        //Walk up the chain until we find a parent that overrode the method.
        while (thisProto[method] === parent[method] && parent !== Component.prototype) {
            thisProto = parent;
            parent = Object.getPrototypeOf(parent);
        }
        this._superStack[method].push(parent);

        //Verify that we found a valid parent that implements this method
        if (!parent) {
            return SUGAR.App.logger.error('Unable to find parent of component ' + this.name);
        }
        if (!parent[method]) {
            return SUGAR.App.logger.error('Unable to find method ' + method + ' on parent class of ' + this.name);
        }

        //Finally make the parent call
        var ret = parent[method].apply(this, args);

        //Reset the last parent to step down the prototype chain
        this._superStack[method].pop();
        //When we reach the end of the chain, also remove the method name requirement
        if (_.isEmpty(this._superStack[method])) {
            this._superStack[method] = null;
        }

        return ret;
    },

    /**
     * Gets the HTML placeholder for this component.
     *
     * @return {Handlebars.SafeString} HTML placeholder to be used in a
     *   Handlebars template.
     * @memberOf View/Component
     * @instance
     */
    getPlaceholder: function() {
        return new Handlebars.SafeString('<span cid="' + this.cid + '"></span>');
    }
});

//Mix in the beforeEvents
_.extend(Component.prototype, BeforeEvent);

module.exports = Component;