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);
}
}
}