feat: add support for pagination on learner page [BD-38] [TNL-9844] (#129)
Adds support for pagination on the learners page, including the learners list, the post, comments and responses lists.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<void>)|*}
|
||||
*/
|
||||
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) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-3 my-3">
|
||||
{comments.map(
|
||||
(comment) => <Comment key={comment.id} comment={comment} showFullThread={false} postType="discussion" />,
|
||||
)}
|
||||
{nextPage && !loading && (
|
||||
<Button
|
||||
onClick={handleLoadMoreComments}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="card p-4"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreComments)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommentsTabContent.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
tab: PropType.string.isRequired,
|
||||
};
|
||||
|
||||
export default CommentsTabContent;
|
||||
export default injectIntl(CommentsTabContent);
|
||||
|
||||
@@ -29,7 +29,7 @@ function LearnerFooter({
|
||||
&& (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<Tooltip id={`learner-${learner.username}`}>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
<span>
|
||||
{intl.formatMessage(messages.reported, { reported: activeFlags })}
|
||||
|
||||
@@ -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 (
|
||||
<div className="d-flex flex-column my-3 mx-3 bg-white rounded">
|
||||
@@ -27,10 +35,22 @@ function PostsTabContent() {
|
||||
<Post post={post} />
|
||||
</div>
|
||||
))}
|
||||
{nextPage && !loading && (
|
||||
<Button
|
||||
onClick={handleLoadMorePosts}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="card p-4"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMorePosts)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PostsTabContent.propTypes = {};
|
||||
PostsTabContent.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default PostsTabContent;
|
||||
export default injectIntl(PostsTabContent);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user