feat: implement learners area new UI (#197)

* feat: implement learners area new UI

* fix: learners list UI

* fix: initial learner sort based on role
This commit is contained in:
Awais Ansari
2022-06-20 16:14:13 +05:00
committed by GitHub
parent 84148954d0
commit 59187d2217
34 changed files with 460 additions and 737 deletions

View File

@@ -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]);

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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 (
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center h-100 overflow-auto">
<Route path={Routes.LEARNERS.LEARNER}>
<LearnerPageHeader />
</Route>
<div className="d-flex flex-column w-100 mw-xl" ref={refContainer}>
{postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
@@ -33,9 +28,6 @@ export default function DiscussionContent() {
<Route path={Routes.COMMENTS.PATH}>
<CommentsView />
</Route>
<Route path={Routes.LEARNERS.LEARNER}>
<LearnersContentView />
</Route>
</Switch>
)}
</div>

View File

@@ -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}
/>
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
<Redirect
from={Routes.DISCUSSIONS.PATH}

View File

@@ -12,7 +12,7 @@ import {
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
} from '../data/hooks';
import { selectDiscussionProvider } from '../data/selectors';
import { EmptyPosts, EmptyTopics } from '../empty-posts';
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
import messages from '../messages';
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { postMessageToParent } from '../utils';
@@ -39,8 +39,7 @@ export default function DiscussionsHome() {
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
// Display the content area if we are currently viewing/editing a post or creating one.
const displayContentArea = postId || postEditorVisible || learnerUsername;
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
let displaySidebar = useSidebarVisible();
const isOnDesktop = useIsOnDesktop();
@@ -96,9 +95,10 @@ export default function DiscussionsHome() {
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
/>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS]}
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
/>
<Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />
</Switch>
)}
</div>

View File

@@ -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 (
<EmptyPage title={intl.formatMessage(messages.emptyTitle)} />
);
}
EmptyLearners.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EmptyLearners);

View File

@@ -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';

View File

@@ -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 (
<div className="d-flex flex-column w-100 bg-white shadow-sm">
<div className="d-flex flex-row align-items-center m-4">
<Avatar src={avatar} alt={learnerUsername} />
<span className="font-weight-bold mx-3">
{profile.username}
</span>
</div>
<div className="d-flex pb-0 bg-light-200 justify-content-center p-2 flex-fill">
<ButtonGroup className="my-2 bg-white">
<NavLink
className={activeTabClass}
to={generatePath(Routes.LEARNERS.TABS.posts, params)}
>
{intl.formatMessage(messages.postsTab)} <span className="ml-3">{learner.threads}</span>
{
learner.activeFlags ? (
<span className="ml-3">
<Icon src={Report} />
</span>
) : null
}
</NavLink>
<NavLink
className={activeTabClass}
to={generatePath(Routes.LEARNERS.TABS.responses, params)}
>
{intl.formatMessage(messages.responsesTab)} <span className="ml-3">{learner.responses}</span>
</NavLink>
<NavLink
className={activeTabClass}
to={generatePath(Routes.LEARNERS.TABS.comments, params)}
>
{intl.formatMessage(messages.commentsTab)} <span className="ml-3">{learner.replies}</span>
</NavLink>
</ButtonGroup>
</div>
</div>
);
}
LearnerPageHeader.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LearnerPageHeader);

View File

@@ -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 (
<React.Fragment key={post.id}>
<div className="p-1 bg-light-400" />
<PostLink post={post} key={post.id} isSelected={checkIsSelected} />
</React.Fragment>
);
}
return (<PostLink post={post} key={post.id} isSelected={checkIsSelected} />);
});
return (
<div className="discussion-posts d-flex flex-column">
<div className="d-flex align-items-center justify-content-between px-2.5">
<IconButton
src={ArrowBack}
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))}
alt={intl.formatMessage(messages.back)}
/>
<div className="text-primary-500 font-style-normal font-family-inter font-weight-bold py-2.5">
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
</div>
<div style={{ padding: '18px' }} />
</div>
<div className="bg-light-400 border border-light-300" />
<div className="list-group list-group-flush">
{postInstances}
{posts?.length === 0 && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
nextPage && (
<ScrollThreshold onScroll={() => {
loadMorePosts();
}}
/>
)
)}
</div>
</div>
);
}
LearnerPostsView.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LearnerPostsView);

View File

@@ -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 (
<div className="learner-content d-flex flex-column">
<Switch>
<Route path={Routes.LEARNERS.LEARNER} exact>
<Redirect to={generatePath(Routes.LEARNERS.TABS.posts, params)} />
</Route>
<Route
path={Routes.LEARNERS.TABS.posts}
component={PostsTabContent}
/>
<Route path={Routes.LEARNERS.TABS.responses}>
<CommentsTabContent tab={LearnerTabs.RESPONSES} />
</Route>
<Route path={Routes.LEARNERS.TABS.comments}>
<CommentsTabContent tab={LearnerTabs.COMMENTS} />
</Route>
</Switch>
{
apiStatus === RequestStatus.IN_PROGRESS && (
<div className="my-3 text-center">
<Spinner animation="border" className="mie-3" />
</div>
)
}
</div>
);
}
LearnersContentView.propTypes = {
};
export default LearnersContentView;

View File

@@ -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(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ learnerUsername: username, courseId }}>
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/${LearnerTabs.POSTS}`]}>
<Route path="/:courseId/learners/:learnerUsername">
<DiscussionContent />
</Route>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
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');
});
});
});

View File

@@ -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 (
<div className="d-flex flex-column border-right border-light-300 h-100">
<div className="list-group list-group-flush ">
<LearnerFilterBar />
<div className="list-group list-group-flush learner">
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
<Redirect
to={{

View File

@@ -14,14 +14,10 @@ export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`;
/**
* Fetches all the learners in the given course.
* @param {string} courseId
* @param {number} page
* @param {string} orderBy
* @param {object} params {page, order_by}
* @returns {Promise<{}>}
*/
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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}
};
}

View File

@@ -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';

View File

@@ -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 (
<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 injectIntl(CommentsTabContent);

View File

@@ -10,12 +10,15 @@ import { learnerShape } from './proptypes';
function LearnerAvatar({ learner }) {
const learnerAvatar = useSelector(selectLearnerAvatar(learner.username));
return (
<div className="mr-2">
<div className="mr-3 mt-1">
<Avatar
size="md"
className="mt-2.5 ml-2.5"
size="sm"
alt={learner.username}
src={learnerAvatar}
style={{
height: '2rem',
width: '2rem',
}}
/>
</div>
);

View File

@@ -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 (
<Link
className="list-group-item list-group-item-action p-0 text-decoration-none text-gray-900 mw-100"
to={linkUrl}
>
<div
className="d-flex flex-row flex-fill mw-100 p-3.5 border-primary-500"
className="d-flex flex-row flex-fill mw-100 py-3 px-4 border-primary-500"
style={learner.username === learnerUsername ? {
borderRightWidth: '4px',
borderRightStyle: 'solid',
} : null}
>
<LearnerAvatar learner={learner} />
<div className="d-flex flex-column" style={{ width: 'calc(100% - 4rem)' }}>
<div className="align-items-center d-flex flex-row flex-fill mb-3">
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="h4 d-flex align-items-center pb-0 mb-0 flex-fill">
<div className="flex-fill text-truncate">
{learner.username}
</div>
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="d-flex align-items-center flex-fill">
<div
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style-normal font-family-inter"
>
{learner.username}
</div>
{learnerLastLogin
&& <span> {intl.formatMessage(messages.lastActive, { lastActiveTime })}</span>}
</div>
{learnerLastLogin && (
<div className="d-flex align-items-center flex-fill">
<div
className="text-gray-500 font-style-normal font-family-inter"
style={{
lineHeight: '20px',
fontSize: '12px',
}}
>
{intl.formatMessage(messages.lastActive, { lastActiveTime })}
</div>
</div>
)}
<LearnerFooter learner={learner} />
</div>
<LearnerFooter learner={learner} />
</div>
</div>
</Link>

View File

@@ -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,
}) => (
<label
htmlFor={id}
className="focus border-bottom-0 d-flex align-items-center w-100 py-2 m-0 font-weight-500"
data-testid={value === selected ? 'selected' : null}
>
<Icon src={Check} className={classNames('text-success mr-2', { invisible: value !== selected })} />
<Form.Radio id={id} className="sr-only sr-only-focusable" value={value} tabIndex={0}>
{label}
</Form.Radio>
<span aria-hidden className="text-truncate">
{label}
</span>
</label>
);
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 (
<Collapsible.Advanced
open={isOpen}
onToggle={() => setOpen(!isOpen)}
className="collapsible-card-lg border-right-0"
>
<Collapsible.Trigger className="collapsible-trigger border-0">
<span className="text-primary-700 pr-4">
{intl.formatMessage(messages.sortFilterStatus, {
sort: currentSorting,
})}
</span>
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form>
<div className="d-flex flex-row py-2 justify-content-between">
<Form.RadioSet
name="sort"
className="d-flex flex-column list-group list-group-flush"
value={currentSorting}
onChange={handleSortFilterChange}
>
<ActionItem
id="sort-activity"
label={intl.formatMessage(messages.mostActivity)}
value={LearnersOrdering.BY_LAST_ACTIVITY}
selected={currentSorting}
/>
{userIsPrivileged && (
<ActionItem
id="sort-reported"
label={intl.formatMessage(messages.reportedActivity)}
value={LearnersOrdering.BY_FLAG}
selected={currentSorting}
/>
)}
</Form.RadioSet>
</div>
</Form>
</Collapsible.Body>
</Collapsible.Advanced>
);
}
LearnerFilterBar.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LearnerFilterBar);

View File

@@ -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 (
<div className="d-flex align-items-center">
<Icon src={QuestionAnswer} className="mx-2 my-0" />
<span style={{ minWidth: '2rem' }}>
<div className="d-flex align-items-center pt-1 mt-2.5" style={{ marginBottom: '2px' }}>
<div className="d-flex align-items-center">
<Icon src={QuestionAnswerOutline} className="icon-size mr-2" />
{learner.threads}
</span>
<Icon src={Edit} className="mx-2 my-0" />
<span style={{ minWidth: '2rem' }}>
</div>
<div className="d-flex align-items-center">
<Icon src={Edit} className="icon-size mr-2 ml-4" />
{learner.replies + learner.responses}
</span>
{Boolean(activeFlags || inactiveFlags)
&& (
<OverlayTrigger
overlay={(
<Tooltip id={`learner-${learner.username}`}>
<div className="d-flex flex-column align-items-start">
<span>
{intl.formatMessage(messages.reported, { reported: activeFlags })}
</span>
{Boolean(inactiveFlags)
&& (
<span>
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
</span>
)}
</div>
</Tooltip>
)}
>
<div className="d-flex">
<Icon src={Report} className="mx-2 my-0 text-danger" />
<span style={{ minWidth: '2rem' }}>
{activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`}
</span>
</div>
</OverlayTrigger>
)}
</div>
{Boolean(activeFlags || inactiveFlags) && (
<OverlayTrigger
overlay={(
<Tooltip id={`learner-${learner.username}`}>
<div className="d-flex flex-column align-items-start">
<span>
{intl.formatMessage(messages.reported, { reported: activeFlags })}
</span>
{Boolean(inactiveFlags)
&& (
<span>
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
</span>
)}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center">
<Icon src={Report} className="icon-size mr-2 ml-4 text-danger" />
{activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`}
</div>
</OverlayTrigger>
)}
</div>
);
}

View File

@@ -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;

View File

@@ -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 (
<div className="d-flex flex-column my-3 mx-3 bg-white rounded">
{posts.map((post) => (
<div
data-testid="post"
key={post.id}
className="px-3 pb-3 border-bottom border-light-500"
>
<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 = {
intl: intlShape.isRequired,
};
export default injectIntl(PostsTabContent);

View File

@@ -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';

View File

@@ -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;

View File

@@ -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',
},
});

View File

@@ -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 (<PostLink post={post} key={post.id} isSelected={checkIsSelected} />);
});
return (
<>
{postInstances}
{posts && posts.length === 0 && <NoResults />}
{posts?.length === 0 && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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];

View File

@@ -60,3 +60,7 @@ $fa-font-path: "~font-awesome/fonts";
.discussion-post:hover {
background-color: unset !important;
}
.learner > a:hover {
background-color: #F2F0EF;
}