mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2025-03-14 15:09:59 -04:00
Merge pull request #5371 from paulkaplan/add-studio-activity-pagination
Add studio activity pagination
This commit is contained in:
commit
2fe3948ef9
5 changed files with 66 additions and 76 deletions
|
@ -16,14 +16,6 @@
|
|||
* 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
|
||||
|
@ -102,22 +94,7 @@ const InfiniteList = key => {
|
|||
error: error => ({type: `${key}_ERROR`, error}),
|
||||
loading: () => ({type: `${key}_LOADING`}),
|
||||
append: (items, moreToLoad) => ({type: `${key}_APPEND`, items, moreToLoad}),
|
||||
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)));
|
||||
})
|
||||
clear: () => ({type: `${key}_CLEAR`})
|
||||
};
|
||||
|
||||
const selector = state => state[key];
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
// 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
|
||||
};
|
43
src/views/studio/lib/studio-activity-actions.js
Normal file
43
src/views/studio/lib/studio-activity-actions.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
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';
|
|||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {useParams} from 'react-router';
|
||||
|
||||
import {activity} from './lib/redux-modules';
|
||||
import {activityFetcher} from './lib/fetchers';
|
||||
import {loadActivity} from './lib/studio-activity-actions';
|
||||
import Debug from './debug.jsx';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import SocialMessage from '../../components/social-message/social-message.jsx';
|
||||
|
||||
|
@ -170,14 +170,10 @@ const getComponentForItem = item => {
|
|||
}
|
||||
};
|
||||
|
||||
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.
|
||||
const StudioActivity = ({items, loading, error, moreToLoad, onLoadMore}) => {
|
||||
useEffect(() => {
|
||||
if (studioId && items.length === 0) onInitialLoad(studioId);
|
||||
}, [studioId]); // items.length intentionally left out
|
||||
if (items.length === 0) onLoadMore();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="studio-activity">
|
||||
|
@ -194,6 +190,18 @@ const StudioActivity = ({items, loading, error, onInitialLoad}) => {
|
|||
getComponentForItem(item)
|
||||
)}
|
||||
</ul>
|
||||
<div>
|
||||
{moreToLoad &&
|
||||
<button
|
||||
className={classNames('button', {
|
||||
'mod-mutating': loading
|
||||
})}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
<FormattedMessage id="general.loadMore" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -202,13 +210,13 @@ 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
|
||||
moreToLoad: PropTypes.bool,
|
||||
onLoadMore: PropTypes.func
|
||||
};
|
||||
|
||||
export default connect(
|
||||
state => activity.selector(state),
|
||||
dispatch => ({
|
||||
onInitialLoad: studioId => dispatch(
|
||||
activity.actions.loadMore(activityFetcher.bind(null, studioId, 0)))
|
||||
})
|
||||
{
|
||||
onLoadMore: loadActivity
|
||||
}
|
||||
)(StudioActivity);
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
/* global Promise */
|
||||
import InfiniteList from '../../../src/redux/infinite-list';
|
||||
|
||||
const module = InfiniteList('test-key');
|
||||
|
@ -150,34 +149,6 @@ describe('Infinite List redux module', () => {
|
|||
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', () => {
|
||||
|
|
Loading…
Reference in a new issue