diff --git a/README.md b/README.md index c457db70..eabd65f9 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,6 @@ For more information [on using recompose visit the docs](https://github.com/acdl import React, { Component } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { isEqual } from 'lodash' import { watchEvents, unWatchEvents } from './actions/query' import { getEventsFromInput, createCallable } from './utils' @@ -164,6 +163,60 @@ export default connect((state) => ({ todos: state.firestore.ordered.todos }))(Todos) ``` +### API +The `store.firestore` instance created by the `reduxFirestore` enhancer extends [Firebase's JS API for Firestore](https://firebase.google.com/docs/reference/js/firebase.firestore). This means all of the methods regularly available through `firebase.firestore()` and the statics available from `firebase.firestore` are available. Certain methods (such as `get`, `set`, and `onSnapshot`) have a different API since they have been extended with action dispatching. The methods which have dispatch actions are listed below: + +#### Actions + +##### get +```js +store.firestore.get({ collection: 'cities' }), +// store.firestore.get({ collection: 'cities', doc: 'SF' }), // doc +``` + +##### set +```js +store.firestore.set({ collection: 'cities', doc: 'SF' }, { name: 'San Francisco' }), +``` + +##### add +```js +store.firestore.add({ collection: 'cities' }, { name: 'Some Place' }), +``` + +##### update +```js +const itemUpdates = { + some: 'value', + updatedAt: store.firestore.FieldValue.serverTimestamp() +} + +store.firestore.update({ collection: 'cities', doc: 'SF' }, itemUpdates), +``` + +##### delete +```js +store.firestore.delete({ collection: 'cities', doc: 'SF' }), +``` + +##### runTransaction +```js +store.firestore.runTransaction(t => { + return t.get(cityRef) + .then(doc => { + // Add one person to the city population + const newPopulation = doc.data().population + 1; + t.update(cityRef, { population: newPopulation }); + }); +}) +.then(result => { + // TRANSACTION_SUCCESS action dispatched + console.log('Transaction success!'); +}).catch(err => { + // TRANSACTION_FAILURE action dispatched + console.log('Transaction failure:', err); +}); +``` #### Types of Queries diff --git a/package-lock.json b/package-lock.json index c940b897..39414137 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "redux-firestore", - "version": "0.4.3", + "version": "0.5.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 98022f91..6a8e5ee4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "redux-firestore", - "version": "0.5.0", + "version": "0.5.1", "description": "Redux bindings for Firestore.", "main": "lib/index.js", "module": "es/index.js", diff --git a/src/actions/firestore.js b/src/actions/firestore.js index 8bbc1cd6..1ada88b7 100644 --- a/src/actions/firestore.js +++ b/src/actions/firestore.js @@ -364,6 +364,27 @@ export function unsetListeners(firebase, dispatch, listeners) { }); } +/** + * Atomic operation with Firestore (either read or write). + * @param {Object} firebase - Internal firebase object + * @param {Function} dispatch - Redux's dispatch function + * @param {Function} transactionPromise - Function which runs transaction + * operation. + * @return {Promise} Resolves with result of transaction operation + */ +export function runTransaction(firebase, dispatch, transactionPromise) { + return wrapInDispatch(dispatch, { + ref: firebase.firestore, + method: 'runTransaction', + args: [transactionPromise], + types: [ + actionTypes.TRANSACTION_START, + actionTypes.TRANSACTION_SUCCESS, + actionTypes.TRANSACTION_FAILURE, + ], + }); +} + export default { get, firestoreRef, @@ -373,4 +394,5 @@ export default { setListeners, unsetListener, unsetListeners, + runTransaction, }; diff --git a/src/constants.js b/src/constants.js index c1e34e48..97c39775 100644 --- a/src/constants.js +++ b/src/constants.js @@ -39,6 +39,9 @@ export const actionsPrefix = '@@reduxFirestore'; * @property {String} ON_SNAPSHOT_REQUEST - `@@reduxFirestore/ON_SNAPSHOT_REQUEST` * @property {String} ON_SNAPSHOT_SUCCESS - `@@reduxFirestore/ON_SNAPSHOT_SUCCESS` * @property {String} ON_SNAPSHOT_FAILURE - `@@reduxFirestore/ON_SNAPSHOT_FAILURE` + * @property {String} TRANSACTION_START - `@@reduxFirestore/TRANSACTION_START` + * @property {String} TRANSACTION_SUCCESS - `@@reduxFirestore/TRANSACTION_SUCCESS` + * @property {String} TRANSACTION_FAILURE - `@@reduxFirestore/TRANSACTION_FAILURE` * @example * import { actionTypes } from 'react-redux-firebase' * actionTypes.SET === '@@reduxFirestore/SET' // true @@ -75,6 +78,9 @@ export const actionTypes = { DOCUMENT_ADDED: `${actionsPrefix}/DOCUMENT_ADDED`, DOCUMENT_MODIFIED: `${actionsPrefix}/DOCUMENT_MODIFIED`, DOCUMENT_REMOVED: `${actionsPrefix}/DOCUMENT_REMOVED`, + TRANSACTION_START: `${actionsPrefix}/TRANSACTION_START`, + TRANSACTION_SUCCESS: `${actionsPrefix}/TRANSACTION_SUCCESS`, + TRANSACTION_FAILURE: `${actionsPrefix}/TRANSACTION_FAILURE`, }; /** diff --git a/src/createFirestoreInstance.js b/src/createFirestoreInstance.js index 7c1cbff4..4c4b4966 100644 --- a/src/createFirestoreInstance.js +++ b/src/createFirestoreInstance.js @@ -36,17 +36,13 @@ export default function createFirestoreInstance(firebase, configs, dispatch) { aliases, ); - // Attach helpers to specified namespace - if (configs.helpersNamespace) { - return { - ...firebase, - ...firebase.firestore, - [configs.helpersNamespace]: methods, - }; - } - return { - ...firebase, - ...firebase.firestore, - ...methods, - }; + return Object.assign( + firebase && firebase.firestore ? firebase.firestore() : {}, + firebase.firestore, + { _: firebase._ }, + configs.helpersNamespace + ? // Attach helpers to specified namespace + { [configs.helpersNamespace]: methods } + : methods, + ); } diff --git a/src/reducers/orderedReducer.js b/src/reducers/orderedReducer.js index f6c684f5..93eef763 100644 --- a/src/reducers/orderedReducer.js +++ b/src/reducers/orderedReducer.js @@ -93,8 +93,8 @@ function writeCollection(collectionState, action) { } if (meta.doc && size(collectionState)) { - // Update item in array (handling storeAs) - return updateItemInArray(collectionState, meta.storeAs || meta.doc, item => + // Update item in array + return updateItemInArray(collectionState, meta.doc, item => mergeObjects(item, action.payload.ordered[0]), ); } diff --git a/src/utils/actions.js b/src/utils/actions.js index 1be37c1c..a9a87e0e 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -23,7 +23,7 @@ function makePayload({ payload }, valToPass) { */ export function wrapInDispatch( dispatch, - { ref, meta, method, args = [], types }, + { ref, meta = {}, method, args = [], types }, ) { const [requestingType, successType, errorType] = types; dispatch({ diff --git a/test/unit/actions/firestore.spec.js b/test/unit/actions/firestore.spec.js index 3f44495b..9e6aee79 100644 --- a/test/unit/actions/firestore.spec.js +++ b/test/unit/actions/firestore.spec.js @@ -536,6 +536,70 @@ describe('firestoreActions', () => { 'Listeners must be an Array of listener configs (Strings/Objects).', ); }); + + describe('oneListenerPerPath', () => { + it('works with one listener', async () => { + const fakeFirebaseWithOneListener = { + _: { + listeners: {}, + config: { ...defaultConfig, oneListenerPerPath: true }, + }, + firestore: () => ({ + collection: collectionClass, + }), + }; + const instance = createFirestoreInstance( + fakeFirebaseWithOneListener, + { helpersNamespace: 'test' }, + dispatchSpy, + ); + const listeners = [ + { + collection: 'test', + doc: '1', + subcollections: [{ collection: 'test2' }], + }, + ]; + const forEachMock = sinon.spy(listeners, 'forEach'); + await instance.test.setListeners(listeners); + expect(forEachMock).to.be.calledOnce; + // SET_LISTENER, LISTENER_RESPONSE, LISTENER_ERROR + expect(dispatchSpy).to.be.calledThrice; + }); + + it('works with two listeners of the same path (only attaches once)', async () => { + const fakeFirebaseWithOneListener = { + _: { + listeners: {}, + config: { ...defaultConfig, oneListenerPerPath: true }, + }, + firestore: () => ({ + collection: collectionClass, + }), + }; + const instance = createFirestoreInstance( + fakeFirebaseWithOneListener, + { helpersNamespace: 'test' }, + dispatchSpy, + ); + const listeners = [ + { + collection: 'test', + doc: '1', + subcollections: [{ collection: 'test3' }], + }, + { + collection: 'test', + doc: '1', + subcollections: [{ collection: 'test3' }], + }, + ]; + const forEachMock = sinon.spy(listeners, 'forEach'); + await instance.test.setListeners(listeners); + expect(forEachMock).to.be.calledOnce; + expect(dispatchSpy).to.be.calledThrice; + }); + }); }); describe('unsetListener', () => { @@ -586,6 +650,62 @@ describe('firestoreActions', () => { type: actionTypes.UNSET_LISTENER, }); }); + + describe('oneListenerPerPath option enabled', () => { + it('dispatches UNSET_LISTENER action', async () => { + const fakeFirebaseWithOneListener = { + _: { + listeners: {}, + config: { ...defaultConfig, oneListenerPerPath: true }, + }, + firestore: () => ({ + collection: collectionClass, + }), + }; + const instance = createFirestoreInstance( + fakeFirebaseWithOneListener, + { helpersNamespace: 'test' }, + dispatchSpy, + ); + await instance.test.unsetListeners([{ collection: 'test' }]); + expect(dispatchSpy).to.have.callCount(0); + }); + + it('dispatches UNSET_LISTENER action if there is more than one listener', async () => { + const fakeFirebaseWithOneListener = { + _: { + listeners: {}, + config: { ...defaultConfig, oneListenerPerPath: true }, + }, + firestore: () => ({ + collection: collectionClass, + }), + }; + const instance = createFirestoreInstance( + fakeFirebaseWithOneListener, + { helpersNamespace: 'test' }, + dispatchSpy, + ); + await instance.test.setListeners([ + { collection: 'test' }, + { collection: 'test' }, + ]); + await instance.test.unsetListeners([{ collection: 'test' }]); + expect(dispatchSpy).to.be.calledThrice; + }); + }); + }); + + describe('runTransaction', () => { + it('throws if invalid path config is provided', () => { + const instance = createFirestoreInstance( + {}, + { helpersNamespace: 'test' }, + ); + expect(() => instance.test.runTransaction()).to.throw( + 'dispatch is not a function', + ); + }); }); }); }); diff --git a/test/unit/enhancer.spec.js b/test/unit/enhancer.spec.js index 547f2263..a1007b2e 100644 --- a/test/unit/enhancer.spec.js +++ b/test/unit/enhancer.spec.js @@ -5,7 +5,7 @@ const reducer = sinon.spy(); const generateCreateStore = () => compose( reduxFirestore( - {}, + { firestore: () => ({ collection: () => ({}) }) }, { userProfile: 'users', }, @@ -23,9 +23,13 @@ describe('enhancer', () => { expect(store).to.have.property('firestore'); }); - it('has the right methods', () => { + it('adds extended methods', () => { expect(store.firestore.setListener).to.be.a('function'); }); + + it('preserves unmodified internal Firebase methods', () => { + expect(store.firestore.collection).to.be.a('function'); + }); }); describe('getFirestore', () => { diff --git a/test/unit/reducers/orderedReducer.spec.js b/test/unit/reducers/orderedReducer.spec.js index d0b1c9e5..949eb426 100644 --- a/test/unit/reducers/orderedReducer.spec.js +++ b/test/unit/reducers/orderedReducer.spec.js @@ -254,6 +254,38 @@ describe('orderedReducer', () => { orderedData, ); }); + + it('updates doc under storeAs', () => { + action = { + type: actionTypes.LISTENER_RESPONSE, + meta: { + collection: 'testing', + doc: '123abc', + storeAs: 'pathName', + }, + payload: { + ordered: [ + { + content: 'new', + }, + ], + }, + merge: {}, + }; + + state = { + pathName: [ + { + id: '123abc', + content: 'old', + }, + ], + }; + expect(orderedReducer(state, action)).to.have.nested.property( + `pathName.0.content`, + 'new', + ); + }); }); describe('GET_SUCCESS', () => {