├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── Makefile ├── README.md ├── index.d.ts ├── package.json ├── src ├── actions.js ├── index.js ├── initialState.js ├── middleware.js └── reducer.js └── tests ├── middleware.js └── reducer.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "plugin:import/warnings", "plugin:import/errors"], 3 | "parser": "babel-eslint" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artefacts 2 | build 3 | 4 | # npm 5 | node_modules 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | 12 | # Optional npm cache directory 13 | .npm 14 | 15 | # Optional REPL history 16 | .node_repl_history 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5.5" 4 | cache: 5 | directories: 6 | - node_modules 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include node_modules/@mathieudutour/js-fatigue/Makefile 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | redux-queue-offline 2 | ============= 3 | 4 | [![build status](https://img.shields.io/travis/mathieudutour/redux-queue-offline/master.svg?style=flat-square)](https://travis-ci.org/mathieudutour/redux-queue-offline) 5 | [![npm version](https://img.shields.io/npm/v/redux-queue-offline.svg?style=flat-square)](https://www.npmjs.com/package/redux-queue-offline) 6 | [![Dependency Status](https://david-dm.org/mathieudutour/redux-queue-offline.svg)](https://david-dm.org/mathieudutour/redux-queue-offline) 7 | [![devDependency Status](https://david-dm.org/mathieudutour/redux-queue-offline/dev-status.svg)](https://david-dm.org/mathieudutour/redux-queue-offline#info=devDependencies) 8 | 9 | Queue actions when offline and dispatch them when getting back online. 10 | 11 | Working nicely together with [redux-optimist-promise](https://github.com/mathieudutour/redux-optimist-promise). 12 | 13 | ```bash 14 | npm install --save redux-queue-offline 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### Step 1: combine your reducers with the `offlineQueue` reducer 20 | 21 | #### `reducers/index.js` 22 | 23 | ```js 24 | import { reducer as offlineQueue } from 'redux-queue-offline' 25 | import { combineReducers } from 'redux' 26 | import todos from './todos' 27 | import status from './status' 28 | 29 | export default combineReducers({ 30 | offlineQueue, 31 | todos, 32 | status 33 | }) 34 | ``` 35 | 36 | ## Step 2: Use the offlineQueue middleware 37 | 38 | First, import the middleware creator and include it in `applyMiddleware` when creating the Redux store. **You need to call it as a function (See later why on configuration section below):** 39 | 40 | ```js 41 | import { middleware as offlineQueueMiddleware } from 'redux-queue-offline' 42 | 43 | const composeStoreWithMiddleware = applyMiddleware( 44 | offlineQueueMiddleware() 45 | )(createStore) 46 | 47 | ``` 48 | 49 | When used with an async middleware, `offlineQueueMiddleware` need to be place before it. 50 | 51 | ### Step 3: Mark your actions to be queued with the `queueIfOffline` meta key 52 | 53 | ```js 54 | store.dispatch({ 55 | type: 'ADD_TODO', 56 | payload: { 57 | text, 58 | promise: {url: '/api/todo', method: 'POST', data: text} 59 | }, 60 | meta: { 61 | queueIfOffline: true 62 | } 63 | }) 64 | ``` 65 | 66 | When the app is offline, the following actions will be dispatch: 67 | 68 | ```js 69 | { // no need to worry about this one though 70 | type: 'redux-queue-offline/QUEUE_ACTION', 71 | payload: { 72 | type: 'ADD_TODO', 73 | payload: { 74 | text, 75 | promise: {url: '/api/todo', method: 'POST', data: text} 76 | }, 77 | meta: { 78 | queueIfOffline: true 79 | } 80 | } 81 | } 82 | 83 | 84 | { 85 | type: 'ADD_TODO', 86 | payload: { 87 | text // notice that the `promise` has disappear 88 | }, 89 | meta: { 90 | queueIfOffline: true 91 | } 92 | } 93 | ``` 94 | 95 | Once getting back online, the following action will be dispatch: 96 | 97 | ```js 98 | { 99 | type: 'ADD_TODO', 100 | payload: { 101 | text, 102 | promise: {url: '/api/todo', method: 'POST', data: text} 103 | }, 104 | meta: { 105 | skipOptimist: true, // useful not to apply the optimist update twice 106 | queueIfOffline: true 107 | } 108 | } 109 | ``` 110 | 111 | 112 | ### Step 4: Fire `ONLINE` and `OFFLINE` action when changing 113 | 114 | The state of the app (online or offline) is stored in the state. To update it, dispatch the `ONLINE` or `OFFLINE` actions. 115 | 116 | ```js 117 | import { ONLINE, OFFLINE } from 'redux-queue-offline' 118 | 119 | dispatch({ type: ONLINE }) 120 | 121 | dispatch({ type: OFFLINE }) 122 | ``` 123 | 124 | You can use the NetworkListener [high order component](https://gist.github.com/sebmarkbage/ef0bf1f338a7182b6775) from [redux-queue-offline-listener](https://github.com/mathieudutour/redux-queue-offline-listener) to wrap the redux Provider and automatically dispatch the ONLINE and OFFLINE action when listening to `window.on('online')` and `window.on('offline')`. 125 | 126 | ```js 127 | import NetworkListener from 'redux-queue-offline-listener' 128 | import { Provider } from 'react-redux' 129 | 130 | const NetworkListenerProvider = NetworkListener(Provider) 131 | 132 | ReactDOM.render( 133 | 134 | 135 | , 136 | rootEl 137 | ) 138 | ``` 139 | 140 | ## Configuration 141 | 142 | You can configure the name of the reducer (default to `offlineQueue`) and the fields being deleted from the action when offline (default to `payload.promise`). 143 | 144 | ```js 145 | import { middleware as offlineQueueMiddleware } from 'redux-queue-offline' 146 | 147 | const composeStoreWithMiddleware = applyMiddleware( 148 | offlineQueueMiddleware('myOfflineQueueReducerName', ['payload.thunk', 'meta.redirect']) 149 | )(createStore) 150 | 151 | ``` 152 | 153 | ## Gotcha 154 | 155 | DO NOT DISPATCH A PROMISE DIRECTLY (otherwise, queuing will be useless). Do use a middleware to transform a descriptive object (`{url: '/api/todo', method: 'POST', data: text}`) to a proper promise. 156 | 157 | ## License 158 | 159 | MIT 160 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'redux-queue-offline' { 2 | import {Middleware} from 'redux' 3 | export const ONLINE: string 4 | export const OFFLINE: string 5 | export const QUEUE_ACTION: string 6 | export function middleware(stateName?: string, asyncPayloadFields?: string[]): Middleware 7 | export function reducer(state?: { 8 | [x: string]: any; 9 | queue: any[]; 10 | isOnline: boolean; 11 | }, action?: { 12 | type: any 13 | }): { 14 | queue: any[]; 15 | isOnline: boolean; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-queue-offline", 3 | "version": "0.4.5", 4 | "description": "Queue actions when offline and dispatch them when getting back online", 5 | "keywords": [], 6 | "main": "build/index.js", 7 | "jsnext:main": "src/index.js", 8 | "files": [ 9 | "build", 10 | "src", 11 | "index.d.ts" 12 | ], 13 | "dependencies": { 14 | "lodash.unset": "4.5.2" 15 | }, 16 | "devDependencies": { 17 | "@mathieudutour/js-fatigue": "1.0.2" 18 | }, 19 | "scripts": { 20 | "test": "make test", 21 | "prepublish": "make clean build" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/mathieudutour/redux-queue-offline.git" 26 | }, 27 | "author": "Mathieu Dutour", 28 | "license": "MIT" 29 | } 30 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | export const ONLINE = 'redux-queue-offline/ONLINE' 2 | export const OFFLINE = 'redux-queue-offline/OFFLINE' 3 | export const QUEUE_ACTION = 'redux-queue-offline/QUEUE_ACTION' 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions' 2 | import middleware from './middleware' 3 | import reducer from './reducer' 4 | 5 | module.exports = { 6 | ONLINE: actions.ONLINE, 7 | OFFLINE: actions.OFFLINE, 8 | QUEUE_ACTION: actions.QUEUE_ACTION, 9 | middleware, 10 | reducer 11 | } 12 | -------------------------------------------------------------------------------- /src/initialState.js: -------------------------------------------------------------------------------- 1 | export const INITIAL_STATE = { 2 | queue: [], 3 | isOnline: true 4 | } 5 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | import unset from 'lodash.unset' 2 | import { INITIAL_STATE } from './initialState' 3 | import { QUEUE_ACTION, ONLINE } from './actions' 4 | 5 | let STATE_NAME = 'offlineQueue' 6 | let ASYNC_PAYLOAD_FIELDS = ['payload.promise'] 7 | 8 | export default function middleware (stateName = STATE_NAME, asyncPayloadFields = ASYNC_PAYLOAD_FIELDS) { 9 | STATE_NAME = stateName 10 | ASYNC_PAYLOAD_FIELDS = asyncPayloadFields 11 | return ({ getState, dispatch }) => (next) => (action) => { 12 | const state = (getState() || {})[STATE_NAME] || INITIAL_STATE 13 | 14 | const { isOnline, queue } = state 15 | 16 | // check if it's a direct action for us 17 | if (action.type === ONLINE) { 18 | const result = next(action) 19 | queue.forEach((actionInQueue) => dispatch(actionInQueue)) 20 | return result 21 | } 22 | 23 | const shouldQueue = (action.meta || {}).queueIfOffline 24 | 25 | // check if we don't need to queue the action 26 | if (isOnline || !shouldQueue) { 27 | return next(action) 28 | } 29 | 30 | let actionToQueue = { 31 | type: action.type, 32 | payload: {...action.payload}, 33 | meta: { 34 | ...action.meta, 35 | skipOptimist: true 36 | } 37 | } 38 | 39 | if (action.meta.skipOptimist) { // if it's a action which was in the queue already 40 | return next({ 41 | type: QUEUE_ACTION, 42 | payload: actionToQueue 43 | }) 44 | } 45 | 46 | dispatch({ 47 | type: QUEUE_ACTION, 48 | payload: actionToQueue 49 | }) 50 | 51 | let actionToDispatchNow = action 52 | ASYNC_PAYLOAD_FIELDS.forEach((field) => { unset(actionToDispatchNow, field) }) 53 | 54 | return next(actionToDispatchNow) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { INITIAL_STATE } from './initialState' 2 | import { QUEUE_ACTION, ONLINE, OFFLINE } from './actions' 3 | 4 | export default function reducer (state = INITIAL_STATE, action = {}) { 5 | switch (action.type) { 6 | case QUEUE_ACTION: 7 | return {...state, queue: state.queue.concat(action.payload)} 8 | case ONLINE: 9 | return {queue: [], isOnline: true} 10 | case OFFLINE: 11 | return {...state, isOnline: false} 12 | default: return state 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/middleware.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { middleware, QUEUE_ACTION, ONLINE } from '../src/' // eslint-disable-line 3 | import { spy } from 'sinon' 4 | import { INITIAL_STATE } from '../src/initialState' 5 | 6 | test.beforeEach((t) => { 7 | t.context.next = spy() 8 | t.context.state = {...INITIAL_STATE} 9 | t.context.dispatch = function d (action) { 10 | const store = { dispatch: d, getState: () => { return {offlineQueue: t.context.state} } } 11 | return middleware()(store)(t.context.next)(action) 12 | } 13 | t.context.foobar = { foo: 'bar' } 14 | t.context.promise = {url: '/api', method: 'GET'} 15 | }) 16 | 17 | test('dispatches action when online', (t) => { 18 | t.context.dispatch({ 19 | type: 'ACTION_TYPE', 20 | payload: { 21 | promise: t.context.promise 22 | }, 23 | meta: { 24 | queueIfOffline: true 25 | } 26 | }) 27 | 28 | t.true(t.context.next.calledOnce) 29 | 30 | t.same(t.context.next.firstCall.args[0], { 31 | type: 'ACTION_TYPE', 32 | payload: { 33 | promise: t.context.promise 34 | }, 35 | meta: { 36 | queueIfOffline: true 37 | } 38 | }) 39 | }) 40 | 41 | test('dispatches QUEUE action and normal action without payload.promise when offline', (t) => { 42 | t.context.state.isOnline = false 43 | t.context.dispatch({ 44 | type: 'ACTION_TYPE', 45 | payload: { 46 | promise: t.context.promise 47 | }, 48 | meta: { 49 | queueIfOffline: true 50 | } 51 | }) 52 | 53 | t.true(t.context.next.calledTwice) 54 | 55 | t.same(t.context.next.firstCall.args[0], { 56 | type: QUEUE_ACTION, 57 | payload: { 58 | type: 'ACTION_TYPE', 59 | payload: { 60 | promise: t.context.promise 61 | }, 62 | meta: { 63 | queueIfOffline: true, 64 | skipOptimist: true 65 | } 66 | } 67 | }) 68 | 69 | t.same(t.context.next.secondCall.args[0], { 70 | type: 'ACTION_TYPE', 71 | payload: {}, 72 | meta: { 73 | queueIfOffline: true 74 | } 75 | }) 76 | }) 77 | 78 | test('dispatches queued actions on ONLINE action', (t) => { 79 | t.context.state.queue = [{ 80 | type: 'ACTION_TYPE' 81 | }] 82 | t.context.dispatch({ 83 | type: ONLINE 84 | }) 85 | 86 | t.true(t.context.next.calledTwice) 87 | 88 | t.same(t.context.next.firstCall.args[0], { 89 | type: ONLINE 90 | }) 91 | 92 | t.same(t.context.next.secondCall.args[0], { 93 | type: 'ACTION_TYPE' 94 | }) 95 | }) 96 | 97 | test('ignores non-promises', (t) => { 98 | t.context.dispatch(t.context.foobar) 99 | t.true(t.context.next.calledOnce) 100 | t.same(t.context.next.firstCall.args[0], t.context.foobar) 101 | 102 | t.context.dispatch({ type: 'ACTION_TYPE', payload: t.context.foobar }) 103 | t.true(t.context.next.calledTwice) 104 | t.same(t.context.next.secondCall.args[0], { 105 | type: 'ACTION_TYPE', 106 | payload: t.context.foobar 107 | }) 108 | }) 109 | 110 | test('ignores non-"queueIfOffline" action', (t) => { 111 | t.context.state.isOnline = false 112 | t.context.dispatch({ 113 | type: 'ACTION_TYPE', 114 | payload: { 115 | promise: t.context.foobar 116 | } 117 | }) 118 | 119 | t.true(t.context.next.calledOnce) 120 | t.same(t.context.next.firstCall.args[0], { 121 | type: 'ACTION_TYPE', 122 | payload: { 123 | promise: t.context.foobar 124 | } 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /tests/reducer.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer, QUEUE_ACTION, ONLINE, OFFLINE } from '../src/' // eslint-disable-line 3 | import { INITIAL_STATE } from '../src/initialState' 4 | 5 | test('return initial state when no state is given', (t) => { 6 | const nextState = reducer() 7 | 8 | t.same(nextState, INITIAL_STATE) 9 | }) 10 | 11 | test('add action to the queue on QUEUE_ACTION', (t) => { 12 | const state = INITIAL_STATE 13 | const nextState = reducer(state, { 14 | type: QUEUE_ACTION, 15 | payload: { 16 | type: 'ACTION_TYPE' 17 | } 18 | }) 19 | 20 | t.same(nextState, { 21 | isOnline: true, 22 | queue: [ 23 | { 24 | type: 'ACTION_TYPE' 25 | } 26 | ] 27 | }) 28 | }) 29 | 30 | test('change isOnline status to `false` on OFFLINE action', (t) => { 31 | const state = {isOnline: true} 32 | const nextState = reducer(state, { 33 | type: OFFLINE 34 | }) 35 | 36 | t.same(nextState, { 37 | isOnline: false 38 | }) 39 | }) 40 | 41 | test('change isOnline status to `true` and empty queue on ONLINE action', (t) => { 42 | const state = {isOnline: false, queue: [{ type: 'ACTION_TYPE' }]} 43 | const nextState = reducer(state, { 44 | type: ONLINE 45 | }) 46 | 47 | t.same(nextState, { 48 | isOnline: true, 49 | queue: [] 50 | }) 51 | }) 52 | --------------------------------------------------------------------------------