Skip to content

Commit

Permalink
Feat: Option to pass in custom comparison function (#208)
Browse files Browse the repository at this point in the history
  • Loading branch information
LiquidSean authored and rgommezz committed Sep 19, 2019
1 parent b4b1bcb commit de83e25
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 29 deletions.
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
): ?(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:

Expand Down
5 changes: 4 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 6 additions & 5 deletions src/redux/reducer.js → src/redux/createReducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,7 +33,7 @@ function handleOfflineAction(
typeof actionToLookUp === 'object'
? { ...actionToLookUp, meta }
: actionToLookUp;
const similarActionQueued = getSimilarActionInQueue(
const similarActionQueued = comparisonFn(
actionWithMetaData,
state.actionQueue,
);
Expand Down Expand Up @@ -80,26 +81,26 @@ function handleDismissActionsFromQueue(
};
}

export default function(
export default (comparisonFn: Function = getSimilarActionInQueue) => (
state: NetworkState = initialState,
action: *,
): NetworkState {
): NetworkState => {
switch (action.type) {
case actionTypes.CONNECTION_CHANGE:
return {
...state,
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:
return handleDismissActionsFromQueue(state, action.payload);
default:
return state;
}
}
};

export function networkSelector(state: { network: NetworkState }) {
return state.network;
Expand Down
2 changes: 1 addition & 1 deletion src/redux/sagas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
104 changes: 85 additions & 19 deletions test/reducer.test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -38,22 +45,22 @@ 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);
});
});

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: [],
});
Expand Down Expand Up @@ -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);
});
});

Expand All @@ -92,24 +99,81 @@ 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,
actionQueue: [prevActionToRetry1],
});

const action2 = actionCreators.fetchOfflineMode(prevActionToRetry2);
nextState = reducer(nextState, action2);
nextState = networkReducer(nextState, action2);

expect(nextState).toEqual(
getState(false, prevActionToRetry1, prevActionToRetry2),
);
});
});

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`, () => {
Expand All @@ -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),
);
Expand All @@ -136,7 +200,7 @@ describe('OFFLINE_ACTION action type', () => {
prevActionToRetry1WithDifferentPayload,
);

expect(reducer(prevState, action)).toEqual(
expect(networkReducer(prevState, action)).toEqual(
getState(
false,
prevActionToRetry2,
Expand All @@ -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,
Expand All @@ -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);
});
});

Expand All @@ -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
Expand All @@ -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));
});
Expand All @@ -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));
});
});
});
Expand Down Expand Up @@ -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),
);
});
Expand All @@ -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),
);
});
Expand All @@ -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),
);
});
Expand Down
2 changes: 1 addition & 1 deletion test/sagas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down

0 comments on commit de83e25

Please sign in to comment.