UNPKG

@converse/skeletor

Version:

Models and Collections for modern web apps

850 lines (736 loc) 27.3 kB
import clone from 'lodash-es/clone'; import countBy from 'lodash-es/countBy'; import groupBy from 'lodash-es/groupBy'; import isFunction from 'lodash-es/isFunction'; import isString from 'lodash-es/isString'; import keyBy from 'lodash-es/keyBy'; import sortBy from 'lodash-es/sortBy'; import {EventEmitterObject} from './eventemitter'; import type Storage from './storage'; import {getResolveablePromise, getSyncMethod, wrapError} from './helpers'; import {Model} from './model'; import { CollectionOptions, Comparator, FetchOrCreateOptions, ModelAttributes, ModelOptions, ObjectWithId, Options, SyncOperation, } from './types'; // Default options for `Collection#set`. const setOptions = {add: true, remove: true, merge: true}; const addOptions = {add: true, remove: false}; /** * @public * 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`. */ export class Collection<T extends Model = Model> extends EventEmitterObject { [key: symbol]: () => CollectionIterator<T>; _browserStorage?: Storage; _comparator?: Comparator<T>; _url: string = ''; models: T[]; protected _byId: Record<string, T>; protected _model?: new (attributes?: Partial<ModelAttributes>, options?: ModelOptions) => T; /** * 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. */ constructor(models?: T[] | ModelAttributes[] | T | ModelAttributes, options?: CollectionOptions<T>) { super(); options = options || {}; this.preinitialize.apply(this, arguments as any); if (options.model) this._model = options.model; if (options.comparator !== undefined) this.comparator = options.comparator; this._reset(); this.initialize.apply(this, arguments as any); if (models) this.reset(models, Object.assign({silent: true}, options)); this[Symbol.iterator] = this.values; } get comparator(): Comparator<T> { return this._comparator; } set comparator(c: Comparator<T>) { this._comparator = c; } set browserStorage(storage: Storage) { this._browserStorage = storage; } get browserStorage(): Storage | undefined { return this._browserStorage; } /** * The default model for a collection is just a **Model**. * This should be overridden in most cases. */ get model(): new (attributes?: Partial<ModelAttributes>, options?: ModelOptions) => T | Model { return this._model ?? Model; } set model(model: new (attributes?: Partial<ModelAttributes>, options?: ModelOptions) => T) { this._model = model; } get length(): number { return this.models.length; } get url(): string { return this._url; } set url(url: string) { this._url = url; } /** * 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(..._args: any[]): void {} /** * Initialize is an empty function by default. Override it with your own * initialization logic. */ initialize(..._args: any[]): void {} /** * The JSON representation of a Collection is an array of the * models' attributes. */ toJSON(): any[] { return this.map(function (model) { return model.toJSON(); }); } sync(method: SyncOperation, model: Model | Collection<any>, options?: Options): any { 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. */ add(models: T[] | T | ModelAttributes | ModelAttributes[], options?: Options): T | T[] { return this.set(models, Object.assign({merge: false}, options, addOptions)); } /** * Remove a model, or a list of models from the set. */ remove(models: T | ObjectWithId | (T | ObjectWithId)[], options?: Options): T | T[] { options = Object.assign({}, options); const singular = !Array.isArray(models); const modelsArray = singular ? [models] : (models as T[]).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. */ set(models: T[] | T | ModelAttributes | ModelAttributes[], options?: Options): T | T[] { 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 ? [models] : (models as T[] | ModelAttributes[]).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: T, i: number; 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) as Partial<ModelAttributes>; existing.set(attrs, options); toMerge.push(existing); if (sortable && !sort) sort = existing.hasChanged(sortAttr as string); } 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 as T); } async clearStore(options: Options = {}, filter: (model: T) => boolean = () => true): Promise<void> { await Promise.all( this.models.filter(filter).map((m) => { return new Promise<void>((resolve) => { m.destroy( Object.assign(options, { 'success': resolve, 'error': (_m: T, e: any) => { 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. */ reset(models?: T[] | T | ModelAttributes | ModelAttributes[], options?: Options): T | T[] { 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 as T | T[]; } /** * Add a model to the end of the collection. */ push(model: T | ModelAttributes, options?: Options): T { return this.add(model, Object.assign({at: this.length}, options)) as T; } /** * Remove a model from the end of the collection. */ pop(options?: Options): T | undefined { const model = this.at(this.length - 1); return this.remove(model, options) as T | undefined; } /** * Add a model to the beginning of the collection. */ unshift(model: T | ModelAttributes, options?: Options): T { return this.add(model, Object.assign({at: 0}, options)) as T; } /** * Remove a model from the beginning of the collection. */ shift(options?: Options): T | undefined { const model = this.at(0); return this.remove(model, options) as T | undefined; } /** Slice out a sub-array of models from the collection. */ slice(start?: number, end?: number): T[] { return this.models.slice(start, end); } filter(callback: ((model: T) => boolean) | string | Partial<ModelAttributes>, thisArg?: any): T[] { return this.models.filter( isFunction(callback) ? (callback as (model: T) => boolean) : (m) => m.matches(callback as Partial<ModelAttributes>), thisArg ); } every(pred: ((attrs: ModelAttributes) => boolean) | Options): boolean { if (isFunction(pred)) { return this.models.map((m) => m.attributes).every(pred as (attrs: ModelAttributes) => boolean); } else { return this.models.every((m) => m.matches(pred)); } } difference(values: T[]): T[] { return this.models.filter((m) => !values.includes(m)); } max(): number { return Math.max.apply(Math, this.models as any); } min(): number { return Math.min.apply(Math, this.models as any); } drop(n: number = 1): T[] { return this.models.slice(n); } some(pred: ((attrs: ModelAttributes) => boolean) | Options): boolean { if (isFunction(pred)) { return this.models.map((m) => m.attributes).some(pred as (attrs: ModelAttributes) => boolean); } else { return this.models.some((m) => m.matches(pred)); } } sortBy(iteratee: string | ((model: T) => any)): T[] { return sortBy( this.models, isFunction(iteratee) ? iteratee : (m: T) => (isString(iteratee) ? m.get(iteratee as string) : m.matches(iteratee as Partial<ModelAttributes>)) ); } isEmpty(): boolean { return !this.models.length; } keyBy(iteratee: string | ((model: T) => string)): Record<string, T> { return keyBy(this.models, iteratee); } each(callback: (model: T, index: number, array: T[]) => void, thisArg?: any): void { return this.forEach(callback, thisArg); } forEach(callback: (model: T, index: number, array: T[]) => void, thisArg?: any): void { return this.models.forEach(callback, thisArg); } includes(item: T): boolean { return this.models.includes(item); } size(): number { return this.models.length; } countBy(f: string | ((model: T) => string) | Partial<ModelAttributes>): Record<string, number> { return countBy(this.models, isFunction(f) ? f : (m) => (isString(f) ? m.get(f) : m.matches(f))); } groupBy(pred: string | ((model: T) => string | number)): Record<string, T[]> { return groupBy(this.models, isFunction(pred) ? pred : (m) => (isString(pred) ? m.get(pred) : m.matches(pred))); } indexOf(model: T, fromIndex?: number): number { return this.models.indexOf(model, fromIndex); } findLastIndex(pred: ((model: T) => boolean) | string | Partial<ModelAttributes>, fromIndex?: number): number { return this.models.findLastIndex( isFunction(pred) ? (pred as (model: T) => boolean) : (m) => (isString(pred) ? m.get(pred as string) : m.matches(pred as Partial<ModelAttributes>)), fromIndex ); } lastIndexOf(model: T, fromIndex?: number): number { return this.models.lastIndexOf(model, fromIndex); } findIndex(pred: ((model: T) => boolean) | string | Partial<ModelAttributes>): number { return this.models.findIndex( isFunction(pred) ? (pred as (model: T) => boolean) : (m) => (isString(pred) ? m.get(pred as string) : m.matches(pred as Partial<ModelAttributes>)) ); } last(): T | undefined { const length = this.models == null ? 0 : this.models.length; return length ? this.models[length - 1] : undefined; } head(): T | undefined { return this.models[0]; } first(): T | undefined { return this.head(); } map<U>(cb: string | ((model: T) => U) | Partial<ModelAttributes>, thisArg?: any): U[] { return this.models.map( isFunction(cb) ? (cb as (model: T) => U) : (m) => (isString(cb) ? m.get(cb as string) : m.matches(cb as Partial<ModelAttributes>)), thisArg ); } reduce<U = T>(callback: (accumulator: U, model: T, index: number, array: T[]) => U, initialValue?: U): U | T { return this.models.reduce(callback, initialValue || this.models[0]); } reduceRight<U = T>(callback: (accumulator: U, model: T, index: number, array: T[]) => U, initialValue?: U): U | T { return this.models.reduceRight(callback, initialValue || this.models[0]); } toArray(): T[] { 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. */ get(obj?: string | number | ModelAttributes | T | null): T | undefined { if (obj == null) return undefined; return ( this._byId[obj as string] || this._byId[this.modelId(this._isModel(obj) ? (obj as T).attributes : (obj as ModelAttributes)) as string] || ((obj as T).cid && this._byId[(obj as T).cid]) ); } /** * Returns `true` if the model is in the collection. */ has(obj: string | number | ModelAttributes | T | null): boolean { return this.get(obj) != null; } /** * Get the model at the given index. */ at(index: number): T | undefined { if (index < 0) index += this.length; return this.models[index]; } /** * Return models with matching attributes. Useful for simple cases of * `filter`. */ where(attrs: ModelAttributes | Partial<ModelAttributes>, first?: boolean): T[] | T | undefined { return this[first ? 'find' : 'filter'](attrs); } /** * Return the first model with matching attributes. Useful for simple cases * of `find`. */ findWhere(attrs: ModelAttributes): T | undefined { return this.where(attrs, true) as T | undefined; } find(predicate: ((model: T) => boolean) | Partial<ModelAttributes> | string, fromIndex?: number): T | undefined { const pred = isFunction(predicate) ? (predicate as (model: T) => boolean) : (m: T) => m.matches(predicate as Partial<ModelAttributes>); 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. */ sort(options?: Options): this { let comparator = this.comparator; if (!comparator) throw new Error('Cannot sort a set without a comparator'); options = options || {}; const length = isFunction(comparator) ? comparator.length : 0; if (isFunction(comparator)) comparator = (comparator as (a: T, b: T) => number).bind(this); // Run sort based on type of `comparator`. if (length === 1 || isString(comparator)) { this.models = this.sortBy(comparator as string | ((model: T) => any)); } else { this.models.sort(comparator as (a: T, b: T) => number); } if (!options.silent) this.trigger('sort', this, options); return this; } /** * Pluck an attribute from each model in the collection. */ pluck(attr: string): any[] { 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`. */ fetch(options?: Options): Promise<any> | any { 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: any) { 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. */ create(model: T | ModelAttributes, options?: FetchOrCreateOptions): Promise<T> | T { options = options ? clone(options) : {}; const wait = options.wait; const return_promise = options.promise; const promise = return_promise && getResolveablePromise(); const preparedModel = this._prepareModel(model, options); if (!preparedModel) return null; if (!wait) this.add(preparedModel, 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: T, resp: any, callbackOpts: Options) { 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: T, e: any, options: Options) { error && error.call(options.context, model, e, options); return_promise && promise.reject(e); }; preparedModel.save(null, Object.assign(options, {'promise': false})); if (return_promise) { return promise; } else { return preparedModel; } } /** * **parse** converts a response into a list of models to be added to the * collection. The default implementation is just to pass it through. */ parse(resp: any, _options?: Options): any { return resp; } /** * Define how to uniquely identify models in the collection. */ modelId(attrs: ModelAttributes): string | number | undefined { return attrs[this.model.prototype?.idAttribute || 'id']; } /** Get an iterator of all models in this collection. */ values(): CollectionIterator<T> { return new CollectionIterator(this, ITERATOR_VALUES); } /** * @public * Enable for...of iteration over the collection. */ [Symbol.iterator] = this.values; /** Get an iterator of all model IDs in this collection. */ keys(): CollectionIterator<T> { return new CollectionIterator(this, ITERATOR_KEYS); } /** Get an iterator of all [ID, model] tuples in this collection. */ entries(): CollectionIterator<T> { return new CollectionIterator(this, ITERATOR_KEYSVALUES); } /** * Private method to reset all internal state. Called when the collection * is first initialized or reset. */ _reset(): void { this.models = []; this._byId = {}; } createModel(attrs: ModelAttributes, options?: Options): T { const Klass = this.model; return new Klass(attrs, options) as T; } /** * Prepare a hash of attributes (or other model) to be added to this * collection. */ _prepareModel(attrs: ModelAttributes | T, options?: Options): T | null { if (this._isModel(attrs)) { if (!(attrs as T).collection) (attrs as T).collection = this; return attrs as T; } options = options ? clone(options) : {}; options.collection = this; const model = this.createModel(attrs as ModelAttributes, options); if (!model.validationError) return model; this.trigger('invalid', this, model.validationError, options); return null; } /** * Internal method called by both remove and set. */ _removeModels(models: (T | ObjectWithId)[], options?: Options): T[] { const removed: T[] = []; 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 = options || {}; 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. */ _isModel(model: any): model is T { return model instanceof Model; } /** * Internal method to create a model's ties to a collection. */ _addReference(model: T, _options?: Options): void { this._byId[model.cid] = model; const id = this.modelId(model.attributes); if (id != null) this._byId[id as string] = model; model.on('all', this._onModelEvent, this); } /** * Internal method to sever a model's ties to a collection. */ _removeReference(model: T, _options?: Options): void { delete this._byId[model.cid]; const id = this.modelId(model.attributes); if (id != null) delete this._byId[id as string]; 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. */ _onModelEvent(event: string, model: T, collection: Collection<T>, options?: Options): void { 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 as string]; if (id != null) this._byId[id as string] = model; } } } this.trigger.apply(this, arguments as any); } } // 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; /** * @public */ export class CollectionIterator<T extends Model> { private _collection: Collection<T> | undefined; private _kind: number; private _index: number; /** * 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. */ constructor(collection: Collection<T>, kind: number) { this._collection = collection; this._kind = kind; this._index = 0; } next(): IteratorResult<any> { 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++; if (!model) { return {value: undefined, done: true}; } // Construct a value depending on what kind of values should be iterated. let value: T | string | number | [string | number, T]; 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, 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](): IterableIterator<any> { return this; } }