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:
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
25
src/discussions/empty-posts/EmptyLearners.jsx
Normal file
25
src/discussions/empty-posts/EmptyLearners.jsx
Normal 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);
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
107
src/discussions/learners/LearnerPostsView.jsx
Normal file
107
src/discussions/learners/LearnerPostsView.jsx
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
116
src/discussions/learners/learner/LearnerFilterBar.jsx
Normal file
116
src/discussions/learners/learner/LearnerFilterBar.jsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -60,3 +60,7 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
.discussion-post:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.learner > a:hover {
|
||||
background-color: #F2F0EF;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user