From 6b87429e65e0d8b5f7e07e3f735951268bd2b259 Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Tue, 16 Mar 2021 13:27:52 -0400 Subject: [PATCH] Create a redux module for infinitely loading editable lists --- src/redux/infinite-list.js | 127 +++++++++++++++++++ test/unit/redux/infinite-list.test.js | 175 ++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 src/redux/infinite-list.js create mode 100644 test/unit/redux/infinite-list.test.js diff --git a/src/redux/infinite-list.js b/src/redux/infinite-list.js new file mode 100644 index 000000000..b09e10b12 --- /dev/null +++ b/src/redux/infinite-list.js @@ -0,0 +1,127 @@ +/** + * @typedef ReduxModule + * A redux "module" for reusable functionality. The module exports + * a reducer function, a set of action creators and a selector + * that are all scoped to the given "key". This allows us to reuse + * this reducer multiple times in the same redux store. + * + * @property {string} key The key to use when registering this + * modules reducer in the redux state tree. + * @property {function} selector Function called with the full + * state tree to select only this modules slice of the state. + * @property {object} actions An object of action creator functions + * to call to make changes to the data in this reducer. + * @property {function} reducer A redux reducer that takes an action + * from the action creators and the current state and returns + * the next state. + */ + +/** + * @typedef {function} InfiniteListFetcher + * A function to call that returns more data for the InfiniteList + * loadMore action. It must resolve to {items: [], moreToLoad} or + * reject with the error {statusCode}. + * @returns {Promise<{items:[], moreToLoad:boolean}>} + */ + +/** + * A redux module to create a list of items where more items can be loaded + * using an API. Additionally, there are actions for prepending items + * to the list, removing items and handling load errors. + * + * @param {string} key - used to scope action names and the selector + * This key must be unique among other instances of this module. + * @returns {ReduxModule} the redux module + */ +const InfiniteList = key => { + + const initialState = { + items: [], + offset: 0, + error: null, + loading: true, + moreToLoad: false + }; + + const reducer = (state, action) => { + if (typeof state === 'undefined') { + state = initialState; + } + + switch (action.type) { + case `${key}_LOADING`: + return { + ...state, + error: null, + loading: true + }; + case `${key}_APPEND`: + return { + ...state, + items: state.items.concat(action.items), + loading: false, + error: null, + moreToLoad: action.moreToLoad + }; + case `${key}_REPLACE`: + return { + ...state, + items: state.items.map((item, i) => { + if (i === action.index) return action.item; + return item; + }) + }; + case `${key}_REMOVE`: + return { + ...state, + items: state.items.filter((_, i) => i !== action.index) + }; + case `${key}_PREPEND`: + return { + ...state, + items: [action.item].concat(state.items) + }; + case `${key}_ERROR`: + return { + ...state, + error: action.error, + loading: false, + moreToLoad: false + }; + default: + return state; + } + }; + + const actions = { + create: item => ({type: `${key}_PREPEND`, item}), + remove: index => ({type: `${key}_REMOVE`, index}), + replace: (index, item) => ({type: `${key}_REPLACE`, index, item}), + error: error => ({type: `${key}_ERROR`, error}), + loading: () => ({type: `${key}_LOADING`}), + append: (items, moreToLoad) => ({type: `${key}_APPEND`, items, moreToLoad}), + + /** + * Load more action returns a thunk. It takes a function to call to get more items. + * It will call the LOADING action before calling the fetcher, and call + * APPEND with the results or call ERROR. + * @param {InfiniteListFetcher} fetcher - function that returns a promise + * which must resolve to {items: [], moreToLoad}. + * @returns {function} a thunk that sequences the load and dispatches + */ + loadMore: fetcher => (dispatch => { + dispatch(actions.loading()); + return fetcher() + .then(({items, moreToLoad}) => dispatch(actions.append(items, moreToLoad))) + .catch(error => dispatch(actions.error(error))); + }) + }; + + const selector = state => state[key]; + + return { + key, actions, reducer, selector + }; +}; + +export default InfiniteList; diff --git a/test/unit/redux/infinite-list.test.js b/test/unit/redux/infinite-list.test.js new file mode 100644 index 000000000..9e9995a3d --- /dev/null +++ b/test/unit/redux/infinite-list.test.js @@ -0,0 +1,175 @@ +/* global Promise */ +import InfiniteList from '../../../src/redux/infinite-list'; + +const module = InfiniteList('test-key'); +let initialState; +describe('Infinite List redux module', () => { + beforeEach(() => { + initialState = module.reducer(undefined, {}); // eslint-disable-line no-undefined + }); + + describe('reducer', () => { + test('module contains a reducer', () => { + expect(typeof module.reducer).toBe('function'); + }); + + test('initial state', () => { + expect(initialState).toMatchObject({ + loading: true, + error: null, + items: [], + moreToLoad: false + }); + }); + + describe('LOADING', () => { + let action; + beforeEach(() => { + action = module.actions.loading(); + initialState.loading = false; + initialState.items = [1, 2, 3]; + initialState.error = new Error(); + }); + test('sets the loading state', () => { + const newState = module.reducer(initialState, action); + expect(newState.loading).toBe(true); + }); + test('maintains any existing data', () => { + const newState = module.reducer(initialState, action); + expect(newState.items).toBe(initialState.items); + }); + test('clears any existing error', () => { + const newState = module.reducer(initialState, action); + expect(newState.error).toBe(null); + }); + }); + + describe('APPEND', () => { + let action; + beforeEach(() => { + action = module.actions.append([4, 5, 6], true); + }); + test('appends the new items', () => { + initialState.items = [1, 2, 3]; + const newState = module.reducer(initialState, action); + expect(newState.items).toEqual([1, 2, 3, 4, 5, 6]); + }); + test('sets the moreToLoad state', () => { + initialState.moreToLoad = false; + const newState = module.reducer(initialState, action); + expect(newState.moreToLoad).toEqual(true); + }); + test('clears any existing error and loading state', () => { + initialState.error = new Error(); + initialState.loading = true; + const newState = module.reducer(initialState, action); + expect(newState.error).toBe(null); + expect(newState.error).toBe(null); + }); + }); + + describe('REPLACE', () => { + let action; + beforeEach(() => { + action = module.actions.replace(2, 55); + }); + test('replaces the given index with the new item', () => { + initialState.items = [8, 9, 10, 11]; + const newState = module.reducer(initialState, action); + expect(newState.items).toEqual([8, 9, 55, 11]); + }); + }); + + describe('REMOVE', () => { + let action; + beforeEach(() => { + action = module.actions.remove(2); + }); + test('removes the given index', () => { + initialState.items = [8, 9, 10, 11]; + const newState = module.reducer(initialState, action); + expect(newState.items).toEqual([8, 9, 11]); + }); + }); + + describe('CREATE', () => { + let action; + beforeEach(() => { + action = module.actions.create(7); + }); + test('prepends the given item', () => { + initialState.items = [8, 9, 10, 11]; + const newState = module.reducer(initialState, action); + expect(newState.items).toEqual([7, 8, 9, 10, 11]); + }); + }); + + describe('ERROR', () => { + let action; + let error = new Error(); + beforeEach(() => { + action = module.actions.error(error); + }); + test('sets the error state', () => { + const newState = module.reducer(initialState, action); + expect(newState.error).toBe(error); + }); + test('resets loading to false', () => { + initialState.loading = true; + const newState = module.reducer(initialState, action); + expect(newState.loading).toBe(false); + }); + test('maintains any existing data', () => { + initialState.items = [1, 2, 3]; + const newState = module.reducer(initialState, action); + expect(newState.items).toEqual([1, 2, 3]); + }); + }); + }); + + describe('action creators', () => { + test('module contains actions creators', () => { + // The actual action creators are tested above in the reducer tests + for (let key in module.actions) { + expect(typeof module.actions[key]).toBe('function'); + } + }); + + describe('loadMore', () => { + test('returns a thunk function, rather than a standard action object', () => { + expect(typeof module.actions.loadMore()).toBe('function'); + }); + test('calls loading and the fetcher', () => { + let dispatch = jest.fn(); + let fetcher = jest.fn(() => new Promise(() => { })); // that never resolves + module.actions.loadMore(fetcher)(dispatch); + expect(dispatch).toHaveBeenCalledWith(module.actions.loading()); + expect(fetcher).toHaveBeenCalled(); + }); + test('calls append with resolved result from fetcher', async () => { + let dispatch = jest.fn(); + let fetcher = jest.fn(() => Promise.resolve({items: ['a', 'b'], moreToLoad: false})); + await module.actions.loadMore(fetcher)(dispatch); + expect(dispatch.mock.calls[1][0]) // the second call to dispatch, after LOADING + .toEqual(module.actions.append(['a', 'b'], false)); + }); + test('calls error with rejecting promise from fetcher', async () => { + let error = new Error(); + let dispatch = jest.fn(); + let fetcher = jest.fn(() => Promise.reject(error)); + await module.actions.loadMore(fetcher)(dispatch); + expect(dispatch.mock.calls[1][0]) // the second call to dispatch, after LOADING + .toEqual(module.actions.error(error)); + }); + }); + }); + + describe('selector', () => { + test('will return the slice of state defined by the key', () => { + const state = { + [module.key]: module.reducer(undefined, {}) // eslint-disable-line no-undefined + }; + expect(module.selector(state)).toBe(initialState); + }); + }); +});