import invariant from 'invariant' const createTypes = (prefix) => { const c = (str) => `${prefix.toUpperCase()}_${str.toUpperCase()}` return { ACTION_CALL: c('call_function'), ACTION_SUCCESS: c('call_function_success'), ACTION_FAIL: c('call_function_failure'), ACTION_LISTEN: c('listen'), ACTION_UNLISTEN: c('unlisten'), ACTION_REMOVE: c('remove'), ACTION_UPDATE: c('update'), ACTION_SET: c('set'), ACTION_GET: c('get'), ITEM_VALUE: c('value'), ITEM_ADDED: c('added'), ITEM_REMOVED: c('remove'), ITEM_CHANGED: c('changed'), UPDATED: c('updated') } } const defaultToObject = child => ({_key: child.key, ...child.val()}) const identity = (i) => i const defaultSortFn = (a, b) => a.timestamp < b.timestamp const defaultInitialState = { items: [], } export class FirestackModule { constructor(refName, opts={}) { invariant(refName && typeof refName !== 'undefined', 'No ref name passed'); this._refName = refName; this._makeRef = opts.makeRef || identity; const initialState = Object.assign({}, opts.initialState || defaultInitialState, { listening: false, items: [] }) this._localState = initialState; this._types = createTypes(this._refName); this._toObject = opts.toObject || defaultToObject this._sortFn = opts.sortFn || defaultSortFn this._onChange = opts.onChange || identity; if (opts.firestack) { this.setFirestack(opts.firestack); } else if (opts.store) { this.setStore(opts.store); } } makeRef(path) { const refName = [this._refName, path] const ref = this._firestack.database.ref(...refName); return this._makeRef(ref); } setFirestack(firestack) { if (firestack) { this._firestack = firestack; } } setStore(store) { if (store) { this._store = store; } } /* * Actions */ listen(cb) { let store = this._getStore(); invariant(store, 'Please set the store'); const T = this._types; const listenRef = this.makeRef(); const toObject = this._toObject; const _itemAdded = (snapshot, prevKey) => { const state = this._getState(); // local state const newItem = toObject(snapshot, state); let list = state.items || []; list.push(newItem) list = list.sort(this._sortFn) return this._handleUpdate(T.ITEM_ADDED, {items: list}, cb); } const _itemRemoved = (snapshot, prevKey) => { const state = this._getState(); // local state const itemKeys = state.items.map(i => i._key); const itemIndex = itemKeys.indexOf(snapshot.key); let newItems = [].concat(state.items); newItems.splice(itemIndex, 1); let list = newItems.sort(this._sortFn) return this._handleUpdate(T.ITEM_REMOVED, {items: list}, cb); } const _itemChanged = (snapshot, prevKey) => { const state = this._getState() const existingItem = toObject(snapshot, state); let list = state.items; let listIds = state.items.map(i => i._key); const itemIdx = listIds.indexOf(existingItem._key); list.splice(itemIdx, 1, existingItem); return this._handleUpdate(T.ITEM_CHANGED, {items: list}, cb); } return new Promise((resolve, reject) => { listenRef.on('child_added', _itemAdded); listenRef.on('child_removed', _itemRemoved); listenRef.on('child_changed', _itemChanged); this._handleUpdate(T.ACTION_LISTEN, null, (state) => { resolve(state) }) }) } unlisten() { const T = this._types; const ref = this.makeRef(); return new Promise((resolve, reject) => { ref.off() .then((success) => { this._handleUpdate(T.ACTION_UNLISTEN, null, (state) => { resolve(state) }) }); }) } // TODO: Untested getAt(path, cb) { const T = this._types; const ref = this.makeRef(path); const toObject = this._toObject; return new Promise((resolve, reject) => { ref.once('value', snapshot => { this._handleUpdate(T.ACTION_GET, null, (state) => { if (cb) { cb(toObject(snapshot, state)); } resolve(state) }) }, reject); }); } setAt(path, value, cb) { const T = this._types; const ref = this.makeRef(path); const toObject = this._toObject; return new Promise((resolve, reject) => { ref.setAt(value, (error) => { this._handleUpdate(T.ACTION_SET, null, (state) => { if (cb) { cb(error, value); } return error ? reject(error) : resolve(value) }); }) }); } updateAt(path, value, cb) { const T = this._types; const ref = this.makeRef(path); const toObject = this._toObject; return new Promise((resolve, reject) => { ref.updateAt(value, (error, snapshot) => { this._handleUpdate(T.ACTION_UPDATE, null, (state) => { if (cb) { cb(toObject(snapshot, state)); } return error ? reject(error) : resolve(value) }); }); }); } removeAt(path, cb) { const T = this._types; const ref = this.makeRef(path); const toObject = this._toObject; return new Promise((resolve, reject) => { ref.removeAt((error, snapshot) => { this._handleUpdate(T.ACTION_SET, null, (state) => { if (cb) { cb(toObject(snapshot, state)); } return error ? reject(error) : resolve(value) }); }); }); } // hackish, for now get actions() { const T = this._types; const wrap = (fn) => (...args) => { const params = args && args.length > 0 ? args : []; const promise = fn.bind(this)(...params) return {type: T.ACTION_CALL, payload: promise} } return [ 'listen', 'unlisten', 'getAt', 'setAt', 'updateAt', 'removeAt' ].reduce((sum, name) => { return { ...sum, [name]: wrap(this[name]) } }, {}) } get initialState() { return this._initialState; } get types() { return this._types } get reducer() { const T = this._types; return (state = this._localState, {type, payload, meta}) => { if (meta && meta.module && meta.module === this._refName) { switch (type) { case T.ACTION_LISTEN: return ({...state, listening: true}); case T.ACTION_UNLISTEN: return ({...state, listening: false}); default: return {...state, ...payload}; } } return state; } } /** * Helpers **/ _handleUpdate(type, newState = {}, cb = identity) { const store = this._getStore(); if (store && store.dispatch && typeof store.dispatch === 'function') { store.dispatch({type, payload: newState, meta: { module: this._refName }}) } return cb(newState); } _getStore() { return this._store ? this._store : (this._firestack ? this._firestack.store : null); } _getState() { const store = this._getStore(); return store.getState()[this._refName]; } } export default FirestackModule