Home Reference Source

src/data/PrioritisedArray.js

/**



 @author: Tom Clement (tjclement)
 @license NPOSL-3.0
 @copyright Bizboard, 2015

 */

import extend                       from 'lodash/extend.js';
import EventEmitter                 from 'eventemitter3';
import {Injection}                  from '../utils/Injection.js';
import {ObjectHelper}               from '../utils/ObjectHelper.js';
import {DataSource}                 from './DataSource.js';
import {Throttler}                  from '../utils/Throttler.js';

/**
 * An array of two-way bound data Models that are automatically synced with the currently used DataSource
 */
export class PrioritisedArray extends Array {

    /**
     * The number of items in the (synchronized or local) data set.
     * @returns {Number}
     */
    get length() {
        /* Extending Array does not work fluently yet. The length property always returns 0,
         * regardless of how many entries are in the array. We'll override the length prop to determine
         * the amount of enumerable properties in our PrioritisedArray instead of using the built-in length property.
         */
        return Object.keys(this).length;
    }

    /**
     * A setter on the length is necessary because internal methods of Array modify the lngth. It won't change the length thoough
     * @param {Number} value
     * @returns {*}
     */
    set length(value) {
        return value;
    }

    /**
     *
     * @param {Function} dataType DataType of the models being added to the PrioritisedArray.
     * @param {DataSource} [dataSource] dataSource to load the models from. If none is given, a new DataSource is made with a path guessed from
     * the model's DataType name.
     * @param {Snapshot} [dataSnapshot] snapshot already containing model data. Prevents initial subscription on all values in the DataSource.
     * @param {Object} [options] options to pass to the dataSource if none is provided and a new one is constructed.
     * @param {Object} [modelOptions] options to merge into the construction of every new Model.
     * @returns {PrioritisedArray} PrioritisedArray instance.
     */
    constructor(dataType, dataSource = null, dataSnapshot = null, options = null, modelOptions = {}) {
        super();
        /**** Callbacks ****/
        this._valueChangedCallback = null;
        this._ids = {};

        /**** Private properties ****/
        this._dataType = dataType;
        this._dataSource = dataSource;
        this._isBeingReordered = false;
        this._modelOptions = modelOptions;
        /* Flag to determine when we're reordering so we don't listen to move updates */
        this._eventEmitter = new EventEmitter();
        this._childAddedThrottler = new Throttler(1, true, this, true);
        this._overrideChildAddedForId = null;

        /* Bind all local methods to the current object instance, so we can refer to "this"
         * in the methods as expected, even when they're called from event handlers.        */
        ObjectHelper.bindAllMethods(this, this);

        /* Hide all private properties (starting with '_') and methods from enumeration,
         * so when you do for( in ), only actual data properties show up. */
        ObjectHelper.hideMethodsAndPrivatePropertiesFromObject(this);

        /* Hide the priority field from enumeration, so we don't save it to the dataSource. */
        ObjectHelper.hidePropertyFromObject(Object.getPrototypeOf(this), 'length');

        /* If no dataSource is given, create own one with guessed path */
        if (!dataSource) {
            /* The this._name property can be set by Arva's babel-plugin-transform-runtime-constructor-name plugin.
             * This allows Arva code to be minified and mangled without losing automated model name resolving.
             * If the plugin is not set up to run, which is done e.g. when not minifying your code, we default back to the runtime constructor name. */
            let path = this.constructor._name || Object.getPrototypeOf(this).constructor.name;
            /* Retrieve dataSource from the DI context */
            dataSource = Injection.get(DataSource);

            if (options) {
                dataSource = dataSource.child(options.path || path, options);
            } else {
                dataSource = dataSource.child(path);
            }

            this._dataSource = dataSource;
        }

        /* If a snapshot is present use it, otherwise generate one by subscribing to the dataSource one time. */
        if (dataSnapshot) {
            this._buildFromSnapshot(dataSnapshot);
        } else {
            this._buildFromDataSource(dataSource);
        }
    }

    /**
     * Subscribes to events emitted by this PrioritisedArray.
     * @param {String} event One of the following Event Types: 'value', 'child_changed', 'child_moved', 'child_removed'.
     * @param {Function} handler Function that is called when the given event type is emitted.
     * @param {Object} context Optional: context of 'this' inside the handler function when it is called.
     * @returns {void}
     */
    on(event, handler, context) {
        /* If we're already ready, fire immediately */
        if ((event === 'ready' || event === 'value') && this._dataSource && this._dataSource.ready) {
            handler.call(context, this);
        }

        /* If we already have children stored locally when the subscriber calls this method,
         * fire their callback for all pre-existing children. */
        if (event === 'child_added') {

            for (let i = 0; i < this.length; i++) {
                this._childAddedThrottler.add(() => {
                    let model = this[i];
                    let previousSiblingID = i > 0 ? this[i - 1].id : null;
                    handler.call(context, model, previousSiblingID);
                });
            }
        }

        this._eventEmitter.on(event, handler, context);
    }

    /**
     * Subscribes to the given event type exactly once; it automatically unsubscribes after the first time it is triggered.
     * @param {String} event One of the following Event Types: 'value', 'child_changed', 'child_moved', 'child_removed'.
     * @param {Function} [handler] Function that is called when the given event type is emitted.
     * @param {Object} [context] context of 'this' inside the handler function when it is called.
     * @returns {Promise} If no callback function provided, a promise that resolves once the event has happened
     */
    once(event, handler, context = this) {
        if (!handler) {
            return new Promise((resolve) => this.once(event, resolve, context));
        }
        return this.on(event, function onceWrapper() {
            this.off(event, onceWrapper, context);
            handler.call(context, ...arguments);
        }, this);
    }

    /**
     * Removes subscription to events emitted by this PrioritisedArray. If no handler or context is given, all handlers for
     * the given event are removed. If no parameters are given at all, all event types will have their handlers removed.
     * @param {String} event One of the following Event Types: 'value', 'child_changed', 'child_moved', 'child_removed'.
     * @param {Function} handler Function to remove from event callbacks.
     * @param {Object} context Object to bind the given callback function to.
     * @returns {void}
     */
    off(event, handler, context) {
        if (event && (handler || context)) {
            this._eventEmitter.removeListener(event, handler, context);
        } else {
            this._eventEmitter.removeAllListeners(event);
        }
    }

    /**
     * Adds a model instance to the rear of the PrioritisedArray, and emits a 'child_added' and possibly 'new_child' event after successful addition.
     * @param {Model|Object} model Instance of a Model.
     * @param {String} prevSiblingId ID of the model preceding the one that will be added.
     * @returns {Object} Same model as the one originally passed as parameter.
     */
    add(model, prevSiblingId = null) {
        if (model instanceof this._dataType) {
            if (this.findIndexById(model.id) < 0) {

                if (prevSiblingId) {
                    let newPosition = this.findIndexById(prevSiblingId) + 1;
                    this.insertAt(model, newPosition);
                } else {
                    this.push(model);
                }

                /* If we've already received an on('value') result, this child addition is
                 * a new entry that wasn't on the dataSource before. */
                if (this._dataSource.ready) {
                    this._eventEmitter.emit('new_child', model, prevSiblingId);
                }

                this._eventEmitter.emit('child_added', model, prevSiblingId);
                return model;
            }
        } else if (model instanceof Object) {
            /* Let's try to parse the object using property reflection */
            var options = {dataSource: this._dataSource};
            /* Prevent child_added from being fired immediately when the model is created by creating a promise that resolves
             * the ID that shouldn't be synced twice
             */

            this._overrideChildAddedForId = this.once('local_child_added');
            let newModel = new this._dataType(null, model, extend({}, this._modelOptions, options));

            this.add(newModel);
            /* Remove lock */
            this._eventEmitter.emit('local_child_added', newModel);
            this._overrideChildAddedForId = null;
            return newModel;
        } else {
            /* TODO: change to throw exception */
            console.log('Tried to append an object that is not the same type as the one this PrioritisedArray was created with.');
        }

        /* Return model so we can do this: let newModel = PrioArray.add(new Model()); newModel.someProperty = true; */
        return null;
    }


    /**
     * Inserts a model instance at the given position of the PrioritisedArray, and recalculates the priority (position)
     * of all models after the inserted position.
     * @param {Model} model Subclass of Model
     * @param {Number} position Zero-based index where to put the new model instance.
     * @returns {Object} Same model as the one originally passed as parameter.
     */
    insertAt(model, position) {
        if (model instanceof this._dataType) {
            for (let i = position; i < this.length; i++) {
                /* Increase the index of items further on in the prio array */
                this._ids[this[i].id]++;
            }
            this.splice(position, 0, model);
            this._ids[model._id] = position;
        } else {
            /* TODO: change to throw exception */
            console.log('Tried to append an object that is not the same type as the PrioritisedArray was created with.');
        }

        /* Return model so we can do this: let newModel = PrioArray.add(new Model()); newModel.someProperty = true; */
        return model;
    }

    /**
     * Adds a model or object to the end of the list.
     * @param {Object|Model} model
     * @returns {Model} The newly inserted model
     */
    push(model){
        return this.insertAt(model, this.length);
    }

    /**
     * Removes the model instance at the given position. Does not remove the model from the datasource, to do that
     * call model.remove() directly, or PrioArray[index].remove().
     * @param {Number} position Index in the PrioritisedArray of the model to remove.
     * @returns {void}
     */
    remove(position) {
        /*
         * TODO: Beware, there might be hard to reproduce prone to errors going on sometimes when deleting many things at once
         * Sometimes, there is an inconsistent state, but I haven't been able to figure out how that happens. /Karl
         */
        if(this.length === 1){
            this._ids = {};
        } else {
            for (let i = position; i < this.length; i++) {
                /* Decrease the index of items further on in the prio array */
                if(!this._ids[this[i].id] && this._ids[this[i].id] !== 0){
                    console.log("Internal error, decreasing index of non-existing id. For ID: " + this[i].id);
                }
                this._ids[this[i].id]--;
            }
        }
        this.splice(position, 1);
    }



    /**
     * Return the position of model's id, saved in an associative array
     * @param {Number} id Id field of the model we're looking for
     * @returns {Number} Zero-based index if found, -1 otherwise
     * @private
     */
    findIndexById(id) {
        let position = this._ids[id];
        return (position == undefined || position == null) ? -1 : position;
    }


    /**
     * Finds an item based on its Id in the datasource.
     * @param id
     * @returns {Model}
     */
    findById(id) {
        return this[this.findIndexById(id)];
    }


    /**
     * Interprets all childs of a given snapshot as instances of the given data type for this PrioritisedArray,
     * and attempts to instantiate new model instances based on these sub-snapshots. It adds them to the
     * PrioritisedArray, which also assigns their priority based on their inserted position.
     * @param {Snapshot} dataSnapshot Snapshot to build the PrioritisedArray from.
     * @returns {void}
     * @private
     */
    _buildFromSnapshot(dataSnapshot) {

        let numChildren = dataSnapshot.numChildren(), currentChild = 1;

        /* If there is no data at this point yet, fire a ready event */
        if (numChildren === 0) {
            this._dataSource.ready = true;
            this._eventEmitter.emit('ready');
            this._eventEmitter.emit('value', this);
        }

        dataSnapshot.forEach(function (child) {
            this._childAddedThrottler.add(function (child) {
                /* Create a new instance of the given data type and prefill it with the snapshot data. */
                let options = {dataSnapshot: child};
                let childRef = this._dataSource.child(child.key);

                /* whenever the ref() is a datasource, we can bind that source to the model.
                 * whenever it's not a datasource, we assume the model should instantiate a new
                 * datasource to bind the model */

                if (childRef instanceof DataSource) {
                    options.dataSource = childRef;
                } else {
                    var rootPath = dataSnapshot.ref().root().toString();
                    options.path = dataSnapshot.ref().toString().replace(rootPath, '/');
                }

                let newModel = new this._dataType(child.key, child.val(), extend({}, this._modelOptions, options));
                this.add(newModel);

                /* If this is the last child, fire a ready event */
                if (currentChild++ === numChildren) {
                    this._dataSource.ready = true;
                    this._eventEmitter.emit('ready');
                    this._eventEmitter.emit('value', this);
                }

            }.bind(this, child));
        }.bind(this))
    }

    /**
     * Clones a dataSource (to not disturb any existing callbacks defined on the original) and uses it
     * to get a dataSnapshot which is used in _buildSnapshot to build our array.
     * @param {DataSource} dataSource DataSource to subscribe to for building the PrioritisedArray.
     * @returns {void}
     * @private
     */
    _buildFromDataSource(dataSource) {
        dataSource.once('value', (dataSnapshot) => {
            this._buildFromSnapshot(dataSnapshot);
            this._registerCallbacks(dataSource);
        });
    }

    /**
     * Registers the added, moved, changed, and removed callbacks to the given DataSource.
     * @param {DataSource} dataSource DataSource to register callbacks on.
     * @return {void}
     * @private
     */
    _registerCallbacks(dataSource) {
        dataSource.on('child_added', this._doOnceReady(this._onChildAdded));
        dataSource.on('child_moved', this._doOnceReady(this._onChildMoved));
        dataSource.on('child_changed', this._doOnceReady(this._onChildChanged));
        dataSource.on('child_removed', this._doOnceReady(this._onChildRemoved));
    }

    _doOnceReady(callback) {
        return (...otherArgs) => {
            if (!this._dataSource.ready) {
                this.once('ready', () => {
                    return callback(...otherArgs)
                });
            } else {
                return callback(...otherArgs)
            }
        }
    }

    /**
     * Called by dataSource when a new child is added.
     * @param {Snapshot} snapshot Snapshot of the added child.
     * @param {String} prevSiblingId ID of the model preceding the added model.
     * @returns {void}
     * @private
     */
    _onChildAdded(snapshot, prevSiblingId) {
        let id = snapshot.key;
        if (this._overrideChildAddedForId) {
            this._overrideChildAddedForId.then((newModel) => {
                /* If the override is concerning another id, then go ahead and make the _onChildAdded */
                if (newModel.id !== id) {
                    this._onChildAdded(snapshot, prevSiblingId)
                }
                /* Otherwise, don't recreate the same model twice */
            });
            return;
        }

        /* Skip addition if an item with identical ID already exists. */
        let previousPosition = this.findIndexById(id);
        if (previousPosition >= 0) {
            return;
        }

        let model = new this._dataType(id, null, extend({}, this._modelOptions, {
            dataSnapshot: snapshot
        }));
        this.add(model, prevSiblingId);

        if (!this._dataSource.ready) {
            this._dataSource.ready = true;
            this._eventEmitter.emit('ready');
        }
        this._eventEmitter.emit('value', this);
    }

    /**
     * Called by dataSource when a child is changed.
     * @param {Snapshot} snapshot Snapshot of the added child.
     * @param {String} prevSiblingId ID of the model preceding the added model.
     * @returns {void}
     * @private
     */
    _onChildChanged(snapshot, prevSiblingId) {
        let id = snapshot.key;

        let previousPosition = this.findIndexById(id);
        if (previousPosition < 0) {
            /* The model doesn't exist, so we won't emit a changed event. */
            return;
        }

        let model = this[previousPosition];
        model._onChildValue(snapshot, prevSiblingId);
        let newPosition = this.findIndexById(prevSiblingId) + 1;

        this._moveItem(previousPosition, newPosition, model);

        this._eventEmitter.emit('child_changed', model, prevSiblingId);
        this._eventEmitter.emit('value', this);
    }

    /**
     * Called by dataSource when a child is moved, which changes its priority.
     * @param {Snapshot} snapshot Snapshot of the added child.
     * @param {String} prevSiblingId ID of the model preceding the added model.
     * @returns {void}
     * @private
     */
    _onChildMoved(snapshot, prevSiblingId) {
        /* Ignore priority updates whilst we're reordering to avoid floods */
        if (!this._isBeingReordered) {

            let id = snapshot.key;
            let previousPosition = this.findIndexById(id);
            let newPosition = this.findIndexById(prevSiblingId) + 1;
            let tempModel = this[previousPosition];
            this._moveItem(previousPosition, newPosition, tempModel);

            let model = this[newPosition];

            this._eventEmitter.emit('child_moved', model, previousPosition);
            this._eventEmitter.emit('value', this);
        }
    }

    _moveItem(previousPosition, newPosition, modelToMove) {
        this._ids[modelToMove._id] = newPosition;
        /* Update the positions of things coming inbetween */
        for (let positionAhead = previousPosition; positionAhead < newPosition; positionAhead++) {
            this._ids[this[positionAhead].id]--;
        }
        for (let positionBefore = newPosition; positionBefore < previousPosition; positionBefore++) {
            this._ids[this[positionBefore].id]++;
        }

        if (previousPosition === newPosition) {
            this[newPosition] = modelToMove;
        } else {
            this.splice(previousPosition, 1);
            this.splice(newPosition, 0, modelToMove);
        }
    }
    /**
     * Called by dataSource when a child is removed.
     * @param {Snapshot} oldSnapshot Snapshot of the added child.
     * @returns {void}
     * @private
     */
    _onChildRemoved(oldSnapshot) {
        /* TODO: figure out if we can use the snapshot's priority as our array index reliably, to avoid big loops. */
        let id = oldSnapshot.key;
        let position = this.findIndexById(id);
        let model = this[position];

        if (position !== -1) {
            this.remove(position);
            delete this._ids[id];

            this._eventEmitter.emit('child_removed', model);
            this._eventEmitter.emit('value', this);
        }
    }

}