Last active
June 9, 2018 00:20
-
-
Save codinronan/fcf57b35a37f1aaa287486e274aa65d9 to your computer and use it in GitHub Desktop.
Decorator for Ionic Storage
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Injectable, NgZone } from '@angular/core'; | |
import { Observable } from 'rxjs/Observable'; | |
import { ReplaySubject } from 'rxjs/ReplaySubject'; | |
// We use a ReplaySubject instead of a Subject or BehaviorSubject because we want to ensure 2 things: | |
// 1. That new subscribers receive any current values | |
// 2. That if the subject has never been invoked, no value is passed when a subscriber is wired up. | |
// Subject does not provide replay behavior (providing the current value), and BehaviorSubject | |
// requires an initial value, which may not be appropriate to the event. | |
// Therefore ReplaySubject, with a 1-element window, is the correct choice. | |
let serviceInstance = null; | |
@Injectable() | |
export class EventService { | |
private subjects: ReplaySubject<any>[] = []; | |
debugEnabled: boolean = false; | |
constructor(public zone?: NgZone) { | |
// Force this class to be a singleton, since it is instantiated outside the angular DI loop | |
// by the IonicStorage decorator. | |
if (serviceInstance) { | |
if (zone && !serviceInstance.zone) { | |
serviceInstance.zone = zone; | |
} | |
return serviceInstance; | |
} | |
serviceInstance = this; | |
} | |
publish(eventName: string, data?: any) { | |
// ensure a subject for the event name exists | |
this.subjects[eventName] = this.subjects[eventName] || new ReplaySubject<any>(1); | |
this.debugEnabled && console.log(`Event '${eventName}' published`); | |
// publish event | |
this.zone.run(() => { | |
this.subjects[eventName].next(data); | |
}); | |
} | |
on(eventName: string): Observable<any> { | |
// ensure a subject for the event name exists | |
this.subjects[eventName] = this.subjects[eventName] || new ReplaySubject<any>(1); | |
// return observable | |
return this.subjects[eventName].asObservable(); | |
} | |
reset(eventName: string) { | |
const currentSubject = this.subjects[eventName] as ReplaySubject<any>; | |
if (!currentSubject) { return; } | |
const currentObservers = currentSubject.observers; | |
this.subjects[eventName] = new ReplaySubject<any>(1); | |
(this.subjects[eventName] as ReplaySubject<any>).observers.push(...currentObservers); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Injectable } from '@angular/core'; | |
import { IonicStorage } from '../decorators/ionic-storage.decorator'; | |
import { EventService } from '../providers/events.service'; | |
// Demo of using the decorator. Dead simple. If you don't load the data, | |
// or delay it until some later point, the `items` field will be populated | |
// from Ionic Storage and made available. | |
@Injectable() | |
export class DataService { | |
@IonicStorage() public items: posts[]; | |
constructor( | |
private eventService: EventService, | |
) { | |
this.getData(); | |
// This event is raised when the items have been read in from storage, if you need it. This is just for convenience. | |
// The 'items' field is populated for you whether you listen to this event or not! | |
// The 'items' part of the event name comes from your class's field name: this will always match the field name you decorate. | |
this.eventService.on('items:loaded').subscribe((data) => { console.log('Got items from Ionic Storage: ', data); }); | |
} | |
async getData() { | |
const response = await fetch('https://jsonplaceholder.typicode.com/posts'); | |
this.items = await response.json(); | |
this.eventsService.publish('data:posts:received', this.items); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Storage } from '@ionic/storage'; | |
import isPlainObject from 'lodash.isplainobject'; | |
import { EventService } from './events.service'; | |
const eventService: EventService = new EventService(); | |
const debug = console; // My local version uses ts-debug but to keep down dependencies.. | |
// The functionality in this file is based on ngx-store but heavily customized | |
// for mobile usage. An eventual 'TODO' is to bring the two in line and offer a | |
// PR back to ngx-store, adding the Ionic Storage functionality (or any async | |
// storage medium, really). | |
const storageConfig = { | |
name: 'ionapp_localdb', | |
storeName: '_ionic_kv', | |
driverOrder: ['sqlite', 'indexeddb', 'websql', 'localstorage'] | |
}; | |
const arrayMethodsToOverwrite = [ | |
'pop', 'push', 'reverse', 'shift', 'unshift', 'splice', | |
'filter', 'forEach', 'map', 'fill', 'sort', 'copyWithin' | |
]; | |
export declare class Webstorable { | |
save(): void; | |
} | |
export type WebstorableArray<T> = Webstorable & Array<T>; | |
export interface IonicStorageDecoratorConfig { | |
verbose?: boolean; | |
emitValue?: boolean; // If the event emitter will display the value loaded in the console. | |
} | |
const CacheEntries = new Map<string, boolean>(); | |
export function IonicStorage(props: IonicStorageDecoratorConfig = { verbose: false, emitValue: false }) { | |
// Not using 'props' for now, but this way the decorator surface does not have to change. | |
// It could be used to specify functions to override, etc. | |
return function IonicStorageDecorator(target: any, propertyName: string): void { | |
const key = `${propertyName}`; // not all constructors have names. | |
let value = target[propertyName]; | |
const container = new StorageContainer(key); | |
const getter = () => { | |
if (props.verbose) { debug.log(`getter: ${target.constructor.name}.${key}: `, value); } | |
return value; | |
}; | |
const setter = (newVal: any) => { | |
if (props.verbose) { | |
debug.groupCollapsed(`IonicStorageDecorator#set for ${key} in ${target.constructor.name}`); | |
debug.log('New value: ', newVal); | |
debug.log('previous value: ', value); | |
debug.log('currentTarget:', target); | |
debug.groupEnd(); | |
} | |
value = newVal; | |
// Fix for older browsers in which bools and strings are not actually objects | |
// Technically we can allow functions here too but those won't have anything | |
// in them worth serializing.. | |
if (value && typeof value === 'object') { | |
// add method for triggering force save | |
const prototype: any = Object.assign(new value.constructor(), value.__proto__); | |
prototype.save = () => { container.save(value); }; | |
if (Array.isArray(value)) { // handle methods that could change value of array | |
for (let method of arrayMethodsToOverwrite) { | |
prototype[method] = function () { | |
// ngx-store reads the latest value here because it provides a 2-way store. | |
// We instead assume that the cached 'value' is always the latest, and so these operations | |
// only ever need to be write-only. | |
const result = Array.prototype[method].apply(value, arguments); | |
debug.log(`Saving value for ${key} by method ${prototype.constructor.name}.${method}`); | |
container.save(value); | |
return result; | |
}; | |
} | |
} | |
Object.setPrototypeOf(value, prototype); | |
} | |
// This is the only line that is STRICTLY required. The rest of that stuff makes this | |
// decorator way more useful.. | |
container.save(value); | |
}; | |
Object.defineProperty(target, propertyName, { | |
get: getter, | |
set: setter, | |
}); | |
container.load().then(val => { | |
setter(val); | |
// This is to ensure that the publish event only occurs once. | |
// if (!CacheEntries.get(key)) { | |
CacheEntries.set(key, true); | |
// We use the PubSubService instead of a normal observable, | |
// because that service ensures that these events cause a UI refresh using NgZone | |
if (props.emitValue) { | |
eventService.publish(`${key}:loaded`, val); | |
} else { | |
eventService.publish(`${key}:loaded`); | |
} | |
// } | |
}); | |
}; | |
} | |
export class StorageContainer { | |
private static storage: Storage = null; | |
private key: string = null; | |
constructor(key: string) { | |
this.key = key; | |
if (!StorageContainer.storage) { | |
StorageContainer.storage = new Storage(storageConfig); | |
} | |
} | |
save(data: any) { | |
StorageContainer.storage.set(this.key, data); | |
} | |
load() { | |
// return this.storage.get(this.key) | |
return StorageContainer.storage.get(this.key) | |
.then((storageData?: any) => { | |
// Actually, we want the code flow to continue even if this value is not found | |
// if (!storageData) { return; } | |
return storageData; | |
}) | |
.catch(() => {}); | |
} | |
} | |
class StorageManager { | |
private storage: Storage = new Storage(storageConfig); | |
constructor() { | |
if (window) { | |
window['IonicStorageManager'] = this; | |
} | |
} | |
clear() { return this.storage.clear(); } | |
forEach(iterator: (value: any, key: string, index: number) => any) { return this.storage.forEach(iterator); } | |
get(key: string) { return this.storage.get(key); } | |
keys() { return this.storage.keys(); } | |
length() { return this.storage.length(); } | |
ready() { return this.storage.ready(); } | |
remove(key: string) { return this.storage.remove(key); } | |
set(key: string, value: any) { return this.storage.set(key, value); } | |
import(data) { | |
if (!this.isSerializable(data)) { return Promise.reject('Object is not serializable'); } | |
const promises = []; | |
for (var key in data) { | |
promises.push(this.storage.set(key, data[key])); | |
} | |
return Promise.all(promises); | |
} | |
toJSON() { | |
return new Promise((resolve, reject) => { | |
const container = {}; | |
this.forEach((value, key) => { | |
if (this.isSerializable(value)) { | |
container[key] = value; | |
} | |
}) | |
.then(() => { | |
resolve(container); | |
}) | |
.catch(error => { | |
reject(error); | |
}); | |
}); | |
} | |
isSerializable(obj) { | |
function isPlain(val) { | |
return (val === undefined | |
|| val === null | |
|| typeof val === 'string' | |
|| typeof val === 'boolean' | |
|| typeof val === 'number' | |
|| Array.isArray(val) | |
|| isPlainObject(val)); | |
} | |
if (!isPlain(obj)) { | |
return false; | |
} | |
for (var property in obj) { | |
if (obj.hasOwnProperty(property)) { | |
if (!isPlain(obj[property])) { | |
return false; | |
} | |
if (typeof obj[property] == "object") { | |
const isNestedSerializable = this.isSerializable(obj[property]); | |
if (!isNestedSerializable) { | |
return false; | |
} | |
} | |
} | |
} | |
return true; | |
} | |
} | |
export const IonicStorageManager = new StorageManager(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment