From 59187d2217f3a67a268521164006434053ff522f Mon Sep 17 00:00:00 2001 From: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> Date: Mon, 20 Jun 2022 16:14:13 +0500 Subject: [PATCH] feat: implement learners area new UI (#197) * feat: implement learners area new UI * fix: learners list UI * fix: initial learner sort based on role --- src/data/constants.js | 22 +-- src/discussions/comments/data/api.js | 26 --- src/discussions/data/thunks.js | 7 + .../discussions-home/DiscussionContent.jsx | 8 - .../discussions-home/DiscussionSidebar.jsx | 3 +- .../discussions-home/DiscussionsHome.jsx | 8 +- src/discussions/empty-posts/EmptyLearners.jsx | 25 +++ src/discussions/empty-posts/index.js | 1 + .../learners/LearnerPageHeader.jsx | 70 ------- src/discussions/learners/LearnerPostsView.jsx | 107 +++++++++++ .../learners/LearnersContentView.jsx | 53 ------ .../learners/LearnersContentView.test.jsx | 178 ------------------ src/discussions/learners/LearnersView.jsx | 11 +- src/discussions/learners/data/api.js | 28 ++- src/discussions/learners/data/selectors.js | 28 --- src/discussions/learners/data/slices.js | 51 ----- src/discussions/learners/data/thunks.js | 80 +++----- src/discussions/learners/index.js | 2 +- .../learners/learner/CommentsTabContent.jsx | 51 ----- .../learners/learner/LearnerAvatar.jsx | 9 +- .../learners/learner/LearnerCard.jsx | 47 +++-- .../learners/learner/LearnerFilterBar.jsx | 116 ++++++++++++ .../learners/learner/LearnerFooter.jsx | 74 ++++---- .../learners/learner/LearnerFooter.test.jsx | 2 +- .../learners/learner/PostsTabContent.jsx | 56 ------ src/discussions/learners/learner/index.js | 1 + src/discussions/learners/learner/messages.js | 23 --- src/discussions/learners/messages.js | 54 ++++-- src/discussions/posts/PostsView.jsx | 22 ++- src/discussions/posts/data/api.js | 18 -- src/discussions/posts/data/slices.js | 8 + src/discussions/posts/data/thunks.js | 2 +- src/discussions/posts/post/PostLink.jsx | 2 + src/index.scss | 4 + 34 files changed, 460 insertions(+), 737 deletions(-) create mode 100644 src/discussions/empty-posts/EmptyLearners.jsx delete mode 100644 src/discussions/learners/LearnerPageHeader.jsx create mode 100644 src/discussions/learners/LearnerPostsView.jsx delete mode 100644 src/discussions/learners/LearnersContentView.jsx delete mode 100644 src/discussions/learners/LearnersContentView.test.jsx delete mode 100644 src/discussions/learners/learner/CommentsTabContent.jsx create mode 100644 src/discussions/learners/learner/LearnerFilterBar.jsx delete mode 100644 src/discussions/learners/learner/PostsTabContent.jsx delete mode 100644 src/discussions/learners/learner/messages.js diff --git a/src/data/constants.js b/src/data/constants.js index 3869b2b6..87cce239 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -134,17 +134,6 @@ export const LearnersOrdering = { BY_LAST_ACTIVITY: 'activity', }; -/** - * Enum for Learner content tabs - * @readonly - * @enum {string} - */ -export const LearnerTabs = { - POSTS: 'posts', - COMMENTS: 'comments', - RESPONSES: 'responses', -}; - /** * Enum for discussion provider types supported by the MFE. * @type {{OPEN_EDX: string, LEGACY: string}} @@ -162,12 +151,7 @@ export const Routes = { }, LEARNERS: { PATH: `${BASE_PATH}/learners`, - LEARNER: `${BASE_PATH}/learners/:learnerUsername`, - TABS: { - posts: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.POSTS}`, - responses: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.RESPONSES}`, - comments: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.COMMENTS}`, - }, + POSTS: `${BASE_PATH}/learners/:learnerUsername/posts(/:postId)?`, }, POSTS: { PATH: `${BASE_PATH}/topics/:topicId`, @@ -191,6 +175,7 @@ export const Routes = { `${BASE_PATH}/topics/:topicId/posts/:postId`, `${BASE_PATH}/posts/:postId`, `${BASE_PATH}/my-posts/:postId`, + `${BASE_PATH}/learners/:learnerUsername/posts/:postId`, ], PAGE: `${BASE_PATH}/:page`, PAGES: { @@ -198,6 +183,7 @@ export const Routes = { topics: `${BASE_PATH}/topics/:topicId/posts/:postId`, posts: `${BASE_PATH}/posts/:postId`, 'my-posts': `${BASE_PATH}/my-posts/:postId`, + learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId`, }, }, TOPICS: { @@ -218,5 +204,5 @@ export const ALL_ROUTES = [] .concat(Routes.COMMENTS.PATH) .concat(Routes.TOPICS.PATH) .concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS]) - .concat([Routes.LEARNERS.LEARNER, Routes.LEARNERS.PATH]) + .concat([Routes.LEARNERS.POSTS, Routes.LEARNERS.PATH]) .concat([Routes.DISCUSSIONS.PATH]); diff --git a/src/discussions/comments/data/api.js b/src/discussions/comments/data/api.js index 6adac30e..c8e37d7e 100644 --- a/src/discussions/comments/data/api.js +++ b/src/discussions/comments/data/api.js @@ -117,29 +117,3 @@ export async function deleteComment(commentId) { await getAuthenticatedHttpClient() .delete(url); } - -/** - * Get the comments by a specific user in a course's discussions - * - * comments = responses + comments in the UI - * - * @param {string} courseId Course ID for the course - * @param {string} username Username of the user - * @returns API response in the format - * { - * results: [array of comments], - * pagination: {count, num_pages, next, previous} - * } - - */ -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/data/thunks.js b/src/discussions/data/thunks.js index c6e9aa92..e0ff9e69 100644 --- a/src/discussions/data/thunks.js +++ b/src/discussions/data/thunks.js @@ -2,6 +2,8 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { logError } from '@edx/frontend-platform/logging'; +import { LearnersOrdering } from '../../data/constants'; +import { setSortedBy } from '../learners/data'; import { getHttpErrorStatus } from '../utils'; import { getDiscussionsConfig, getDiscussionsSettings } from './api'; import { @@ -16,13 +18,18 @@ import { export function fetchCourseConfig(courseId) { return async (dispatch) => { try { + let learnerSort = LearnersOrdering.BY_LAST_ACTIVITY; dispatch(fetchConfigRequest()); + const config = await getDiscussionsConfig(courseId); if (config.is_user_admin || config.user_is_privileged) { const settings = await getDiscussionsSettings(courseId); Object.assign(config, { settings }); + learnerSort = LearnersOrdering.BY_FLAG; } + dispatch(fetchConfigSuccess(camelCaseObject(config))); + dispatch(setSortedBy(learnerSort)); } catch (error) { if (getHttpErrorStatus(error) === 403) { dispatch(fetchConfigDenied()); diff --git a/src/discussions/discussions-home/DiscussionContent.jsx b/src/discussions/discussions-home/DiscussionContent.jsx index 66b43201..d3ffb490 100644 --- a/src/discussions/discussions-home/DiscussionContent.jsx +++ b/src/discussions/discussions-home/DiscussionContent.jsx @@ -6,8 +6,6 @@ import { Route, Switch } from 'react-router'; import { Routes } from '../../data/constants'; import { CommentsView } from '../comments'; import { useContainerSizeForParent } from '../data/hooks'; -import { LearnersContentView } from '../learners'; -import LearnerPageHeader from '../learners/LearnerPageHeader'; import { PostEditor } from '../posts'; export default function DiscussionContent() { @@ -17,9 +15,6 @@ export default function DiscussionContent() { return (
- - -
{postEditorVisible ? ( @@ -33,9 +28,6 @@ export default function DiscussionContent() { - - - )}
diff --git a/src/discussions/discussions-home/DiscussionSidebar.jsx b/src/discussions/discussions-home/DiscussionSidebar.jsx index 40e4bcd0..9888c324 100644 --- a/src/discussions/discussions-home/DiscussionSidebar.jsx +++ b/src/discussions/discussions-home/DiscussionSidebar.jsx @@ -7,7 +7,7 @@ import { } from 'react-router'; import { Routes } from '../../data/constants'; -import { LearnersView } from '../learners'; +import { LearnerPostsView, LearnersView } from '../learners'; import { PostsView } from '../posts'; import { TopicsView } from '../topics'; @@ -29,6 +29,7 @@ export default function DiscussionSidebar({ displaySidebar }) { component={PostsView} /> + } /> } /> + )}
diff --git a/src/discussions/empty-posts/EmptyLearners.jsx b/src/discussions/empty-posts/EmptyLearners.jsx new file mode 100644 index 00000000..dd217a0c --- /dev/null +++ b/src/discussions/empty-posts/EmptyLearners.jsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import { useIsOnDesktop } from '../data/hooks'; +import messages from '../messages'; +import EmptyPage from './EmptyPage'; + +function EmptyLearners({ intl }) { + const isOnDesktop = useIsOnDesktop(); + + if (!isOnDesktop) { + return null; + } + + return ( + + ); +} + +EmptyLearners.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(EmptyLearners); diff --git a/src/discussions/empty-posts/index.js b/src/discussions/empty-posts/index.js index 3b9263a5..ff3ecac2 100644 --- a/src/discussions/empty-posts/index.js +++ b/src/discussions/empty-posts/index.js @@ -1,3 +1,4 @@ +export { default as EmptyLearners } from './EmptyLearners'; export { default as EmptyPage } from './EmptyPage'; export { default as EmptyPosts } from './EmptyPosts'; export { default as EmptyTopics } from './EmptyTopics'; diff --git a/src/discussions/learners/LearnerPageHeader.jsx b/src/discussions/learners/LearnerPageHeader.jsx deleted file mode 100644 index 7582e8d8..00000000 --- a/src/discussions/learners/LearnerPageHeader.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useContext } from 'react'; - -import classNames from 'classnames'; -import { useSelector } from 'react-redux'; -import { generatePath, NavLink } from 'react-router-dom'; - -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Avatar, ButtonGroup, Icon } from '@edx/paragon'; -import { Report } from '@edx/paragon/icons'; - -import { Routes } from '../../data/constants'; -import { DiscussionContext } from '../common/context'; -import { selectLearner, selectLearnerAvatar, selectLearnerProfile } from './data/selectors'; -import messages from './messages'; - -function LearnerPageHeader({ intl }) { - const { courseId, learnerUsername } = useContext(DiscussionContext); - const params = { courseId, learnerUsername }; - const learner = useSelector(selectLearner(learnerUsername)); - const profile = useSelector(selectLearnerProfile(learnerUsername)); - const avatar = useSelector(selectLearnerAvatar(learnerUsername)); - - const activeTabClass = (active) => classNames('btn', { 'btn-primary': active, 'btn-outline-primary': !active }); - - return ( -
-
- - - {profile.username} - -
-
- - - {intl.formatMessage(messages.postsTab)} {learner.threads} - { - learner.activeFlags ? ( - - - - ) : null - } - - - {intl.formatMessage(messages.responsesTab)} {learner.responses} - - - {intl.formatMessage(messages.commentsTab)} {learner.replies} - - -
-
- ); -} - -LearnerPageHeader.propTypes = { - intl: intlShape.isRequired, -}; - -export default injectIntl(LearnerPageHeader); diff --git a/src/discussions/learners/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx new file mode 100644 index 00000000..449b884b --- /dev/null +++ b/src/discussions/learners/LearnerPostsView.jsx @@ -0,0 +1,107 @@ +import React, { useContext, useEffect } from 'react'; + +import capitalize from 'lodash/capitalize'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Icon, IconButton, Spinner } from '@edx/paragon'; +import { ArrowBack } from '@edx/paragon/icons'; + +import ScrollThreshold from '../../components/ScrollThreshold'; +import { RequestStatus, Routes } from '../../data/constants'; +import { DiscussionContext } from '../common/context'; +import { + selectAllThreads, + selectThreadNextPage, + threadsLoadingStatus, +} from '../posts/data/selectors'; +import NoResults from '../posts/NoResults'; +import { PostLink } from '../posts/post'; +import { discussionsPath } from '../utils'; +import { selectLearnerProfile } from './data/selectors'; +import { fetchUserPosts } from './data/thunks'; +import messages from './messages'; + +function LearnerPostsView({ intl }) { + const location = useLocation(); + const history = useHistory(); + const dispatch = useDispatch(); + + const posts = useSelector(selectAllThreads); + const loadingStatus = useSelector(threadsLoadingStatus()); + const { courseId, learnerUsername: username } = useContext(DiscussionContext); + const nextPage = useSelector(selectThreadNextPage()); + const { id: userId } = useSelector(selectLearnerProfile(username)); + + useEffect(() => { + dispatch(fetchUserPosts(courseId, username, userId)); + }, [courseId, username]); + + const loadMorePosts = () => ( + dispatch(fetchUserPosts(courseId, username, userId, { + page: nextPage, + })) + ); + + const checkIsSelected = (id) => window.location.pathname.includes(id); + + let lastPinnedIdx = null; + const postInstances = posts?.map((post, idx) => { + if (post.pinned && lastPinnedIdx !== false) { + lastPinnedIdx = idx; + } else if (lastPinnedIdx != null && lastPinnedIdx !== false) { + lastPinnedIdx = false; + // Add a spacing after the group of pinned posts + return ( + +
+ + + ); + } + return (); + }); + + return ( +
+
+ history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))} + alt={intl.formatMessage(messages.back)} + /> +
+ {intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })} +
+
+
+
+
+ {postInstances} + {posts?.length === 0 && } + {loadingStatus === RequestStatus.IN_PROGRESS ? ( +
+ +
+ ) : ( + nextPage && ( + { + loadMorePosts(); + }} + /> + ) + )} +
+
+ ); +} + +LearnerPostsView.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(LearnerPostsView); diff --git a/src/discussions/learners/LearnersContentView.jsx b/src/discussions/learners/LearnersContentView.jsx deleted file mode 100644 index db650d77..00000000 --- a/src/discussions/learners/LearnersContentView.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { useContext } from 'react'; - -import { useSelector } from 'react-redux'; -import { - generatePath, Redirect, Route, Switch, -} from 'react-router-dom'; - -import { Spinner } from '@edx/paragon'; - -import { LearnerTabs, RequestStatus, Routes } from '../../data/constants'; -import { DiscussionContext } from '../common/context'; -import { learnersLoadingStatus } from './data/selectors'; -import CommentsTabContent from './learner/CommentsTabContent'; -import PostsTabContent from './learner/PostsTabContent'; - -function LearnersContentView() { - const { courseId, learnerUsername } = useContext(DiscussionContext); - const params = { courseId, learnerUsername }; - const apiStatus = useSelector(learnersLoadingStatus()); - - return ( -
- - - - - - - - - - - - - - { - apiStatus === RequestStatus.IN_PROGRESS && ( -
- -
- ) - } -
- ); -} - -LearnersContentView.propTypes = { -}; - -export default LearnersContentView; diff --git a/src/discussions/learners/LearnersContentView.test.jsx b/src/discussions/learners/LearnersContentView.test.jsx deleted file mode 100644 index 432c7fb0..00000000 --- a/src/discussions/learners/LearnersContentView.test.jsx +++ /dev/null @@ -1,178 +0,0 @@ -import React from 'react'; - -import { - fireEvent, render, screen, waitFor, -} from '@testing-library/react'; -import MockAdapter from 'axios-mock-adapter'; -import { act } from 'react-dom/test-utils'; -import { IntlProvider } from 'react-intl'; -import { MemoryRouter, Route } from 'react-router'; -import { Factory } from 'rosie'; - -import { initializeMockApp } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { AppProvider } from '@edx/frontend-platform/react'; - -import { LearnerTabs } from '../../data/constants'; -import { initializeStore } from '../../store'; -import { executeThunk } from '../../test-utils'; -import { commentsApiUrl } from '../comments/data/api'; -import { DiscussionContext } from '../common/context'; -import DiscussionContent from '../discussions-home/DiscussionContent'; -import { threadsApiUrl } from '../posts/data/api'; -import { coursesApiUrl, userProfileApiUrl } from './data/api'; -import { fetchLearners } from './data/thunks'; - -import '../comments/data/__factories__'; -import '../posts/data/__factories__'; -import './data/__factories__'; - -let store; -let axiosMock; -const courseId = 'course-v1:edX+TestX+Test_Course'; -const testUsername = 'leaner-1'; - -function renderComponent(username = testUsername) { - return render( - - - - - - - - - - - , - ); -} - -describe('LearnersContentView', () => { - const learnerCount = 1; - - beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - store = initializeStore({}); - Factory.resetAll(); - - axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`) - .reply( - 200, - Factory.build('learnersResult', {}, { - count: learnerCount, - pageSize: 5, - }), - ); - - axiosMock.onGet(`${userProfileApiUrl}?username=${testUsername}`) - .reply( - 200, - Factory.build('learnersProfile', {}, { - username: [testUsername], - }).profiles, - ); - await executeThunk(fetchLearners(courseId), store.dispatch, store.getState); - - axiosMock.onGet(threadsApiUrl) - .reply(200, Factory.build('threadsResult', {}, { - topicId: undefined, - count: 6, - pageSize: 5, - })); - - axiosMock.onGet(commentsApiUrl) - .reply(200, Factory.build('commentsResult', {}, { - count: 9, - pageSize: 8, - })); - }); - - test('it loads the posts view by default', async () => { - await act(async () => { - await renderComponent(); - }); - expect(screen.queryAllByTestId('post')).toHaveLength(5); - expect(screen.queryAllByText('This is Thread', { exact: false })).toHaveLength(5); - }); - - test('it renders all the comments WITHOUT parent id in responses tab', async () => { - await act(async () => { - await renderComponent(); - }); - await act(async () => { - fireEvent.click(screen.getByText('Responses', { exact: false })); - }); - - expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8); - }); - - test('it renders all the comments with parent id in comments tab', async () => { - axiosMock.onGet(commentsApiUrl) - .reply(200, Factory.build('commentsResult', {}, { - count: 4, - parentId: 'test_parent_id', - })); - await act(async () => { - await renderComponent(); - }); - await act(async () => { - fireEvent.click(screen.getByRole('link', { name: /Comments \d+/i })); - }); - expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(4); - }); - - test('it can switch back to the posts tab', async () => { - await act(async () => { - await renderComponent(); - }); - await act(async () => { - fireEvent.click(screen.getByRole('link', { name: /Responses \d+/i })); - }); - expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8); - - await act(async () => { - fireEvent.click(screen.getByRole('link', { name: /Posts \d+/i })); - }); - await waitFor(() => expect(screen.queryAllByTestId('post')).toHaveLength(5)); - }); - - describe('Posts Tab Button', () => { - it('does not show Report Icon when the learner has NO active flags', async () => { - await act(async () => { - await renderComponent('leaner-2'); - }); - const button = screen.getByRole('link', { name: /Posts/i }); - expect(button.innerHTML).not.toContain('svg'); - }); - - it('shows the Report Icon when the learner has active Flags', async () => { - axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`) - .reply(() => [200, Factory.build('learnersResult', {}, { - count: 1, - pageSize: 5, - activeFlags: 1, - })]); - axiosMock.onGet(`${userProfileApiUrl}?username=leaner-2`) - .reply(() => [200, Factory.build('learnersProfile', {}, { - username: ['leaner-2'], - }).profiles]); - await executeThunk(fetchLearners(courseId), store.dispatch, store.getState); - - await act(async () => { - await renderComponent('leaner-2'); - }); - const button = screen.getByRole('link', { name: /Posts/i }); - expect(button.innerHTML).toContain('svg'); - }); - }); -}); diff --git a/src/discussions/learners/LearnersView.jsx b/src/discussions/learners/LearnersView.jsx index 4a954c9b..f2fab0d0 100644 --- a/src/discussions/learners/LearnersView.jsx +++ b/src/discussions/learners/LearnersView.jsx @@ -17,12 +17,10 @@ import { selectLearnerSorting, } from './data/selectors'; import { fetchLearners } from './data/thunks'; -import { LearnerCard } from './learner'; +import { LearnerCard, LearnerFilterBar } from './learner'; function LearnersView() { - const { - courseId, - } = useParams(); + const { courseId } = useParams(); const location = useLocation(); const dispatch = useDispatch(); const orderBy = useSelector(selectLearnerSorting()); @@ -31,6 +29,7 @@ function LearnersView() { const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus); const learnersTabEnabled = useSelector(selectLearnersTabEnabled); const learners = useSelector(selectAllLearners); + useEffect(() => { if (learnersTabEnabled) { dispatch(fetchLearners(courseId, { orderBy })); @@ -45,9 +44,11 @@ function LearnersView() { })); } }; + return (
-
+ +
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && ( } */ -export async function getLearners( - courseId, { page, orderBy }, -) { - const params = { page, orderBy }; +export async function getLearners(courseId, params) { const url = `${coursesApiUrl}${courseId}/activity_stats/`; const { data } = await getAuthenticatedHttpClient().get(url, { params }); return data; @@ -36,3 +32,23 @@ export async function getUserProfiles(usernames) { const { data } = await getAuthenticatedHttpClient().get(url); return data; } + +/** + * Get the posts by a specific user in a course's discussions + * + * @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, userId, { page }) { + const learnerPostsApiUrl = `${coursesApiUrl}${courseId}/learner/`; + + const { data } = await getAuthenticatedHttpClient() + .get(learnerPostsApiUrl, { params: { user_id: userId, page } }); + return data; +} diff --git a/src/discussions/learners/data/selectors.js b/src/discussions/learners/data/selectors.js index 6f629c76..15926553 100644 --- a/src/discussions/learners/data/selectors.js +++ b/src/discussions/learners/data/selectors.js @@ -2,8 +2,6 @@ import { createSelector } from '@reduxjs/toolkit'; -import { LearnerTabs } from '../../../data/constants'; - export const selectAllLearners = createSelector( state => state.learners.pages, pages => pages.flat(), @@ -13,18 +11,8 @@ export const learnersLoadingStatus = () => state => state.learners.status; export const selectLearnerSorting = () => state => state.learners.sortedBy; -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 ); @@ -39,19 +27,3 @@ export const selectLearner = (username) => createSelector( ); export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {}; - -export const selectUserPosts = username => state => (state.learners.postsByUser[username] || []).flat(); - -/** - * Get the comments of a post. - * @param {string} username Username of the learner to get the comments of - * @param {LearnerTabs} commentType Type of comments to get - * @returns {Array} Array of comments - */ -export const selectUserComments = (username, commentType) => state => ( - commentType === LearnerTabs.COMMENTS - ? (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 6ab501f8..69e9dae8 100644 --- a/src/discussions/learners/data/slices.js +++ b/src/discussions/learners/data/slices.js @@ -10,25 +10,12 @@ const learnersSlice = createSlice({ name: 'learner', initialState: { status: RequestStatus.IN_PROGRESS, - avatars: {}, 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 - }, }, reducers: { fetchLearnersSuccess: (state, { payload }) => { @@ -54,37 +41,6 @@ const learnersSlice = createSlice({ setSortedBy: (state, { payload }) => { state.sortedBy = payload; }, - fetchUserCommentsRequest: (state) => { - state.status = RequestStatus.IN_PROGRESS; - }, - fetchUserCommentsSuccess: (state, { payload }) => { - 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, - }; - state.status = RequestStatus.SUCCESSFUL; - }, - fetchUserCommentsDenied: (state) => { - state.status = RequestStatus.DENIED; - }, - fetchUserPostsRequest: (state) => { - state.status = RequestStatus.IN_PROGRESS; - }, - fetchUserPostsSuccess: (state, { payload }) => { - 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) => { - state.status = RequestStatus.DENIED; - }, - }, }); @@ -94,13 +50,6 @@ export const { fetchLearnersSuccess, fetchLearnersDenied, setSortedBy, - fetchUserCommentsRequest, - fetchUserCommentsDenied, - fetchUserCommentsSuccess, - fetchUserPostsRequest, - fetchUserPostsDenied, - fetchUserPostsSuccess, - } = learnersSlice.actions; export const learnersReducer = learnersSlice.reducer; diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index 26666a63..33718eca 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -1,24 +1,21 @@ /* eslint-disable import/prefer-default-export */ -import { camelCaseObject } from '@edx/frontend-platform'; +import { camelCaseObject, snakeCaseObject } from '@edx/frontend-platform'; import { logError } from '@edx/frontend-platform/logging'; -import { getUserComments } from '../../comments/data/api'; -import { getUserPosts } from '../../posts/data/api'; -import { getHttpErrorStatus } from '../../utils'; import { - getLearners, getUserProfiles, -} from './api'; + fetchLearnerThreadsRequest, + fetchThreadsDenied, + fetchThreadsFailed, + fetchThreadsSuccess, +} from '../../posts/data/slices'; +import { normaliseThreads } from '../../posts/data/thunks'; +import { getHttpErrorStatus } from '../../utils'; +import { getLearners, getUserPosts, getUserProfiles } from './api'; import { fetchLearnersDenied, fetchLearnersFailed, fetchLearnersRequest, fetchLearnersSuccess, - fetchUserCommentsDenied, - fetchUserCommentsRequest, - fetchUserCommentsSuccess, - fetchUserPostsDenied, - fetchUserPostsRequest, - fetchUserPostsSuccess, } from './slices'; /** @@ -33,13 +30,11 @@ export function fetchLearners(courseId, { page = 1, } = {}) { return async (dispatch) => { - const options = { - orderBy, - page, - }; try { + const params = snakeCaseObject({ orderBy, page }); + dispatch(fetchLearnersRequest({ courseId })); - const learnerStats = await getLearners(courseId, options); + const learnerStats = await getLearners(courseId, params); const learnerProfilesData = await getUserProfiles(learnerStats.results.map((l) => l.username)); const learnerProfiles = {}; learnerProfilesData.forEach( @@ -59,58 +54,31 @@ export function fetchLearners(courseId, { }; } -/** - * Fetch the comments of a user for the specified course and update the - * redux state - * - * @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, { page = 1 } = {}) { - return async (dispatch) => { - try { - dispatch(fetchUserCommentsRequest()); - const data = await getUserComments(courseId, username, { page }); - dispatch(fetchUserCommentsSuccess(camelCaseObject({ - page, - username, - comments: data.results, - pagination: data.pagination, - }))); - } catch (error) { - if (getHttpErrorStatus(error) === 403) { - dispatch(fetchUserCommentsDenied()); - } - } - }; -} - /** * Fetch the posts of a user for the specified course and update the * redux state * * @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z - * @param {string} username Username of the learner + * @param {string} userId userId of the learner * @param page * @returns a promise that will update the state with the learner's posts */ -export function fetchUserPosts(courseId, username, { page = 1 } = {}) { +export function fetchUserPosts(courseId, username, userId, { page = 1 } = {}) { return async (dispatch) => { try { - dispatch(fetchUserPostsRequest()); - const data = await getUserPosts(courseId, username, { page }); - dispatch(fetchUserPostsSuccess(camelCaseObject({ - page, - username, - posts: data.results, - pagination: data.pagination, - }))); + dispatch(fetchLearnerThreadsRequest({ courseId, author: username })); + + const data = await getUserPosts(courseId, userId, { page }); + const normalisedData = normaliseThreads(camelCaseObject(data)); + + dispatch(fetchThreadsSuccess({ ...normalisedData, page, author: username })); } catch (error) { if (getHttpErrorStatus(error) === 403) { - dispatch(fetchUserPostsDenied()); + dispatch(fetchThreadsDenied()); + } else { + dispatch(fetchThreadsFailed()); } + logError(error); } }; } diff --git a/src/discussions/learners/index.js b/src/discussions/learners/index.js index 3dc1fdb5..e4cf49c4 100644 --- a/src/discussions/learners/index.js +++ b/src/discussions/learners/index.js @@ -1,3 +1,3 @@ /* eslint-disable import/prefer-default-export */ -export { default as LearnersContentView } from './LearnersContentView'; +export { default as LearnerPostsView } from './LearnerPostsView'; export { default as LearnersView } from './LearnersView'; diff --git a/src/discussions/learners/learner/CommentsTabContent.jsx b/src/discussions/learners/learner/CommentsTabContent.jsx deleted file mode 100644 index 26cbd47b..00000000 --- a/src/discussions/learners/learner/CommentsTabContent.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useContext, useEffect } from 'react'; -import PropType from 'prop-types'; - -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 { selectLearnerCommentsNextPage, selectUserComments } from '../data/selectors'; -import { fetchUserComments } from '../data/thunks'; - -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 injectIntl(CommentsTabContent); diff --git a/src/discussions/learners/learner/LearnerAvatar.jsx b/src/discussions/learners/learner/LearnerAvatar.jsx index d3f966f7..d63a4d8a 100644 --- a/src/discussions/learners/learner/LearnerAvatar.jsx +++ b/src/discussions/learners/learner/LearnerAvatar.jsx @@ -10,12 +10,15 @@ import { learnerShape } from './proptypes'; function LearnerAvatar({ learner }) { const learnerAvatar = useSelector(selectLearnerAvatar(learner.username)); return ( -
+
); diff --git a/src/discussions/learners/learner/LearnerCard.jsx b/src/discussions/learners/learner/LearnerCard.jsx index 6d4710c3..56efe514 100644 --- a/src/discussions/learners/learner/LearnerCard.jsx +++ b/src/discussions/learners/learner/LearnerCard.jsx @@ -11,9 +11,9 @@ import { Routes } from '../../../data/constants'; import { DiscussionContext } from '../../common/context'; import { discussionsPath } from '../../utils'; import { selectLearnerLastLogin } from '../data/selectors'; +import messages from '../messages'; import LearnerAvatar from './LearnerAvatar'; import LearnerFooter from './LearnerFooter'; -import messages from './messages'; import { learnerShape } from './proptypes'; function LearnerCard({ @@ -21,43 +21,52 @@ function LearnerCard({ intl, courseId, }) { - const { - inContext, - learnerUsername, - } = useContext(DiscussionContext); - const linkUrl = discussionsPath(Routes.LEARNERS.LEARNER, { + const { inContext, learnerUsername } = useContext(DiscussionContext); + const learnerLastLogin = useSelector(selectLearnerLastLogin(learner.username)); + const lastActiveTime = timeago.format(new Date(learnerLastLogin), intl.locale); + const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, { 0: inContext ? 'in-context' : undefined, learnerUsername: learner.username, courseId, }); - const learnerLastLogin = useSelector(selectLearnerLastLogin(learner.username)); - const lastActiveTime = timeago.format(new Date(learnerLastLogin), intl.locale); + return (
-
-
-
-
-
- {learner.username} -
+
+
+
+
+ {learner.username}
- {learnerLastLogin - && {intl.formatMessage(messages.lastActive, { lastActiveTime })}}
+ {learnerLastLogin && ( +
+
+ {intl.formatMessage(messages.lastActive, { lastActiveTime })} +
+
+ )} +
-
diff --git a/src/discussions/learners/learner/LearnerFilterBar.jsx b/src/discussions/learners/learner/LearnerFilterBar.jsx new file mode 100644 index 00000000..f0ca59f8 --- /dev/null +++ b/src/discussions/learners/learner/LearnerFilterBar.jsx @@ -0,0 +1,116 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import classNames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Collapsible, Form, Icon } from '@edx/paragon'; +import { Check, Tune } from '@edx/paragon/icons'; + +import { LearnersOrdering } from '../../../data/constants'; +import { selectUserIsPrivileged } from '../../data/selectors'; +import { setSortedBy } from '../data'; +import { selectLearnerSorting } from '../data/selectors'; +import messages from '../messages'; + +const ActionItem = ({ + id, + label, + value, + selected, +}) => ( + +); + +ActionItem.propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + selected: PropTypes.string.isRequired, +}; + +function LearnerFilterBar({ + intl, +}) { + const dispatch = useDispatch(); + const userIsPrivileged = useSelector(selectUserIsPrivileged); + const currentSorting = useSelector(selectLearnerSorting()); + const [isOpen, setOpen] = useState(false); + + const handleSortFilterChange = (event) => { + const { name, value } = event.currentTarget; + + if (name === 'sort') { + dispatch(setSortedBy(value)); + } + setOpen(false); + }; + + return ( + setOpen(!isOpen)} + className="collapsible-card-lg border-right-0" + > + + + {intl.formatMessage(messages.sortFilterStatus, { + sort: currentSorting, + })} + + + + + + + + + +
+
+ + + {userIsPrivileged && ( + + )} + +
+
+
+
+ ); +} + +LearnerFilterBar.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(LearnerFilterBar); diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx index eaf5deb9..612f42ae 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -1,12 +1,11 @@ import React from 'react'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { - Icon, OverlayTrigger, Tooltip, -} from '@edx/paragon'; -import { Edit, QuestionAnswer, Report } from '@edx/paragon/icons'; +import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; +import { Edit, Report } from '@edx/paragon/icons'; -import messages from './messages'; +import { QuestionAnswerOutline } from '../../../components/icons'; +import messages from '../messages'; import { learnerShape } from './proptypes'; function LearnerFooter({ @@ -16,42 +15,39 @@ function LearnerFooter({ const { inactiveFlags } = learner; const { activeFlags } = learner; return ( -
- - +
+
+ {learner.threads} - - - +
+
+ {learner.replies + learner.responses} - - {Boolean(activeFlags || inactiveFlags) - && ( - -
- - {intl.formatMessage(messages.reported, { reported: activeFlags })} - - {Boolean(inactiveFlags) - && ( - - {intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })} - - )} -
- - )} - > -
- - - {activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`} - -
-
- )} +
+ {Boolean(activeFlags || inactiveFlags) && ( + +
+ + {intl.formatMessage(messages.reported, { reported: activeFlags })} + + {Boolean(inactiveFlags) + && ( + + {intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })} + + )} +
+ + )} + > +
+ + {activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`} +
+
+ )}
); } diff --git a/src/discussions/learners/learner/LearnerFooter.test.jsx b/src/discussions/learners/learner/LearnerFooter.test.jsx index f991bfc9..1d1ca51c 100644 --- a/src/discussions/learners/learner/LearnerFooter.test.jsx +++ b/src/discussions/learners/learner/LearnerFooter.test.jsx @@ -7,8 +7,8 @@ import { initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; import { initializeStore } from '../../../store'; +import messages from '../messages'; import LearnerFooter from './LearnerFooter'; -import messages from './messages'; let store; diff --git a/src/discussions/learners/learner/PostsTabContent.jsx b/src/discussions/learners/learner/PostsTabContent.jsx deleted file mode 100644 index a378a2ea..00000000 --- a/src/discussions/learners/learner/PostsTabContent.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useContext, useEffect } from 'react'; - -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 { selectLearnerPostsNextPage, selectUserPosts } from '../data/selectors'; -import { fetchUserPosts } from '../data/thunks'; -import messages from './messages'; - -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 ( -
- {posts.map((post) => ( -
- -
- ))} - {nextPage && !loading && ( - - )} -
- ); -} - -PostsTabContent.propTypes = { - intl: intlShape.isRequired, -}; - -export default injectIntl(PostsTabContent); diff --git a/src/discussions/learners/learner/index.js b/src/discussions/learners/learner/index.js index 20ce0187..89774d75 100644 --- a/src/discussions/learners/learner/index.js +++ b/src/discussions/learners/learner/index.js @@ -1,3 +1,4 @@ /* eslint-disable import/prefer-default-export */ export { default as LearnerCard } from './LearnerCard'; +export { default as LearnerFilterBar } from './LearnerFilterBar'; export { default as LearnerFooter } from './LearnerFooter'; diff --git a/src/discussions/learners/learner/messages.js b/src/discussions/learners/learner/messages.js deleted file mode 100644 index 2636bd9b..00000000 --- a/src/discussions/learners/learner/messages.js +++ /dev/null @@ -1,23 +0,0 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; - -const messages = defineMessages({ - reported: { - id: 'discussions.learner.reported', - defaultMessage: '{reported} reported', - }, - previouslyReported: { - id: 'discussions.learner.previouslyReported', - defaultMessage: '{previouslyReported} previously reported', - }, - lastActive: { - 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/learners/messages.js b/src/discussions/learners/messages.js index 3fa5a8c6..0f4fae3c 100644 --- a/src/discussions/learners/messages.js +++ b/src/discussions/learners/messages.js @@ -1,20 +1,50 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - postsTab: { - id: 'discussions.learner.tab.posts', - defaultMessage: 'Posts', - description: "Label for the learner's posts tab", + reported: { + id: 'discussions.learner.reported', + defaultMessage: '{reported} reported', }, - responsesTab: { - id: 'discussions.learner.tab.responses', - defaultMessage: 'Responses', - description: "Label for the learner's responses tab", + previouslyReported: { + id: 'discussions.learner.previouslyReported', + defaultMessage: '{previouslyReported} previously reported', }, - commentsTab: { - id: 'discussions.learner.tab.comments', - defaultMessage: 'Comments', - description: "Label for the learner's comments tab", + lastActive: { + 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', + }, + back: { + id: 'discussions.learner.back', + defaultMessage: 'Back', + description: 'Text on button for back to learners list', + }, + activityForLearner: { + id: 'discussions.learner.activityForLearner', + defaultMessage: 'Activity for {username}', + description: 'Text for learners post header', + }, + mostActivity: { + id: 'discussions.learner.mostActivity', + defaultMessage: 'Most activity', + description: 'Text for learners sorting by most activity', + }, + reportedActivity: { + id: 'discussions.learner.reportedActivity', + defaultMessage: 'Reported activity', + description: 'Text for learners sorting by reported activity', + }, + sortFilterStatus: { + id: 'discussions.learner.sortFilterStatus', + defaultMessage: `All learners by {sort, select, + flagged {reported activity} + activity {most activity} + }`, + description: 'Text for current selected learners filter', }, }); diff --git a/src/discussions/posts/PostsView.jsx b/src/discussions/posts/PostsView.jsx index c8b984e6..9fbe7cf1 100644 --- a/src/discussions/posts/PostsView.jsx +++ b/src/discussions/posts/PostsView.jsx @@ -38,14 +38,17 @@ function PostsList({ posts, topics }) { const showOwnPosts = page === 'my-posts'; const userIsPrivileged = useSelector(selectUserIsPrivileged); const userIsStaff = useSelector(selectUserIsStaff); - const loadThreads = (topicIds, pageNum = undefined) => dispatch(fetchThreads(courseId, { - topicIds, - orderBy, - filters, - page: pageNum, - author: showOwnPosts ? authenticatedUser.username : null, - countFlagged: userIsPrivileged || userIsStaff, - })); + + const loadThreads = (topicIds, pageNum = undefined) => ( + dispatch(fetchThreads(courseId, { + topicIds, + orderBy, + filters, + page: pageNum, + author: showOwnPosts ? authenticatedUser.username : null, + countFlagged: userIsPrivileged || userIsStaff, + })) + ); useEffect(() => { if (topics !== undefined) { @@ -71,10 +74,11 @@ function PostsList({ posts, topics }) { } return (); }); + return ( <> {postInstances} - {posts && posts.length === 0 && } + {posts?.length === 0 && } {loadingStatus === RequestStatus.IN_PROGRESS ? (
diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js index be4a5d5b..530edb1d 100644 --- a/src/discussions/posts/data/api.js +++ b/src/discussions/posts/data/api.js @@ -200,21 +200,3 @@ export async function uploadFile(blob, filename, courseId, threadKey) { } return data; } - -/** - * Get the posts by a specific user in a course's discussions - * - * @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, { page }) { - const { data } = await getAuthenticatedHttpClient() - .get(threadsApiUrl, { params: { course_id: courseId, author: username, page } }); - return data; -} diff --git a/src/discussions/posts/data/slices.js b/src/discussions/posts/data/slices.js index a3248fb9..12a82ef4 100644 --- a/src/discussions/posts/data/slices.js +++ b/src/discussions/posts/data/slices.js @@ -39,6 +39,13 @@ const threadsSlice = createSlice({ sortedBy: ThreadOrdering.BY_LAST_ACTIVITY, }, reducers: { + fetchLearnerThreadsRequest: (state, { payload }) => { + if (state.author !== payload.author) { + state.pages = []; + state.author = payload.author; + } + state.status = RequestStatus.IN_PROGRESS; + }, fetchThreadsRequest: (state) => { state.status = RequestStatus.IN_PROGRESS; }, @@ -177,6 +184,7 @@ export const { deleteThreadFailed, deleteThreadRequest, deleteThreadSuccess, + fetchLearnerThreadsRequest, fetchThreadDenied, fetchThreadFailed, fetchThreadRequest, diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index ba8412eb..b9883d26 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -46,7 +46,7 @@ import { * @param data * @returns {{pagination, threadsById: {}, threadsInTopic: {}, avatars: {}}} */ -function normaliseThreads(data) { +export function normaliseThreads(data) { const normalized = {}; let threads; if ('results' in data) { diff --git a/src/discussions/posts/post/PostLink.jsx b/src/discussions/posts/post/PostLink.jsx index ec8440f8..64b3e5e4 100644 --- a/src/discussions/posts/post/PostLink.jsx +++ b/src/discussions/posts/post/PostLink.jsx @@ -27,6 +27,7 @@ function PostLink({ postId, inContext, category, + learnerUsername, } = useContext(DiscussionContext); const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], { 0: inContext ? 'in-context' : undefined, @@ -34,6 +35,7 @@ function PostLink({ topicId: post.topicId, postId: post.id, category, + learnerUsername, }); const showAnsweredBadge = post.hasEndorsed && post.type === ThreadType.QUESTION; const authorLabelColor = AvatarBorderAndLabelColors[post.authorLabel]; diff --git a/src/index.scss b/src/index.scss index ad40eb5e..26b3f961 100755 --- a/src/index.scss +++ b/src/index.scss @@ -60,3 +60,7 @@ $fa-font-path: "~font-awesome/fonts"; .discussion-post:hover { background-color: unset !important; } + +.learner > a:hover { + background-color: #F2F0EF; +}