diff --git a/src/components/ScrollThreshold.jsx b/src/components/ScrollThreshold.jsx new file mode 100644 index 00000000..be8272b5 --- /dev/null +++ b/src/components/ScrollThreshold.jsx @@ -0,0 +1,41 @@ +import React, { + useEffect, + useRef, +} from 'react'; +import PropTypes from 'prop-types'; + +function ScrollThreshold({ onScroll }) { + const elementRef = useRef(null); + + useEffect(() => { + if (!elementRef.current) { + return undefined; + } + + // create the observer + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + onScroll(); + } + }, + ); + + observer.observe(elementRef.current); + + // cleanup callback + return () => { + observer.disconnect(); + }; + }, [elementRef]); + + return ( +
+ ); +} + +ScrollThreshold.propTypes = { + onScroll: PropTypes.func.isRequired, +}; + +export default ScrollThreshold; diff --git a/src/discussions/posts/PostsView.jsx b/src/discussions/posts/PostsView.jsx index b6eb369a..0ad2730c 100644 --- a/src/discussions/posts/PostsView.jsx +++ b/src/discussions/posts/PostsView.jsx @@ -7,10 +7,12 @@ import { useParams } from 'react-router'; import { AppContext } from '@edx/frontend-platform/react'; import { Spinner } from '@edx/paragon'; +import ScrollThreshold from '../../components/ScrollThreshold'; import { RequestStatus } from '../../data/constants'; import { selectAllThreads, selectThreadFilters, + selectThreadNextPage, selectThreadSorting, selectTopicThreads, selectUserThreads, @@ -30,6 +32,7 @@ function PostsView({ showOwnPosts }) { const { authenticatedUser } = useContext(AppContext); const orderBy = useSelector(selectThreadSorting()); const filters = useSelector(selectThreadFilters()); + const nextPage = useSelector(selectThreadNextPage()); const loadingStatus = useSelector(threadsLoadingStatus()); let posts = []; @@ -48,19 +51,33 @@ function PostsView({ showOwnPosts }) { })); }, [courseId, orderBy, filters]); + const loadMorePosts = async () => { + if (nextPage) { + dispatch(fetchThreads(courseId, { + orderBy, + filters, + page: nextPage, + })); + } + }; + return (
- {posts && posts.length > 0 && ( -
- {posts.map(post => ())} -
- )} - {loadingStatus === RequestStatus.IN_PROGRESS && ( -
- -
- )} +
+ {posts && posts.map(post => ( + + ))} + {loadingStatus === RequestStatus.IN_PROGRESS ? ( +
+ +
+ ) : ( + nextPage && ( + + ) + )} +
); } diff --git a/src/discussions/posts/data/redux.test.js b/src/discussions/posts/data/redux.test.js index 0e1d7148..39738764 100644 --- a/src/discussions/posts/data/redux.test.js +++ b/src/discussions/posts/data/redux.test.js @@ -58,6 +58,35 @@ describe('Threads/Posts data layer tests', () => { .toEqual('test-topic'); }); + test('successfully processes threads pagination', async () => { + const mockPage = page => axiosMock + .onGet(threadsApiUrl) + .reply(200, Factory.build('threadsResult', null, { + page, + count: 5, + pageSize: 3, + })); + + mockPage(1); + await executeThunk(fetchThreads(courseId), store.dispatch, store.getState); + expect(store.getState().threads.pages) + .toEqual([ + ['thread-1', 'thread-2', 'thread-3'], + ]); + expect(store.getState().threads.nextPage) + .toEqual(2); + + mockPage(2); + await executeThunk(fetchThreads(courseId, { page: 2 }), store.dispatch, store.getState); + expect(store.getState().threads.pages) + .toEqual([ + ['thread-1', 'thread-2', 'thread-3'], + ['thread-4', 'thread-5'], + ]); + expect(store.getState().threads.nextPage) + .toBeNull(); + }); + test('successfully processes single thread', async () => { const threadId = 'thread-1'; axiosMock.onGet(`${threadsApiUrl}${threadId}/`) diff --git a/src/discussions/posts/data/selectors.js b/src/discussions/posts/data/selectors.js index 2f8098c6..ed110d9f 100644 --- a/src/discussions/posts/data/selectors.js +++ b/src/discussions/posts/data/selectors.js @@ -47,6 +47,8 @@ export const selectThreadSorting = () => state => state.threads.sortedBy; export const selectThreadFilters = () => state => state.threads.filters; +export const selectThreadNextPage = () => state => state.threads.nextPage; + export const selectAuthorAvatars = author => state => ( state.threads.avatars?.[author]?.profile.image ); diff --git a/src/discussions/posts/data/slices.js b/src/discussions/posts/data/slices.js index 8f402d53..1d1a3bef 100644 --- a/src/discussions/posts/data/slices.js +++ b/src/discussions/posts/data/slices.js @@ -24,6 +24,7 @@ const threadsSlice = createSlice({ }, pages: [], threadDraft: null, + nextPage: null, totalPages: null, totalThreads: null, postStatus: RequestStatus.SUCCESSFUL, @@ -47,6 +48,7 @@ const threadsSlice = createSlice({ state.threadsById = { ...state.threadsById, ...payload.threadsById }; state.threadsInTopic = { ...state.threadsInTopic, ...payload.threadsInTopic }; state.avatars = { ...state.avatars, ...payload.avatars }; + state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null; state.totalPages = payload.pagination.numPages; state.totalThreads = payload.pagination.count; },