@converse/skeletor
Version:
Models and Collections for modern web apps
521 lines (443 loc) • 16.3 kB
text/typescript
import { getResolveablePromise, getSyncMethod, urlError, wrapError } from './helpers';
import clone from 'lodash-es/clone';
import defaults from 'lodash-es/defaults';
import defer from 'lodash-es/defer';
import has from 'lodash-es/has';
import invert from 'lodash-es/invert';
import isEmpty from 'lodash-es/isEmpty';
import isEqual from 'lodash-es/isEqual';
import iteratee from 'lodash-es/iteratee';
import omit from 'lodash-es/omit';
import pick from 'lodash-es/pick';
import result from 'lodash-es/result';
import uniqueId from 'lodash-es/uniqueId';
import { EventEmitterObject } from './eventemitter';
// Import types
import type { Collection } from './collection';
import type Storage from './storage';
import { ModelAttributes, ObjectWithId, SyncOperation, ModelOptions, Options } from './types';
/**
* @public
* **Models** are the basic data object in the framework --
* frequently representing a row in a table in a database on your server.
* A discrete chunk of data and a bunch of useful, related methods for
* performing computations and transformations on that data.
*/
export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmitterObject {
_browserStorage?: Storage;
_changing = false;
_pending: boolean | ModelOptions = false;
_previousAttributes?: T;
_url: string = '';
_urlRoot: string;
attributes: T;
changed: Partial<T> = {};
cid: string;
collection?: Collection;
id: string | number;
validationError: string | number | null = null;
/**
* Create a new model with the specified attributes. A client id (`cid`)
* is automatically generated and assigned for you.
*/
constructor(attributes?: Partial<T>, options?: ModelOptions) {
super();
let attrs: Partial<T> = attributes || ({} as Partial<T>);
options = options || {};
this.preinitialize.apply(this, arguments as any);
this.cid = uniqueId(this.cidPrefix);
this.attributes = {} as T;
if (options.collection) this.collection = options.collection;
if (options.parse) attrs = this.parse(attrs, options) || ({} as Partial<T>);
const default_attrs = result(this, 'defaults');
attrs = defaults(Object.assign({}, default_attrs, attrs), default_attrs);
this.set(attrs, options);
this.initialize.apply(this, arguments as any);
// Reset changed after initial set
this.changed = {};
}
set browserStorage(storage: Storage) {
this._browserStorage = storage;
}
get browserStorage(): Storage | undefined {
return this._browserStorage;
}
/**
* The default name for the JSON `id` attribute is `"id"`. MongoDB and
* CouchDB users may want to set this to `"_id"` (by overriding this getter
* in a subclass).
*/
get idAttribute(): string {
return 'id';
}
/**
* The prefix is used to create the client id which is used to identify models locally.
* You may want to override this if you're experiencing name clashes with model ids.
*/
get cidPrefix(): string {
return 'c';
}
/**
* 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 Model.
*/
preinitialize(...args: any[]): void {}
/**
* Initialize is an empty function by default. Override it with your own
* initialization logic.
*/
initialize(attrs?: Partial<T>, options?: ModelOptions): void {}
validate(attrs: Partial<T> | ObjectWithId, options?: ModelOptions): string | number | null | void {
return null;
}
/**
* Return a hash of defaults for the model's attributes.
*/
defaults(): Partial<T> {
return {} as Partial<T>;
}
/**
* Return a copy of the model's `attributes` object.
*/
toJSON(): T {
return clone(this.attributes);
}
/**
* Override this if you need custom syncing semantics for *this* particular model.
*/
sync(method: SyncOperation, model: Model<any>, options: Options): any {
return getSyncMethod(model)(method, model, options);
}
/**
* Get the value of an attribute.
*/
get<K extends keyof T>(attr: K): T[K] {
return this.attributes[attr];
}
keys(): string[] {
return Object.keys(this.attributes);
}
values(): T[keyof T][] {
return Object.values(this.attributes);
}
pairs(): [keyof T, T[keyof T]][] {
return this.entries();
}
entries(): [keyof T, T[keyof T]][] {
return Object.entries(this.attributes) as [keyof T, T[keyof T]][];
}
invert(): Record<string, keyof T> {
return invert(this.attributes);
}
pick<K extends keyof T>(...args: K[]): Pick<T, K> {
if (args.length === 1 && Array.isArray(args[0])) {
args = args[0] as K[];
}
return pick(this.attributes, args) as Pick<T, K>;
}
omit<K extends keyof T>(...args: K[]): Omit<T, K> {
if (args.length === 1 && Array.isArray(args[0])) {
args = args[0] as K[];
}
return omit(this.attributes, args) as Omit<T, K>;
}
isEmpty(): boolean {
return isEmpty(this.attributes);
}
/**
* Returns `true` if the attribute contains a value that is not null
* or undefined.
*/
has(attr: keyof T): boolean {
return this.get(attr) != null;
}
/**
* Special-cased proxy to lodash's `matches` method.
*/
matches(attrs: Partial<T>): boolean {
return !!iteratee(attrs)(this.attributes);
}
/**
* Set a hash of model attributes on the object, firing `"change"`. This is
* the core primitive operation of a model, updating the data and notifying
* anyone who needs to know about the change in state. The heart of the beast.
*/
set(key: string | Partial<T> | ObjectWithId, val?: any, options?: ModelOptions): this {
if (key == null) return this;
// Handle both `"key", value` and `{key: value}` -style arguments.
let attrs: Partial<T> | ObjectWithId;
if (typeof key === 'object') {
attrs = key;
options = val;
} else {
attrs = {} as Partial<T>;
attrs[key as keyof T] = val;
}
options = options || {};
// Run validation.
if (!this._validate(attrs, options)) return null;
// Extract attributes and options.
const unset = options.unset;
const silent = options.silent;
const changes = [];
const changing = this._changing;
this._changing = true;
if (!changing) {
this._previousAttributes = clone(this.attributes);
this.changed = {};
}
const current = this.attributes;
const changed = this.changed;
const prev = this._previousAttributes;
// For each `set` attribute, update or delete the current value.
for (const attr in attrs) {
val = attrs[attr as keyof (T | ObjectWithId)];
if (!isEqual(current[attr as keyof T], val)) changes.push(attr);
if (!isEqual(prev[attr as keyof T], val)) {
changed[attr as keyof T] = val;
} else {
delete changed[attr as keyof T];
}
unset ? delete current[attr as keyof T] : (current[attr as keyof T] = val);
}
// Update the `id`.
if (this.idAttribute in attrs) this.id = this.get(this.idAttribute);
// Trigger all relevant attribute changes.
if (!silent) {
if (changes.length) this._pending = options;
for (let i = 0; i < changes.length; i++) {
this.trigger('change:' + changes[i], this, current[changes[i]], options);
}
}
if (changing) return this;
if (!silent) {
// You might be wondering why there's a `while` loop here. Changes can
// be recursively nested within `"change"` events.
while (this._pending) {
options = this._pending as ModelOptions;
this._pending = false;
this.trigger('change', this, options);
}
}
this._pending = false;
this._changing = false;
return this;
}
/**
* Remove an attribute from the model, firing `"change"`. `unset` is a noop
* if the attribute doesn't exist.
*/
unset(attr: keyof T, options?: ModelOptions): this {
return this.set(attr as string, undefined, Object.assign({}, options, { unset: true }));
}
/**
* Clear all attributes on the model, firing `"change"`.
*/
clear(options?: ModelOptions): this {
const attrs: Partial<T> = {};
for (const key in this.attributes) attrs[key as keyof T] = undefined;
return this.set(attrs, Object.assign({}, options, { unset: true }));
}
/**
* Determine if the model has changed since the last `"change"` event.
* If you specify an attribute name, determine if that attribute has changed.
*/
hasChanged(attr?: keyof T): boolean {
if (attr == null) return !isEmpty(this.changed);
return has(this.changed, attr as string);
}
/**
* Return an object containing all the attributes that have changed, or
* false if there are no changed attributes. Useful for determining what
* parts of a view need to be updated and/or what attributes need to be
* persisted to the server. Unset attributes will be set to undefined.
* You can also pass an attributes object to diff against the model,
* determining if there *would be* a change.
*/
changedAttributes(diff?: Partial<T>): Partial<T> | false {
if (!diff) {
return this.hasChanged() ? clone(this.changed) : false;
}
const old = this._changing ? this._previousAttributes : this.attributes;
const changed: Partial<T> = {};
let hasChanged = false;
for (const attr in diff) {
const val = diff[attr as keyof T];
if (isEqual(old[attr as keyof T], val)) continue;
changed[attr as keyof T] = val;
hasChanged = true;
}
return hasChanged ? changed : false;
}
/**
* Get the previous value of an attribute, recorded at the time the last
* `"change"` event was fired.
*/
previous<K extends keyof T>(attr: K): T[K] | null {
if (attr == null || !this._previousAttributes) return null;
return this._previousAttributes[attr];
}
/**
* Get all of the attributes of the model at the time of the previous
* `"change"` event.
*/
previousAttributes(): T | undefined {
return this._previousAttributes ? clone(this._previousAttributes) : undefined;
}
/**
* Fetch the model from the server, merging the response with the model's
* local attributes. Any changed attributes will trigger a "change" event.
*/
fetch(options: Options = {}): any {
options = Object.assign({ parse: true }, options);
const success = options.success;
options.success = (resp: any) => {
const serverAttrs = options.parse ? this.parse(resp, options) : resp;
if (!this.set(serverAttrs, options)) return false;
if (success) success.call(options.context, this, resp, options);
this.trigger('sync', this, resp, options);
};
wrapError(this, options);
return this.sync('read', this, options);
}
/**
* Set a hash of model attributes, and sync the model to the server.
* If the server returns an attributes hash that differs, the model's
* state will be `set` again.
*/
save(key?: string | null | Partial<T>, val?: any, options?: ModelOptions): any {
// Handle both `"key", value` and `{key: value}` -style arguments.
let attrs: Partial<T> | undefined;
if (key == null || typeof key === 'object') {
attrs = key as Partial<T>;
options = val as ModelOptions;
} else {
attrs = {} as Partial<T>;
attrs[key as keyof T] = val;
}
options = Object.assign({ validate: true, parse: true }, options);
const wait = options.wait;
const return_promise = options.promise;
const promise = return_promise && getResolveablePromise();
// If we're not waiting and attributes exist, save acts as
// `set(attr).save(null, opts)` with validation. Otherwise, check if
// the model will be valid when the attributes, if any, are set.
if (attrs && !wait) {
if (!this.set(attrs, options)) return false;
} else if (!this._validate(attrs || {}, options)) {
return false;
}
// After a successful server-side save, the client is (optionally)
// updated with the server-side state.
const success = options.success;
const error = options.error;
const attributes = this.attributes;
options.success = (resp: any) => {
// Ensure attributes are restored during synchronous saves.
this.attributes = attributes;
let serverAttrs = options.parse ? this.parse(resp, options) : resp;
if (wait) serverAttrs = Object.assign({}, attrs, serverAttrs);
if (serverAttrs && !this.set(serverAttrs, options)) return false;
if (success) success.call(options.context, this, resp, options);
this.trigger('sync', this, resp, options);
return_promise && promise.resolve();
};
options.error = (model: this, e: any, options: Options) => {
error && error.call(options.context, model, e, options);
return_promise && promise.reject(e);
};
wrapError(this, options);
// Set temporary attributes if `{wait: true}` to properly find new ids.
if (attrs && wait) this.attributes = Object.assign({}, attributes, attrs);
const method = this.isNew() ? 'create' : options.patch ? 'patch' : 'update';
if (method === 'patch' && !options.attrs) options.attrs = attrs;
const xhr = this.sync(method, this, options);
// Restore attributes.
this.attributes = attributes;
if (return_promise) {
return promise;
} else {
return xhr;
}
}
/**
* Destroy this model on the server if it was already persisted.
* Optimistically removes the model from its collection, if it has one.
* If `wait: true` is passed, waits for the server to respond before removal.
*/
destroy(options?: ModelOptions): any {
options = options ? clone(options) : {};
const success = options.success;
const wait = options.wait;
const destroy = () => {
this.stopListening();
this.trigger('destroy', this, this.collection, options);
};
options.success = (resp: any) => {
if (wait) destroy();
if (success) success.call(options.context, this, resp, options);
if (!this.isNew()) this.trigger('sync', this, resp, options);
};
let xhr: any = false;
if (this.isNew()) {
defer(options.success);
} else {
wrapError(this, options);
xhr = this.sync('delete', this, options);
}
if (!wait) destroy();
return xhr;
}
get urlRoot(): string {
return this._urlRoot;
}
set urlRoot(root: string) {
this._urlRoot = root;
}
/**
* Default URL for the model's representation on the server -- if you're
* using Backbone's restful methods, override this to change the endpoint
* that will be called.
*/
get url(): string {
if (this._url) return this._url;
const base = result(this, 'urlRoot') || result(this.collection, 'url') || urlError();
if (this.isNew()) return base as string;
const id = this.get(this.idAttribute);
return (base as string).replace(/[^\/]$/, '$&/') + encodeURIComponent(String(id));
}
set url(url: string) {
this._url = url;
}
/**
* **parse** converts a response into the hash of attributes to be `set` on
* the model. The default implementation is just to pass the response along.
*/
parse(resp: any, options?: ModelOptions): Partial<T> | null | void {
return resp;
}
/**
* A model is new if it has never been saved to the server, and lacks an id.
*/
isNew(): boolean {
return !this.has(this.idAttribute);
}
/**
* Check if the model is currently in a valid state.
*/
isValid(options?: ModelOptions): boolean {
return this._validate({}, Object.assign({}, options, { validate: true }));
}
/**
* Run validation against the next complete set of model attributes,
* returning `true` if all is well. Otherwise, fire an `"invalid"` event.
*/
_validate(attrs: Partial<T> | ObjectWithId, options?: ModelOptions): boolean {
if (!options?.validate || !this.validate) return true;
attrs = Object.assign({}, this.attributes, attrs);
const error = (this.validationError = this.validate(attrs, options) || null);
if (!error) return true;
this.trigger('invalid', this, error, Object.assign(options, { validationError: error }));
return false;
}
}