@ortoo/angular-resource
Version:
Magical CRUD resource management for angular
382 lines (314 loc) • 11.7 kB
text/typescript
// tslint:disable only-arrow-functions
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { Subject } from 'rxjs/Subject';
import { EventEmitter, Handler, Subscription } from './emitter';
import * as utils from './utils';
import { Chain } from './chain';
import { ChainableQuery,
Collection,
CollectionSearcher,
LocalForage,
LocalModel,
LocalResourceFactory,
Model,
ModelArray,
Plugin,
Query,
QueryFunction,
ResourceFactory as IResourceFactory} from './types';
type LastRequest = (Date | number);
// The length of time since an object has been requested that we keep in persistent storage.
// In milliseconds - default to 7 days
export const defaultPStorageMaxLen = 7 * 24 * 60 * 60 * 1000;
export function createResourceFactory(LocalResourceFactory: LocalResourceFactory,
localforage: LocalForage,
plugins: Array<Plugin<Model>>,
pStorageMaxLen: number = defaultPStorageMaxLen): IResourceFactory<Model> {
const ResourceFactory: IResourceFactory<Model> = (url: string, rootKey: string, rootKeyPlural?: string) => {
rootKeyPlural = rootKeyPlural || (rootKey + 's');
// Create the local resource
let LocalResource = LocalResourceFactory(url, rootKey, rootKeyPlural);
// In memory resource store
let _resources: {[id: string]: Model} = {};
let _resFromLocal: {[$id: string]: Model} = {};
// Create an event emitter
let emitter = new EventEmitter();
// Load from persistent storage (if we have something more exciting than localstorage)
if (utils.advancedStorage(localforage)) {
let pStorageKey = utils.persistentStorageKey(url);
localforage.getItem(pStorageKey).then(function(data) {
if (!(data && Array.isArray(data))) {
return;
}
let now = new Date().getTime();
data.forEach(function(datum) {
let obj = datum.obj;
let lastRequested = datum.lastreq;
// Only create if the time between now and the last request is less than
// pStorageMaxLen
if ((now - lastRequested) <= pStorageMaxLen) {
createFromStorage(obj, lastRequested);
}
});
});
}
function localToRes(loc: LocalModel, id: string) {
let res = _resFromLocal[loc.$id];
// If we don't have a resource then create it
if (!res) {
res = new (<any> Resource)(loc, true);
}
if (id) {
addedResource(id, res);
}
return res;
}
function toObject(this: Model) {
return utils.toObject(this);
}
function reset(this: Model) {
// If we aren't saved at all then just remove
if (!this.$created) {
this.$remove(true);
return;
}
let newValues = this.$loc.$toObject();
utils.removeResValues(this);
utils.setResValues(this, newValues);
}
// The promise resolves once the save has been committed
function save(this: Model) {
let res = this;
res.$created = true;
return this.$loc.$save(this.$toObject()).then(function() {
emitter.emit('updated', res);
res.$subject.next(res);
if (!res.$resolved) {
res.$deferred.resolve(res);
res.$resolved = true;
}
return res;
});
}
function remove(this: Model, noPrompt?: boolean, skipLocal?: boolean) {
if (!noPrompt) {
// Add confirmation alert
if (!window.confirm('Do you really want to delete this item?')) {
return;
}
}
this.$deleted = true;
if (this._id) {
removedResource(this._id, this);
}
let prom;
if (skipLocal) {
prom = Promise.resolve(true);
} else {
prom = this.$loc.$remove();
}
emitter.emit('remove', this);
return prom;
}
function type() {
return rootKey[0].toUpperCase() + rootKey.slice(1);
}
function collection(seeds: Model | ModelArray<Model>, relation: CollectionSearcher): Collection<Model> {
return LocalResource.collect(Resource, seeds, relation);
}
function chain(origQry: ChainableQuery<Model>,
modelOrQryFn: (typeof Resource | QueryFunction),
qryFn?: QueryFunction): Query<Model> {
return Chain(origQry, modelOrQryFn, qryFn);
}
function query(qry: any, limit?: number) {
let result = LocalResource.query(qry, limit, Resource);
emitter.emit('query', result, qry, limit);
return result;
}
function get(id: string, force?: boolean): Model;
function get(ids: string[], force?: boolean): ModelArray<Model>;
function get(ids: (string | string[]), force?: boolean) {
let results = LocalResource.get(ids, force, localToRes);
emitter.emit('get', ids, results, force);
return results;
}
function addedResource(id: string, res: Model) {
let existing = _resources[id];
_resources[id] = res;
if (!existing) {
emitter.emit('created', id, res);
}
}
function removedResource(id: string, res: Model) {
let existing = _resources[id];
delete _resources[id];
if (existing) {
emitter.emit('deleted', id, res);
res.$subject.next(null);
}
}
function updatedLocal(res: Model, newVal: any, oldVal: any) {
// We could have been deleted (existing oldVal, null newval)
if (oldVal && !newVal) {
// We have been deleted. Cleanup ourselves
res.$remove(true, true);
} else {
res.$created = true;
if (oldVal._id && (newVal._id !== oldVal._id)) {
throw new Error('Not allowed to change id');
}
// Merge the objects together using the oldVal as a base, and using *our* version
// to resolve any conflicts. We may need to put in some conflict resolution logic
// somewhere on a case by case basis
let merge = utils.mergeObjects(res.$toObject(), oldVal, newVal);
// Now put back in the merge values
utils.removeResValues(res);
utils.setResValues(res, merge);
// Make sure we are stored
addedResource(res._id, res);
}
// Notify on the subject that we've updated
res.$subject.next(res);
}
function updateServer(this: Model, val: any) {
this.$loc.$updateServer(val);
}
// updates or creates a model depending on whether we know about it already. This
// is usually used when we recieve a response with model data from the server using
// a non-standard method
function updateOrCreate(val: any) {
let res = _resources[val._id];
if (!res) {
res = new (<any> Resource)(val, false, new Date().getTime());
} else {
res.$updateServer(val);
}
return res;
}
function createFromStorage(val: any, lastRequested: LastRequest) {
let res = _resources[val._id];
// If we do have already know about a resource then lets assume it is more up to date
// than the storage version. If we haven't resolved yet though (or we don't yet have
// an ID on the object) lets update our values
if (!res) {
res = new (<any> Resource)(val, false, lastRequested);
} else if (!res._id || !res.$resolved) {
res.$updateServer(val);
}
return res;
}
function refresh() {
this.$loc.$refresh();
}
class Resource implements Model {
public static get = get;
public static query = query;
public static chain = chain;
public static type = type;
public static updateOrCreate = updateOrCreate;
public static collect = collection;
public static dispose: {(): void};
public static addListener: {(name: string, handler: Handler): void};
public static emit: {(fnName: string, ...data: any[]): void};
public static listen: {(name: string, handler: Handler): Subscription};
public static off: {(name: string, handler: Handler): void};
public static on: {(name: string, handler: Handler): void};
public static once: {(name: string, handler: Handler): void};
public static removeListener: {(name: string, handler: Handler): void};
public _id?: string;
public $created: boolean;
public $deleted: boolean;
public $promise: Promise<Resource>;
public $subject: BehaviorSubject<Resource>;
public $resolvedSubject: Subject<Resource>;
public $deferred: utils.Deferred<Resource>;
public $resolved: boolean;
public $loc: LocalModel;
public $id: string;
public $save = save;
public $remove = remove;
public $delete = remove;
public $type = type;
public $reset = reset;
public $refresh = refresh;
public $toObject = toObject;
public $updateServer = updateServer;
constructor(val?: any, fromLoc?: boolean, lastRequested?: LastRequest) {
let res = this;
this.$deleted = false;
this.$deferred = new utils.Deferred();
this.$promise = this.$deferred.promise; // An initial promise for our initial
// fetch or create of data
this.$resolved = false; // Have we had an initial resolution of the promise
this.$created = false; // Goes true on an initial save
this.$subject = new BehaviorSubject(this);
this.$resolvedSubject = utils.convertToResolvedSubject(this.$subject, this.$promise);
// If we've been given values put them on
if (val) {
let props = fromLoc ? val.$toObject() : val;
for (let key of Object.keys(props)) {
res[key] = props[key];
}
}
// Create the local resource
if (fromLoc) {
this.$loc = val;
this.$loc.$mod = this;
// Resolve the promise once the local copy has resolved
this.$loc.$promise.then(function() {
if (!res.$resolved) {
res.$deferred.resolve(res);
res.$resolved = true;
}
});
} else if (this._id) {
// We have an id - so we persist down
this.$loc = new (<any> LocalResource)(val, false, this, lastRequested);
// We immediately resolve our promise since we have data
this.$deferred.resolve(this);
this.$resolved = true;
} else {
// Don't add in the values until we save
this.$loc = new (<any> LocalResource)(null, false, this);
}
this.$id = this.$loc.$id;
_resFromLocal[this.$id] = this;
// If we have an id then add us to the store
if (this._id) {
addedResource(this._id, this);
}
// Listen for changes on the local resource
this.$loc.$emitter.on('update', function(newVal, oldVal) {
updatedLocal(res, newVal, oldVal);
});
}
}
// Trigger any plugins
for (let plugin of plugins) {
plugin(Resource, rootKey);
}
inheritEventEmitter(Resource, emitter);
return Resource;
};
ResourceFactory.Chain = Chain;
return ResourceFactory;
}
function inheritEventEmitter(obj: any, emitter: EventEmitter) {
let props = [
'dispose',
'addListener',
'emit',
'listen',
'off',
'on',
'once',
'removeListener',
];
// Event emitter 'inheritance'
for (let prop of props) {
obj[prop] = function() {
return emitter[prop].apply(emitter, arguments);
};
}
}