From de83e254117a516118357eb0d18c5ad0b8908bfd Mon Sep 17 00:00:00 2001 From: Sean Luthjohn Date: Thu, 19 Sep 2019 03:51:07 -0500 Subject: [PATCH] Feat: Option to pass in custom comparison function (#208) --- README.md | 32 ++++++- src/index.js | 5 +- src/redux/{reducer.js => createReducer.js} | 11 ++- src/redux/sagas.js | 2 +- test/reducer.test.js | 104 +++++++++++++++++---- test/sagas.test.js | 2 +- 6 files changed, 127 insertions(+), 29 deletions(-) rename src/redux/{reducer.js => createReducer.js} (92%) diff --git a/README.md b/README.md index e5d77832..da1bd8a6 100644 --- a/README.md +++ b/README.md @@ -440,11 +440,39 @@ if(action.type === offlineActionTypes.FETCH_OFFLINE_MODE) // do something in you SnackBars, Dialog, Popups, or simple informative text are good means of conveying to the user that the operation failed due to lack of internet connection. ### Offline Queue -A queue system to store actions that failed due to lack of connectivity. It works for both plain object actions and thunks. -It allows you to: +A queue system to store actions that failed due to lack of connectivity. It works for both plain object actions and thunks. It allows you to: + - Re-dispatch the action/thunk as soon as the internet connection is back online again - Dismiss the action from the queue based on a different action dispatched (i.e. navigating to a different screen, the fetch action is no longer relevant) +#### Managing duplicate actions +If a similar action already exists on the queue, we remove it and push it again to the end, so it has an overriding effect. +The default criteria to detect duplicates is by using `lodash.isEqual` for plain actions and `thunk.toString()` for thunks/functions. However, you can customise the comparison function to acommodate it to your needs. For that, you need to use the factory version for your network reducer. + +```js +// configureStore.js +import { createStore, combineReducers } from 'redux' +import { createReducer as createNetworkReducer } from 'react-native-offline'; +import { comparisonFn } from './utils'; + +const rootReducer = combineReducers({ + // ... your other reducers here ... + createNetworkReducer(comparisonFn), +}); + +const store = createStore(rootReducer); +export default store; +``` + +The comparison function receives the action dispatched when offline and the current `actionQueue`. The result of the function will be either `undefined`, meaning no match found, or the action that matches the passed in action. So basically, you need to return the upcoming action if you wish to replace an existing one. An example of how to use it can be found [here](https://github.com/rgommezz/react-native-offline/blob/master/test/reducer.test.js#L121). + +```js +function comparisonFn( + action: ReduxAction | ReduxThunk, + actionQueue: Array, +): ?(ReduxAction | ReduxThunk) +``` + #### Plain Objects In order to configure your PO actions to interact with the offline queue you need to use the `meta` property in your actions, following [flux standard actions convention](https://github.com/acdlite/flux-standard-action#meta). They need to adhere to the below API: diff --git a/src/index.js b/src/index.js index b9962064..cb900049 100644 --- a/src/index.js +++ b/src/index.js @@ -9,7 +9,10 @@ module.exports = { return require('./components/NetworkConsumer').default; }, get reducer() { - return require('./redux/reducer').default; + return require('./redux/createReducer').default(); + }, + get createReducer() { + return require('./redux/createReducer').default; }, get createNetworkMiddleware() { return require('./redux/createNetworkMiddleware').default; diff --git a/src/redux/reducer.js b/src/redux/createReducer.js similarity index 92% rename from src/redux/reducer.js rename to src/redux/createReducer.js index 2330b55e..9f2eda36 100644 --- a/src/redux/reducer.js +++ b/src/redux/createReducer.js @@ -18,6 +18,7 @@ export const initialState = { function handleOfflineAction( state: NetworkState, { payload: { prevAction, prevThunk }, meta }: FluxActionWithPreviousIntent, + comparisonFn: Function, ): NetworkState { const isActionToRetry = typeof prevAction === 'object' && get(meta, 'retry') === true; @@ -32,7 +33,7 @@ function handleOfflineAction( typeof actionToLookUp === 'object' ? { ...actionToLookUp, meta } : actionToLookUp; - const similarActionQueued = getSimilarActionInQueue( + const similarActionQueued = comparisonFn( actionWithMetaData, state.actionQueue, ); @@ -80,10 +81,10 @@ function handleDismissActionsFromQueue( }; } -export default function( +export default (comparisonFn: Function = getSimilarActionInQueue) => ( state: NetworkState = initialState, action: *, -): NetworkState { +): NetworkState => { switch (action.type) { case actionTypes.CONNECTION_CHANGE: return { @@ -91,7 +92,7 @@ export default function( isConnected: action.payload, }; case actionTypes.FETCH_OFFLINE_MODE: - return handleOfflineAction(state, action); + return handleOfflineAction(state, action, comparisonFn); case actionTypes.REMOVE_FROM_ACTION_QUEUE: return handleRemoveActionFromQueue(state, action.payload); case actionTypes.DISMISS_ACTIONS_FROM_QUEUE: @@ -99,7 +100,7 @@ export default function( default: return state; } -} +}; export function networkSelector(state: { network: NetworkState }) { return state.network; diff --git a/src/redux/sagas.js b/src/redux/sagas.js index d6db5dd9..ae79b2d0 100644 --- a/src/redux/sagas.js +++ b/src/redux/sagas.js @@ -4,7 +4,7 @@ import { put, select, call, take, cancelled, fork } from 'redux-saga/effects'; import { eventChannel } from 'redux-saga'; import { AppState, Platform } from 'react-native'; import NetInfo from '@react-native-community/netinfo'; -import { networkSelector } from './reducer'; +import { networkSelector } from './createReducer'; import checkInternetAccess from '../utils/checkInternetAccess'; import { connectionChange } from './actionCreators'; import type { HTTPMethod } from '../types'; diff --git a/test/reducer.test.js b/test/reducer.test.js index 0c2b68a6..bedb66cc 100644 --- a/test/reducer.test.js +++ b/test/reducer.test.js @@ -1,6 +1,13 @@ /* eslint flowtype/require-parameter-type: 0 */ -import reducer, { initialState, networkSelector } from '../src/redux/reducer'; +import { isEqual } from 'lodash'; +import createReducer, { + initialState, + networkSelector, +} from '../src/redux/createReducer'; import * as actionCreators from '../src/redux/actionCreators'; +import getSimilarActionInQueue from '../src/utils/getSimilarActionInQueue'; + +const networkReducer = createReducer(); const getState = (isConnected = false, ...actionQueue) => ({ isConnected, @@ -38,14 +45,14 @@ const prevActionToRetry1WithDifferentPayload = { describe('unknown action type', () => { it('returns prevState on initialization', () => { - expect(reducer(undefined, { type: 'ACTION_I_DONT_CARE' })).toEqual( + expect(networkReducer(undefined, { type: 'ACTION_I_DONT_CARE' })).toEqual( initialState, ); }); it('returns prevState if the action is not handled', () => { expect( - reducer(initialState, { type: 'ANOTHER_ACTION_I_DONT_CARE' }), + networkReducer(initialState, { type: 'ANOTHER_ACTION_I_DONT_CARE' }), ).toEqual(initialState); }); }); @@ -53,7 +60,7 @@ describe('unknown action type', () => { describe('CONNECTION_CHANGE action type', () => { it('changes isConnected state properly', () => { const mockAction = actionCreators.connectionChange(false); - expect(reducer(initialState, mockAction)).toEqual({ + expect(networkReducer(initialState, mockAction)).toEqual({ isConnected: false, actionQueue: [], }); @@ -82,8 +89,8 @@ describe('OFFLINE_ACTION action type', () => { const action = actionCreators.fetchOfflineMode(prevAction); const anotherAction = actionCreators.fetchOfflineMode(anotherPrevAction); - expect(reducer(initialState, action)).toEqual(initialState); - expect(reducer(initialState, anotherAction)).toEqual(initialState); + expect(networkReducer(initialState, action)).toEqual(initialState); + expect(networkReducer(initialState, anotherAction)).toEqual(initialState); }); }); @@ -92,9 +99,9 @@ describe('OFFLINE_ACTION action type', () => { it('actions are pushed into the queue in order of arrival', () => { const preAction = actionCreators.connectionChange(false); const action1 = actionCreators.fetchOfflineMode(prevActionToRetry1); - const prevState = reducer(initialState, preAction); + const prevState = networkReducer(initialState, preAction); - let nextState = reducer(prevState, action1); + let nextState = networkReducer(prevState, action1); expect(nextState).toEqual({ isConnected: false, @@ -102,7 +109,7 @@ describe('OFFLINE_ACTION action type', () => { }); const action2 = actionCreators.fetchOfflineMode(prevActionToRetry2); - nextState = reducer(nextState, action2); + nextState = networkReducer(nextState, action2); expect(nextState).toEqual( getState(false, prevActionToRetry1, prevActionToRetry2), @@ -110,6 +117,63 @@ describe('OFFLINE_ACTION action type', () => { }); }); + describe('thunks that are the same with custom comparison function', () => { + function comparisonFn(action, actionQueue) { + if (typeof action === 'object') { + return actionQueue.find(queued => isEqual(queued, action)); + } + if (typeof action === 'function') { + return actionQueue.find( + queued => + action.meta.name === queued.meta.name && + action.meta.args.id === queued.meta.args.id, + ); + } + return undefined; + } + + const thunkFactory = (id, name, age) => { + function thunk(dispatch) { + dispatch({ type: 'UPDATE_DATA_REQUEST', payload: { id, name, age } }); + } + thunk.meta = { + args: { id, name, age }, + retry: true, + }; + return thunk; + }; + + it(`should add thunks if function is same but thunks are modifying different items`, () => { + const prevState = getState(false, thunkFactory(1, 'Bilbo', 55)); + const thunk = actionCreators.fetchOfflineMode( + thunkFactory(2, 'Link', 54), + ); + + expect(getSimilarActionInQueue(thunk, prevState.actionQueue)).toEqual( + prevState.actionQueue[0].action, + ); + + const nextState = createReducer(comparisonFn)(prevState, thunk); + + expect(nextState.actionQueue).toHaveLength(2); + }); + + it(`should replace a thunk if thunk already exists to modify same item`, () => { + const prevState = getState(false, thunkFactory(1, 'Bilbo', 55)); + const thunk = actionCreators.fetchOfflineMode( + thunkFactory(1, 'Bilbo', 65), + ); + + expect(getSimilarActionInQueue(thunk, prevState.actionQueue)).toEqual( + prevState.actionQueue[0].action, + ); + + const nextState = createReducer(comparisonFn)(prevState, thunk); + + expect(nextState.actionQueue).toHaveLength(1); + }); + }); + describe('actions with the same type', () => { it(`should remove the action and add it back at the end of the queue if the action has the same payload`, () => { @@ -120,7 +184,7 @@ describe('OFFLINE_ACTION action type', () => { ); const action = actionCreators.fetchOfflineMode(prevActionToRetry1); - const nextState = reducer(prevState, action); + const nextState = networkReducer(prevState, action); expect(nextState).toEqual( getState(false, prevActionToRetry2, prevActionToRetry1), ); @@ -136,7 +200,7 @@ describe('OFFLINE_ACTION action type', () => { prevActionToRetry1WithDifferentPayload, ); - expect(reducer(prevState, action)).toEqual( + expect(networkReducer(prevState, action)).toEqual( getState( false, prevActionToRetry2, @@ -162,7 +226,7 @@ describe('REMOVE_ACTION_FROM_QUEUE action type', () => { ...prevActionToRetry2, }); - expect(reducer(prevState, action)).toEqual( + expect(networkReducer(prevState, action)).toEqual( getState( false, prevActionToRetry1, @@ -181,7 +245,7 @@ describe('thunks', () => { describe('action with meta.retry !== true', () => { it('should NOT add the action to the queue', () => { const action = actionCreators.fetchOfflineMode(fetchThunk); - expect(reducer(initialState, action)).toEqual(initialState); + expect(networkReducer(initialState, action)).toEqual(initialState); }); }); @@ -193,7 +257,9 @@ describe('thunks', () => { }; const action = actionCreators.fetchOfflineMode(fetchThunk); - expect(reducer(prevState, action)).toEqual(getState(false, fetchThunk)); + expect(networkReducer(prevState, action)).toEqual( + getState(false, fetchThunk), + ); }); it(`should remove the thunk and add it back at the end of the queue @@ -212,7 +278,7 @@ describe('thunks', () => { retry: true, }; const action = actionCreators.fetchOfflineMode(similarThunk); - const nextState = reducer(prevState, action); + const nextState = networkReducer(prevState, action); expect(nextState).toEqual(getState(false, similarThunk)); }); @@ -224,7 +290,7 @@ describe('thunks', () => { const prevState = getState(false, fetchThunk); const action = actionCreators.removeActionFromQueue(fetchThunk); - expect(reducer(prevState, action)).toEqual(getState(false)); + expect(networkReducer(prevState, action)).toEqual(getState(false)); }); }); }); @@ -269,7 +335,7 @@ describe('dismiss feature', () => { ); const action = actionCreators.dismissActionsFromQueue('NAVIGATE_BACK'); - expect(reducer(prevState, action)).toEqual( + expect(networkReducer(prevState, action)).toEqual( getState(false, actionEnqueued2, actionEnqueued3), ); }); @@ -283,7 +349,7 @@ describe('dismiss feature', () => { ); const action = actionCreators.dismissActionsFromQueue('NAVIGATE_TO_LOGIN'); - expect(reducer(prevState, action)).toEqual( + expect(networkReducer(prevState, action)).toEqual( getState(false, actionEnqueued3), ); }); @@ -297,7 +363,7 @@ describe('dismiss feature', () => { ); const action = actionCreators.dismissActionsFromQueue('NAVIGATE_AWAY'); - expect(reducer(prevState, action)).toEqual( + expect(networkReducer(prevState, action)).toEqual( getState(false, actionEnqueued1, actionEnqueued2, actionEnqueued3), ); }); diff --git a/test/sagas.test.js b/test/sagas.test.js index 3ee2ca51..d5be78a7 100644 --- a/test/sagas.test.js +++ b/test/sagas.test.js @@ -19,7 +19,7 @@ import { DEFAULT_PING_SERVER_URL, DEFAULT_TIMEOUT, } from '../src/utils/constants'; -import { networkSelector } from '../src/redux/reducer'; +import { networkSelector } from '../src/redux/createReducer'; import checkInternetAccess from '../src/utils/checkInternetAccess'; const args = {