diff --git a/src/discussions/comments/data/api.js b/src/discussions/comments/data/api.js index 3c2cb44c..6adac30e 100644 --- a/src/discussions/comments/data/api.js +++ b/src/discussions/comments/data/api.js @@ -132,12 +132,13 @@ export async function deleteComment(commentId) { * } */ -export async function getUserComments(courseId, username) { +export async function getUserComments(courseId, username, { page }) { const { data } = await getAuthenticatedHttpClient() .get(commentsApiUrl, { params: { course_id: courseId, username, + page, }, }); return data; diff --git a/src/discussions/learners/LearnersContentView.test.jsx b/src/discussions/learners/LearnersContentView.test.jsx index 0f539912..8dbe8533 100644 --- a/src/discussions/learners/LearnersContentView.test.jsx +++ b/src/discussions/learners/LearnersContentView.test.jsx @@ -18,12 +18,12 @@ import { commentsApiUrl } from '../comments/data/api'; import { DiscussionContext } from '../common/context'; import { threadsApiUrl } from '../posts/data/api'; import { coursesApiUrl, userProfileApiUrl } from './data/api'; -import { fetchLearners, fetchUserComments } from './data/thunks'; +import { fetchLearners } from './data/thunks'; import LearnersContentView from './LearnersContentView'; -import './data/__factories__'; import '../comments/data/__factories__'; import '../posts/data/__factories__'; +import './data/__factories__'; let store; let axiosMock; @@ -64,28 +64,34 @@ describe('LearnersContentView', () => { Factory.resetAll(); axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`) - .reply(() => [200, Factory.build('learnersResult', {}, { - count: learnerCount, - pageSize: 5, - })]); + .reply( + 200, + Factory.build('learnersResult', {}, { + count: learnerCount, + pageSize: 5, + }), + ); axiosMock.onGet(`${userProfileApiUrl}?username=${testUsername}`) - .reply(() => [200, Factory.build('learnersProfile', {}, { - username: [testUsername], - }).profiles]); + .reply( + 200, + Factory.build('learnersProfile', {}, { + username: [testUsername], + }).profiles, + ); await executeThunk(fetchLearners(courseId), store.dispatch, store.getState); - axiosMock.onGet(threadsApiUrl, { params: { course_id: courseId, author: testUsername } }) + axiosMock.onGet(threadsApiUrl) .reply(200, Factory.build('threadsResult', {}, { topicId: undefined, - count: 5, - pageSize: 6, + count: 6, + pageSize: 5, })); - axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } }) + axiosMock.onGet(commentsApiUrl) .reply(200, Factory.build('commentsResult', {}, { - count: 8, - pageSize: 10, + count: 9, + pageSize: 8, })); }); @@ -109,19 +115,17 @@ describe('LearnersContentView', () => { }); test('it renders all the comments with parent id in comments tab', async () => { - axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } }) + axiosMock.onGet(commentsApiUrl) .reply(200, Factory.build('commentsResult', {}, { count: 4, parentId: 'test_parent_id', })); - executeThunk(fetchUserComments(courseId, testUsername), store.dispatch, store.state); await act(async () => { await renderComponent(); }); await act(async () => { - fireEvent.click(screen.getByText('Comments', { exact: false })); + fireEvent.click(screen.getByRole('link', { name: /Comments \d+/i })); }); - expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(4); }); @@ -130,12 +134,12 @@ describe('LearnersContentView', () => { await renderComponent(); }); await act(async () => { - fireEvent.click(screen.getByText('Responses', { exact: false })); + fireEvent.click(screen.getByRole('link', { name: /Responses \d+/i })); }); expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8); await act(async () => { - fireEvent.click(screen.getByText('Posts', { exact: false })); + fireEvent.click(screen.getByRole('link', { name: /Posts \d+/i })); }); expect(screen.queryAllByTestId('post')).toHaveLength(5); }); @@ -145,7 +149,7 @@ describe('LearnersContentView', () => { await act(async () => { await renderComponent('leaner-2'); }); - const button = screen.getByText('Posts', { exact: false }); + const button = screen.getByRole('link', { name: /Posts/i }); expect(button.innerHTML).not.toContain('svg'); }); @@ -165,7 +169,7 @@ describe('LearnersContentView', () => { await act(async () => { await renderComponent('leaner-2'); }); - const button = screen.getByText('Posts', { exact: false }); + const button = screen.getByRole('link', { name: /Posts/i }); expect(button.innerHTML).toContain('svg'); }); }); diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js index eb7cd745..1ad70d00 100644 --- a/src/discussions/learners/data/api.js +++ b/src/discussions/learners/data/api.js @@ -10,19 +10,20 @@ const apiBaseUrl = getConfig().LMS_BASE_URL; export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`; export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`; -export const postsApiUrl = `${apiBaseUrl}/api/discussion/v1/threads/`; -export const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`; /** * Fetches all the learners in the given course. * @param {string} courseId + * @param {number} page + * @param {string} orderBy * @returns {Promise<{}>} */ export async function getLearners( - courseId, + courseId, { page, orderBy }, ) { + const params = { page, orderBy }; const url = `${coursesApiUrl}${courseId}/activity_stats/`; - const { data } = await getAuthenticatedHttpClient().get(url); + const { data } = await getAuthenticatedHttpClient().get(url, { params }); return data; } diff --git a/src/discussions/learners/data/selectors.js b/src/discussions/learners/data/selectors.js index d1aba0f9..6f629c76 100644 --- a/src/discussions/learners/data/selectors.js +++ b/src/discussions/learners/data/selectors.js @@ -5,8 +5,8 @@ import { createSelector } from '@reduxjs/toolkit'; import { LearnerTabs } from '../../../data/constants'; export const selectAllLearners = createSelector( - state => state.learners, - learners => learners.learners, + state => state.learners.pages, + pages => pages.flat(), ); export const learnersLoadingStatus = () => state => state.learners.status; @@ -17,6 +17,14 @@ export const selectLearnerFilters = () => state => state.learners.filters; export const selectLearnerNextPage = () => state => state.learners.nextPage; +export const selectLearnerCommentsNextPage = (learner) => state => ( + state.learners.commentPaginationByUser?.[learner]?.nextPage +); + +export const selectLearnerPostsNextPage = (learner) => state => ( + state.learners.postPaginationByUser?.[learner]?.nextPage +); + export const selectLearnerAvatar = author => state => ( state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall ); @@ -32,7 +40,7 @@ export const selectLearner = (username) => createSelector( export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {}; -export const selectUserPosts = username => state => state.learners.postsByUser[username] || []; +export const selectUserPosts = username => state => (state.learners.postsByUser[username] || []).flat(); /** * Get the comments of a post. @@ -42,8 +50,8 @@ export const selectUserPosts = username => state => state.learners.postsByUser[u */ export const selectUserComments = (username, commentType) => state => ( commentType === LearnerTabs.COMMENTS - ? (state.learners.commentsByUser[username] || []).filter(c => c.parentId) - : (state.learners.commentsByUser[username] || []).filter(c => !c.parentId) + ? (state.learners.commentsByUser[username] || []).flat().filter(c => c.parentId) + : (state.learners.commentsByUser[username] || []).flat().filter(c => !c.parentId) ); export const flaggedCommentCount = (username) => state => state.learners.flaggedCommentsByUser[username] || 0; diff --git a/src/discussions/learners/data/slices.js b/src/discussions/learners/data/slices.js index 4cf7ea9a..04911a98 100644 --- a/src/discussions/learners/data/slices.js +++ b/src/discussions/learners/data/slices.js @@ -11,35 +11,34 @@ const learnersSlice = createSlice({ initialState: { status: RequestStatus.IN_PROGRESS, avatars: {}, - learners: [], learnerProfiles: {}, pages: [], nextPage: null, totalPages: null, totalLearners: null, sortedBy: LearnersOrdering.BY_LAST_ACTIVITY, + commentPaginationByUser: { + + }, commentsByUser: { // Map username to comments + }, + postPaginationByUser: { + }, postsByUser: { // Map username to posts }, - commentCountByUser: { - // Map of username and comment count - }, - postCountByUser: { - // Map of username and post count - }, }, reducers: { fetchLearnersSuccess: (state, { payload }) => { state.status = RequestStatus.SUCCESSFUL; - state.learners = payload.results; + state.pages[payload.page - 1] = payload.results; state.learnerProfiles = { ...state.learnerProfiles, ...(payload.learnerProfiles || {}), }; - state.nextPage = payload.pagination.next; + state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null; state.totalPages = payload.pagination.numPages; state.totalLearners = payload.pagination.count; }, @@ -54,15 +53,17 @@ const learnersSlice = createSlice({ }, setSortedBy: (state, { payload }) => { state.sortedBy = payload; - state.pages = []; }, fetchUserCommentsRequest: (state) => { state.status = RequestStatus.IN_PROGRESS; }, fetchUserCommentsSuccess: (state, { payload }) => { - state.commentsByUser[payload.username] = payload.comments; - state.commentCountByUser[payload.username] = payload.pagination.count; - state.status = RequestStatus.SUCCESS; + state.commentsByUser[payload.username] ??= []; + state.commentsByUser[payload.username][payload.page - 1] = payload.comments; + state.commentPaginationByUser[payload.username] = { + nextPage: (payload.page < payload.pagination.numPages) ? payload.page + 1 : null, + totalPages: payload.pagination.numPages, + }; }, fetchUserCommentsDenied: (state) => { state.status = RequestStatus.DENIED; @@ -71,8 +72,12 @@ const learnersSlice = createSlice({ state.status = RequestStatus.IN_PROGRESS; }, fetchUserPostsSuccess: (state, { payload }) => { - state.postsByUser[payload.username] = payload.posts; - state.postCountByUser[payload.username] = payload.pagination.count; + state.postsByUser[payload.username] ??= []; + state.postsByUser[payload.username][payload.page - 1] = payload.posts; + state.postPaginationByUser[payload.username] = { + nextPage: (payload.page < payload.pagination.numPages) ? payload.page + 1 : null, + totalPages: payload.pagination.numPages, + }; state.status = RequestStatus.SUCCESS; }, fetchUserPostsDenied: (state) => { diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index 6da05119..26666a63 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -24,17 +24,19 @@ import { /** * Fetches the learners for the course courseId. * @param {string} courseId The course ID for the course to fetch data for. + * @param {string} orderBy + * @param {number} page * @returns {(function(*): Promise)|*} */ export function fetchLearners(courseId, { orderBy, page = 1, } = {}) { - const options = { - orderBy, - page, - }; return async (dispatch) => { + const options = { + orderBy, + page, + }; try { dispatch(fetchLearnersRequest({ courseId })); const learnerStats = await getLearners(courseId, options); @@ -45,7 +47,7 @@ export function fetchLearners(courseId, { learnerProfiles[learnerProfile.username] = camelCaseObject(learnerProfile); }, ); - dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles })); + dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles, page })); } catch (error) { if (getHttpErrorStatus(error) === 403) { dispatch(fetchLearnersDenied()); @@ -63,14 +65,16 @@ export function fetchLearners(courseId, { * * @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z * @param {string} username Username of the learner + * @param {number} page * @returns a promise that will update the state with the learner's comments */ -export function fetchUserComments(courseId, username) { +export function fetchUserComments(courseId, username, { page = 1 } = {}) { return async (dispatch) => { try { dispatch(fetchUserCommentsRequest()); - const data = await getUserComments(courseId, username); + const data = await getUserComments(courseId, username, { page }); dispatch(fetchUserCommentsSuccess(camelCaseObject({ + page, username, comments: data.results, pagination: data.pagination, @@ -87,17 +91,21 @@ export function fetchUserComments(courseId, username) { * Fetch the posts of a user for the specified course and update the * redux state * - * @param {sting} courseId Course ID of the course eg., course-v1:X+Y+Z + * @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z * @param {string} username Username of the learner + * @param page * @returns a promise that will update the state with the learner's posts */ -export function fetchUserPosts(courseId, username) { +export function fetchUserPosts(courseId, username, { page = 1 } = {}) { return async (dispatch) => { try { dispatch(fetchUserPostsRequest()); - const data = await getUserPosts(courseId, username, true); + const data = await getUserPosts(courseId, username, { page }); dispatch(fetchUserPostsSuccess(camelCaseObject({ - username, posts: data.results, pagination: data.pagination, + page, + username, + posts: data.results, + pagination: data.pagination, }))); } catch (error) { if (getHttpErrorStatus(error) === 403) { diff --git a/src/discussions/learners/learner/CommentsTabContent.jsx b/src/discussions/learners/learner/CommentsTabContent.jsx index 1d671e26..26cbd47b 100644 --- a/src/discussions/learners/learner/CommentsTabContent.jsx +++ b/src/discussions/learners/learner/CommentsTabContent.jsx @@ -1,33 +1,51 @@ import React, { useContext, useEffect } from 'react'; import PropType from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Button } from '@edx/paragon'; + +import { useDispatchWithState } from '../../../data/hooks'; import Comment from '../../comments/comment/Comment'; +import messages from '../../comments/messages'; import { DiscussionContext } from '../../common/context'; -import { selectUserComments } from '../data/selectors'; +import { selectLearnerCommentsNextPage, selectUserComments } from '../data/selectors'; import { fetchUserComments } from '../data/thunks'; -function CommentsTabContent({ tab }) { - const dispatch = useDispatch(); +function CommentsTabContent({ tab, intl }) { + const [loading, dispatch] = useDispatchWithState(); const { courseId, learnerUsername: username } = useContext(DiscussionContext); const comments = useSelector(selectUserComments(username, tab)); + const nextPage = useSelector(selectLearnerCommentsNextPage(username)); useEffect(() => { dispatch(fetchUserComments(courseId, username)); }, [courseId, username]); + const handleLoadMoreComments = () => dispatch(fetchUserComments(courseId, username, { page: nextPage })); return (
{comments.map( (comment) => , )} + {nextPage && !loading && ( + + )}
); } CommentsTabContent.propTypes = { + intl: intlShape.isRequired, tab: PropType.string.isRequired, }; -export default CommentsTabContent; +export default injectIntl(CommentsTabContent); diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx index a50d1e75..eaf5deb9 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -29,7 +29,7 @@ function LearnerFooter({ && ( +
{intl.formatMessage(messages.reported, { reported: activeFlags })} diff --git a/src/discussions/learners/learner/PostsTabContent.jsx b/src/discussions/learners/learner/PostsTabContent.jsx index e7c0b45f..a378a2ea 100644 --- a/src/discussions/learners/learner/PostsTabContent.jsx +++ b/src/discussions/learners/learner/PostsTabContent.jsx @@ -1,20 +1,28 @@ import React, { useContext, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Button } from '@edx/paragon'; + +import { useDispatchWithState } from '../../../data/hooks'; import { DiscussionContext } from '../../common/context'; import { Post } from '../../posts'; -import { selectUserPosts } from '../data/selectors'; +import { selectLearnerPostsNextPage, selectUserPosts } from '../data/selectors'; import { fetchUserPosts } from '../data/thunks'; +import messages from './messages'; -function PostsTabContent() { - const dispatch = useDispatch(); +function PostsTabContent({ intl }) { + const [loading, dispatch] = useDispatchWithState(); const { courseId, learnerUsername: username } = useContext(DiscussionContext); const posts = useSelector(selectUserPosts(username)); + const nextPage = useSelector(selectLearnerPostsNextPage(username)); useEffect(() => { dispatch(fetchUserPosts(courseId, username)); }, [courseId, username]); + // console.log({ posts }); + const handleLoadMorePosts = () => dispatch(fetchUserPosts(courseId, username, { page: nextPage })); return (
@@ -27,10 +35,22 @@ function PostsTabContent() {
))} + {nextPage && !loading && ( + + )}
); } -PostsTabContent.propTypes = {}; +PostsTabContent.propTypes = { + intl: intlShape.isRequired, +}; -export default PostsTabContent; +export default injectIntl(PostsTabContent); diff --git a/src/discussions/learners/learner/messages.js b/src/discussions/learners/learner/messages.js index 3cc887b8..2636bd9b 100644 --- a/src/discussions/learners/learner/messages.js +++ b/src/discussions/learners/learner/messages.js @@ -13,6 +13,11 @@ const messages = defineMessages({ id: 'discussions.learner.lastLogin', defaultMessage: 'Last active {lastActiveTime}', }, + loadMorePosts: { + id: 'discussions.learner.loadMostPosts', + defaultMessage: 'Load more posts', + description: 'Text on button for loading more posts by a user', + }, }); export default messages; diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js index 1da56e3d..7a8ec1b1 100644 --- a/src/discussions/posts/data/api.js +++ b/src/discussions/posts/data/api.js @@ -202,14 +202,15 @@ export async function uploadFile(blob, filename, courseId, threadKey) { * * @param {string} courseId Course ID of the course * @param {string} username Username of the user + * @param {number} page * @returns API Response object in the format * { * results: [array of posts], * pagination: {count, num_pages, next, previous} * } */ -export async function getUserPosts(courseId, username) { +export async function getUserPosts(courseId, username, { page }) { const { data } = await getAuthenticatedHttpClient() - .get(threadsApiUrl, { params: { course_id: courseId, author: username } }); + .get(threadsApiUrl, { params: { course_id: courseId, author: username, page } }); return data; }