data/mixed-bean-collection.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 Bean = require('./bean');
const BeanCollection = require('./bean-collection');

/**
 * Mixed collection class.
 *
 * Supports wrapping multiple related collections by link to allow aggregated
 * interactions across multiple bean relationships. Does not support direct
 * fetch at this time except in search mode.
 *
 * **Filtering and searching**
 *
 * The collection's {@link Data/BeanCollection#fetch} method supports filter and
 * search options. For example, to search across accounts, opportunities, and
 * contacts for favorite records that have the string `"Acme"` in their
 * searchable fields:
 * ```
 * const DataManager = require('./data-manager');
 * var records = SUGAR.App.data.getMixedBeanCollection();
 * records.fetch({
 *     favorites: true,
 *     query: 'Acme',
 *     module_list: 'Accounts,Opportunities,Contacts'
 * });
 * ```
 *
 * @module Data/MixedBeanCollection
 * @class
 * @extends Data/BeanCollection
 */
module.exports = BeanCollection.extend({
    /**
     * Creates a bean collection for each one of the links passed in the options.
     * The mixed bean collection will keep those collection in sync with
     * the mixed bean collection.
     *
     * @param {Array} models The initial models of the mixed bean collection.
     * @param {Object} [options] A hash of options.
     * @param {Array} [options.links] The links related to the mixed bean
     *   collection. A link is a collection of a particular module linked to a
     *   Bean. It represents a 1-to-many or many-to-many relationship. If this
     *   argument is provided, the mixed bean collection will handle
     *   synchronization between its records and the ones in its link
     *   collections. This allows a bean to have a mixed bean collection as part
     *   of its attributes to handle modification on its 1-to-many and
     *   many-to-many relationships.
     * @param {Data/Bean} [options.parentBean] The parent bean. It is required
     *   in the case where the MixedBeanCollection is used for a collection
     *   field.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    initialize: function (models, options) {
        this._linkedCollections = {};

        options = options || {};
        if (options.links) {
            if (!options.parentBean) {
                SUGAR.App.logger.error('You cannot instantiate a mixed-bean-collection with linked collections ' +
                    'but without a parent bean.');
                return;

            }

            /**
             * The parent bean. It exists in the case where a
             * MixedBeanCollection is used for a collection field.
             *
             * @property {Data/Bean}
             * @private
             */
            this._parentBean = options.parentBean;
            /**
             * The name of the collection field for which the mixed bean
             * collection is used.
             *
             * @property {string}
             * @private
             */
            this._collectionField = options.collectionField;

            _.each(options.links, function(collection, link) {
                //Check if collection is a module name rather than an existing collection
                if (_.isString(collection)) {
                    this._linkedCollections[link] = SUGAR.App.data.createBeanCollection(collection);
                } else {
                    this._linkedCollections[link] = collection;
                }

                this.listenTo(this._linkedCollections[link], 'add', this._onLinkAdd);
                this.listenTo(this._linkedCollections[link], 'remove', this._onLinkRemove);
                this.listenTo(this._linkedCollections[link], 'reset', this._onLinkReset);
            }, this);

            delete options.links;
        }

        BeanCollection.prototype.initialize.call(this, models, options);
    },

    /**
     * Sets the `model` class to match the given model.
     *
     * @param {Data/Bean|Object} model The bean to be added to the collection.
     * @param {Object} [options] A hash of options.
     * @return {Data/Bean} The prepared bean.
     * @private
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    _prepareModel: function (model, options) {
        var module = model instanceof Bean ? model.module : model._module;
        this.model = SUGAR.App.data.getBeanClass(module);
        return BeanCollection.prototype._prepareModel.call(this, model, options);
    },

    /**
     * Adds a bean to this collection when it got added to a
     * linked collection.
     *
     * @param {Data/Bean} model The bean to add.
     * @param {Data/BeanCollection} collection The collection where the
     *   bean got added.
     * @param {Object} [options] A hash of options.
     * @private
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    _onLinkAdd: function(model, collection, options) {
        if (this._updating) {
            return;
        }

        BeanCollection.prototype.set.call(this, model, {merge: false, add: true, remove: false});
    },

    /**
     * Removes a bean from this collection when it gets removed
     * from a linked collection.
     *
     * @param {Data/Bean} model The bean to remove.
     * @param {Data/BeanCollection} collection The collection from which the
     *   bean got removed.
     * @param {Object} [options] A hash of options.
     * @private
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    _onLinkRemove: function (model, collection, options) {
        if (this._updating) {
            return;
        }

        BeanCollection.prototype.remove.call(this, model);
    },

    /**
     * Updates this collection when a linked collection gets reset.
     *
     * @param {Data/BeanCollection} collection The linked collection that
     *   got reset.
     * @private
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    _onLinkReset: function (collection) {
        let groupedBeans = _.groupBy(this.models, function (model) {
            return model instanceof Bean ? model.get('_link') : model._link;
        });

        let linkName = _.findKey(this._linkedCollections, val => val === collection);

        let removeOptions = {};
        let setOptions = {merge: false, add: true, remove: false};
        // If `_updating` is `true`, it means we called `reset` on this
        // collection, so we do not want to trigger `add` neither `remove`.
        if (this._updating) {
            _.extend(removeOptions, {silent: true});
            _.extend(setOptions, {silent: true});
        }

        BeanCollection.prototype.remove.call(this, groupedBeans[linkName], removeOptions);
        BeanCollection.prototype.set.call(this, collection.models, setOptions);
    },

    /**
     * Adds models to the matching linked collections.
     *
     * @param {Data/Bean[]|Object[]} models The beans to be added to the
     *   collection.
     * @param {Object} [options] A hash of options.
     * @return {Data/Bean[]} The beans added to the collection.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    set: function (models, options) {
        // We don't call the BeanCollection method because a mixed bean
        // collection does not need to track it's own deltas.
        models = Backbone.Collection.prototype.set.call(this, models, options);
        if (!this._parentBean) {
            return models;
        }

        this._updating = true;
        options = options || {};

        if (!_.isUndefined(models) && !_.isArray(models)) {
            models = [models];
        }

        _.each(models, function (model) {
            var link = model.get('_link');
            if (!link) {
                return;
            }

            if (this._linkedCollections[link]) {
                this._linkedCollections[link].add(model, options);
            }
        }, this);

        this._updating = false;

        return models;
    },

    /**
     * Removes models from the matching linked collections.
     *
     * @param {Data/Bean[]|Object[]} models The beans to be removed from the
     *   collection.
     * @param {Object} [options] A hash of options.
     * @return {Data/Bean[]} The beans removed from the collection.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    remove: function (models, options) {
        // We don't call the BeanCollection method because a mixed bean
        // collection does not need to track it's own deltas.
        models = Backbone.Collection.prototype.remove.call(this, models, options);

        if (!this._parentBean) {
            return models;
        }

        this._updating = true;
        options = options || {};

        if (!_.isUndefined(models) && !_.isArray(models)) {
            models = [models];
        }

        _.each(models, function (model) {
            var link = model.get('_link');

            if (link) {
                if (this._linkedCollections[link]) {
                    this._linkedCollections[link].remove(model, options);
                }
            } else {
                _.each(this._linkedCollections, function (collection) {
                    var modelModule = model instanceof Bean ? model.get('module') : model._module;
                    if (collection.module === modelModule) {
                        collection.remove(model, options);
                    }
                });
            }
        }, this);

        this._updating = false;

        return models;
    },

    /**
     * Resets all linked collections.
     *
     * @param {Data/Bean[]|Object[]} models The beans to reset the
     *   collection with.
     * @param {Object} [options] A hash of options.
     * @return {Data/Bean[]} The beans set to the collection.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    reset: function (models, options) {
        if (!this._parentBean) {
            // We don't call the BeanCollection method because a mixed bean
            // collection does not need to track it's own deltas.
            return Backbone.Collection.prototype.reset.call(this, models, options);
        }

        options = options ? _.clone(options) : {};
        if (!_.isUndefined(models) && !_.isArray(models)) {
            models = [models];
        }

        var sortedBeans = _.groupBy(models, function (model) {
            return model instanceof Bean ? model.get('_link') : model._link;
        });

        this._updating = true;
        _.each(this._linkedCollections, function(collection, link) {
            // When a collection field's collection is reset silently, we still
            // want the linked collections to trigger `reset` so `_onLinkReset`
            // is called and the records of this mixed bean collection reflects
            // the ones presents in the linked collection.
            collection.reset(sortedBeans[link], _.omit(options, 'silent'));
        }, this);
        this._updating = false;
        if (!options.silent) {
            this.trigger('reset', this, options);
        }

        return models;
    },

    /**
     * Gets changes made on the linked collections.
     *
     * @return {Object} The object representing the changes made on the
     * linked collections since the last sync.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    getDelta: function() {
        var result = {};
        // TODO: SC-6145 will add `add` and `delete` arrays.
        _.each(this._linkedCollections, (val, linkName) => {
            if (!val.hasDelta()) {
                return;
            }

            result[linkName] = val.getDelta();
        }, this);

        return result;
    },

    /**
     * Checks if at least one of the linked collections has a delta.
     *
     * @return {boolean} `true` if at least 1 linked collection has a delta.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    hasDelta: function() {
        return _.some(this._linkedCollections, (coll) => {
            return coll.hasDelta();
        });
    },

    /**
     * Resets the delta object on each linked collection.
     *
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    resetDelta: function() {
        _.each(this._linkedCollections, (val, linkName) => {
            val.resetDelta();
        });
    },

    /**
     * Fetches records.
     *
     * This method performs global search across multiple modules.
     *
     * @param {Object} [options] Fetch options.
     * @param {string} [options.module_list] Comma-delimited list of
     *   modules to search across. The default is a list of all displayable
     *   modules.
     * @return {SUGAR.HttpRequest} The created fetch request.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    fetch: function(options) {
        options = options || {};
        // We set a list of all modules by default
        options.module_list = this.module_list = options.module_list || this.module_list || SUGAR.App.metadata.getModuleNames({filter: 'visible'});
        return BeanCollection.prototype.fetch.call(this, options);
    },

    /**
     * Groups models by module name.
     *
     * @return {Object} Sets of models. Key is module name, value is array of
     *   models.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    groupByModule: function() {
        return _.groupBy(this.models, function(model) {
            return model.module;
        });
    },

    /**
     * Returns string representation of this collection.
     *
     * The format is:
     * ```mcoll:[length]```
     *
     * @return {string} String representation of this collection.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    toString: function() {
        return "mcoll:" + this.length;
    },

    /**
     * Paginates the collection. In the use case of a collection field, we
     * pass `collectionField` option in order to hit the collection API.
     *
     * @param {Object} [options] A hash of options.
     * @param {string[]} [options.storedFilter] An array of existing filters to
     *   be applied.
     * @param {string} [options.view] If you are paginating a collection field,
     *   passing the `view` option will make sure we read the field definition
     *   specified in the view defs. It is especially important in order to use
     *   the same `order_by` value that was used for the first fetch of the
     *   collection.
     * @return {SUGAR.HttpRequest} The created fetch request.
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    paginate: function(options) {
        options = options || {};
        if (this._parentBean) {
            _.extend(options,
                {
                    collectionField: this._collectionField,
                    beanId: this._parentBean.id,
                    module: this._parentBean.module,
                    offset: this.offset
                }
            );
        }

        return BeanCollection.prototype.paginate.call(this, options);
    },

    /**
     * In a use case of a collection field, we set the `offset` and
     * `next_offset` to an empty object. In other use cases, we call the parent.
     *
     * @memberOf Data/MixedBeanCollection
     * @instance
     */
    resetPagination: function() {
        if (!this._parentBean) {
            return BeanCollection.prototype.resetPagination.call(this);
        }

        this.offset = this.next_offset = {};
    }
});