Skip to content

Commit

Permalink
Lots more tests and some other changes
Browse files Browse the repository at this point in the history
  • Loading branch information
pcardune committed Dec 7, 2016
1 parent 669f464 commit 5af03ff
Show file tree
Hide file tree
Showing 11 changed files with 235 additions and 132 deletions.
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,17 @@ store.dispatch(reduxFirebaseMirror.subscribeToValues([
### Looking up mirrored data

Any firebase paths that are subscribed to will be mirrored directly in your
redux store, so you can look them up with the `getFirebaseMirror()` function:
redux store, so you can look them up with the `valueAtPath()` function:

```
const mirror = reduxFirebareMirror.getFirebaseMirror(store.getState());
mirror.getIn(['first', 'path', 'to', 'mirror']);
```jsx
reduxFirebareMirror.valueAtPath(store.getState(), 'first/path/to/mirror');
```

Note that rather than storing the plain json values returned from firebase,
`redux-firebase-mirror` converts them
to [immutable-js](https://facebook.github.io/immutable-js/) objects.

### Usage with react
## Usage with react

`redux-firebase-mirror` provides a `subscribePaths` higher order component to
make it simple to declaratively specify what paths a particular react component
Expand Down
31 changes: 31 additions & 0 deletions src/Subscription.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//@flow
import type {Store} from 'redux';
import * as Immutable from 'immutable';
import * as actions from './actions';

export class Subscription<S, P, R> {

paths: (state: S, props: P) => string[];
value: (state: S, props: P) => R;

constructor(props: {paths: (state: S, props: P) => string[], value: (state: S, props: P) => R}) {
this.paths = props.paths;
this.value = props.value;
}

fetchNow(store: Store<*, *>, props: P): Promise<R> {
return new Promise((resolve) => {
let paths = Immutable.Set(this.paths(store.getState(), props));
store.dispatch(
actions.fetchValues(paths.toJS(), () => {
let newPaths = Immutable.Set(this.paths(store.getState(), props));
if (newPaths.subtract(paths).size == 0) {
resolve(this.value(store.getState(), props));
} else {
this.fetchNow(store, props).then(resolve);
}
})
);
});
}
}
11 changes: 7 additions & 4 deletions src/__test__/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import firebase from 'firebase';
import type {Store} from 'redux';
import * as Immutable from 'immutable';

import {configureReducer} from '../index';
import reduxFirebaseMirror from '../index';

import type {Action} from '../actions';
import {
Expand Down Expand Up @@ -35,10 +35,13 @@ describe("The actions module", () => {
return next(action);
},
];

const {reducer} = reduxFirebaseMirror({
getFirebaseState: (state) => state,
});

store = createStore(
configureReducer({
getFirebaseState: (state) => state,
}),
reducer,
Immutable.Map(),
applyMiddleware(...middlewares)
);
Expand Down
67 changes: 65 additions & 2 deletions src/__test__/index.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,72 @@
//@flow
import * as reduxFirebaseMirror from '../index';
import thunkMiddleware from 'redux-thunk';
import {createStore, applyMiddleware} from 'redux';
import * as Immutable from 'immutable';
import reduxFirebaseMirror, {subscribeToValues, unsubscribeFromValues, fetchValues} from '../index';
import * as actions from '../actions';

describe("The redux-firebase-mirror module", () => {
it("should export all the functions from the actions module", () => {
Object.keys(actions).forEach(key => expect(reduxFirebaseMirror[key]).toBe(actions[key]));
expect(subscribeToValues).toBe(actions.subscribeToValues);
expect(unsubscribeFromValues).toBe(actions.unsubscribeFromValues);
expect(fetchValues).toBe(actions.fetchValues);
});

describe("the default exported function", () => {
let api, store;
beforeEach(() => {
api = reduxFirebaseMirror({
getFirebaseState: state => state,
});
store = createStore(
api.reducer,
Immutable.Map(),
applyMiddleware(thunkMiddleware)
);
});

it("should return some selectors", () => {
expect(api.selectors.getKeysAtPath).toEqual(jasmine.any(Function));
expect(api.selectors.getValueAtPath).toEqual(jasmine.any(Function));
});

describe("the getKeysAtPath selector", () => {
it("should return an empty list for paths that have not been fetched yet", () => {
expect(api.selectors.getKeysAtPath(store.getState(), 'foo')).toEqual([]);
expect(api.selectors.getKeysAtPath(store.getState(), 'foo/bar/baz')).toEqual([]);
});

it("should return a list of keys for a path that has been fetched", () => {
store.dispatch({
type: 'FIREBASE/RECEIVE_SNAPSHOT',
path: 'foo/bar/baz',
value: 1,
});
expect(api.selectors.getKeysAtPath(store.getState(), 'foo')).toEqual(['bar']);
expect(api.selectors.getKeysAtPath(store.getState(), 'foo/bar')).toEqual(['baz']);
expect(api.selectors.getKeysAtPath(store.getState(), 'foo/bar/baz')).toEqual([]);
});
});

describe("the getValueAtPath selector", () => {
it("should return undefined for paths that have not been fetched yet", () => {
expect(api.selectors.getValueAtPath(store.getState(), 'foo')).toBeUndefined();
expect(api.selectors.getValueAtPath(store.getState(), 'foo/bar/baz')).toBeUndefined();
});
it("should return the value when it has been fetched", () => {
store.dispatch({
type: 'FIREBASE/RECEIVE_SNAPSHOT',
path: 'foo/bar/baz',
value: 1,
});
const foo = api.selectors.getValueAtPath(store.getState(), 'foo');
if (foo instanceof Immutable.Map) {
expect(foo.toJS()).toEqual({bar:{baz:1}});
} else {
throw new Error("expected to get a map");
}
expect(api.selectors.getValueAtPath(store.getState(), 'foo/bar/baz')).toEqual(1);
});
});
});
});
2 changes: 1 addition & 1 deletion src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
} from 'redux';
import firebase from 'firebase';

import type {JSONType} from './index';
import type {JSONType} from './types';
import {isSubscribedToValue} from './index';
export type Action =
| {type: 'FIREBASE/RECEIVE_SNAPSHOT', path: string, value: JSONType}
Expand Down
4 changes: 2 additions & 2 deletions src/cachingStorageReducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ interface Storage {
getItem(key: string): ?string;
}

export default (config: {storagePrefix?: ?string, storage?: ?Storage}) => combineReducers({
export default (config: {storagePrefix?: ?string, cacheStorage?: ?Storage}) => combineReducers({
mirror(state=Immutable.Map(), action) {
const storagePrefix = config.storagePrefix || '';
const storage = config.storage || localStorage;
const storage = config.cacheStorage || localStorage;
switch (action.type) {
case 'FIREBASE/RECEIVE_SNAPSHOT':
storage.setItem(
Expand Down
35 changes: 35 additions & 0 deletions src/immutableStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//@flow
import * as Immutable from 'immutable';
import type {JSONType, StorageAPI} from './types';

type MirrorType = Immutable.Map<string, mixed>;
type ValueType = mixed;

const immutableStorage: StorageAPI<MirrorType, ValueType> = {
getKeysAtPath(mirror: MirrorType, path: string): string[] {
const map = mirror.getIn(path.split('/'), Immutable.Map());
if (!(map instanceof Immutable.Map)) {
return [];
}
return map.keySeq().toArray();
},

getValueAtPath(mirror: MirrorType, path: string): ValueType {
return mirror.getIn(path.split('/'));
},

setValues(mirror: MirrorType, values: {[path: string]: JSONType}): MirrorType {
return mirror.withMutations((mirror) => {
Object.entries(values).forEach(([path, value]) => {
mirror.setIn(path.split('/'), Immutable.fromJS(value));
});
return mirror;
});
},

getInitialMirror(): MirrorType {
return Immutable.Map();
},
};

export default immutableStorage;
137 changes: 25 additions & 112 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
//@flow
import React, {Component} from 'react';
import * as Immutable from 'immutable';
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {compose} from 'redux';
import type {Store, Dispatch} from 'redux';
import cachingStorageReducer from './cachingStorageReducer';
import immutableReducer from './immutableReducer';
import * as actions from './actions';
import getReducer from './reducer';
export * from './actions';

export type JSONType = | string | number | boolean | null | JSONObject | JSONArray;
type JSONObject = { [key: string]: JSONType };
type JSONArray = Array<JSONType>;
import immutableStorage from './immutableStorage';
import type {StorageAPI} from './types';

const CONFIG: {
localStoragePrefix: string,
Expand Down Expand Up @@ -56,101 +50,6 @@ export const getFirebaseMirror = createSelector(
firebaseState => firebaseState.get('mirror')
);

export class Subscription<S, P, R> {

paths: (state: S, props: P) => string[];
value: (state: S, props: P) => R;

constructor(props: {paths: (state: S, props: P) => string[], value: (state: S, props: P) => R}) {
this.paths = props.paths;
this.value = props.value;
}

fetchNow(store: Store<*, *>, props: P): Promise<R> {
return new Promise((resolve) => {
let paths = Immutable.Set(this.paths(store.getState(), props));
store.dispatch(
actions.fetchValues(paths.toJS(), () => {
let newPaths = Immutable.Set(this.paths(store.getState(), props));
if (newPaths.subtract(paths).size == 0) {
resolve(this.value(store.getState(), props));
} else {
this.fetchNow(store, props).then(resolve);
}
})
);
});
}
}

// ----------- subscription utilities ---------

export function subscribePaths<RS, NP:Object, D, P:Object, S, C: React$Component<D, P, S>>(
mapPropsToPaths: (state: RS, props: NP) => string[]
): (c: Class<C>) => Class<React$Component<void, P & NP, void>> {
return (ComponentToWrap: Class<C>) => {
return connect(
(state: RS)=>({state}),
(dispatch: Dispatch<*, *>) => ({dispatch})
)(class WrapperComponent extends Component {

props: NP & P & {
dispatch: Dispatch<*, *>,
state: RS,
};

componentDidMount() {
this.props.dispatch(actions.subscribeToValues(mapPropsToPaths(this.props.state, this.props)));
}

componentDidUpdate() {
const paths = mapPropsToPaths(this.props.state, this.props);
// TODO: make this work.
// in theory we should have already subscribed to the values in the prev paths
// but that doesn't seem to be happening for some reason I do not yet understand.
// so to play it safe, ditch this micro-opitmization for now.
// const prevPaths = new Set(mapPropsToPaths(this.props.state, prevProps));
// const newPaths = paths.filter(path => !prevPaths.has(path));
this.props.dispatch(actions.subscribeToValues(paths));
}

render() {
let {
dispatch: ignoreDispatch, //eslint-disable-line
state: ignoreState, //eslint-disable-line
...rest
} = this.props;
return <ComponentToWrap {...rest} />;
}
});
};
}

export function keysAtPath(state: any, props: any, path: string | string[]) {
if (typeof path !== "string") {
throw new Error("You can only use keysAtPath for a single path");
}
const map = getFirebaseMirror(state).getIn(path.split('/'), Immutable.Map());
if (!map) {
return [];
}
return map.keySeq().toArray();
}

export function valueAtPath(state: any, path: string) {
return getFirebaseMirror(state).getIn(path.split('/'));
}

export function valuesAtPaths(state: any, paths: string[]) {
const values:Immutable.Map<string, mixed> = Immutable.Map();
return values.withMutations(values => {
paths.forEach(path => {
const parts = path.split('/');
values.set(parts[parts.length - 1], valueAtPath(state, path));
});
});
}

/**
* Configure the redux-firebase-mirror module.
* @param config configuration required to use `redux-firebase-mirror`
Expand All @@ -161,18 +60,32 @@ export function valuesAtPaths(state: any, paths: string[]) {
* @param config.storagePrefix an optional prefix for the keys used in local
* storage
*/
export function configureReducer(config: {
export default function configureReducer(config: {
getFirebaseState: (state: any) => Immutable.Map<string, *>,
persistToLocalStorage?: ?boolean,
storagePrefix?: ?string,
}): typeof immutableReducer {
storageAPI?: ?StorageAPI<*, *>
}) {
CONFIG.getFirebaseState = config.getFirebaseState;
let storageAPI: StorageAPI<*, *> = config.storageAPI || immutableStorage;

const selectors = {
getKeysAtPath(state: any, path: string) {
return storageAPI.getKeysAtPath(getFirebaseMirror(state), path);
},

getValueAtPath(state: any, path: string) {
return storageAPI.getValueAtPath(getFirebaseMirror(state), path);
},
};


let reducer = getReducer(storageAPI);
if (config.persistToLocalStorage) {
return compose(cachingStorageReducer({storagePrefix: config.storagePrefix}), immutableReducer());
reducer = compose(
cachingStorageReducer({storagePrefix: config.storagePrefix, storageAPI}),
reducer,
);
}
return immutableReducer();
}

export function getConfig(): typeof CONFIG {
return CONFIG;
return {reducer, selectors};
}
Loading

0 comments on commit 5af03ff

Please sign in to comment.