view/field.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 Acl = require('core/acl');
const Component = require('view/component');
const PluginManager = require('core/plugin-manager');
const Language = require('core/language');
const Template = require('view/template');

/**
 * SugarField widget. A field widget is a low level field widget. Some examples
 * of fields are text boxes, date pickers, drop down menus.
 *
 * ## Creating a SugarField
 * SugarCRM allows for customized "fields" which are visual representations of a type of data (e.g. url would
 * be displayed as a hyperlink).
 *
 * ### Anatomy of a SugarField
 * Field files reside in the **`sugarcrm/clients/base/fields/{field_type}`** folder.
 *
 * Inside the {field_type} directory is a set of files that define templates for different views and field controller.
 * A typical directory structure will look like the following:
 * <pre>
 * clients
 * |- base
 *    |- bool
 *       |- bool.js
 *       |- detail.hbs
 *       |- edit.hbs
 *       |- list.hbs
 *    |- int
 *       ...
 *    |- text
 *       ...
 * |- portal
 *    |- portal specific overrides
 * |- mobile
 *    |- mobile specific overrides
 * </pre>
 * **`[sugarFieldType].js`** files are optional.
 * Sometimes a SugarField needs to do more than just display a simple input element, other times input elements
 * need additional data such as drop down menu choices. To support advanced functionality, just add your additional
 * controller logic to **`[sugarFieldType].js`** javascript file where sugarFieldType is the type of the SugarField.
 * Example for `bool.js` controller:
 * ```
 * const ViewManager = require('view/view-manager');
 * ({
 *    events: {
 *         handler: function() {
 *             // Actions
 *         }
 *    },
 *
 *    initialize: function(options) {
 *       ViewManager.Field.prototype.initialize(options);
 *       // Your constructor code here follows...
 *    },
 *
 *    unformat: function(value) {
 *        value = this.el.children[0].children[1].checked ? '1' : '0';
 *        return value;
 *    },
 *
 *    format: function(value) {
 *        value = (value == '1') ? true : false;
 *        return value;
 *    }
 * })
 * ```
 *
 * **`.hbs`** files contain your templates corresponding to the type of {@link View/View} the field is to be displayed on.
 * Sugar uses Handlebars.js as its client side template of choice. At this time no other templating engines are
 * supported. Sample:
 * ```
 * &lt;span name="{{name}}"&gt;{{value}}&lt;/span&gt;
 * ```
 *
 * These files will be used by the metadata manager to generate metadata for your SugarFields and pass them onto the
 * Sugar JavaScript client.
 *
 * @module View/Field
 * @class
 * @extends View/Component
 */
const Field = Component.extend({
    /**
     * HTML tag of the field.
     * @type {string}
     * @memberOf View/Field
     * @instance
     */
    fieldTag: "input",

    /**
     * Initializes this view.
     *
     * @param {Object} options The `Backbone.View` initialization options.
     * @memberOf View/Field
     * @instance
     */
    initialize: function(options) {
        PluginManager.attach(this, 'field');
        Component.prototype.initialize.call(this, options);

        /**
         * ID of the field (autogenerated).
         * @type {number}
         * @name sfId
         * @memberOf View/Field
         * @instance
         */
        this.sfId = options.sfId;

        /**
         * Reference to the view this field is attached to.
         * @type {View/View}
         * @name view
         * @memberOf View/Field
         * @instance
         */
        this.view = options.view;

        /**
         * View definitions for the field (doesn't include vardefs).
         *
         * The view definitions are the properties used for field rendering.
         * Example: A `link` property can specify if the field should be
         * displayed as a link.
         *
         * @type {Object}
         * @name viewDefs
         * @property {Object} defs A hash of properties that
         *   will override the field definitions (vardefs).
         * @memberOf View/Field
         * @instance
         */
        this.viewDefs = options.viewDefs || {};

        /**
         * Field name.
         * @type {string}
         * @name name
         * @memberOf View/Field
         * @instance
         */
        this.name = this.viewDefs.name || options.def && options.def.name;

        var fieldDefs = this.model && this.model.fields ? this.model.fields[this.name] : null;

        /**
         * Field definitions (vardefs).
         *
         * Field definitions are properties that define the field and its
         * internal behavior.
         *
         * @type {Object}
         * @name fieldDefs
         * @memberOf View/Field
         * @instance
         */
        this.fieldDefs = _.extend({}, fieldDefs, this.viewDefs.defs);

        /**
         * Field metadata definition (fieldDefs + viewdefs).
         *
         * Viewdef are copied over vardef.
         * @type {Object}
         * @name def
         * @memberOf View/Field
         * @deprecated
         * @instance
         */
        this.def = _.extend({}, fieldDefs, options.def);

        /**
         * Widget type (text, bool, int, etc.).
         * @type {string}
         * @name type
         * @memberOf View/Field
         * @instance
         */
        this.type = this.viewDefs.type || this.def.type || this.fieldDefs.type;

        // TODO deprecate this fallback, since label vs vname is already patched in metadata
        var oldLabel = this.def.label || this.def.vname || this.name;
        /**
         * i18n-ed field label.
         * @type {string}
         * @name label
         * @memberOf View/Field
         * @instance
         */
        this.label = Language.get(this.viewDefs.label || oldLabel, this.module);

        /**
         * Compiled template.
         * @type {Function}
         * @name template
         * @memberOf View/Field
         * @instance
         */
        this.template = Template.empty;

        // Bind validation error event
        // Note we bind it regardless of which view we on (only need for edit type views)
        if (this.model) {
            this.model.on('error:validation:' + this.name, this.handleValidationError, this);
            this.model.on('validation:start attributes:revert', this.removeValidationErrors, this);
            this.model.on('acl:change:' + this.name, this.handleAclChange, this);
        }
    },

    /**
     * Defines fallback rules for ACL checking.
     *
     * For example, if a user doesn't have `edit` permission for the given field
     * the template falls back to `detail` view template.
     *
     * @type {Object}
     * @memberOf View/Field
     */
    viewFallbackMap: {
        'edit': 'detail'
    },

    /**
     * Handler for when an ACL changes at the field-level.
     *
     * @memberOf View/Field
     * @instance
     */
    handleAclChange: function() {
        /* FIXME SC-3363: The previous action needs to be cleared in order to
           load the correct template when the user potentially loses access to a
           field. SC-3363 will address this. */
        this._resetAction();
        this.render();
    },

    /**
     * Sets this field's action to undefined.
     *
     * @memberOf View/Field
     * @instance
     * @protected
     */
    _resetAction: function () {
        this.action = void 0;
    },

    /**
     * Checks ACLs to see if the current user has access to action.
     *
     * @param {string} action Action name.
     * @return {boolean} Flag indicating if the current user has access to the given action.
     * @see {@link View/Field#_loadTemplate}
     * @memberOf View/Field
     * @instance
     * @private
     */
    _checkAccessToAction: function(action) {
        return Acl.hasAccessToModel(action, this.model, this.name);
    },

    /**
     * Gets the fallback template for this field's view.
     *
     * @param {string} viewName Name of the view.
     * @return {string} If disabled and `viewName` is 'disabled', then 'edit'.
     *   If not, then either `this.view.fallbackFieldTemplate` or
     *   'detail'.
     * @protected
     * @memberOf View/Field
     * @instance
     */
    _getFallbackTemplate: function(viewName) {
        return (this.isDisabled() && viewName === 'disabled') ? 'edit' :
            (this.view.fallbackFieldTemplate || 'detail');
    },

    /**
     * Loads the template for this field.
     *
     * @memberOf View/Field
     * @instance
     * @private
     */
    _loadTemplate: function() {
        var fallbackFieldTemplate;

        // options.viewName or view metadata type is used to override the template
        var viewName = this.options.viewName || this.options.def.view ||
            (this.view.meta && this.view.meta.type ? this.view.meta.type : this.view.name);

        var actionName = this.action;
        if (this.isDisabled() && viewName === 'edit') {
            viewName = this.action;
        } else {
            actionName = this.action || (this.view.action && this.view.name != this.view.action ? this.view.action : viewName);
        }

        while (viewName) {
            if (this._checkAccessToAction(actionName)) break;
            viewName = this.viewFallbackMap[viewName];
            actionName = viewName;
        }

        if (viewName) {
            // Set fallback template to base/view or default.
            var moduleName = this.module || this.context.get('module');

            fallbackFieldTemplate = this._getFallbackTemplate(viewName);
            this.template = Template.getField(this.type, viewName, moduleName, fallbackFieldTemplate) ||
                            // Fallback to text field if template is not defined for this type
                            Template.getField('base', viewName, moduleName, fallbackFieldTemplate) ||
                            // Safeguard with an empty template
                            Template.empty;
        } else {
            // Safeguard with an empty template
            this.template = Template.empty;
        }

        // Update template name and action.
        // These properties are useful for a client app to make decisions when formatting values, rendering, etc.
        /**
         * Template (view) name.
         *
         * The view name can be different from the one the field belongs to.
         * The template is selected based on ACLs. It may also be overridden by field's metadata definition.
         * @type {string}
         * @memberOf View/Field
         * @name tplName
         * @instance
         */
        this.tplName = viewName;

        /**
         * Action name.
         *
         * The action the field is rendered for. Usually, the action name
         * equals to {@link View/Field#tplName}.
         *
         * @type {string}
         * @memberOf View/Field
         * @name action
         * @instance
         */
        this.action = actionName;
    },

    /**
     * Overrides default Backbone.Events to also use custom handlers.
     *
     * The events hash is similar to Backbone.View events hash.
     * You can specify event handler as method of field or as name of event
     * that should be triggered. You cannot use javascript code in metadata.
     * The framework stores the event handlers as anonymous function bound
     * to this field as part of the field instance with the `"callback_"`
     * prefix. When event is fired it calls `triggerMetadataEvent` method
     * if it exists in field. The default behavior of `triggerMetadataEvent`
     * is implemented in {@link MetadataEventDriven} plugin.
     *
     * ```
     * events: {
     *     handler: triggerSomeEvent;
     * }
     * ```
     *
     * triggerSomeEvent should be method of certain field.
     *
     * OR
     *
     * ```
     * events: {
     *     handler: 'fire:some:event';
     * }
     * ```
     *
     * @param {Object} events Hash of events and their handlers.
     * @private
     */
    delegateEvents: function(events) {
        // We may have:
        // this.events -- comes from custom .js controllers
        // this.def.events -- comes from metadata. See, for example, buttons section in portal.js file
        // FIXME SC-5676: The metadata events should typically be extended
        // or mixed-in to the events, not OR'ed with `this.events`. SC-5676
        // should address refactoring this method.
        events = events || _.result(this, 'events') || (this.def ? this.def.events : null);
        if (!events) return;

        events = _.clone(events);

        _.each(events, function(eventHandler, handlerName) {
            var callback = this[eventHandler];

            // If our callbacks / events have not been registered in field,
            // go ahead and registered it as method.
            if (!callback && _.isString(eventHandler)) {
                callback = _.bind(function(event) {
                    if (_.isFunction(this.triggerMetadataEvent)) {
                        this.triggerMetadataEvent(event, eventHandler);
                    }
                }, this);
                this['callback_' + handlerName] = callback;
                events[handlerName] = 'callback_' + handlerName;
            }

            if (!_.isFunction(eventHandler) && !callback) {
                delete events[handlerName];
            }
        }, this);

        Backbone.View.prototype.delegateEvents.call(this, events);
    },

    /**
     * Renders a field widget.
     *
     * This method checks ACLs to choose the correct template.
     * Once the template is rendered, DOM changes are bound to the model.
     *
     * @return {View/Field} The instance of this field.
     * @memberOf View/Field
     * @instance
     * @protected
     */
    _render: function() {
        this._loadTemplate();
        if (this.model instanceof Backbone.Model) {
            /**
             * Model property value.
             * @type {string}
             * @name value
             * @memberOf View/Field
             * @instance
             */
            this.value = this.getFormattedValue();
        }

        /**
         * Override this property with a specific direction string if the
         * field has a set direction that it always follows.
         *
         * Override this property with a function if logic is needed to
         * determine the direction of the field. The function should return
         * either a `string` indicating the direction of the field or
         * `undefined`. This function is only called after the value is set
         * so that we can base the direction on the value.
         *
         * The default value undefined means that the field would use the
         * inherited direction from the DOM.
         *
         * Example using a function:
         *
         * ```
         * direction: function() {
         *     return this.isRTL ? 'rtl' : 'ltr';
         * }
         * ```
         * @type {Function|string|undefined}
         * @name direction
         * @memberOf View/Field
         * @instance
         */

        /**
         * The direction of the field. The default value `undefined` means
         * that the field would use the inherited direction from the DOM.
         *
         * Do not override this property directly; override
         * {@link View/Field#direction} instead.
         *
         * @type {string|undefined}
         * @name dir
         * @memberOf View/Field
         * @instance
         * @readonly
         */
        this.dir = _.result(this, 'direction');
        if (Language.direction === this.dir) {
            delete this.dir;
        }

        this.unbindDom();
        this.$el.html(this.template(this) || '');

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

        this.$(this.fieldTag).attr('dir', this.dir);

        this.bindDomChange();
        return this;
    },

    /**
     * Gets the corresponding field DOM element.
     *
     * This method will return the placeholder element.
     * Override this method in the subclass to point the specified field
     * element.
     *
     * @return {Object} DOM Element
     * @memberOf View/Field
     * @instance
     */
    getFieldElement: function() {
        return this.$el;
    },

    /**
     * Binds DOM changes to a model.
     *
     * The default implementation of this method binds value changes of
     * {@link View/Field#fieldTag} element to model's `Backbone.Model#set`
     * method. Override this method if you need custom binding.
     *
     * @memberOf View/Field
     * @instance
     */
    bindDomChange: function() {
        if (!(this.model instanceof Backbone.Model)) return;

        var self = this;
        var el = this.$el.find(this.fieldTag);
        el.on("change", function() {
            self.model.set(self.name, self.unformat(el.val()));
        });
    },

    /**
     * Binds model changes to this field.
     *
     * The default implementation makes sure this field gets re-rendered
     * whenever the corresponding model attribute changes.
     *
     * @memberOf View/Field
     * @instance
     */
    bindDataChange: function() {
        if (this.model) {
            this.model.on("change:" + this.name, function(model, value) {
                // FIXME: we need to track the `options` send by
                // `model.set()` and only call `this.render()` if it didn't
                // came from `bindDomChange`
                if (this.action === 'edit') {
                    // Should directly set the value in edit instead of re-rendering the whole field (see MAR-1617).
                    this.$(this.fieldTag).val(this.format(value));
                } else {
                    this.render();
                }
            }, this);
        }
    },

    /**
     * Checks to see if the field's value has been changed from the saved model
     * This is not the same as {@link Backbone.Model#hasChanged} which checks
     * if the model has changed from the last time it was set whereas this
     * function checks if what is currently in the input field is the same as
     * what is synced on the model
     *
     * @return {boolean} `true` if the value is different, `false` if field is
     *   synced with the saved model
     * @memberOf View/Field
     * @instance
     */
    hasChanged: function() {
        return !_.isEqual(this.format(this.model.getSynced(this.name)), this.format(this.model.get(this.name)));
    },

    /**
     * Formats the value to be used in handlebars template and displayed on
     * screen.
     *
     * The default implementation returns `value` without modifying it.
     * Override this method to provide custom formatting in field
     * controller (`[type].js` file).
     *
     * @param {Array|Object|string|number|boolean} value The value to format.
     * @return {Array|Object|string|number|boolean} Formatted value.
     * @memberOf View/Field
     * @instance
     */
    format: function(value) {
        return value;
    },

    /**
     * Unformats the value for storing in a model. This should do the
     * inverse of {@link #format}.
     *
     * The default implementation returns `value` without modifying it.
     * Override this method to provide custom unformatting in field
     * controller (`[type].js` file).
     *
     * @param {string} value The value to unformat.
     * @return {Array|Object|string|number|boolean} Unformatted value.
     * @memberOf View/Field
     * @instance
     */
    unformat: function(value) {
        return value;
    },

    /**
     * Returns the value of this field in the associated model.
     *
     * If you need to override the formatted value please override
     * {@link View/Field#format}.
     *
     * @return {Array|Object|string|number|boolean} The formatted data as
     *   provided by {@link View/Field#format}.
     * @memberOf View/Field
     * @instance
     */
    getFormattedValue: function() {
        return this.format(this.model.has(this.name) ? this.model.get(this.name) : null);
    },

    /**
     * Handles validation errors.
     *
     * The default implementation does nothing.
     * Override this method to provide custom display logic.
     * ```
     * ViewManager.Field = ViewManager.Field.extend({
     *     handleValidationError: function(errors) {
     *         // Your custom logic goes here
     *     }
     * });
     * ```
     *
     * @param {Object} errors hash of validation errors
     * @memberOf View/Field
     * @instance
     */
    handleValidationError: function(errors) {
        // Override this method
    },

    /**
     * Removes the validation error properties on the field that were set by
     * {@link #handleValidationError}.
     *
     * The default implementation does nothing.
     *
     * Override this method to provide custom logic:
     *
     * ```
     * ViewManager.Field = ViewManager.Field.extend({
     *     removeValidationErrors: function(errors) {
     *         // Your custom logic goes here
     *     }
     * });
     * ```
     *
     * @memberOf View/Field
     * @instance
     */
    removeValidationErrors: function() {
        // Override this method
    },

    /**
     * Gets the HTML placeholder for a field.
     *
     * @return {Handlebars.SafeString} HTML placeholder for the field as
     *   Handlebars safe string.
     * @memberOf View/Field
     * @instance
     */
    getPlaceholder: function() {
        return new Handlebars.SafeString('<span sfuuid="' + this.sfId + '"></span>');
    },

    /**
     * Disables edit mode by switching the element as detail mode.
     *
     * @param {boolean} disable `true` or `undefined` to disable the edit mode
     *   otherwise, it will restore back to the previous mode.
     * @param {Object} [options = {}] A hash of options.
     * @param {boolean} [options.trigger] `true` to trigger the `field:disabled`
     *   event in the context. The event passes 2 arguments: the field name and
     *   the `disable` boolean.
     * @memberOf View/Field
     * @instance
     */
    setDisabled: function(disable, options = {}) {
        disable = _.isUndefined(disable) ? true : disable;
        let swapped = false;
        if(disable && this.isDisabled() === false) {
            //Set disabled
            this._previousAction = this.action;
            this.action = 'disabled';
            this.render();
            swapped = true;
        } else if(disable === false && this.isDisabled()) {
            //disabled release
            this.action = this._previousAction;
            delete this._previousAction;
            this.render();
            swapped = true;
        }

        if (swapped && options.trigger) {
            this.context.trigger('field:disabled', this.name, disable, this);
        }
    },

    /**
     * Checks if this field is disabled.
     *
     * @return {boolean} `true` if this field is disabled, `false` otherwise.
     * @memberOf View/Field
     * @instance
     */
    isDisabled: function() {
        return (this.action === 'disabled');
    },

    /**
     * Set view name of this field.
     * This only switches the template reference.
     *
     * @param {string} view View name.
     * @memberOf View/Field
     * @instance
     */
    setViewName: function(view) {
        this.options.viewName = view;
    },

    /**
     * Set action name of this field.
     * This switches action name as well as the template reference.
     *
     * @param {string} name Action name.
     * @memberOf View/Field
     * @instance
     */
    setMode: function(name) {
        if(this.isDisabled()) {
            this._previousAction = name;
        } else {
            this.action = name;
        }
        this.setViewName(name);
        this.render();
    },

    /**
     * Unbinds DOM changes from this field's element.
     *
     * This method performs the opposite of what
     * {@link View/Field#bindDomChange} method does.
     * Override this method if you need custom logic.
     *
     * @memberOf View/Field
     * @instance
     */
    unbindDom: function() {
        this.$el.find(this.fieldTag).off();
    },

    /**
     * Disposes a field.
     *
     * Calls {@link View/Field#unbindDom} and {@link View/Component#_dispose}
     * method of the base class.
     *
     * @protected
     * @memberOf View/Field
     * @instance
     */
    _dispose: function() {
        PluginManager.detach(this, 'field');
        this.unbindDom();
        Component.prototype._dispose.call(this);
    },

    /**
     * Gets a string representation of this field.
     *
     * @return {string} String representation of this field.
     * @memberOf View/Field
     * @instance
     */
    toString: function() {
        return "field-" + this.name + "-" + this.sfId + "-" +
            Component.prototype.toString.call(this);
    },

    /**
     * @inheritdoc
     * @memberOf View/Field
     */
    _show: function() {
        this.getFieldElement().removeClass('hide').show();
        this.updateVisibleState(true);
    },

    /**
     * @inheritdoc
     * @memberOf View/Field
     */
    _hide: function() {
        this.getFieldElement().addClass('hide').hide();
        this.updateVisibleState(false);
    },

    /**
     * Compares formatted values for equality.
     *
     * @param {View/Field} other Other field component.
     * @return {boolean} It returns `true` for equal value.
     * @memberOf View/Field
     * @instance
     */
    equals: function(other) {
        return this.type === other.type && _.isEqual(this.getFormattedValue(), other.getFormattedValue());
    },

    // FIXME: THIS METHOD NEEDS TO BE DOCUMENTED!
    closestComponent: function(name) {
        if (!this.view) {
            return;
        }
        if (this.view.name === name) {
            return this.view;
        }
        return this.view.closestComponent(name);
    }
});

module.exports = Field;