mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2024-11-27 09:35:56 -05:00
Create a redux module for infinitely loading editable lists
This commit is contained in:
parent
560379f9fb
commit
6b87429e65
2 changed files with 302 additions and 0 deletions
127
src/redux/infinite-list.js
Normal file
127
src/redux/infinite-list.js
Normal 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;
|
175
test/unit/redux/infinite-list.test.js
Normal file
175
test/unit/redux/infinite-list.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue