UNPKG

@onehat/data

Version:

JS data modeling package with adapters for many storage mediums.

1,185 lines (1,027 loc) 28 kB
/** @module Repository */ import Repository from './Repository.js'; // so we can use static methods import ReaderTypes from '../Reader/index.js'; import WriterTypes from '../Writer/index.js'; import axios from 'axios'; import qs from 'qs'; import _ from 'lodash'; /** * Class represents a Repository that stores its data on a remote server, * through HTTP requests. * * @extends Repository */ class AjaxRepository extends Repository { constructor(config = {}) { super(...arguments); const defaults = { isRemote: true, isRemoteFilter: true, isRemoteSort: true, isPaginated: !config.schema.model.isTree, // If it's a tree, don't paginate /** * @member {object} api - List of relative URIs to API endpoints. */ api: { get: null, add: null, edit: null, delete: null, batchAdd: null, batchEdit: null, batchDelete: null, baseURL: '', // e.g. 'https://example.com/myapp/' }, /** * @member {object} methods - List of methods for all four CRUD operations */ methods: { get: 'GET', add: 'POST', edit: 'POST', delete: 'POST', }, /** * @member {string|object} reader - Reader. Options: json|xml */ reader: 'json', /** * @member {string|object} writer - Writer. Options: json|xml */ writer: 'json', /** * @member {string} paramPageNum - Parameter name for currentl page number */ paramPageNum: 'page', /** * @member {string} paramPageSize - Parameter name for current page size */ paramPageSize: 'limit', /** * @member {string} paramSort - Parameter name for sorting property */ paramSort: 'order', /** * @member {string} paramSort - Parameter name for sorting direction */ paramDirection: 'dir', /** * @member {integer} timeout - Number of milliseconds to wait before canceling request */ timeout: 4000, /** * @member {Object} headers - Object of headers to submit to server on every request * @private */ headers: {}, /** * @member {object} _baseParams - Params that will be applied to every request */ _baseParams: {}, /** * @member {boolean} isOnline - Whether the remote storage medium is available. * This must be managed by outside software, calling setIsOnline at appropriate times. * @private */ isOnline: true, }; _.merge(this, defaults, config); /** * @member {boolean} allowsMultiSort - Whether to allow >1 sorter */ this.allowsMultiSort = false; /** * @member {object} lastSendOptions - Last options sent to server */ this.lastSendOptions = null; /** * @member {Object} _params - Object of query params to submit to server * @private */ this._params = {}; this._operations = { add: false, edit: false, delete: false, deletePhantom: false, }; } async initialize() { this.registerEvents([ 'beforeLoad', ]); // Respond to Repository events this.on('beforeSave', this._onBeforeSave); // Create Reader let readerConfig; if (this.reader && this.reader.type) { readerConfig = this.reader; } else if (_.isString(this.reader)) { readerConfig = { type: this.reader, }; } if (readerConfig && ReaderTypes[readerConfig.type]) { const Reader = ReaderTypes[readerConfig.type]; this.reader = new Reader(readerConfig); } else { this.reader = null; } // Create Writer let writerConfig; if (this.writer && this.writer.type) { writerConfig = this.writer; } else if (_.isString(this.writer)) { writerConfig = { type: this.writer, }; } if (writerConfig && WriterTypes[writerConfig.type]) { const Writer = WriterTypes[writerConfig.type]; this.writer = new Writer(writerConfig); } else { this.writer = null; } // Initialize query params this._setInitialQueryParams(); await super.initialize(); } /** * Convenience alias so subclasses can have direct access to Axios. * this.sendDirect() */ axios = axios; /** * Helper for initialize * Sets the query params for initial loading */ _setInitialQueryParams() { // Pagination if (this.isPaginated) { this._onChangePagination(); } // Sorting if (this.isAutoSort) { if (!this.sorters.length) { this.sorters = this.getDefaultSorters(); // Need this here, because _setInitialQueryParams() runs before this.load() in super.initialize() } this._onChangeSorters(); } } // ____ // / __ \____ __________ _____ ___ _____ // / /_/ / __ `/ ___/ __ `/ __ `__ \/ ___/ // / ____/ /_/ / / / /_/ / / / / / (__ ) // /_/ \__,_/_/ \__,_/_/ /_/ /_/____/ /** * Sets a single query param. * @param {string} name - Param name to set. * @param {any} value - Param value to set. * @param {boolean} isBaseParam - Whether param is a base param (to be sent on every request). */ setParam(name, value, isBaseParam = false) { const re = /^([^\[]+)\[([^\]]+)\](.*)$/, matches = name.match(re), paramsToChange = isBaseParam ? this._baseParams : this._params; if (matches) { // name has array notation like 'conditions[username]' const first = matches[1], second = matches[2]; if (paramsToChange && !paramsToChange.hasOwnProperty(first)) { paramsToChange[first] = {}; } if (_.isNil(value) && paramsToChange[first] && paramsToChange[first].hasOwnProperty(second)) { delete paramsToChange[first][second]; if (_.isEmpty(paramsToChange[first])) { delete paramsToChange[first]; } return; } paramsToChange[first][second] = value; return; } if (_.isNil(value) && paramsToChange && paramsToChange.hasOwnProperty(name)) { delete paramsToChange[name]; return; } paramsToChange[name] = value; } /** * Same as setParam, but without any value * Sets a single query param. * @param {string} name - Param name to set. * @param {boolean} isBaseParam - Whether param is a base param (to be sent on every request). */ setValuelessParam(name, isBaseParam = false) { const re = /^([^\[]+)\[([^\]]+)\](.*)$/, matches = name.match(re), paramsToChange = isBaseParam ? this._baseParams : this._params; let first, second; if (matches) { // name has array notation like 'conditions[username]' first = matches[1], second = matches[2]; if (paramsToChange && !paramsToChange.hasOwnProperty(first)) { paramsToChange[first] = []; } if (paramsToChange[first] && paramsToChange[first].hasOwnProperty(second)) { delete paramsToChange[first][second]; return; } paramsToChange[first][ paramsToChange[first].length ] = second; return; } paramsToChange[paramsToChange.length] = second; } /** * Sets query params * @param {object} params - Params to set. Key is parameter name, value is parameter value */ setParams(params) { const oThis = this; _.each(params, (value, name) => { oThis.setParam(name, value); }); } /** * Determines if base query param exists * @param {string} name - Param name */ hasBaseParam(name) { if (this._baseParams.hasOwnProperty(name)) { return true; } // Check for array notation const keys = name.split(/[\[\].]+/).filter(Boolean); let current = this._baseParams, key; for(key of keys) { if (!current || !current.hasOwnProperty(key)) { return false; } current = current[key]; } return true; } /** * Returns current value of base query param * @param {string} name - Param name */ getBaseParam(name) { if (!this.hasBaseParam(name)) { return null; } // Handle simple property access if (this._baseParams.hasOwnProperty(name)) { return this._baseParams[name]; } // Handle array notation like "conditions[fleets__enterprise_id]" const keys = name.split(/[\[\].]+/).filter(Boolean); let current = this._baseParams; for(const key of keys) { if (!current || !current.hasOwnProperty(key)) { return null; } current = current[key]; } return current; } /** * Returns current value of base query params * @param {object} params - Params to set. Key is parameter name, value is parameter value */ getBaseParams() { return this._baseParams; } /** * Determines if query param exists * @param {string} name - Param name */ hasParam(name) { if (this._params.hasOwnProperty(name)) { return true; } // Check for array notation const keys = name.split(/[\[\].]+/).filter(Boolean); let current = this._params, key; for(key of keys) { if (!current || !current.hasOwnProperty(key)) { return false; } current = current[key]; } return true; } /** * Sets base query param * @param {string} name - Param name to set. * @param {any} value - Param value to set. */ setBaseParam(name, value) { this.setParam(name, value, true); } /** * Sets base query params. These params are sent on every request. * @param {object} params - Base params to set. Key is parameter name, value is parameter value */ setBaseParams(params) { const oThis = this; _.each(params, (value, name) => { oThis.setBaseParam(name, value); }); } /** * Manually clears all (non-base) params including filtering. * *Not intended for normal usage,* but rather for testing. * @param {boolean} reload - Whether to reload repository. Defaults to false. */ clearParams(reload = false, clearBase = false) { this._params = {}; if (clearBase) { this._baseParams = {}; } if (reload && this.isLoaded && !this.eventsPaused) { return this.reload(); } } /** * Sets sort and direction params. * Only one sorter is allowed with this Repository type. * Refreshes entities. */ _onChangeSorters() { const sorter = this.sorters[0]; this.setBaseParam(this.paramSort, sorter.name); this.setBaseParam(this.paramDirection, sorter.direction); if (this.isLoaded && !this.eventsPaused) { return this.reload(); } } /** * Sets filter params. * Refreshes entities. */ _onChangeFilters() { const oThis = this; _.each(this.filters, (value, name) => { oThis.setParam(name, value); }); if (this.isLoaded && !this.eventsPaused) { return this.reload(); } } /** * Sets pagination params. * Refreshes entities. */ _onChangePagination() { this.setBaseParam(this.paramPageNum, this.isPaginated ? this.page : null); this.setBaseParam(this.paramPageSize, this.isPaginated ? this.pageSize : null); if (this.isLoaded && !this.eventsPaused) { return this.reload(); } } // __________ __ ______ // / ____/ __ \/ / / / __ \ // / / / /_/ / / / / / / / // / /___/ _, _/ /_/ / /_/ / // \____/_/ |_|\____/_____/ /** * Loads data into the Repository. * This loads only a single page of data. * @param {object} params - Params to send to server * @param {function} callback - Function to call after loading is complete * @fires beforeLoad,changeData,load,error */ async load(params, callback = null) { if (this.isTree && this.loadRootNodes) { return this.loadRootNodes(); } if (this.isDestroyed) { this.throwError('this.load is no longer valid. Repository has been destroyed.'); return; } if (!this.api.get) { this.throwError('No "get" api endpoint defined.'); return; } this.emit('beforeLoad'); // TODO: canceling beforeLoad will cancel the load operation this.markLoading(); if (params?.showMore) { delete params.showMore; this.isShowingMore = true; } else { this.isShowingMore = false; } if (this.isShowingMore) { this.pauseEvents(); this.setPage(this.page +1); this.resumeEvents(); } if (!_.isNil(params) && _.isObject(params)) { this.setParams(params); } const repository = this, url = this.getModel() + '/' + this.api.get, data = _.merge({}, this._baseParams, this._params); return this._send(this.methods.get, url, data) .then(result => { if (this.debugMode) { console.log('Response for ' + this.name, result); } if (this.isDestroyed) { // If this repository gets destroyed before it has a chance // to process the Ajax request, just ignore the response. return; } const { root, success, total, message } = this._processServerResponse(result); const oThis = this; if (this.isShowingMore) { // Add to the current entities const newEntities = _.map(root, (data) => { const entity = Repository._createEntity(oThis.schema, data, repository, true); oThis._relayEntityEvents(entity); return entity; }); this.entities = this.entities.concat(newEntities); } else { // Replace the current entities this._destroyEntities(); this.entities = _.map(root, (data) => { const entity = Repository._createEntity(oThis.schema, data, repository, true); oThis._relayEntityEvents(entity); return entity; }); } // Set the total records that pass filter this.total = total; this._setPaginationVars(); this.markLoaded(); if (this.isTree) { this.assembleTreeNodes(); } this.emit('changeData', this.entities); this.emit('load', this); if (callback) { callback(this.entities); } }) .finally(() => { this.markLoading(false); }); } showMore(params = {}, callback) { params.showMore = true; return this.load(params, callback); } /** * Reload a single entity from storage. * If the entity is in the internal representation, update it. * @param {function} callback - Function to call after loading is complete * @returns {entity} The newly updated entity * @fires reloadEntity,beforeLoad,changeData,load,error */ async reloadEntity(entity, callback = null) { // use this notation so we can override it in subclasses if (this.isDestroyed) { this.throwError('this.reloadEntity is no longer valid. Repository has been destroyed.'); return; } if (!this.api.get) { this.throwError('No "get" api endpoint defined.'); return; } this.emit('beforeLoad'); // TODO: canceling beforeLoad will cancel the load operation this.markLoading(); const params = this._getReloadEntityParams(entity); if (this.debugMode) { console.log('reloadEntity ' + entity.id, params); } const url = entity.getModel() + '/' + this.api.get; return this._send(this.methods.get, url, params) .then(result => { if (this.debugMode) { console.log('reloadEntity response ' + entity.id, result); } const { root, success, total, message } = this._processServerResponse(result); if (!success) { this.throwError(message, root); return; } const updatedData = root[0]; entity.loadOriginalData(updatedData); entity.emit('reload', entity); this.markLoaded(); this.emit('changeData', this.entities); this.emit('load', this); this.emit('reloadEntity', entity); if (callback) { callback(entity); } }) .finally(() => { this.markLoading(false); }); } /** * Helper for reloadEntity. * Subclasses may override this to provide additional * or differing functionality. * @private */ _getReloadEntityParams(entity) { const params = { id: entity.id, }; return _.assign({}, this._baseParams, params); } /** * Helper for save. * @private */ _onBeforeSave() { this._operations = { add: false, edit: false, delete: false, deletePhantom: false, }; } /** * Helper for save. * @returns {promise} - Axios Promise. * @private */ _doAdd(entity) { // standard function notation if (!this.api.add) { this.throwError('No "add" api endpoint defined.'); return; } if (!this.canAdd) { this.throwError('Adding has been disabled on this repository.'); return; } this._operations.add = true; entity.isSaving = true; const method = this.methods.add, url = entity.getModel() + '/' + this.api.add, data = entity.getSubmitValues(); return this._send(method, url, data) .then(result => { if (this.debugMode) { console.log(this.api.add + ' response', result); } const { root, success, total, message } = this._processServerResponse(result); entity.isSaving = false; if (!success) { this.throwError(message, root); return; } entity.loadOriginalData(root[0]); if (entity.isRemotePhantomMode) { entity.isRemotePhantom = true; } if (this.isTree) { this.assembleTreeNodes(); } }); } /** * Helper for save. * Add multiple entities to storage medium * @param {array} entities - Entities * @returns {promise} - Axios Promise. * @private */ _doBatchAdd(entities) { // standard function notation if (!this.api.batchAdd) { this.throwError('No "batchAdd" api endpoint defined.'); return; } if (!this.canAdd) { this.throwError('Adding has been disabled on this repository.'); return; } this._operations.add = true; const method = this.methods.add, url = this.getModel() + '/' + this.api.batchAdd, data = { entities: _.map(entities, entity => { const values = entity.submitValues; entity.isSaving = true; return values; }), }; return this._send(method, url, data) .then(result => { if (this.debugMode) { console.log(this.api.batchAdd + ' response', result); } const { root, success, total, message } = this._processServerResponse(result); _.each(entities, (entity) => { entity.isSaving = false; }); if (!success) { this.throwError(message, root); return; } // Reload each entity with new data // TODO: Check this _.each(entities, (entity, ix) => { entity.loadOriginalData(root[ix]); if (entity.isRemotePhantomMode) { entity.isRemotePhantom = true; } }); if (this.isTree) { this.assembleTreeNodes(); } }); } /** * Helper for save. * @returns {promise} - Axios Promise. * @private */ _doEdit(entity) { // standard function notation if (!this.api.edit) { this.throwError('No "edit" api endpoint defined.'); return; } if (!this.canEdit) { this.throwError('Editing has been disabled on this repository.'); return; } this._operations.edit = true; entity.isSaving = true; const method = this.methods.edit, url = entity.getModel() + '/' + this.api.edit, data = entity.getSubmitValues(); return this._send(method, url, data) .then(result => { if (this.debugMode) { console.log(this.api.edit + ' response', result); } const { root, success, total, message } = this._processServerResponse(result); entity.isSaving = false; if (!success) { this.throwError(message, root); return; } entity.loadOriginalData(root[0]); if (entity.isRemotePhantomMode && entity.isRemotePhantom) { entity.isRemotePhantom = false; } if (this.isTree) { this.assembleTreeNodes(); } }); } /** * Helper for save. * Edit multiple entities in storage medium * @param {array} entities - Entities * @returns {promise} - Axios Promise. * @private */ _doBatchEdit(entities) { // standard function notation if (!this.api.batchEdit) { this.throwError('No "batchEdit" api endpoint defined.'); return; } if (!this.canEdit) { this.throwError('Editing has been disabled on this repository.'); return; } this._operations.edit = true; const method = this.methods.edit, url = this.getModel() + '/' + this.api.batchEdit, data = { entities: _.map(entities, entity => { const values = entity.submitValues; entity.isSaving = true; return values; }), }; return this._send(method, url, data) .then(result => { if (this.debugMode) { console.log(this.api.batchEdit + ' response', result); } const { root, success, total, message } = this._processServerResponse(result); _.each(entities, (entity) => { entity.isSaving = false; }); if (!success) { this.throwError(message, root); return; } // Reload each entity with new data // TODO: Check this _.each(entities, (entity, ix) => { entity.loadOriginalData(root[ix]); if (entity.isRemotePhantomMode && entity.isRemotePhantom) { entity.isRemotePhantom = false; } }); if (this.isTree) { this.assembleTreeNodes(); } }); } /** * Helper for save. * @returns {promise} - Axios Promise. * @private */ _doDelete(entity) { // standard function notation if (!this.api.delete) { this.throwError('No "delete" api endpoint defined.'); return; } if (!this.canDelete) { this.throwError('Deleting has been disabled on this repository.'); return; } if (entity.isRemotePhantomMode && entity.isPhantom) { this._operations.deletePhantom = true; } else { this._operations.delete = true; } entity.isSaving = true; const method = this.methods.delete, url = entity.getModel() + '/' + this.api.delete, data = { id: entity.id, }; if (this.isTree && this.moveSubtreeUp) { data.moveSubtreeUp = this.moveSubtreeUp; } return this._send(method, url, data) .then(result => { if (this.debugMode) { console.log(this.api.delete + ' response', result); } const { root, success, total, message } = this._processServerResponse(result); entity.isSaving = false; if (!success) { this.throwError(message, root); return; } // Delete it from this.entities const id = entity.id; this.entities = _.filter(this.entities, (entity) => entity.id !== id); entity.destroy(); if (this.isTree) { this.assembleTreeNodes(); } }); } /** * Helper for save. * Delete multiple entities from storage medium * @param {array} entities - Entities * @returns {promise} - Axios Promise. * @private */ _doBatchDelete(entities) { // standard function notation if (!this.api.batchDelete) { this.throwError('No "batchDelete" api endpoint defined.'); return; } if (!this.canDelete) { this.throwError('Deleting has been disabled on this repository.'); return; } this._operations.delete = true; // NOTE: We don't use batchDelete for remotePhantom records const method = this.methods.delete, url = this.getModel() + '/' + this.api.batchDelete, ids = _.map(entities, (entity) => { entity.isSaving = true; return entity.id; }), data = { ids, }; return this._send(method, url, data) .then(result => { if (this.debugMode) { console.log(this.api.batchDelete + ' response', result); } const { root, success, total, message } = this._processServerResponse(result); _.each(entities, (entity) => { entity.isSaving = false; }); if (!success) { this.throwError(message, root); return; } // Delete it from this.entities this.entities = _.filter(this.entities, (entity) => { const deleteIt = ids.includes(entity.id); if (deleteIt) { entity.destroy(); } return !deleteIt; }); if (this.isTree) { this.assembleTreeNodes(); } }); } /** * Helper for save. * Tells repository to delete entity without ever having saved it * to storage medium * @private */ _doDeleteNonPersisted(entity) { this.entities = _.filter(this.entities, (item) => { const match = item === entity; if (match) { entity.destroy(); } return !match; }); if (this.isTree) { this.assembleTreeNodes(); } return true; } /** * Helper for _do* save operations. * Fires off axios request to server * @private */ _send(method, url, data) { if (!url) { this.throwError('No url submitted'); return; } if (!this.isOnline) { this.throwError('Offline'); return; } const headers = _.merge({ 'Content-Type': 'application/json', 'Accept': 'application/json', }, this.headers); const options = { url, method, baseURL: this.api.baseURL, transformResponse: null, headers, params: method === 'GET' ? data : null, data: method !== 'GET' ? qs.stringify(data) : null, timeout: this.timeout, }; if (this.debugMode) { console.log(url, options); } this.lastSendOptions = options; return this.axios(options) .catch(error => { if (this.debugMode) { console.log(url + ' error', error); console.log('response:', error.response); } this.throwError(error); return; }); } /** * Helper for _send. * Handles server's response to _send(). * This is basically just looking for errors. * @fires error * @private */ _processServerResponse(result) { return this.reader.read(result); } /** * Sorts the items in the current page of memory * This is mainly used to sort isPhantom entities, * since the server normally sorts, and they haven't yet gone to server. */ sortInMemory() { const sorters = this.sorters; let sortNames = [], sortDirections = []; _.each(sorters, (sorter) => { sortNames.push(sorter.name); sortDirections.push(sorter.direction.toLowerCase()); }); let entities = this.entities; entities = _.orderBy(entities, sortNames, sortDirections); this.entities = entities; } /** * Helper for save. * Takes an array of Promises from axios. When they are all resolved, * emit save. * @param {array} promises - Results of batch operations * @fires save, changeData * @private */ _finalizeSave(promises) { if (!promises.length) { return; } return Promise.all(promises) .then(this.axios.spread((...batchOperationResults) => { // All requests are now complete this.isSaving = false; this.emit('save', batchOperationResults); // Do we need to reload? if (!this.eventsPaused) { if (this.isTree) { this.assembleTreeNodes(); this.emit('changeData', this.entities); } else if (this.isRemotePhantomMode && (this._operations.add || this._operations.deletePhantom)) { if (this._operations.add) { // Do nothing, as we don't want to immediately reload on add for a remote phantom mode record. // The entity wouldn't appear, and it would cause all kinds of trouble! } if (this._operations.deletePhantom) { // sweep existing deleted records and remove them const oThis = this; _.each(this.entities, (entity) => { if (entity.isDeleted && entity.isDestroyed) { oThis.removeEntity(entity); } }) } } else if (this._operations.delete) { this.reload(); } else { this.emit('changeData', this.entities); } } })); } /** * Clears all state and storage of entities, as though nothing was ever loaded. */ clearAll() { this._destroyEntities(); this.entities = []; this.total = 0; this._setPaginationVars(); this.markUnloaded(); this.emit('changeData', this.entities); } setIsOnline(isOnline) { this.isOnline = !!isOnline; // force convert type to boolean } } AjaxRepository.className = 'Ajax'; AjaxRepository.type = 'ajax'; export default AjaxRepository;