UNPKG

@converse/skeletor

Version:

Modernized Backbone with web components

540 lines (468 loc) 15.9 kB
import { getResolveablePromise, getSyncMethod, urlError, wrapError } from './helpers.js'; import clone from 'lodash-es/clone.js'; import defaults from 'lodash-es/defaults.js'; import defer from 'lodash-es/defer.js'; import has from 'lodash-es/has.js'; import invert from 'lodash-es/invert.js'; import isEmpty from 'lodash-es/isEmpty.js'; import isEqual from 'lodash-es/isEqual.js'; import iteratee from 'lodash-es/iteratee.js'; import omit from 'lodash-es/omit.js'; import pick from 'lodash-es/pick.js'; import result from 'lodash-es/result.js'; import uniqueId from 'lodash-es/uniqueId.js'; import EventEmitter from './eventemitter.js'; /** * @typedef {import('./collection.js').Collection} Collection * @typedef {Record.<string, any>} Attributes * * @typedef {Record.<string, any>} Options * @property {boolean} [validate] * * @typedef {Record.<string, any>} ModelOptions * @property {Collection} [collection] * @property {boolean} [parse] * @property {boolean} [unset] * @property {boolean} [silent] */ /** * **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. */ class Model extends EventEmitter(Object) { /** * Create a new model with the specified attributes. A client id (`cid`) * is automatically generated and assigned for you. * @param {Attributes} [attributes] * @param {ModelOptions} [options] */ constructor(attributes, options) { super(); let attrs = attributes || {}; options || (options = {}); this.preinitialize.apply(this, arguments); this.cid = uniqueId(this.cidPrefix); this.attributes = {}; // The value returned during the last failed validation. this.validationError = null; if (options.collection) this.collection = options.collection; if (options.parse) attrs = this.parse(attrs, options) || {}; const default_attrs = result(this, 'defaults'); attrs = defaults(Object.assign({}, default_attrs, attrs), default_attrs); this.set(attrs, options); this.initialize.apply(this, arguments); // A hash of attributes whose current and previous value differ. this.changed = {}; } /** * @param {Storage} storage */ set browserStorage(storage) { this._browserStorage = storage; } /** * @returns {Storage} storage */ get browserStorage() { 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). */ // eslint-disable-next-line class-methods-use-this get idAttribute() { 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. */ // eslint-disable-next-line class-methods-use-this get cidPrefix() { 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. */ // eslint-disable-next-line class-methods-use-this preinitialize() {} /** * Initialize is an empty function by default. Override it with your own * initialization logic. * @param {Attributes} [attrs] * @param {ModelOptions} [options] */ initialize(attrs, options) {} /** * @param {object} attrs * @param {object} [options] * @returns {string} The validation error message */ validate(attrs, options) { return ''; } /** * Return a copy of the model's `attributes` object. */ toJSON() { return clone(this.attributes); } /** * Override this if you need custom syncing semantics for *this* particular model. * @param {'create'|'update'|'patch'|'delete'|'read'} method * @param {Model} model * @param {Options} options */ // eslint-disable-next-line class-methods-use-this sync(method, model, options) { return getSyncMethod(model)(method, model, options); } /** * Get the value of an attribute. * @param {string} attr */ get(attr) { return this.attributes[attr]; } keys() { return Object.keys(this.attributes); } values() { return Object.values(this.attributes); } pairs() { return this.entries(); } entries() { return Object.entries(this.attributes); } invert() { return invert(this.attributes); } pick(...args) { if (args.length === 1 && Array.isArray(args[0])) { args = args[0]; } return pick(this.attributes, args); } omit(...args) { if (args.length === 1 && Array.isArray(args[0])) { args = args[0]; } return omit(this.attributes, args); } isEmpty() { return isEmpty(this.attributes); } /** * Returns `true` if the attribute contains a value that is not null * or undefined. * @param {string} attr */ has(attr) { return this.get(attr) != null; } /** * Special-cased proxy to lodash's `matches` method. * @param {Attributes} attrs */ matches(attrs) { return !!iteratee(attrs, this)(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. * @param {string|Object} key * @param {string|Object} [val] * @param {Options} [options] */ set(key, val, options) { if (key == null) return this; // Handle both `"key", value` and `{key: value}` -style arguments. let attrs; if (typeof key === 'object') { attrs = key; options = val; } else { (attrs = {})[key] = val; } options || (options = {}); // Run validation. if (!this._validate(attrs, options)) return false; // 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]; if (!isEqual(current[attr], val)) changes.push(attr); if (!isEqual(prev[attr], val)) { changed[attr] = val; } else { delete changed[attr]; } unset ? delete current[attr] : (current[attr] = 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); } } // You might be wondering why there's a `while` loop here. Changes can // be recursively nested within `"change"` events. if (changing) return this; if (!silent) { while (this._pending) { options = /** @type {Options} */ (this._pending); 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. * @param {string} attr * @param {Options} [options] */ unset(attr, options) { return this.set(attr, undefined, Object.assign({}, options, { unset: true })); } /** * Clear all attributes on the model, firing `"change"`. * @param {Options} options */ clear(options) { const attrs = {}; for (const key in this.attributes) attrs[key] = 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. * @param {string} [attr] */ hasChanged(attr) { if (attr == null) return !isEmpty(this.changed); return has(this.changed, attr); } /** * 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. * @param {Object} diff */ changedAttributes(diff) { if (!diff) { return this.hasChanged() ? clone(this.changed) : false; } const old = this._changing ? this._previousAttributes : this.attributes; const changed = {}; let hasChanged; for (const attr in diff) { const val = diff[attr]; if (isEqual(old[attr], val)) continue; changed[attr] = val; hasChanged = true; } return hasChanged ? changed : false; } /** * Get the previous value of an attribute, recorded at the time the last * `"change"` event was fired. * @param {string} [attr] */ previous(attr) { 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() { return clone(this._previousAttributes); } /** * Fetch the model from the server, merging the response with the model's * local attributes. Any changed attributes will trigger a "change" event. * @param {Options} [options={}] */ fetch(options={}) { options = Object.assign({ parse: true }, options); const success = options.success; options.success = (resp) => { 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. * @param {string|Attributes} [key] * @param {boolean|number|string|Options} [val] * @param {Options} [options] */ save(key, val, options) { // Handle both `"key", value` and `{key: value}` -style arguments. let attrs; if (key == null || typeof key === 'object') { attrs = key; options = /** @type {Options} */ (val); } else { (attrs = {})[key] = 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(/** @type {Object} */ (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) => { // 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, e, 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. * @param {Options} [options] */ destroy(options) { 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) => { if (wait) destroy(); if (success) success.call(options.context, this, resp, options); if (!this.isNew()) this.trigger('sync', this, resp, options); }; let xhr = false; if (this.isNew()) { defer(options.success); } else { wrapError(this, options); xhr = this.sync('delete', this, options); } if (!wait) destroy(); return xhr; } /** * 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. */ url() { const base = result(this, 'urlRoot') || result(this.collection, 'url') || urlError(); if (this.isNew()) return base; const id = this.get(this.idAttribute); return base.replace(/[^\/]$/, '$&/') + encodeURIComponent(id); } /** * **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. * @param {Options} resp * @param {Options} [options] */ parse(resp, options) { return resp; } /** * A model is new if it has never been saved to the server, and lacks an id. */ isNew() { return !this.has(this.idAttribute); } /** * Check if the model is currently in a valid state. * @param {Options} [options] */ isValid(options) { 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. * @param {Attributes} attrs * @param {Options} [options] */ _validate(attrs, options) { 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; } } export { Model };