@converse/skeletor
Version:
Modernized Backbone with web components
886 lines (780 loc) • 25.2 kB
JavaScript
import { getResolveablePromise, getSyncMethod, wrapError } from './helpers.js';
import { Model } from './model.js';
import clone from 'lodash-es/clone.js';
import countBy from 'lodash-es/countBy.js';
import groupBy from 'lodash-es/groupBy.js';
import isFunction from 'lodash-es/isFunction.js';
import isString from 'lodash-es/isString.js';
import keyBy from 'lodash-es/keyBy.js';
import sortBy from 'lodash-es/sortBy.js';
import EventEmitter from './eventemitter.js';
const slice = Array.prototype.slice;
// Default options for `Collection#set`.
const setOptions = { add: true, remove: true, merge: true };
const addOptions = { add: true, remove: false };
/**
* @typedef {Record.<string, any>} Options
* @typedef {Record.<string, any>} Attributes
*
* @typedef {import('./storage.js').default} Storage
*
* @typedef {Record.<string, any>} CollectionOptions
* @property {Model} [model]
* @property {Function} [comparator]
*/
/**
* If models tend to represent a single row of data, a Collection is
* more analogous to a table full of data ... or a small slice or page of that
* table, or a collection of rows that belong together for a particular reason
* -- all of the messages in this particular folder, all of the documents
* belonging to this particular author, and so on. Collections maintain
* indexes of their models, both in order, and for lookup by `id`.
*/
class Collection extends EventEmitter(Object) {
/**
* Create a new **Collection**, perhaps to contain a specific type of `model`.
* If a `comparator` is specified, the Collection will maintain
* its models in sort order, as they're added and removed.
* @param {Model[]} [models]
* @param {CollectionOptions} [options]
*/
constructor(models, options) {
super();
options || (options = {});
this.preinitialize.apply(this, arguments);
if (options.model) this._model = options.model;
if (options.comparator !== undefined) this.comparator = options.comparator;
this._reset();
this.initialize.apply(this, arguments);
if (models) this.reset(models, Object.assign({ silent: true }, options));
this[Symbol.iterator] = this.values;
}
/**
* @param {Storage} storage
*/
set browserStorage(storage) {
this._browserStorage = storage;
}
/**
* @returns {Storage} storage
*/
get browserStorage() {
return this._browserStorage;
}
/**
* The default model for a collection is just a **Model**.
* This should be overridden in most cases.
* @returns {typeof Model}
*/
get model() {
return this._model ?? Model;
}
/**
* @param {Model} model
*/
set model(model) {
this._model = model;
}
get length() {
return this.models.length;
}
/**
* preinitialize is an empty function by default. You can override it with a function
* or object. preinitialize will run before any instantiation logic is run in the Collection.
*/
preinitialize() {}
/**
* Initialize is an empty function by default. Override it with your own
* initialization logic.
*/
initialize() {}
/**
* The JSON representation of a Collection is an array of the
* models' attributes.
*@param {Options} options
*/
toJSON(options) {
return this.map(function (model) {
return model.toJSON(options);
});
}
/**
*@param {string} method
*@param {Model|Collection} model
*@param {Options} options
*/
sync(method, model, options) {
return getSyncMethod(this)(method, model, options);
}
/**
* Add a model, or list of models to the set. `models` may be
* Models or raw JavaScript objects to be converted to Models, or any
* combination of the two.
*@param {Model[]|Model|Attributes|Attributes[]} models
*@param {Options} options
*/
add(models, options) {
return this.set(models, Object.assign({ merge: false }, options, addOptions));
}
/**
* Remove a model, or a list of models from the set.
* @param {Model|Model[]} models
* @param {Options} options
*/
remove(models, options) {
options = Object.assign({}, options);
const singular = !Array.isArray(models);
const modelsArray = singular ? [models] : /** @type {Model[]} */ (models).slice();
const removed = this._removeModels(modelsArray, options);
if (!options.silent && removed.length) {
options.changes = { added: [], merged: [], removed: removed };
this.trigger('update', this, options);
}
return singular ? removed[0] : removed;
}
/**
* Update a collection by `set`-ing a new list of models, adding new ones,
* removing models that are no longer present, and merging models that
* already exist in the collection, as necessary. Similar to **Model#set**,
* the core operation for updating the data contained by the collection.
*@param {Model[]|Model|Attributes|Attributes[]} models
* @param {Options} options
*/
set(models, options) {
if (models == null) return;
options = Object.assign({}, setOptions, options);
if (options.parse && !this._isModel(models)) {
models = this.parse(models, options) || [];
}
const singular = !Array.isArray(models);
models = singular ? [/** @type {Model} */ (models)] : /** @type {Model[]} */ (models).slice();
let at = options.at;
if (at != null) at = +at;
if (at > this.length) at = this.length;
if (at < 0) at += this.length + 1;
const set = [];
const toAdd = [];
const toMerge = [];
const toRemove = [];
const modelMap = {};
const add = options.add;
const merge = options.merge;
const remove = options.remove;
let sort = false;
const sortable = this.comparator && at == null && options.sort !== false;
const sortAttr = isString(this.comparator) ? this.comparator : null;
// Turn bare objects into model references, and prevent invalid models
// from being added.
let model, i;
for (i = 0; i < models.length; i++) {
model = models[i];
// If a duplicate is found, prevent it from being added and
// optionally merge it into the existing model.
const existing = this.get(model);
if (existing) {
if (merge && model !== existing) {
let attrs = this._isModel(model) ? model.attributes : model;
if (options.parse) attrs = existing.parse(attrs, options);
existing.set(attrs, options);
toMerge.push(existing);
if (sortable && !sort) sort = existing.hasChanged(sortAttr);
}
if (!modelMap[existing.cid]) {
modelMap[existing.cid] = true;
set.push(existing);
}
models[i] = existing;
// If this is a new, valid model, push it to the `toAdd` list.
} else if (add) {
model = models[i] = this._prepareModel(model, options);
if (model) {
toAdd.push(model);
this._addReference(model, options);
modelMap[model.cid] = true;
set.push(model);
}
}
}
// Remove stale models.
if (remove) {
for (i = 0; i < this.length; i++) {
model = this.models[i];
if (!modelMap[model.cid]) toRemove.push(model);
}
if (toRemove.length) this._removeModels(toRemove, options);
}
// See if sorting is needed, update `length` and splice in new models.
let orderChanged = false;
const replace = !sortable && add && remove;
if (set.length && replace) {
orderChanged = this.length !== set.length || this.models.some((m, idx) => m !== set[idx]);
this.models.length = 0;
this.models.splice(0, 0, ...set);
} else if (toAdd.length) {
if (sortable) sort = true;
let idx = at == null ? this.length : at;
idx = Math.min(Math.max(idx, 0), this.models.length);
this.models.splice(idx, 0, ...toAdd);
}
// Silently sort the collection if appropriate.
if (sort) this.sort({ silent: true });
// Unless silenced, it's time to fire all appropriate add/sort/update events.
if (!options.silent) {
for (i = 0; i < toAdd.length; i++) {
if (at != null) options.index = at + i;
model = toAdd[i];
model.trigger('add', model, this, options);
}
if (sort || orderChanged) this.trigger('sort', this, options);
if (toAdd.length || toRemove.length || toMerge.length) {
options.changes = {
added: toAdd,
removed: toRemove,
merged: toMerge,
};
this.trigger('update', this, options);
}
}
// Return the added (or merged) model (or models).
return singular ? models[0] : models;
}
async clearStore(options = {}, filter = (o) => o) {
await Promise.all(
this.models.filter(filter).map((m) => {
return new Promise((resolve) => {
m.destroy(
Object.assign(options, {
'success': resolve,
'error': (m, e) => {
console.error(e);
resolve();
},
}),
);
});
}),
);
await this.browserStorage.clear();
this.reset();
}
/**
* When you have more items than you want to add or remove individually,
* you can reset the entire set with a new list of models, without firing
* any granular `add` or `remove` events. Fires `reset` when finished.
* Useful for bulk operations and optimizations.
* @param {Model|Model[]} [models]
* @param {Options} [options]
*/
reset(models, options) {
options = options ? clone(options) : {};
for (let i = 0; i < this.models.length; i++) {
this._removeReference(this.models[i], options);
}
options.previousModels = this.models;
this._reset();
models = this.add(models, Object.assign({ silent: true }, options));
if (!options.silent) this.trigger('reset', this, options);
return models;
}
/**
* Add a model to the end of the collection.
* @param {Model} model
* @param {Options} [options]
*/
push(model, options) {
return this.add(model, Object.assign({ at: this.length }, options));
}
/**
* Remove a model from the end of the collection.
* @param {Options} [options]
*/
pop(options) {
const model = this.at(this.length - 1);
return this.remove(model, options);
}
/**
* Add a model to the beginning of the collection.
* @param {Model} model
* @param {Options} [options]
*/
unshift(model, options) {
return this.add(model, Object.assign({ at: 0 }, options));
}
/**
* Remove a model from the beginning of the collection.
* @param {Options} [options]
*/
shift(options) {
const model = this.at(0);
return this.remove(model, options);
}
/** Slice out a sub-array of models from the collection. */
slice() {
return slice.apply(this.models, arguments);
}
/**
* @param {Function|Object} callback
* @param {any} thisArg
*/
filter(callback, thisArg) {
return this.models.filter(isFunction(callback) ? callback : (m) => m.matches(callback), thisArg);
}
/**
* @param {Function} pred
*/
every(pred) {
if (isFunction(pred)) {
return this.models.map((m) => m.attributes).every(pred);
} else {
return this.models.every((m) => m.matches(pred));
}
}
/**
* @param {Model[]} values
*/
difference(values) {
return this.models.filter((m) => !values.includes(m));
}
max() {
return Math.max.apply(Math, this.models);
}
min() {
return Math.min.apply(Math, this.models);
}
drop(n = 1) {
return this.models.slice(n);
}
/**
* @param {Function|Object} pred
*/
some(pred) {
if (isFunction(pred)) {
return this.models.map((m) => m.attributes).some(pred);
} else {
return this.models.some((m) => m.matches(pred));
}
}
sortBy(iteratee) {
return sortBy(
this.models,
isFunction(iteratee) ? iteratee : (m) => (isString(iteratee) ? m.get(iteratee) : m.matches(iteratee)),
);
}
isEmpty() {
return !this.models.length;
}
keyBy(iteratee) {
return keyBy(this.models, iteratee);
}
each(callback, thisArg) {
return this.forEach(callback, thisArg);
}
forEach(callback, thisArg) {
return this.models.forEach(callback, thisArg);
}
includes(item) {
return this.models.includes(item);
}
size() {
return this.models.length;
}
countBy(f) {
return countBy(this.models, isFunction(f) ? f : (m) => (isString(f) ? m.get(f) : m.matches(f)));
}
groupBy(pred) {
return groupBy(this.models, isFunction(pred) ? pred : (m) => (isString(pred) ? m.get(pred) : m.matches(pred)));
}
/**
* @param {number} fromIndex
*/
indexOf(fromIndex) {
return this.models.indexOf(fromIndex);
}
/**
* @param {Function|string|RegExp} pred
* @param {number} fromIndex
*/
findLastIndex(pred, fromIndex) {
return this.models.findLastIndex(
isFunction(pred) ? pred : (m) => (isString(pred) ? m.get(pred) : m.matches(pred)),
fromIndex,
);
}
/**
* @param {number} fromIndex
*/
lastIndexOf(fromIndex) {
return this.models.lastIndexOf(fromIndex);
}
/**
* @param {Function|string|RegExp} pred
*/
findIndex(pred) {
return this.models.findIndex(isFunction(pred) ? pred : (m) => (isString(pred) ? m.get(pred) : m.matches(pred)));
}
last() {
const length = this.models == null ? 0 : this.models.length;
return length ? this.models[length - 1] : undefined;
}
head() {
return this.models[0];
}
first() {
return this.head();
}
map(cb, thisArg) {
return this.models.map(isFunction(cb) ? cb : (m) => (isString(cb) ? m.get(cb) : m.matches(cb)), thisArg);
}
reduce(callback, initialValue) {
return this.models.reduce(callback, initialValue || this.models[0]);
}
reduceRight(callback, initialValue) {
return this.models.reduceRight(callback, initialValue || this.models[0]);
}
toArray() {
return Array.from(this.models);
}
/**
* Get a model from the set by id, cid, model object with id or cid
* properties, or an attributes object that is transformed through modelId.
* @param {string|number|Object|Model} obj
*/
get(obj) {
if (obj == null) return undefined;
return (
this._byId[obj] ||
this._byId[this.modelId(this._isModel(obj) ? obj.attributes : obj)] ||
(obj.cid && this._byId[obj.cid])
);
}
/**
* Returns `true` if the model is in the collection.
* @param {string|number|Object|Model} obj
*/
has(obj) {
return this.get(obj) != null;
}
/**
* Get the model at the given index.
* @param {number} index
*/
at(index) {
if (index < 0) index += this.length;
return this.models[index];
}
/**
* Return models with matching attributes. Useful for simple cases of
* `filter`.
* @param {Attributes} attrs
* @param {boolean} [first]
*/
where(attrs, first) {
return this[first ? 'find' : 'filter'](attrs);
}
/**
* Return the first model with matching attributes. Useful for simple cases
* of `find`.
* @param {Attributes} attrs
*/
findWhere(attrs) {
return this.where(attrs, true);
}
/**
* @param {Attributes} predicate
* @param {number} [fromIndex]
*/
find(predicate, fromIndex) {
const pred = isFunction(predicate) ? predicate : (m) => m.matches(predicate);
return this.models.find(pred, fromIndex);
}
/**
* Force the collection to re-sort itself. You don't need to call this under
* normal circumstances, as the set will maintain sort order as each item
* is added.
* @param {Options} [options]
*/
sort(options) {
let comparator = this.comparator;
if (!comparator) throw new Error('Cannot sort a set without a comparator');
options || (options = {});
const length = comparator.length;
if (isFunction(comparator)) comparator = comparator.bind(this);
// Run sort based on type of `comparator`.
if (length === 1 || isString(comparator)) {
this.models = this.sortBy(comparator);
} else {
this.models.sort(comparator);
}
if (!options.silent) this.trigger('sort', this, options);
return this;
}
/**
* Pluck an attribute from each model in the collection.
* @param {string} attr
*/
pluck(attr) {
return this.map(attr + '');
}
/**
* Fetch the default set of models for this collection, resetting the
* collection when they arrive. If `reset: true` is passed, the response
* data will be passed through the `reset` method instead of `set`.
* @param {Options} options
*/
fetch(options) {
options = Object.assign({ parse: true }, options);
const success = options.success;
// eslint-disable-next-line @typescript-eslint/no-this-alias
const collection = this;
const promise = options.promise && getResolveablePromise();
options.success = function (resp) {
const method = options.reset ? 'reset' : 'set';
collection[method](resp, options);
if (success) success.call(options.context, collection, resp, options);
promise && promise.resolve();
collection.trigger('sync', collection, resp, options);
};
wrapError(this, options);
return promise ? promise : this.sync('read', this, options);
}
/**
* Create a new instance of a model in this collection. Add the model to the
* collection immediately, unless `wait: true` is passed, in which case we
* wait for the server to agree.
* @param {Model|Attributes} model
* @param {Options} [options]
*/
create(model, options) {
options = options ? clone(options) : {};
const wait = options.wait;
const return_promise = options.promise;
const promise = return_promise && getResolveablePromise();
model = this._prepareModel(model, options);
if (!model) return false;
if (!wait) this.add(model, options);
// eslint-disable-next-line @typescript-eslint/no-this-alias
const collection = this;
const success = options.success;
const error = options.error;
options.success = function (m, resp, callbackOpts) {
if (wait) {
collection.add(m, callbackOpts);
}
if (success) {
success.call(callbackOpts.context, m, resp, callbackOpts);
}
if (return_promise) {
promise.resolve(m);
}
};
options.error = function (model, e, options) {
error && error.call(options.context, model, e, options);
return_promise && promise.reject(e);
};
model.save(null, Object.assign(options, { 'promise': false }));
if (return_promise) {
return promise;
} else {
return model;
}
}
/**
* **parse** converts a response into a list of models to be added to the
* collection. The default implementation is just to pass it through.
* @param {Object} resp
* @param {Options} [options]
*/
parse(resp, options) {
return resp;
}
/**
* Define how to uniquely identify models in the collection.
* @param {Attributes} attrs
*/
modelId(attrs) {
return attrs[this.model.prototype?.idAttribute || 'id'];
}
/** Get an iterator of all models in this collection. */
values() {
return new CollectionIterator(this, ITERATOR_VALUES);
}
/** Get an iterator of all model IDs in this collection. */
keys() {
return new CollectionIterator(this, ITERATOR_KEYS);
}
/** Get an iterator of all [ID, model] tuples in this collection. */
entries() {
return new CollectionIterator(this, ITERATOR_KEYSVALUES);
}
/**
* Private method to reset all internal state. Called when the collection
* is first initialized or reset.
*/
_reset() {
this.models = [];
this._byId = {};
}
/**
* @param {Attributes} attrs
* @param {Options} [options]
*/
createModel(attrs, options) {
const Klass = this.model;
return new Klass(attrs, options);
}
/**
* Prepare a hash of attributes (or other model) to be added to this
* collection.
* @param {Attributes|Model} attrs
* @param {Options} [options]
* @return {Model}
*/
_prepareModel(attrs, options) {
if (this._isModel(attrs)) {
if (!attrs.collection) attrs.collection = this;
return /** @type {Model} */ (attrs);
}
options = options ? clone(options) : {};
options.collection = this;
const model = this.createModel(attrs, options);
if (!model.validationError) return model;
this.trigger('invalid', this, model.validationError, options);
return null;
}
/**
* Internal method called by both remove and set.
* @param {Model[]} models
* @param {Options} [options]
*/
_removeModels(models, options) {
const removed = [];
for (let i = 0; i < models.length; i++) {
const model = this.get(models[i]);
if (!model) continue;
const index = this.indexOf(model);
this.models.splice(index, 1);
// Remove references before triggering 'remove' event to prevent an
// infinite loop. #3693
delete this._byId[model.cid];
const id = this.modelId(model.attributes);
if (id != null) delete this._byId[id];
if (!options.silent) {
options.index = index;
model.trigger('remove', model, this, options);
}
removed.push(model);
this._removeReference(model, options);
}
return removed;
}
/**
* Method for checking whether an object should be considered a model for
* the purposes of adding to the collection.
* @param {any} model
*/
_isModel(model) {
return model instanceof Model;
}
/**
* Internal method to create a model's ties to a collection.
* @param {Model} model
* @param {Options} [options]
*/
_addReference(model, options) {
this._byId[model.cid] = model;
const id = this.modelId(model.attributes);
if (id != null) this._byId[id] = model;
model.on('all', this._onModelEvent, this);
}
/**
* Internal method to sever a model's ties to a collection.
* @private
* @param {Model} model
* @param {Options} [options]
*/
_removeReference(model, options) {
delete this._byId[model.cid];
const id = this.modelId(model.attributes);
if (id != null) delete this._byId[id];
if (this === model.collection) delete model.collection;
model.off('all', this._onModelEvent, this);
}
/**
* Internal method called every time a model in the set fires an event.
* Sets need to update their indexes when models change ids. All other
* events simply proxy through. "add" and "remove" events that originate
* in other collections are ignored.
* @private
* @param {any} event
* @param {Model} model
* @param {Collection} collection
* @param {Options} [options]
*/
_onModelEvent(event, model, collection, options) {
if (model) {
if ((event === 'add' || event === 'remove') && collection !== this) return;
if (event === 'destroy') this.remove(model, options);
if (event === 'change') {
const prevId = this.modelId(model.previousAttributes());
const id = this.modelId(model.attributes);
if (prevId !== id) {
if (prevId != null) delete this._byId[prevId];
if (id != null) this._byId[id] = model;
}
}
}
this.trigger.apply(this, arguments);
}
}
// This "enum" defines the three possible kinds of values which can be emitted
// by a CollectionIterator that correspond to the values(), keys() and entries()
// methods on Collection, respectively.
const ITERATOR_VALUES = 1;
const ITERATOR_KEYS = 2;
const ITERATOR_KEYSVALUES = 3;
class CollectionIterator {
/**
* A CollectionIterator implements JavaScript's Iterator protocol, allowing the
* use of `for of` loops in modern browsers and interoperation between
* Collection and other JavaScript functions and third-party libraries
* which can operate on Iterables.
* @param {Collection} collection
* @param {Number} kind
*/
constructor(collection, kind) {
this._collection = collection;
this._kind = kind;
this._index = 0;
}
next() {
if (this._collection) {
// Only continue iterating if the iterated collection is long enough.
if (this._index < this._collection.length) {
const model = this._collection.at(this._index);
this._index++;
// Construct a value depending on what kind of values should be iterated.
let value;
if (this._kind === ITERATOR_VALUES) {
value = model;
} else {
const id = this._collection.modelId(model.attributes);
if (this._kind === ITERATOR_KEYS) {
value = id;
} else {
// ITERATOR_KEYSVALUES
value = [id, model];
}
}
return { value: value, done: false };
}
// Once exhausted, remove the reference to the collection so future
// calls to the next method always return done.
this._collection = undefined;
}
return { value: undefined, done: true };
}
[Symbol.iterator]() {
return this;
}
}
export { Collection };