Home Reference Source

src/data/PrioritisedObject.js

/**



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

 */

import isEqual          from 'lodash/isEqual.js';
import EventEmitter     from 'eventemitter3';
import {ObjectHelper}   from '../utils/ObjectHelper.js';

export class PrioritisedObject extends EventEmitter {

    get id() {
        return this._id;
    }

    set id(value) {
        this._id = value;
    }

    /** Priority (positioning) of the object in the dataSource */
    get priority() {
        return this._priority;
    }

    get dataSource() {
        return this._dataSource;
    }

    set priority(value) {
        if (this._priority !== value) {
            this._priority = value;
            this._dataSource.setPriority(value);
        }
    }

    /* TODO: refactor out after we've resolved SharepointDataSource specific issue. */
    get _inheritable() {
        return this._dataSource ? this._dataSource.inheritable : false;
    }

    /**
     * @param {DataSource} dataSource DataSource to construct this PrioritisedObject with.
     * @param {Snapshot} dataSnapshot Optional: dataSnapshot already containing model data, so we can skip subscribing to the full data on the dataSource.
     * @returns {PrioritisedObject} PrioritisedObject instance.
     */
    constructor(dataSource, dataSnapshot = null) {
        super();

        /**** Callbacks ****/
        this._valueChangedCallback = null;

        /**** Private properties ****/
        this._id = dataSource ? dataSource.key() : 0;
        this._events = this._events || [];
        this._dataSource = dataSource;
        this._priority = 0; // Priority of this object on remote dataSource
        this._isBeingWrittenByDatasource = false; // Flag to determine when dataSource is updating object

        /* 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 id field from enumeration, so we don't save it to the dataSource. */
        ObjectHelper.hidePropertyFromObject(this, 'id');

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

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

        if (dataSnapshot) {
            this._buildFromSnapshot(dataSnapshot);
        } else {
            this._buildFromDataSource(dataSource);
        }
    }

    /**
     *  Deletes the current object from the dataSource, and clears itself to free memory.
     *  @returns {void}
     */
    remove() {
        this.off();
        this._dataSource.remove(this);
        delete this;
    }

    /**
     * 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 Optional: context of 'this' inside the handler function when it is called.
     * @returns {void}
     */
    once(event, handler, context = this) {
        return this.on(event, function onceWrapper() {
            handler.call(context, ...arguments);
            this.off(event, onceWrapper, context);
        }, this);
    }

    /**
     * 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 = this) {
        let haveListeners = this.listeners(event, true);
        super.on(event, handler, context);

        switch (event) {
            case 'ready':
                /* If we're already ready, fire immediately */
                if (this._dataSource && this._dataSource.ready) {
                    handler.call(context, this);
                }
                break;
            case 'value':
                if (!haveListeners) {
                    /* Only subscribe to the dataSource if there are no previous listeners for this event type. */
                    this._dataSource.setValueChangedCallback(this._onChildValue);
                } else {
                    if (this._dataSource.ready) {
                        /* If there are previous listeners, fire the value callback once to present the subscriber with inital data. */
                        handler.call(context, this);
                    }
                }
                break;
            case 'added':
                if (!haveListeners) {
                    this._dataSource.setChildAddedCallback(this._onChildAdded);
                }
                break;
            case 'moved':
                if (!haveListeners) {
                    this._dataSource.setChildMovedCallback(this._onChildMoved);
                }
                break;
            case 'removed':
                if (!haveListeners) {
                    this._dataSource.setChildRemovedCallback(this._onChildRemoved);
                }
                break;
            default:
                break;
        }
    }

    /**
     * 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)) {
            super.removeListener(event, handler, context);
        } else {
            super.removeAllListeners(event);
        }

        /* If we have no more listeners of this event type, remove dataSource callback. */
        if (!this.listeners(event, true)) {
            switch (event) {
                case 'ready':
                    break;
                case 'value':
                    this._dataSource.removeValueChangedCallback();
                    break;
                case 'added':
                    this._dataSource.removeChildAddedCallback();
                    break;
                case 'moved':
                    this._dataSource.removeChildMovedCallback();
                    break;
                case 'removed':
                    this._dataSource.removeChildRemovedCallback();
                    break;
                default:
                    break;
            }
        }
    }

    /**
     * Allows multiple modifications to be made to the model without triggering dataSource pushes and event emits for each change.
     * Triggers a push to the dataSource after executing the given method. This push should then emit an event notifying subscribers of any changes.
     * @param {Function} method Function in which the model can be modified.
     * @returns {void}
     */
    transaction(method) {
        this.disableChangeListener();
        method();
        this.enableChangeListener();
        return this._onSetterTriggered();
    }

    /**
     * Disables pushes of local changes to the dataSource, and stops event emits that refer to the model's data.
     * @returns {void}
     */
    disableChangeListener() {
        this._isBeingWrittenByDatasource = true;
    }


    /**
     * Enables pushes of local changes to the dataSource, and enables event emits that refer to the model's data.
     * The change listener is active by default, so you'll only need to call this method if you've previously called disableChangeListener().
     * @returns {void}
     */
    enableChangeListener() {
        this._isBeingWrittenByDatasource = false;
    }

    /**
     * Recursively builds getter/setter based properties on current PrioritisedObject from
     * a given dataSnapshot. If an object value is detected, the object itself gets built as
     * another PrioritisedObject and set to the current PrioritisedObject as a property.
     * @param {Snapshot} dataSnapshot DataSnapshot to build the PrioritisedObject from.
     * @returns {void}
     * @private
     */
    _buildFromSnapshot(dataSnapshot) {

        /* Set root object _priority */
        this._priority = dataSnapshot.getPriority();
        let data = dataSnapshot.val();
        let numChildren = dataSnapshot.numChildren();

        if (!this._id) {
            this._id = dataSnapshot.key;
        }

        if (!this._dataSource) {
            this._dataSource = dataSnapshot.ref;
        }

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

        for (let key in data) {
            /* Only map properties that exists on our model */
            let ownPropertyDescriptor = Object.getOwnPropertyDescriptor(this, key);
            if (ownPropertyDescriptor && ownPropertyDescriptor.enumerable) {
                /* If child is a primitive, listen to changes so we can synch with Firebase */
                ObjectHelper.addPropertyToObject(this, key, data[key], true, true, this._onSetterTriggered);
            }

        }

        this._dataSource.ready = true;
        this.emit('ready');
    }

    /**
     * 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 object.
     * @param {DataSource} dataSource DataSource to build the PrioritisedObject from.
     * @returns {void}
     * @private
     */
    _buildFromDataSource(dataSource) {
        if (!dataSource) {
            return;
        }
        dataSource.once('value', this._buildFromSnapshot);
    }

    /**
     * Gets called whenever a property value is set on this object.
     * This can happen when local code modifies it, or when the dataSource updates it.
     * We only propagate changes to the dataSource if the change was local.
     * @returns {void}
     * @private
     */
    _onSetterTriggered() {
        if (!this._isBeingWrittenByDatasource) {
            return this._dataSource.setWithPriority(ObjectHelper.getEnumerableProperties(this), this._priority);
        }
    }

    /**
     * Gets called whenever the current PrioritisedObject is changed by the dataSource.
     * @param {Snapshot} dataSnapshot Snapshot of the new object value.
     * @param {String} previousSiblingID ID of the model preceding the current one.
     * @returns {void}
     * @private
     */
    _onChildValue(dataSnapshot, previousSiblingID) {

        /* If the new dataSource data is equal to what we have locally,
         * this is an update triggered by a local change having been pushed
         * to the remote dataSource. We can ignore it.
         */
        if (isEqual(ObjectHelper.getEnumerableProperties(this), dataSnapshot.val())) {
            this.emit('value', this, previousSiblingID);
            return;
        }

        /* Make sure we don't trigger pushes to dataSource whilst repopulating with new dataSource data */
        this._isBeingWrittenByDatasource = true;
        this._buildFromSnapshot(dataSnapshot);
        this._isBeingWrittenByDatasource = false;

        this.emit('value', this, previousSiblingID);
    }

    /* TODO: implement partial updates of model */
    _onChildAdded(dataSnapshot, previousSiblingID) {
        this.emit('added', this, previousSiblingID);
    }

    _onChildMoved(dataSnapshot, previousSiblingID) {
        this.emit('moved', this, previousSiblingID);
    }

    _onChildRemoved(dataSnapshot, previousSiblingID) {
        this.emit('removed', this, previousSiblingID);
    }
}