Unverified Commit 183a31d5 authored by Paul Kaplan's avatar Paul Kaplan Committed by GitHub

Merge pull request #5149 from paulkaplan/studio-redux-modules

Reusable redux module for fetching paginated data
parents 32a36123 96589f40
/**
* @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;
import React from 'react';
import PropTypes from 'prop-types';
const Debug = ({label, data}) => (<div style={{padding: '2rem', border: '1px solid red', margin: '2rem'}}>
<small>{label}</small>
<code>
<pre style={{fontSize: '0.75rem'}}>
{JSON.stringify(data, null, ' ')}
</pre>
</code>
</div>);
Debug.propTypes = {
label: PropTypes.string,
data: PropTypes.any // eslint-disable-line react/forbid-prop-types
};
export default Debug;
const ITEM_LIMIT = 4;
const infoFetcher = studioId => fetch(`${process.env.API_HOST}/studios/${studioId}`)
.then(response => response.json());
const projectFetcher = (studioId, offset) =>
fetch(`${process.env.API_HOST}/studios/${studioId}/projects?limit=${ITEM_LIMIT}&offset=${offset}`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: data.length === ITEM_LIMIT}));
const curatorFetcher = (studioId, offset) =>
fetch(`${process.env.API_HOST}/studios/${studioId}/curators?limit=${ITEM_LIMIT}&offset=${offset}`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: data.length === ITEM_LIMIT}));
const managerFetcher = (studioId, offset) =>
fetch(`${process.env.API_HOST}/studios/${studioId}/managers?limit=${ITEM_LIMIT}&offset=${offset}`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: data.length === ITEM_LIMIT}));
const activityFetcher = studioId =>
fetch(`${process.env.API_HOST}/studios/${studioId}/activity`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: false})); // No pagination on the activity feed
export {
activityFetcher,
infoFetcher,
projectFetcher,
curatorFetcher,
managerFetcher
};
import InfiniteList from '../../../redux/infinite-list';
const projects = InfiniteList('projects');
const curators = InfiniteList('curators');
const managers = InfiniteList('managers');
const activity = InfiniteList('activity');
export {
projects, curators, managers, activity
};
import React from 'react';
import {useParams} from 'react-router-dom';
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
const StudioActivity = () => {
import {connect} from 'react-redux';
import {useParams} from 'react-router';
import {activity} from './lib/redux-modules';
import {activityFetcher} from './lib/fetchers';
import Debug from './debug.jsx';
const StudioActivity = ({items, loading, error, onInitialLoad}) => {
const {studioId} = useParams();
// Fetch the data if none has been loaded yet. This would run only once,
// since studioId doesnt change, but the component is potentially mounted
// multiple times because of tab routing, so need to check for empty items.
useEffect(() => {
if (studioId && items.length === 0) onInitialLoad(studioId);
}, [studioId]); // items.length intentionally left out
return (
<div>
<h2>Activity</h2>
<p>Studio {studioId}</p>
{loading && <div>Loading...</div>}
{error && <Debug
label="Error"
data={error}
/>}
<div>
{items.map((item, index) =>
(<Debug
label="Activity Item"
data={item}
key={index}
/>)
)}
</div>
</div>
);
};
export default StudioActivity;
StudioActivity.propTypes = {
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onInitialLoad: PropTypes.func
};
export default connect(
state => activity.selector(state),
dispatch => ({
onInitialLoad: studioId => dispatch(
activity.actions.loadMore(activityFetcher.bind(null, studioId, 0)))
})
)(StudioActivity);
import React from 'react';
import React, {useEffect, useCallback} from 'react';
import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
import {curators, managers} from './lib/redux-modules';
import {curatorFetcher, managerFetcher} from './lib/fetchers';
import Debug from './debug.jsx';
const StudioCurators = () => {
const {studioId} = useParams();
return (
<div>
<h2>Curators</h2>
<p>Studio {studioId}</p>
<h3>Managers</h3>
<ManagerList studioId={studioId} />
<hr />
<h3>Curators</h3>
<CuratorList studioId={studioId} />
</div>
);
};
const MemberList = ({studioId, items, error, loading, moreToLoad, onLoadMore}) => {
useEffect(() => {
if (studioId && items.length === 0) onLoadMore(studioId, 0);
}, [studioId]);
const handleLoadMore = useCallback(() => onLoadMore(studioId, items.length), [studioId, items.length]);
return (<React.Fragment>
{error && <Debug
label="Error"
data={error}
/>}
{items.map((item, index) =>
(<Debug
label="Member"
data={item}
key={index}
/>)
)}
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={handleLoadMore}>
Load more
</button> :
<small>No more to load</small>
)}
</React.Fragment>);
};
MemberList.propTypes = {
studioId: PropTypes.string,
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
};
const ManagerList = connect(
state => managers.selector(state),
dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
managers.actions.loadMore(managerFetcher.bind(null, studioId, offset)))
})
)(MemberList);
const CuratorList = connect(
state => curators.selector(state),
dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
curators.actions.loadMore(curatorFetcher.bind(null, studioId, offset)))
})
)(MemberList);
export default StudioCurators;
import React from 'react';
import React, {useState, useEffect} from 'react';
import {useParams} from 'react-router-dom';
import {infoFetcher} from './lib/fetchers';
import Debug from './debug.jsx';
const StudioInfo = () => {
const {studioId} = useParams();
const [state, setState] = useState({loading: false, error: null, data: null});
// Since this data is in a component that is always visible, it doesn't necessarily
// need to be kept in redux. One alternative is to use the infinite-list redux
// module and just treat the studio info as the first and only item in the list.
useEffect(() => {
if (!studioId) return;
infoFetcher(studioId)
.then(data => setState({loading: false, error: null, data}))
.catch(error => setState({loading: false, error, data: null}));
}, [studioId]);
return (
<div>
<h2>Studio Info</h2>
<p>Studio {studioId}</p>
{state.loading && <div>Loading..</div>}
{state.error && <Debug
label="Error"
data={state.error}
/>}
<Debug
label="Studio Info"
data={state.data}
/>
</div>
);
};
......
import React from 'react';
import React, {useEffect, useCallback} from 'react';
import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
const StudioProjects = () => {
import {projectFetcher} from './lib/fetchers';
import {projects} from './lib/redux-modules';
import Debug from './debug.jsx';
const {actions, selector} = projects;
const StudioProjects = ({
items, error, loading, moreToLoad, onLoadMore
}) => {
const {studioId} = useParams();
useEffect(() => {
if (studioId && items.length === 0) onLoadMore(studioId, 0);
}, [studioId]);
const handleLoadMore = useCallback(() => onLoadMore(studioId, items.length), [studioId, items.length]);
return (
<div>
<h2>Projects</h2>
<p>Studio {studioId}</p>
{error && <Debug
label="Error"
data={error}
/>}
<div>
{items.map((item, index) =>
(<Debug
label="Project"
data={item}
key={index}
/>)
)}
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={handleLoadMore}>
Load more
</button> :
<small>No more to load</small>
)}
</div>
</div>
);
};
export default StudioProjects;
StudioProjects.propTypes = {
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
};
const mapStateToProps = state => selector(state);
const mapDispatchToProps = dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
actions.loadMore(projectFetcher.bind(null, studioId, offset))
)
});
export default connect(mapStateToProps, mapDispatchToProps)(StudioProjects);
......@@ -9,6 +9,7 @@ import {
import Page from '../../components/page/www/page.jsx';
import render from '../../lib/render.jsx';
import StudioTabNav from './studio-tab-nav.jsx';
import StudioProjects from './studio-projects.jsx';
import StudioInfo from './studio-info.jsx';
......@@ -16,6 +17,13 @@ import StudioCurators from './studio-curators.jsx';
import StudioComments from './studio-comments.jsx';
import StudioActivity from './studio-activity.jsx';
import {
projects,
curators,
managers,
activity
} from './lib/redux-modules';
const StudioShell = () => {
const match = useRouteMatch();
......@@ -59,5 +67,11 @@ render(
</Switch>
</Router>
</Page>,
document.getElementById('app')
document.getElementById('app'),
{
[projects.key]: projects.reducer,
[curators.key]: curators.reducer,
[managers.key]: managers.reducer,
[activity.key]: activity.reducer
}
);
/* 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);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment