Skip to content

Instantly share code, notes, and snippets.

@codinronan
Last active June 9, 2018 00:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save codinronan/fcf57b35a37f1aaa287486e274aa65d9 to your computer and use it in GitHub Desktop.
Save codinronan/fcf57b35a37f1aaa287486e274aa65d9 to your computer and use it in GitHub Desktop.
Decorator for Ionic Storage
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);
}
}
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);
}
}
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