Create a redux module for infinitely loading editable lists

This commit is contained in:
Paul Kaplan 2021-03-16 13:27:52 -04:00
parent 560379f9fb
commit 6b87429e65
2 changed files with 302 additions and 0 deletions

127
src/redux/infinite-list.js Normal file
View file

@ -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;

View file

@ -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);
});
});
});