Unverified Commit 2fe3948e authored by Paul Kaplan's avatar Paul Kaplan Committed by GitHub

Merge pull request #5371 from paulkaplan/add-studio-activity-pagination

Add studio activity pagination
parents 051fc107 f61047dd
...@@ -16,14 +16,6 @@ ...@@ -16,14 +16,6 @@
* the next state. * 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 * 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 * using an API. Additionally, there are actions for prepending items
...@@ -102,22 +94,7 @@ const InfiniteList = key => { ...@@ -102,22 +94,7 @@ const InfiniteList = key => {
error: error => ({type: `${key}_ERROR`, error}), error: error => ({type: `${key}_ERROR`, error}),
loading: () => ({type: `${key}_LOADING`}), loading: () => ({type: `${key}_LOADING`}),
append: (items, moreToLoad) => ({type: `${key}_APPEND`, items, moreToLoad}), append: (items, moreToLoad) => ({type: `${key}_APPEND`, items, moreToLoad}),
clear: () => ({type: `${key}_CLEAR`}), clear: () => ({type: `${key}_CLEAR`})
/**
* 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]; const selector = state => state[key];
......
// TODO move this to studio-activity-actions, include pagination
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
};
import keyMirror from 'keymirror';
import api from '../../../lib/api';
import {activity} from './redux-modules';
import {selectStudioId} from '../../../redux/studio';
const Errors = keyMirror({
NETWORK: null,
SERVER: null,
PERMISSION: null
});
const normalizeError = (err, body, res) => {
if (err) return Errors.NETWORK;
if (res.statusCode === 401 || res.statusCode === 403) return Errors.PERMISSION;
if (res.statusCode !== 200) return Errors.SERVER;
return null;
};
const loadActivity = () => ((dispatch, getState) => {
const state = getState();
const studioId = selectStudioId(state);
const items = activity.selector(state).items;
const params = {limit: 20};
if (items.length > 0) {
// dateLimit is the newest notification you want to get back, which is
// the date of the oldest one we've already loaded
params.dateLimit = items[items.length - 1].datetime_created;
}
api({
uri: `/studios/${studioId}/activity/`,
params
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return dispatch(activity.actions.error(error));
const ids = items.map(item => item.id);
// Deduplication is needed because pagination based on date can contain duplicates
const deduped = body.filter(item => ids.indexOf(item.id) === -1);
dispatch(activity.actions.append(deduped, body.length === params.limit));
});
});
export {loadActivity};
...@@ -3,11 +3,11 @@ import PropTypes from 'prop-types'; ...@@ -3,11 +3,11 @@ import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl'; import {FormattedMessage} from 'react-intl';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import {useParams} from 'react-router';
import {activity} from './lib/redux-modules'; import {activity} from './lib/redux-modules';
import {activityFetcher} from './lib/fetchers'; import {loadActivity} from './lib/studio-activity-actions';
import Debug from './debug.jsx'; import Debug from './debug.jsx';
import classNames from 'classnames';
import SocialMessage from '../../components/social-message/social-message.jsx'; import SocialMessage from '../../components/social-message/social-message.jsx';
...@@ -170,14 +170,10 @@ const getComponentForItem = item => { ...@@ -170,14 +170,10 @@ const getComponentForItem = item => {
} }
}; };
const StudioActivity = ({items, loading, error, onInitialLoad}) => { const StudioActivity = ({items, loading, error, moreToLoad, onLoadMore}) => {
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(() => { useEffect(() => {
if (studioId && items.length === 0) onInitialLoad(studioId); if (items.length === 0) onLoadMore();
}, [studioId]); // items.length intentionally left out }, []);
return ( return (
<div className="studio-activity"> <div className="studio-activity">
...@@ -194,6 +190,18 @@ const StudioActivity = ({items, loading, error, onInitialLoad}) => { ...@@ -194,6 +190,18 @@ const StudioActivity = ({items, loading, error, onInitialLoad}) => {
getComponentForItem(item) getComponentForItem(item)
)} )}
</ul> </ul>
<div>
{moreToLoad &&
<button
className={classNames('button', {
'mod-mutating': loading
})}
onClick={onLoadMore}
>
<FormattedMessage id="general.loadMore" />
</button>
}
</div>
</div> </div>
); );
}; };
...@@ -202,13 +210,13 @@ StudioActivity.propTypes = { ...@@ -202,13 +210,13 @@ StudioActivity.propTypes = {
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
loading: PropTypes.bool, loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onInitialLoad: PropTypes.func moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
}; };
export default connect( export default connect(
state => activity.selector(state), state => activity.selector(state),
dispatch => ({ {
onInitialLoad: studioId => dispatch( onLoadMore: loadActivity
activity.actions.loadMore(activityFetcher.bind(null, studioId, 0))) }
})
)(StudioActivity); )(StudioActivity);
/* global Promise */
import InfiniteList from '../../../src/redux/infinite-list'; import InfiniteList from '../../../src/redux/infinite-list';
const module = InfiniteList('test-key'); const module = InfiniteList('test-key');
...@@ -150,34 +149,6 @@ describe('Infinite List redux module', () => { ...@@ -150,34 +149,6 @@ describe('Infinite List redux module', () => {
expect(typeof module.actions[key]).toBe('function'); 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', () => { describe('selector', () => {
......
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