data/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 PluginManager = require('../core/plugin-manager');

/**
 * Base bean collection class. It extends `Backbone.Collection`.
 *
 * A bean collection is a collection of beans. To instantiate a bean collection,
 * you need to use {@link Data/DataManager#createBeanCollection}.
 *
 * Example of usage:
 *
 * The following snippet will create a collection of bean which belongs to the
 * module 'Accounts':
 *
 * ```
 * const DataManager = require('./data-manager');
 * let accounts = DataManager.createBeanCollection('Accounts');
 * accounts.add({name: 'account1', industry: 'Banking'});
 * ```
 *
 * **Filtering and searching**
 *
 * The collection's {@link Data/BeanCollection#fetch} method supports filter and
 * search options. For example, to search favorite accounts that have the string
 * `"Acme"` in their name:
 * ```
 * const DataManager = require('./data-manager');
 * let accounts = DataManager.createBeanCollection('Accounts');
 * accounts.fetch({
 *     favorites: true,
 *     query: "Acme"
 * });
 * ```
 *
 * @module Data/BeanCollection
 * @class
 */
const BeanCollection = Backbone.Collection.extend({
    /**
     * The request object that is currently syncing against the server.
     *
     * This object is needed to determine if a fetch request should be
     * aborted for the collection (e.g. if a new fetch request returns a
     * response prior to a previous fetch request).
     *
     * @private
     * @type {SUGAR.HttpRequest}
     * @memberOf Data/BeanCollection
     */
    _activeFetchRequest: null,

    /**
     * The default model of a bean collection is a {@link Data/Bean}.
     *
     * @readonly
     * @type {Data/Bean}
     * @memberOf Data/BeanCollection
     * @instance
     */
    model: Bean,

    /**
     * Prepares related bean collections and attach collection plugins.
     *
     * @param {Object[]|Data/Bean[]} models Initial array of models.
     * @param {Object} [options] A hash of options.
     * @param {Object} [options.link] A link specification.
     * @memberOf Data/BeanCollection
     * @instance
     */
    constructor: function(models, options) {
        PluginManager.attach(this, 'collection');
        if (options && options.link) {
            /**
             * Reference to a relationship.
             *
             * @type {Object}
             * @memberOf Data/BeanCollection
             * @instance
             * @name link
             */
            this.link = options.link;
            delete options.link;
        }
        Backbone.Collection.prototype.constructor.call(this, models, options);
    },

    /**
     * Sets the given options persistently on the bean collection. They will be
     * used by {@link Data/BeanCollection#fetch}.
     *
     * @param {Object[]|Data/Bean[]} models Initial array of models.
     * @param {Object} options Backbone collection options.
     * @memberOf Data/BeanCollection
     * @instance
     */
    initialize: function(models, options) {
        /**
         * List of persistent fetch options.
         *
         * @type {Object}
         * @private
         */
        this._persistentOptions = {};

        /**
         * Object keeping track of new models added to the collection
         * that are to be created and linked to their parent bean.
         * It contains an array of attribute hashes.
         *
         * @type {Array}
         */
        this._create = [];

        /**
         * Object keeping track of removed models from the collection that
         * are to be unlinked from their parent bean. It contains an array
         * of ids.
         *
         * @type {Array}
         */
        this._delete = [];

        /**
         * Object keeping track of added models to the collection that are
         * to be linked to their parent bean. It contains an array of ids.
         *
         * @type {Array}
         */
        this._add = [];

        this.setOption(options);
        this._bindCollectionEvents();

        Backbone.Collection.prototype.initialize.call(this, models, options);

        /**
         * Readonly property for the total records in server.
         *
         * Use {@link Data/BeanCollection#fetchTotal} to get the current total.
         *
         * @type {number}
         * @readOnly
         * @memberOf Data/BeanCollection
         * @name total
         * @instance
         */
        this.total = null;
    },

    /**
     * Bind collection events to activeRequest.
     *
     * @private
     * @memberOf Data/BeanCollection
     * @instance
     */
    _bindCollectionEvents: function() {
        this.on('data:sync:complete', function() {
            var activeFetchRequest = this.getFetchRequest();
            if (activeFetchRequest) {
                this._activeFetchRequest = null;
            }
        }, this);

        this.on('remove', this._decrementTotal, this);
        if (!this.link) {
            return;
        }

        this.on('sync', this.resetDelta, this);
    },

    /**
     * Decrements the {@link Data/BeanCollection#total collection total}.
     *
     * @protected
     * @memberOf Data/BeanCollection
     * @instance
     */
    _decrementTotal: function() {
        if (this.total) {
            this.total--;
        }
    },

    /**
     * Keeps track of the added models.
     *
     * @param {Object|Object[]|Data/Bean|Data/Bean[]} models The models to add.
     * @param {Object} options A hash of options.
     * @return {Data/Bean|Data/Bean[]} The added models.
     * @memberOf Data/BeanCollection
     * @instance
     */
    add: function (models, options) {
        models = Backbone.Collection.prototype.add.call(this, models, options);

        if (_.isUndefined(models) || !this.link || (options && options.method)) {
            return models;
        }

        models = _.isArray(models) ? models : [models];

        _.each(models, (model) => {
            if (!model) {
                return;
            }

            if (model.isNew()) {
                this._create.push(model);
            } else {
                const toDelete = _.find(this._delete, model);
                if (toDelete) {
                    this._delete = _.without(this._delete, toDelete);
                } else {
                    this._add.push(model);
                }
            }
        }, this);

        return models.length === 1 ? models[0] : models;
    },

    /**
     * Keeps track of the removed models.
     *
     * @param {Object|Object[]|Data/Bean|Data/Bean[]} models The models to
     *   remove.
     * @param {Object} options A hash of options.
     * @return {Data/Bean|Data/Bean[]} The removed models.
     * @memberOf Data/BeanCollection
     * @instance
     */
    remove: function (models, options) {
        models = Backbone.Collection.prototype.remove.call(this, models, options);

        if (_.isUndefined(models) || !this.link) {
            return models;
        }

        models = _.isArray(models) ? models : [models];

        _.each(models, (model) => {
            if (!model) {
                return;
            }

            if (model.isNew()) {
                this._create = _.without(this._create, model);
            } else if (_.contains(this._add, model)) {
                this._add = _.without(this._add, model);
            } else {
                this._delete.push(model);
            }
        }, this);

        return models.length === 1 ? models[0] : models;
    },

    /**
     * Keeps track of the unsynced changes on this collection.
     *
     * @param {Object|Object[]|Data/Bean|Data/Bean[]} models The models to
     *   reset the collection with.
     * @param {Object} options A hash of options.
     * @return {Data/Bean|Data/Bean[]} The model(s) you have reset the
     *   collection with.
     * @memberOf Data/BeanCollection
     * @instance
     */
    reset: function(models, options) {
        this.resetDelta();

        return Backbone.Collection.prototype.reset.call(this, models, options);
    },

    /**
     * Gets a hash of unsynced changes operated on the collection.
     *
     * Adds the relationship fields for records to be linked.
     *
     * @return {Object} A hash representing the unsynced changes.
     * @memberOf Data/BeanCollection
     * @instance
     */
    getDelta: function() {
        let delta = {};
        delta.create = _.invoke(this._create, 'toJSON');

        // Gets the link field name of the relationship between the parent bean
        // and the collection from this collection's module vardefs.
        let oppositeLink = SUGAR.App.data.getOppositeLink(this.link.bean.module, this.link.name);
        let relationshipFields = SUGAR.App.data.getRelationFields(this.module, oppositeLink);

        delta.add = _.map(this._add, (model) => {
            let objToAdd = {id: model.id};
            _.each(relationshipFields, (relationshipField) => {
                if (model.get(relationshipField)) {
                    objToAdd[relationshipField] = model.get(relationshipField);
                }
            });

            return objToAdd;
        });

        delta.delete = _.pluck(this._delete, 'id');

        return delta;
    },

    /**
     * Checks if there is anything in the deltas.
     *
     * @return {boolean} `true` if some records are to be created, linked
     * or unlinked to the bean.
     * @memberOf Data/BeanCollection
     * @instance
     */
    hasDelta: function() {
        return !_.all([this._add, this._delete, this._create], _.isEmpty);
    },

    /**
     * Resets the delta object representing the unsaved collection changes.
     *
     * @memberOf Data/BeanCollection
     * @instance
     */
    resetDelta: function() {
        this._create = [];
        this._add = [];
        this._delete = [];
    },

    /**
     * Disposes this collection.
     *
     * @memberOf Data/BeanCollection
     * @instance
     */
    dispose: function() {
        PluginManager.detach(this, 'collection');
    },

    /**
     * Prepares the model to be added to the collection.
     *
     * @param {Data/Bean} model Model to be added to the collection.
     * @param {Object} [options] A hash of options.
     * @return {Data/Bean} prepared model.
     * @private
     * @memberOf Data/BeanCollection
     * @instance
     */
    _prepareModel: function(model, options) {
        var searchInfo = model._search;
        delete model._search;

        model = Backbone.Collection.prototype._prepareModel.call(this, model, options);
        if (model && !model.link) model.link = this.link;
        if (searchInfo) {
            /**
             * FTS search results.
             *
             * Example:
             * ```
             * {
             *     highlighted: {
             *         account_name: {
             *             label: 'LBL_ACCOUNT_NAME',
             *             module: "Leads",
             *             text: 'Kings Royalty <span class="highlight">Trust</span>'
             *         }
             *     },
             *     score: 1
             * }
             * ```
             *
             * @memberOf Data/Bean
             * @type {Object}
             * @name searchInfo
             * @instance
             */
            model.searchInfo = searchInfo;
        }
        return model;
    },

    /**
     * Fetches beans. See {@link Data/BeanCollection#paginate} for details
     * about pagination options.
     *
     * Only one fetch request can be executed at a time - previous fetch
     * requests will be aborted.
     *
     * @param {Object} [options] Fetch options.
     * @param {boolean} [options.relate] `true` if relationships should be
     *   fetched. `false` otherwise.
     * @param {boolean} [options.myItems] `true` if only records assigned to
     *   the current user should be fetched. `false` otherwise.
     * @param {boolean} [options.favorites] `true` if favorited records should
     *   be fetched. `false` otherwise.
     * @param {boolean} [options.add] `true` if new records should be appended
     *   to the collection. `false` otherwise.
     * @param {string} [options.query] Search query string.
     * @param {Function} [options.success] The success callback to execute.
     * @param {Function} [options.error] The error callback to execute.
     * @return {SUGAR.HttpRequest} The created fetch request.
     * @memberOf Data/BeanCollection
     * @instance
     */
    fetch: function(options) {
        options = _.extend({}, this.getOption(), options);

        this.abortFetchRequest();
        /**
         * Field names.
         *
         * A list of fields that are populated on collection members.
         * This property is used to build the `fields` URL parameter when
         * fetching beans.
         *
         * @memberOf Data/BeanCollection
         * @name fields
         * @type {Array}
         * @instance
         */
        options.fields = this.fields = options.fields || this.fields || null;
        options.myItems = _.isUndefined(options.myItems) ? this.myItems : options.myItems;
        options.favorites = _.isUndefined(options.favorites) ? this.favorites : options.favorites;
        options.query = _.isUndefined(options.query) ? this.query : options.query;

        // FIXME SC-5596: This option is temporary, and was added as part of
        // SC-2703. It will be removed when we update our sidecar components
        // to listen on `update` instead of reset.
        options.reset = _.isUndefined(options.reset) ? true : options.reset;

        this._activeFetchRequest = Backbone.Collection.prototype.fetch.call(this, options);
        return this.getFetchRequest();
    },

    /**
     * Gets the currently active fetch request.
     *
     * @return {SUGAR.HttpRequest} The currently active http fetch request.
     * @memberOf Data/BeanCollection
     * @instance
     */
    getFetchRequest: function () {
        if (_.isFunction(this._activeFetchRequest)) {
            return this._activeFetchRequest();
        } else {
            return this._activeFetchRequest;
        }
    },

    /**
     * Aborts the currently active fetch request.
     *
     * @memberOf Data/BeanCollection
     * @instance
     */
    abortFetchRequest: function () {
        var activeFetchRequest = this.getFetchRequest();
        if (activeFetchRequest) {
            SUGAR.App.api.abortRequest(activeFetchRequest.uid);
            this._activeFetchRequest = null;
        }
    },

    /**
     * Resets pagination properties on this collection to initial values.
     *
     * @memberOf Data/BeanCollection
     * @instance
     */
    resetPagination: function() {
        this.offset = this.getOption('offset') || 0;
        this.next_offset = 0;
        this.page = this.getOption('page') || 1;
    },

    /**
     * Paginates a collection. This methods calls
     * {@link Data/BeanCollection#fetch}, hence it
     * supports the same options as well as the one described below.
     *
     * @param {Object} [options] Fetch options.
     * @param {number} [options.page=1] Page index from the current page to
     *   paginate to.
     * @memberOf Data/BeanCollection
     * @instance
     */
    paginate: function(options) {
        options = options || {};
        var maxSize = options.limit || SUGAR.App.config.maxQueryResult;
        options.page = options.page || 1;

        // Since we're paginating, we want the Collection.set method to be
        // invoked instead of `reset`.
        options.reset = _.isUndefined(options.reset) ? false : options.reset;

        // fix page number since our offset is already at the end of the collection subset
        options.page--;

        if (!_.isUndefined(options.strictOffset) && options.strictOffset === true) {
            options.offset = options.page * maxSize;
        } else if (maxSize && _.isNumber(this.offset)) {
            options.offset = this.offset + (options.page * maxSize);
        }

        if (options.add){
            options = _.extend({update:true, remove:false}, options);
        }

        this.fetch(options);
    },

    /**
     * Fetches the total amount of records on the bean collection, and sets
     * it on the {@link Data/BeanCollection#total} property.
     *
     * Returns the total amount of filtered records if a `filterDef`
     * property is set on the collection.
     *
     * @param {Object} [options] Fetch total options.
     * @param {Function} [options.success] Success callback.
     * @param {Function} [options.complete] Complete callback.
     * @param {Function} [options.error] Error callback.
     * @return {SUGAR.HttpRequest|undefined} Result of {@link SUGAR.Api#call},
     *   or `undefined` if {@link Data/BeanCollection#total} is not `null`.
     * @memberOf Data/BeanCollection
     * @instance
     */
    fetchTotal: function(options) {
        options = options || {};

        if (!_.isNull(this.total) && _.isFunction(options.success)) {
            options.success.call(this, this.total);
            return;
        }

        options.success = _.wrap(options.success, _.bind(function(orig, data) {
            this.total = parseInt(data.record_count, 10);
            if (orig && _.isFunction(orig)) {
                orig(this.total);
            }
        }, this));

        var module = this.module;
        var data = null;
        options.filter = options.filterDef || this.filterDef;

        if (this.link) {
            data = {
                id: this.link.bean.id,
                link: this.link.name
            };
            module = this.link.bean.module;
        }

        var callbacks = _.pick(options, 'success', 'complete', 'error');
        options = _.omit(options, 'success', 'complete', 'error');

        return SUGAR.App.api.count(module, data, callbacks, options);
    },

    /**
     * A convenience method that checks to see if there are at least the
     * amount of records passed in `amount`. Also passes to a provided
     * success callback the length of records up to `amount`, and if there
     * are more records to be fetched (`hasMore`).
     *
     * Fetches the partial amount of filtered records if a `filterDef`
     * property is set on the collection.
     *
     * @param {number} amount The number of records to check if there are a
     *   minimum of.
     * @param {Object} [options] Fetch partial total options.
     * @param {Object} [options.filterDef] Filter definition to be applied.
     * @param {Function} [options.success] Success callback.
     * @param {Function} [options.complete] Complete callback.
     * @param {Function} [options.error] Error callback.
     * @return {SUGAR.HttpRequest} Result of {@link SUGAR.Api#call}.
     * @memberOf Data/BeanCollection
     * @instance
     */
    hasAtLeast: function(amount, options) {
        options = options || {};
        var method = 'read';
        options.fields = ['id'];
        delete options.view;
        options.silent = true;
        options.success = _.wrap(options.success, _.bind(function(orig, data) {
            var length = this.link ? data.record_count : data.records.length;
            var hasAtLeastAmount = length >= amount;
            var properties = {
                length: length,
                hasMore: this.link ? data.has_more : data.next_offset !== -1
            };

            if (_.isFunction(orig)) {
                orig(hasAtLeastAmount, properties);
            }

            this.reset(null, {silent: true});
        }, this));

        var module = this.module;
        var data = null;
        var endpoint = 'records';
        options.filter = options.filterDef || this.filterDef;
        options.limit = options.limit || amount;

        if (this.link) {
            data = {
                id: this.link.bean.id,
                link: this.link.name
            };
            module = this.link.bean.module;
            endpoint = 'relatedLeanCount';
        }

        var callbacks = _.pick(options, 'success', 'complete', 'error');
        options = _.omit(options, 'success', 'complete', 'error');
        options = SUGAR.App.data.parseOptionsForSync(method, this, options);

        if (this.link) {
            return SUGAR.App.api[endpoint](module, data, options.params, callbacks, options.apiOptions);
        }
        return SUGAR.App.api[endpoint](method, module, data, options.params, callbacks, options.apiOptions);

    },

    /**
     * Gets the current page of collection being displayed depending on the
     * offset.
     *
     * @param {Object} [options] Fetch options used when paginating.
     * @param {number} [options.limit=App.config.maxQueryResult] The size of
     *   each page.
     * @return {number} The current page number.
     * @memberOf Data/BeanCollection
     * @instance
     */
    getPageNumber: function(options) {
        var pageNumber = 1;
        var maxSize = SUGAR.App.config.maxQueryResult;
        if(options){
            maxSize = options.limit || maxSize;
        }
        if (this.offset && maxSize) {
            pageNumber = Math.ceil(this.offset / maxSize);
        }
        return pageNumber;
    },

    /**
     * Returns string representation useful for debugging.
     *
     * Format:
     * <code>coll:[module-name]-[length]</code>  or
     * <code>coll:[related-module-name]/[id]/[module-name]-[length]</code>
     * if it's a collection of related beans.
     *
     * @return {string} The string representation of this collection.
     * @memberOf Data/BeanCollection
     * @instance
     */
    toString: function() {
        return "coll:" + (this.link ?
            (this.link.bean.module + "/" + this.link.bean.id + "/") : "") +
            this.module + "-" + this.length;
    },

    /**
     * Returns the next model in a collection, paginating if needed.
     *
     * @param {Object} current Current model or id of a model.
     * @param {Object} callback Callback for success call.
     * @memberOf Data/BeanCollection
     * @instance
     */
    getNext: function(current, callback) {
        var ind = -1;
        var nextFn = () => { callback.apply(this, [this.at(ind + 1), 'next']); };

        if (this.hasNextModel(current)) {
            ind = this.getModelIndex(current);
            if (ind + 1 >= this.length) {
                this.paginate({
                    add: true,
                    success: nextFn,
                });
            } else {
                nextFn();
            }
        }
    },

    /**
     * Finds the previous model in a collection and calls a function on it.
     *
     * @param {Object} current Current model or id of a model.
     * @param {Function} callback Callback for success call.
     * @memberOf Data/BeanCollection
     * @instance
     */
    getPrev: function (current, callback) {
        var ind = -1,
            result = null,
            self = this;
        if (this.hasPreviousModel(current)) {
            ind = this.getModelIndex(current);
            result = this.at(ind - 1);
        }
        callback.apply(self, [result, 'prev']);
    },

    /**
     * Checks whether is there next model in collection.
     *
     * @param {Object} current Current model or id of a model.
     * @return {boolean} `true` if has next model, `false` otherwise.
     * @memberOf Data/BeanCollection
     * @instance
     */
    hasNextModel: function(current) {
        var index = this.getModelIndex(current),
            offset = !_.isUndefined(this.next_offset) ? parseInt(this.next_offset, 10) : -1;
        return index >= 0 && ((this.length > index + 1 ) || offset !== -1);
    },

    /**
     * Checks whether is there previous model in this collection.
     *
     * @param {Object} current Current model or id of a model.
     * @return {boolean} `true` if has previous model, `false` otherwise.
     * @memberOf Data/BeanCollection
     * @instance
     */
    hasPreviousModel: function (current) {
        return this.getModelIndex(current) > 0;
    },

    /**
     * Returns the index of the model in this collection.
     *
     * @param {Object} model Current model.
     * @return {number} The index of the passed `model` in this array.
     * @memberOf Data/BeanCollection
     * @instance
     */
    getModelIndex: function (model) {
        return this.indexOf(this.get(model.id));
    },

    /**
     * Sets the default fetch options (one or many) on the model.
     *
     * @param {string|Object} key The name of the attribute, or a hash of
     *   attributes.
     * @param {*} [val] The default value for the `key` argument.
     * @return {Data/BeanCollection} This instance.
     * @memberOf Data/BeanCollection
     * @instance
     */
    setOption: function(key, val) {
        var attrs;
        if (_.isObject(key)) {
            attrs = key;
        } else {
            (attrs = {})[key] = val;
        }

        _.extend(this._persistentOptions, attrs);
        return this;
    },

    /**
     * Unsets a default fetch option (or all).
     *
     * @param {string|Object} [key] The name of the option to unset, or
     *   nothing to unset all options.
     * @return {Data/BeanCollection} This instance.
     * @memberOf Data/BeanCollection
     * @instance
     */
    unsetOption: function(key) {
        if (key) {
            this.setOption(key, void 0);
        } else {
            this._persistentOptions = {};
        }

        return this;
    },

    /**
     * Gets one or all persistent fetch options.
     *
     * @param {string|Object} [key] The name of the option to retrieve, or
     *   nothing to retrieve all options.
     * @return {*} A specific option, or the list of options.
     * @memberOf Data/BeanCollection
     * @instance
     */
    getOption: function(key) {
        if (key) {
            return this._persistentOptions[key];
        }
        return this._persistentOptions;
    },

    /**
     * Clones the collection including the {@link Data/BeanCollection#link} and
     * all the persistent options.
     *
     * @return {Data/BeanCollection} The new collection with an identical
     *   list of models as this one.
     * @memberOf Data/BeanCollection
     * @instance
     */
    clone: function() {
        var newCol = Backbone.Collection.prototype.clone.call(this);
        newCol.link = _.clone(this.link);
        newCol.setOption(_.clone(this.getOption()));
        return newCol;
    }
});

module.exports = BeanCollection;