app.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 Logger = require('./utils/logger');
const Cache = require('./core/cache');
const Utils = require('./utils/utils');
const Controller = require('./core/controller');
const Language = require('./core/language');
const Routing = require('./core/routing');
const MetadataManager = require('./core/metadata-manager');

/**
 * SugarCRM namespace.
 *
 * @ignore
 */
var SUGAR = SUGAR || {};

SUGAR.App = (function() {
    var _app,
        _modules = {};

    /**
     * Flag indicating an `app.sync` is in progress.
     *
     * @type {boolean}
     * @private
     */
    var _isSyncing = false;

    /**
     * A list of callback functions to call whenever `sync` is completed.
     *
     * @type {Array}
     * @private
     */
    var _syncCallbacks = [];

    var _make$ = function(selector) {
        return selector instanceof $ ? selector : $(selector);
    };

    /**
     * Constructor class for the main framework app.
     *
     * `SUGAR.App` contains the core instance of the app. All related modules
     * can be found within the `SUGAR` namespace.
     *
     * An uninitialized instance will exist on page load but you will need to
     * call {@link App#init} to initialize your instance.
     *
     * By default, the app uses `body` element and `div#content` as root element
     * and content element respectively.
     *
     * ```
     * var app = SUGAR.App.init({
     *     el: '#root',
     *     contentEl: '#content'
     * });
     * ```
     *
     * If you want to initialize an app without initializing its modules,
     *
     * ```
     * var app = SUGAR.App.init({el: '#root', silent: true});
     * ```
     *
     * @class
     * @name App
     * @param {Object} [opts] Configuration options. See full list of options
     *   in {@link #init}.
     * @return {App} Application instance.
     * @mixes Core/BeforeEvent
     */
    function App(opts) {
        var appId = _.uniqueId('SugarApp_');
        opts = opts || {};

        return _.extend({
            /**
             * Unique application ID.
             *
             * @type {string}
             * @memberOf App
             * @instance
             */
            appId: appId,

            /**
             * Base element to use as the root of the app.
             *
             * @type {jQuery}
             * @memberOf App
             * @instance
             */
            $rootEl: _make$(opts.el || 'body'),

            /**
             * Content element selector.
             *
             * The {@link Core.Controller application controller} loads layouts
             * into the content element.
             *
             * @type {jQuery}
             * @memberOf App
             * @instance
             */
            $contentEl: _make$(opts.contentEl || '#content'),

            /**
             * Additional components.
             *
             * These components are created and rendered only once, when the
             * application starts.
             *
             * Application specific code is needed for managing the components
             * after they have been put into DOM by the framework.
             *
             * @type {Object}
             * @memberOf App
             * @instance
             */
            additionalComponents: {}

        }, this, Backbone.Events);
    }

    return {
        /**
         * Initializes the app.
         *
         * @param {Object} [opts] Initialization options.
         * @param {string} [opts.el='body'] The selector for the
         *   {@link #$rootEl base element} to use as the root of the app.
         * @param {string} [opts.contentEl='#content'] The selector for the
         *   {@link #$contentEl content element}.
         * @param {boolean} [opts.silent=false] Flag to indicate if modules
         *   should be initialized during application init process.
         * @param {Function} [opts.defaultErrorHandler] Allows you to define a
         *   custom error handler. Defaults to
         *   {@link Core.Error#handleHttpError}.
         * @return {App} Application instance
         * @fires app:init if `opts.silent` is not `false`.
         * @fires app:sync:error if the public metadata could not be synced.
         * @memberOf App
         */
        init: function(opts) {
            _app = _app || _.extend(this, new App(opts));
            _.extend(_app, BeforeEvent);

            // Register app specific events
            _app.events.register(
                /**
                 * Fires when the app object is initialized. Modules bound to
                 * this event will initialize.
                 *
                 * @event app:init
                 * @memberOf App
                 */
                'app:init',
                this
            );

            _app.events.register(
                /**
                 * Fires when the application has
                 * finished loading its dependencies and should initialize
                 * everything.
                 *
                 * @event app:start
                 * @memberOf App
                 */
                'app:start',
                this
            );

            _app.events.register(
                /**
                 * Fires when the app is beginning to sync data / metadata from
                 * the server.
                 *
                 * @event app:sync
                 * @memberOf App
                 */
                'app:sync',
                this
            );

            _app.events.register(
                /**
                 * Fires when the app has finished its syncing process and is
                 * ready to proceed.
                 *
                 * @event app:sync:complete
                 * @memberOf App
                 */
                'app:sync:complete',
                this
            );

            _app.events.register(
                /**
                 * Fires when a sync process failed.
                 *
                 * @event app:sync:error
                 * @memberOf App
                 */
                'app:sync:error',
                this
            );

            _app.events.register(
                /**
                 * Fires when a sync process failed during initialization of
                 * the app.
                 *
                 * @event app:sync:public:error
                 * @memberOf App
                 */
                'app:sync:public:error',
                this
            );

            _app.events.register(
                /**
                 * * Fires when logging in.
                 *
                 * @event app:login
                 * @memberOf App
                 */
                'app:login',
                this
            );

            _app.events.register(
                /**
                 * Fires when login succeeds.
                 *
                 * @event app:login:success
                 * @memberOf App
                 */
                'app:login:success',
                this
            );

            _app.events.register(
                /**
                 * Fires when the app logs out.
                 *
                 * @event app:logout
                 * @memberOf App
                 */
                'app:logout',
                this
            );

            _app.events.register(
                /**
                 * Fires when route changes a new view has been loaded.
                 *
                 * @event app:view:change
                 * @memberOf App
                 */
                'app:view:change',
                this
            );

            _app.events.register(
                /**
                 * Fires when client application's user changes the locale, thus
                 * indicating that the application should "re-render" itself.
                 *
                 * @event app:locale:change
                 * @memberOf App
                 */
                'app:locale:change',
                this
            );

            _app.events.register(
                /**
                 * Fires when the language display direction changes.
                 *
                 * Possible language display directions are `RTL` and `LTR`.
                 *
                 * @event lang:direction:change
                 * @memberOf App
                 */
                'lang:direction:change',
                this
            );

            // App cache must be initialized first
            if (_app.cache) {
                _app.cache.init(this);
            }

            // Instantiate controller: <Capitalized-appId>Controller or Controller.
            var className = Utils.capitalize(_app.config ? _app.config.appId : '') + 'Controller';
            var Klass = this[className] || Controller;

            /**
             * Reference to the main controller.
             *
             * @type {Core.Controller}
             * @memberOf App
             * @instance
             */
            this.controller = new Klass();

            /**
             * Reference to the API interface that the application uses to
             * request the server.
             *
             * @type {SUGAR.Api}
             * @memberOf App
             * @instance
             */
            _app.api = SUGAR.Api.getInstance({
                defaultErrorHandler: (opts && opts.defaultErrorHandler) ? opts.defaultErrorHandler : SUGAR.App.error.handleHttpError,
                serverUrl: _app.config.serverUrl,
                platform: _app.config.platform,
                timeout: _app.config.serverTimeout,
                keyValueStore: _app[_app.config.authStore || 'cache'],
                clientID: _app.config.clientID,
                disableBulkApi: _app.config.disableBulkApi,
                externalLoginUICallback: opts && opts.externalLoginUICallback
            });

            this._init(opts);

            return _app;
        },

        /**
         * Initializes application.
         *
         * Performs loading css (only if `config.loadCss` is `true`), metadata
         * sync and calls sync callback.
         *
         * @param {Object} opts Options.
         * @param {boolean} [opts.silent=false] Flag to indicate if modules
         *   should be initialized during application init process.
         * @return {App} Application instance.
         * @private
         * @memberOf App
         */
        _init: function(opts) {
            var self = this;
            var syncCallback = function(error) {

                // _app will be nulled out if destroy was called on app before we
                // asynchronously get here. This happens when running tests (see spec-helper).
                if (!_app) {
                    return;
                }
                if (error) {
                    self.trigger('app:sync:public:error', error);
                    return;
                }
                self._initModules();
                if (!opts.silent) {
                    _app.controller.setElement(_app.$rootEl);
                    _app.trigger('app:init', self);
                }

                if (opts.callback && _.isFunction(opts.callback)) {
                    opts.callback(_app);
                }
            };
            var cssCallback = function(callback) {
                if (_app.config.loadCss) {
                    _app.loadCss(callback);
                } else {
                    callback();
                }
            };
            if (_app.config.syncConfig !== false) {
                var options = {
                    getPublic: true
                };
                cssCallback(function() {
                    MetadataManager.sync(syncCallback, options);
                });
            } else {
                cssCallback(function() {
                    syncCallback();
                });
            }
            return _app;
        },

        /**
         * Initializes all modules that have an `init` function.
         *
         * @private
         * @memberOf App
         */
        _initModules: function() {
            _.each(_modules, function(module) {
                if (_.isFunction(module.init)) {
                    module.init(this);
                }
            }, this);
        },

        /**
         * Extends base settings with settings from the server.
         *
         * @private
         * @deprecated since 7.10. Please use {@link #setConfig} instead.
         * @memberOf App
         */
        _loadConfig: function() {
            this.logger.warn('`App._loadConfig` is deprecated since 7.10. Please use `App.setConfig` instead.');
            _app.config = _app.config || {};
            _app.config = _.extend(_app.config, MetadataManager.getConfig());
        },

        /**
         * Updates the config object based on the new metadata.
         *
         * Reloads modules that depend on the config.
         *
         * @memberOf App
         * @param {Object} [config] The config object. If not passed, we'll grab
         *   it using {@link Core/MetadataManager#getConfig}.
         */
        setConfig: function(config) {
            // extend our config with settings from local storage if we have it
            this.config = this.config || {};
            this.config = _.extend(this.config, config);

            // Reload the modules that depend on the configuration.
            this.logger = Logger(this.config.logger);
            this.cache = Cache({
                uniqueKey: this.config.uniqueKey,
                env: this.config.env,
                appId: this.config.appId
            });
        },

        /**
         * Loads application CSS.
         *
         * Will make an HTTP request and retrieve either a list of CSS files to
         * load, or directly plain text css.
         *
         * @param {Function} [callback] Function called once CSS is loaded.
         * @memberOf App
         */
        loadCss: function(callback) {
            _app.api.css(_app.config.platform, _app.config.themeName, {
                success: function(rsp) {

                    if (_app.config.loadCss === 'url') {
                        _.each(rsp.url, function(url) {
                            $('<link>')
                                .attr({
                                    rel: 'stylesheet',
                                    href: Utils.buildUrl(url),
                                })
                                .appendTo('head');
                        });
                    }
                    else {
                        _.each(rsp.text, function(text) {
                            $('<style>').html(text).appendTo('head');
                        });
                    }

                    if (_.isFunction(callback)) {
                        callback();
                    }
                }
            });
        },

        /**
         * Starts the main execution phase of the application.
         *
         * @memberOf App
         * @fires app:start
         */
        start: function() {
            _app.events.registerAjaxEvents();
            _app.controller.loadAdditionalComponents(_app.config.additionalComponents);
            _app.trigger('app:start', this);
            Routing.start();
        },

        /**
         * Destroys the instance of the current app.
         *
         * @memberOf App
         */
        destroy: function() {
            // TODO: Not properly implemented
            if (Backbone.history) {
                Backbone.history.stop();
            }

            _app = null;
        },

        /**
         * Augments the application with a module.
         *
         * Module should be an object with an optional `init(app)` function.
         * The init function is passed the current instance of
         * the application when app's {@link App#init} method gets called.
         * Use the `init` function to perform custom initialization logic during
         * app initialization.
         *
         * @param {string} name Name of the module.
         * @param {Object} obj Module to augment the app with.
         * @param {boolean} [init=false] Flag indicating if the module should be
         *   initialized immediately.
         * @memberOf App
         */
        augment: function(name, obj, init) {
            this[name] = obj;
            _modules[name] = obj;

            if (name === 'config') {
                this.setConfig(obj);
            }

            if (init && obj.init && _.isFunction(obj.init)) {
                obj.init.call(obj, _app);
            }
        },

        /**
         * Syncs an app.
         *
         * The events are not fired if the sync happens for public metadata.
         *
         * @param {Object} [options] Options. See full list of options
         *   you can pass to {@link Core.MetadataManager#sync}.
         * @param {Function} [options.callback] Function to be invoked when the
         *   sync operation completes.
         * @param {boolean} [options.getPublic=false] Flag indicating if only
         *   public metadata should be synced.
         *
         * @fires app:sync when the synchronization process begins.
         * @fires app:sync:complete when the series of synchronization
         *   operations have finished.
         * @fires app:sync:error if synchronization fails.
         * @memberOf App
         */
        sync: function(options) {
            var self = this;
            options = options || {};

            // For public call, we need to do just metadata sync without triggering events
            if (options.getPublic) {
                return self.syncPublic(options);
            }

            // Register the callback if any.
            if (options.callback) {
                _syncCallbacks.push(options.callback);
            }

            // If already in `sync`, we can skip as the callback is registered
            // and will be called.
            if (_isSyncing) {
                return;
            }
            _isSyncing = true;

            // 1. Update server info and run compatibility check
            // 2. Update preferred language if it was changed
            // 3. Load user preferences
            // 4. Fetch metadata
            // 5. Declare models
            async.waterfall([function(callback) {
                self.isSynced = false;
                self.trigger('app:sync');
                var doUpdateLanguage = !options.noUserUpdate && (options.language || self.cache.get('langHasChanged'));
                if (doUpdateLanguage) {
                    var language = options.language || Language.getLanguage();
                    self.user.updateLanguage(language, callback);
                    _app.cache.cut('langHasChanged');
                }
                else {
                    callback();
                }
            }, function(cbw) {
                async.parallel([
                    function(callback) {
                        self.user.load(callback);
                    }, function(callback) {
                        self.metadata.sync(function(err) {
                            self.data.declareModels();
                            callback(err);
                        }, options);
                    }], function(err) {
                    cbw(err);
                });
            }, function(callback) {
                var serverInfo = self.metadata.getServerInfo();
                self.config.sugarLogic = self.config.sugarLogic || {};

                if (serverInfo &&
                    self.config.sugarLogic.enabled &&
                    self.utils.versionCompare(serverInfo.version, self.config.sugarLogic.minServerVersion, ">="))
                {
                    self.fetchSugarLogic(callback);
                }
                else {
                    self.config.sugarLogic.enabled = false;
                    callback();
                }
            }],
                function(err) {
                    if (err) {
                        self.trigger('app:sync:error', err);
                    } else {
                        self.isSynced = true;
                        self.trigger('app:sync:complete');
                        // TODO this will be removed by SC-5256.
                        if (window.jQuery) {
                            jQuery.migrateMute = self.logger.getLevel().value > self.logger.levels.WARN.value;
                        }
                    }

                    _.each(_syncCallbacks, function(callback) {
                        callback(err);
                    });
                    // Reset the properties.
                    _isSyncing = false;
                    _syncCallbacks = [];
                }
            );
        },

        /**
         * Syncs public metadata.
         *
         * @param {Object} [options] Options. See full list of options
         *   you can pass to {@link Core.MetadataManager#sync}.
         * @param {Function} [options.callback] Function to be invoked when the
         *   sync operation completes.
         * @memberOf App
         */
        syncPublic: function(options) {
            options = options || {};
            options.getPublic = true;
            this.metadata.sync(options.callback, options);
        },

        /**
         * Navigates to a new route.
         *
         * @param {Core/Context} [context] Context object to extract the module
         *   from.
         * @param {Data/Bean} [model] Model object to route with.
         * @param {string} [action] Action name.
         * @memberOf App
         */
        navigate: function(context, model, action) {
            var route, id, module;
            context = context || _app.controller.context;
            model = model || context.get('model');
            id = model.id;
            module = context.get('module') || model.module;

            route = this.router.buildRoute(module, id, action);
            this.router.navigate(route, {trigger: true});
        },

        /**
         * Logs in to the app.
         *
         * @param {Object} credentials User credentials.
         * @param {Object} credentials.username User name.
         * @param {Object} credentials.password User password.
         * @param {Object} [info] Extra data to be passed in login request such
         *   as client user agent, etc.
         * @param {Object} [callbacks] Object containing the callbacks.
         * @param {Function} [callbacks.success] The success callback.
         * @param {Function} [callbacks.error] The error callback.
         * @param {Function} [callbacks.complete] The complete callback.
         * @memberOf App
         * @fires app:login:success on successful login.
         */
        login: function(credentials, info, callbacks) {
            callbacks = callbacks || {};

            info = info || {};
            info.current_language = Language.getLanguage();
            _app.api.login(credentials, info, {
                success: function(data) {
                    _app.trigger('app:login:success', data, credentials.username);
                    if (callbacks.success) callbacks.success(data);
                },
                error: function(error) {
                    _app.error.handleHttpError(error);
                    if (callbacks.error) callbacks.error(error);
                },
                complete: callbacks.complete
            });
        },

        /**
         * Logs out of this app.
         *
         * @param {Object} [callbacks] Object containing the callbacks.
         * @param {Function} [callbacks.success] The success callback.
         * @param {Function} [callbacks.error] The error callback.
         * @param {Function} [callbacks.complete] The complete callback.
         * @param {boolean} [clear=false] Flag indicating if user information
         *   must be deleted from cache.
         * @param {Object} [options={}] jQuery/Zepto request options.
         * @return {SUGAR.HttpRequest} XHR request object.
         * @fires app:logout
         * @memberOf App
         */
        logout: function(callbacks, clear, options) {
            var originalComplete, originalError;
            callbacks = callbacks || {};
            originalComplete = callbacks.complete;
            originalError = callbacks.error;

            callbacks.complete = function(data) {
                // The 'clear' comes from the logout URL (see router.js)
                _app.trigger('app:logout', clear);
                if (originalComplete) {
                    originalComplete(data);
                }
            };
            callbacks.error = function(error) {
                _app.error.handleHttpError(error);
                if (originalError) originalError(error);
            };

            return _app.api.logout(callbacks, options);
        },

        fetchSugarLogic: function(callback) {
            if (_app.config.sugarLogic.isDynamic) {
                _app.api.call(
                    'read',
                    _app.api.buildURL('ExpressionEngine', 'functions'),
                    null,
                    {
                        success: function(expressions) {
                            _app.cacheSugarLogicExpressions(expressions);
                            _app.loadSugarLogic(expressions, callback);
                        },
                        error: function(err) {
                            // TODO: Consider turning off SL altogether
                            callback(err);
                        }
                    },
                    { dataType: 'application/text' }
                );
            }
            else callback();

        },

        cacheSugarLogicExpressions: function(expressions) {
            _app.cache.set("sugarlogic", expressions);
        },

        _loadSugarLogic: function() {
            return _app.cache.get("sugarlogic");
        },

        loadSugarLogic: function(expressions, callback) {
            return _app.compileJs(expressions || _app._loadSugarLogic(), callback);
        },

        compileJs: function(js, callback) {
            try {
                eval.call(window, js); // jshint ignore:line
                if (callback) callback();
            }
            catch (e) {
                Logger.fatal("Failed to compile js");
                // TODO: Consider turning off SL altogether
                if (callback) callback(e);
                return e;
            }

            return null;
        },

        /**
         * Checks if the server version and flavor are compatible.
         *
         * @param {Object} data Server information.
         * @return {boolean|Object} `true` if server is compatible and an error
         *   object if not.
         * @memberOf App
         */
        isServerCompatible: function(data) {
            var flavors = this.config.supportedServerFlavors,
                minVersion = this.config.minServerVersion,
                isSupportedFlavor,
                isSupportedVersion,
                error;

            // We assume the app is not interested in the compatibility check if it doesn't have compatibility config.
            if (_.isEmpty(flavors) && !minVersion) {
                return true;
            }

            // Undefined or null data with defined compatibility config means the server is incompatible

            isSupportedFlavor = !!((_.isEmpty(flavors)) || (data && _.contains(flavors, data.flavor)));
            isSupportedVersion = !!(!minVersion || (data && this.utils.versionCompare(data.version, minVersion, '>=')));

            if (isSupportedFlavor && isSupportedVersion) {
                return true;
            } else if (!isSupportedVersion) {
                error = {
                    code: 'server_version_incompatible',
                    label: 'ERR_SERVER_VERSION_INCOMPATIBLE'
                };
            } else {
                error = {
                    code: 'server_flavor_incompatible',
                    label: 'ERR_SERVER_FLAVOR_INCOMPATIBLE'
                };
            }

            error.server_info = data;
            return error;
        },

        modules: _modules
    };

}());

// The assignments below are temporary since components should just require the
// mixins/components they need instead of relying on the global variable.
const Context = require('./core/context');
SUGAR.App.Context = Context;
const ctxFactory = {
    /**
     * Gets a new instance of the context object.
     *
     * @param {Object} [attributes] Any parameters and state properties to
     *   attach to the context.
     * @return {Core/Context} New context instance.
     * @deprecated since 7.10.
     */
    getContext: function (attributes) {
        SUGAR.App.logger.warn('The function `app.context.getContext()` is deprecated in 7.10. ' +
            'Please use the `new` keyword to create a context.');
        return new Context(attributes);
    }
};

SUGAR.App.augment('Bean', require('./data/bean'));
SUGAR.App.augment('BeanCollection', require('./data/bean-collection'));
SUGAR.App.augment('MixedBeanCollection', require('./data/mixed-bean-collection'));
SUGAR.App.augment('error', require('./core/error'));
SUGAR.App.augment('context', ctxFactory);
SUGAR.App.augment('utils', Utils);
SUGAR.App.augment('cookie', require('./utils/cookie'));
SUGAR.App.augment('Controller', Controller);
SUGAR.App.augment('events', require('./core/events'));
SUGAR.App.augment('acl', require('./core/acl'));
SUGAR.App.augment('metadata', MetadataManager);
SUGAR.App.augment('currency', require('./utils/currency'));
SUGAR.App.augment('date', require('./utils/date'));
SUGAR.App.augment('math', require('./utils/math'));
SUGAR.App.augment('plugins', require('./core/plugin-manager'));
_.mixin(require('./utils/underscore-mixins'));
SUGAR.App.augment('user', require('./core/user'));
Handlebars.registerHelper(require('./view/hbs-helpers'));
SUGAR.App.augment('validation', require('./data/validation'));
SUGAR.App.augment('data', require('./data/data-manager'));
SUGAR.App.augment('lang', Language);
SUGAR.App.augment('template', require('./view/template'));
SUGAR.App.Router = require('./core/router');
SUGAR.App.augment('routing', Routing);
const ViewManager = require('./view/view-manager');
SUGAR.App.augment('view', ViewManager);
ViewManager.Component = require('./view/component');
SUGAR.App.augment('alert', require('./view/alert'));
ViewManager.AlertView = require('./view/alert-view');
ViewManager.Field = require('./view/field');
ViewManager.Layout = require('./view/layout');
ViewManager.View = require('./view/view');