/** * The `Model` class represents a local data resource. It provides methods to * persist changes via the API. * * @abstract */ export default class Model { /** * @param {Object} data A resource object from the API. * @param {Store} store The data store that this model should be persisted to. * @public */ constructor(data = {}, store = null) { /** * The resource object from the API. * * @type {Object} * @public */ this.data = data; /** * The time at which the model's data was last updated. Watching the value * of this property is a fast way to retain/cache a subtree if data hasn't * changed. * * @type {Date} * @public */ this.freshness = new Date(); /** * Whether or not the resource exists on the server. * * @type {Boolean} * @public */ this.exists = false; /** * The data store that this resource should be persisted to. * * @type {Store} * @protected */ this.store = store; } /** * Get the model's ID. * * @return {Integer} * @public * @final */ id() { return this.data.id; } /** * Get one of the model's attributes. * * @param {String} attribute * @return {*} * @public * @final */ attribute(attribute) { return this.data.attributes[attribute]; } /** * Merge new data into this model locally. * * @param {Object} data A resource object to merge into this model * @public */ pushData(data) { // Since most of the top-level items in a resource object are objects // (e.g. relationships, attributes), we'll need to check and perform the // merge at the second level if that's the case. for (const key in data) { if (typeof data[key] === 'object') { this.data[key] = this.data[key] || {}; // For every item in a second-level object, we want to check if we've // been handed a Model instance. If so, we will convert it to a // relationship data object. for (const innerKey in data[key]) { if (data[key][innerKey] instanceof Model) { data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) }; } this.data[key][innerKey] = data[key][innerKey]; } } else { this.data[key] = data[key]; } } // Now that we've updated the data, we can say that the model is fresh. // This is an easy way to invalidate retained subtrees etc. this.freshness = new Date(); } /** * Merge new attributes into this model locally. * * @param {Object} attributes The attributes to merge. * @public */ pushAttributes(attributes) { this.pushData({ attributes }); } /** * Merge new attributes into this model, both locally and with persistence. * * @param {Object} attributes The attributes to save. If a 'relationships' key * exists, it will be extracted and relationships will also be saved. * @param {Object} [options] * @return {Promise} * @public */ save(attributes, options = {}) { const data = { type: this.data.type, id: this.data.id, attributes, }; // If a 'relationships' key exists, extract it from the attributes hash and // set it on the top-level data object instead. We will be sending this data // object to the API for persistence. if (attributes.relationships) { data.relationships = {}; for (const key in attributes.relationships) { const model = attributes.relationships[key]; data.relationships[key] = { data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model), }; } delete attributes.relationships; } // Before we update the model's data, we should make a copy of the model's // old data so that we can revert back to it if something goes awry during // persistence. const oldData = this.copyData(); this.pushData(data); const request = { data }; if (options.meta) request.meta = options.meta; return app .request( Object.assign( { method: this.exists ? 'PATCH' : 'POST', url: app.forum.attribute('apiUrl') + this.apiEndpoint(), data: request, }, options ) ) .then( // If everything went well, we'll make sure the store knows that this // model exists now (if it didn't already), and we'll push the data that // the API returned into the store. (payload) => { this.store.data[payload.data.type] = this.store.data[payload.data.type] || {}; this.store.data[payload.data.type][payload.data.id] = this; return this.store.pushPayload(payload); }, // If something went wrong, though... good thing we backed up our model's // old data! We'll revert to that and let others handle the error. (response) => { this.pushData(oldData); m.lazyRedraw(); throw response; } ); } /** * Send a request to delete the resource. * * @param {Object} data Data to send along with the DELETE request. * @param {Object} [options] * @return {Promise} * @public */ delete(data, options = {}) { if (!this.exists) return m.deferred().resolve().promise; return app .request( Object.assign( { method: 'DELETE', url: app.forum.attribute('apiUrl') + this.apiEndpoint(), data, }, options ) ) .then(() => { this.exists = false; this.store.remove(this); }); } /** * Construct a path to the API endpoint for this resource. * * @return {String} * @protected */ apiEndpoint() { return '/' + this.data.type + (this.exists ? '/' + this.data.id : ''); } copyData() { return JSON.parse(JSON.stringify(this.data)); } /** * Generate a function which returns the value of the given attribute. * * @param {String} name * @param {function} [transform] A function to transform the attribute value * @return {*} * @public */ static attribute(name, transform) { return function () { const value = this.data.attributes && this.data.attributes[name]; return transform ? transform(value) : value; }; } /** * Generate a function which returns the value of the given has-one * relationship. * * @param {String} name * @return {Model|Boolean|undefined} false if no information about the * relationship exists; undefined if the relationship exists but the model * has not been loaded; or the model if it has been loaded. * @public */ static hasOne(name) { return function () { if (this.data.relationships) { const relationship = this.data.relationships[name]; if (relationship) { return app.store.getById(relationship.data.type, relationship.data.id); } } return false; }; } /** * Generate a function which returns the value of the given has-many * relationship. * * @param {String} name * @return {Array|Boolean} false if no information about the relationship * exists; an array if it does, containing models if they have been * loaded, and undefined for those that have not. * @public */ static hasMany(name) { return function () { if (this.data.relationships) { const relationship = this.data.relationships[name]; if (relationship) { return relationship.data.map((data) => app.store.getById(data.type, data.id)); } } return false; }; } /** * Transform the given value into a Date object. * * @param {String} value * @return {Date|null} * @public */ static transformDate(value) { return value ? new Date(value) : null; } /** * Get a resource identifier object for the given model. * * @param {Model} model * @return {Object} * @protected */ static getIdentifier(model) { return { type: model.data.type, id: model.data.id, }; } }