Compare commits
4 Commits
jenkins/dr
...
revert-nod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a51f3ed7c6 | ||
|
|
7d6d2c4f79 | ||
|
|
038b8f8966 | ||
|
|
14fe0d4ea5 |
25654
package-lock.json
generated
25654
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,17 @@ 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}}
|
||||
@@ -152,6 +163,11 @@ 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: {
|
||||
PATH: `${BASE_PATH}/topics/:topicId`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, 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';
|
||||
@@ -21,6 +22,7 @@ import Reply from './Reply';
|
||||
function Comment({
|
||||
postType,
|
||||
comment,
|
||||
showFullThread = true,
|
||||
intl,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
@@ -34,7 +36,7 @@ function Comment({
|
||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
if (hasChildren && !currentPage) {
|
||||
if (hasChildren && !currentPage && showFullThread) {
|
||||
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
||||
}
|
||||
}, [comment.id]);
|
||||
@@ -50,9 +52,10 @@ function Comment({
|
||||
const handleLoadMoreComments = () => (
|
||||
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
||||
);
|
||||
const commentClasses = classNames('d-flex flex-column card', { 'my-3': showFullThread });
|
||||
|
||||
return (
|
||||
<div className="discussion-comment d-flex flex-column card my-3" data-testid={`comment-${comment.id}`}>
|
||||
<div className={commentClasses} data-testid={`comment-${comment.id}`}>
|
||||
<DeleteConfirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||
@@ -100,7 +103,7 @@ function Comment({
|
||||
{intl.formatMessage(messages.loadMoreResponses)}
|
||||
</Button>
|
||||
)}
|
||||
{!isNested
|
||||
{!isNested && showFullThread
|
||||
&& (
|
||||
isReplying
|
||||
? (
|
||||
@@ -126,7 +129,12 @@ function Comment({
|
||||
Comment.propTypes = {
|
||||
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
||||
comment: commentShape.isRequired,
|
||||
showFullThread: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Comment.defaultProps = {
|
||||
showFullThread: true,
|
||||
};
|
||||
|
||||
export default injectIntl(Comment);
|
||||
|
||||
@@ -117,3 +117,28 @@ 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) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(commentsApiUrl, {
|
||||
params: {
|
||||
course_id: courseId,
|
||||
username,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { PostEditor } from '../posts';
|
||||
|
||||
export default function DiscussionContent() {
|
||||
@@ -28,6 +29,9 @@ export default function DiscussionContent() {
|
||||
<Route path={Routes.COMMENTS.PATH}>
|
||||
<CommentsView />
|
||||
</Route>
|
||||
<Route path={Routes.LEARNERS.LEARNER}>
|
||||
<LearnersContentView />
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
|
||||
110
src/discussions/learners/LearnersContentView.jsx
Normal file
110
src/discussions/learners/LearnersContentView.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
generatePath, NavLink, Redirect, Route, Switch,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Avatar, ButtonGroup, Card, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { MoreHoriz, Report } from '@edx/paragon/icons';
|
||||
|
||||
import { LearnerTabs, RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
learnersLoadingStatus, selectLearner, selectLearnerAvatar, selectLearnerProfile,
|
||||
} from './data/selectors';
|
||||
import CommentsTabContent from './learner/CommentsTabContent';
|
||||
import PostsTabContent from './learner/PostsTabContent';
|
||||
import messages from './messages';
|
||||
|
||||
function LearnersContentView({ intl }) {
|
||||
const { courseId, learnerUsername } = useContext(DiscussionContext);
|
||||
const params = { courseId, learnerUsername };
|
||||
const apiStatus = useSelector(learnersLoadingStatus());
|
||||
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="learner-content d-flex flex-column">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="d-flex flex-row align-items-center m-3">
|
||||
<Avatar src={avatar} alt={learnerUsername} />
|
||||
<span className="font-weight-bold mx-3">
|
||||
{profile.username}
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<IconButton iconAs={Icon} src={MoreHoriz} alt="Options" />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
<Card.Footer className="pb-0 bg-light-200 justify-content-center">
|
||||
<ButtonGroup className="my-2">
|
||||
<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>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
|
||||
<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 = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearnersContentView);
|
||||
172
src/discussions/learners/LearnersContentView.test.jsx
Normal file
172
src/discussions/learners/LearnersContentView.test.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
|
||||
import { fireEvent, render, screen } 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 { threadsApiUrl } from '../posts/data/api';
|
||||
import { coursesApiUrl, userProfileApiUrl } from './data/api';
|
||||
import { fetchLearners, fetchUserComments } from './data/thunks';
|
||||
import LearnersContentView from './LearnersContentView';
|
||||
|
||||
import './data/__factories__';
|
||||
import '../comments/data/__factories__';
|
||||
import '../posts/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">
|
||||
<LearnersContentView />
|
||||
</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, { params: { course_id: courseId, author: testUsername } })
|
||||
.reply(200, Factory.build('threadsResult', {}, {
|
||||
topicId: undefined,
|
||||
count: 5,
|
||||
pageSize: 6,
|
||||
}));
|
||||
|
||||
axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } })
|
||||
.reply(200, Factory.build('commentsResult', {}, {
|
||||
count: 8,
|
||||
pageSize: 10,
|
||||
}));
|
||||
});
|
||||
|
||||
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, { params: { course_id: courseId, username: testUsername } })
|
||||
.reply(200, Factory.build('commentsResult', {}, {
|
||||
count: 4,
|
||||
parentId: 'test_parent_id',
|
||||
}));
|
||||
executeThunk(fetchUserComments(courseId, testUsername), store.dispatch, store.state);
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Comments', { exact: false }));
|
||||
});
|
||||
|
||||
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.getByText('Responses', { exact: false }));
|
||||
});
|
||||
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Posts', { exact: false }));
|
||||
});
|
||||
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.getByText('Posts', { exact: false });
|
||||
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.getByText('Posts', { exact: false });
|
||||
expect(button.innerHTML).toContain('svg');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,12 @@ import { Factory } from 'rosie';
|
||||
Factory.define('learner')
|
||||
.sequence('id')
|
||||
.attr('username', ['id'], (id) => `leaner-${id}`)
|
||||
.option('activeFlags', null, null)
|
||||
.attr('active_flags', ['activeFlags'], (activeFlags) => activeFlags)
|
||||
.attrs({
|
||||
threads: 1,
|
||||
replies: 0,
|
||||
responses: 3,
|
||||
active_flags: null,
|
||||
inactive_flags: null,
|
||||
});
|
||||
|
||||
@@ -16,19 +17,18 @@ Factory.define('learnersResult')
|
||||
.option('page', null, 1)
|
||||
.option('pageSize', null, 5)
|
||||
.option('courseId', null, 'course-v1:Test+TestX+Test_Course')
|
||||
.option('activeFlags', null, 0)
|
||||
.attr(
|
||||
'pagination',
|
||||
['courseId', 'count', 'page', 'pageSize'],
|
||||
(courseId, count, page, pageSize) => {
|
||||
const numPages = Math.ceil(count / pageSize);
|
||||
const next = page < numPages
|
||||
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${
|
||||
page + 1
|
||||
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page + 1
|
||||
}`
|
||||
: null;
|
||||
const prev = page > 1
|
||||
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${
|
||||
page - 1
|
||||
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page - 1
|
||||
}`
|
||||
: null;
|
||||
return {
|
||||
@@ -41,12 +41,26 @@ Factory.define('learnersResult')
|
||||
)
|
||||
.attr(
|
||||
'results',
|
||||
['count', 'pageSize', 'page', 'courseId'],
|
||||
(count, pageSize, page, courseId) => {
|
||||
['count', 'pageSize', 'page', 'courseId', 'activeFlags'],
|
||||
(count, pageSize, page, courseId, activeFlags) => {
|
||||
const attrs = { course_id: courseId };
|
||||
Object.keys(attrs).forEach((key) => (attrs[key] === undefined ? delete attrs[key] : {}));
|
||||
const len = pageSize * page <= count ? pageSize : count % pageSize;
|
||||
return Factory.buildList('learner', len, attrs);
|
||||
let learners = [];
|
||||
|
||||
if (activeFlags && activeFlags <= len) {
|
||||
learners = Factory.buildList('learner', len - activeFlags, attrs);
|
||||
learners = learners.concat(
|
||||
Factory.buildList(
|
||||
'learner',
|
||||
activeFlags,
|
||||
{ ...attrs, active_flags: Math.floor(Math.random() * 10) + 1 },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
learners = Factory.buildList('learner', len, attrs);
|
||||
}
|
||||
return learners;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -68,6 +82,7 @@ Factory.define('learnersProfile')
|
||||
},
|
||||
last_login: new Date(Date.now() - 1000 * 60).toISOString(),
|
||||
username: user,
|
||||
name: 'Test User',
|
||||
}));
|
||||
return profiles;
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
|
||||
export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`;
|
||||
export const postsApiUrl = `${apiBaseUrl}/api/discussion/v1/threads/`;
|
||||
export const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`;
|
||||
|
||||
/**
|
||||
* Fetches all the learners in the given course.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { LearnerTabs } from '../../../data/constants';
|
||||
|
||||
export const selectAllLearners = createSelector(
|
||||
state => state.learners,
|
||||
learners => learners.learners,
|
||||
@@ -22,3 +24,26 @@ export const selectLearnerAvatar = author => state => (
|
||||
export const selectLearnerLastLogin = author => state => (
|
||||
state.learners.learnerProfiles[author]?.lastLogin
|
||||
);
|
||||
|
||||
export const selectLearner = (username) => createSelector(
|
||||
[selectAllLearners],
|
||||
learners => learners.find(l => l.username === username) || {},
|
||||
);
|
||||
|
||||
export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {};
|
||||
|
||||
export const selectUserPosts = username => state => state.learners.postsByUser[username] || [];
|
||||
|
||||
/**
|
||||
* 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] || []).filter(c => c.parentId)
|
||||
: (state.learners.commentsByUser[username] || []).filter(c => !c.parentId)
|
||||
);
|
||||
|
||||
export const flaggedCommentCount = (username) => state => state.learners.flaggedCommentsByUser[username] || 0;
|
||||
|
||||
@@ -18,6 +18,18 @@ const learnersSlice = createSlice({
|
||||
totalPages: null,
|
||||
totalLearners: null,
|
||||
sortedBy: LearnersOrdering.BY_LAST_ACTIVITY,
|
||||
commentsByUser: {
|
||||
// Map username to comments
|
||||
},
|
||||
postsByUser: {
|
||||
// Map username to posts
|
||||
},
|
||||
commentCountByUser: {
|
||||
// Map of username and comment count
|
||||
},
|
||||
postCountByUser: {
|
||||
// Map of username and post count
|
||||
},
|
||||
},
|
||||
reducers: {
|
||||
fetchLearnersSuccess: (state, { payload }) => {
|
||||
@@ -44,6 +56,29 @@ const learnersSlice = createSlice({
|
||||
state.sortedBy = payload;
|
||||
state.pages = [];
|
||||
},
|
||||
fetchUserCommentsRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchUserCommentsSuccess: (state, { payload }) => {
|
||||
state.commentsByUser[payload.username] = payload.comments;
|
||||
state.commentCountByUser[payload.username] = payload.pagination.count;
|
||||
state.status = RequestStatus.SUCCESS;
|
||||
},
|
||||
fetchUserCommentsDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
fetchUserPostsRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchUserPostsSuccess: (state, { payload }) => {
|
||||
state.postsByUser[payload.username] = payload.posts;
|
||||
state.postCountByUser[payload.username] = payload.pagination.count;
|
||||
state.status = RequestStatus.SUCCESS;
|
||||
},
|
||||
fetchUserPostsDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,6 +88,13 @@ export const {
|
||||
fetchLearnersSuccess,
|
||||
fetchLearnersDenied,
|
||||
setSortedBy,
|
||||
fetchUserCommentsRequest,
|
||||
fetchUserCommentsDenied,
|
||||
fetchUserCommentsSuccess,
|
||||
fetchUserPostsRequest,
|
||||
fetchUserPostsDenied,
|
||||
fetchUserPostsSuccess,
|
||||
|
||||
} = learnersSlice.actions;
|
||||
|
||||
export const learnersReducer = learnersSlice.reducer;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { camelCaseObject } 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,
|
||||
@@ -11,6 +13,12 @@ import {
|
||||
fetchLearnersFailed,
|
||||
fetchLearnersRequest,
|
||||
fetchLearnersSuccess,
|
||||
fetchUserCommentsDenied,
|
||||
fetchUserCommentsRequest,
|
||||
fetchUserCommentsSuccess,
|
||||
fetchUserPostsDenied,
|
||||
fetchUserPostsRequest,
|
||||
fetchUserPostsSuccess,
|
||||
} from './slices';
|
||||
|
||||
/**
|
||||
@@ -48,3 +56,53 @@ 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
|
||||
* @returns a promise that will update the state with the learner's comments
|
||||
*/
|
||||
export function fetchUserComments(courseId, username) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchUserCommentsRequest());
|
||||
const data = await getUserComments(courseId, username);
|
||||
dispatch(fetchUserCommentsSuccess(camelCaseObject({
|
||||
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 {sting} courseId Course ID of the course eg., course-v1:X+Y+Z
|
||||
* @param {string} username Username of the learner
|
||||
* @returns a promise that will update the state with the learner's posts
|
||||
*/
|
||||
export function fetchUserPosts(courseId, username) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchUserPostsRequest());
|
||||
const data = await getUserPosts(courseId, username, true);
|
||||
dispatch(fetchUserPostsSuccess(camelCaseObject({
|
||||
username, posts: data.results, pagination: data.pagination,
|
||||
})));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchUserPostsDenied());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as LearnersContentView } from './LearnersContentView';
|
||||
export { default as LearnersView } from './LearnersView';
|
||||
|
||||
33
src/discussions/learners/learner/CommentsTabContent.jsx
Normal file
33
src/discussions/learners/learner/CommentsTabContent.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import PropType from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import Comment from '../../comments/comment/Comment';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectUserComments } from '../data/selectors';
|
||||
import { fetchUserComments } from '../data/thunks';
|
||||
|
||||
function CommentsTabContent({ tab }) {
|
||||
const dispatch = useDispatch();
|
||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
||||
const comments = useSelector(selectUserComments(username, tab));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUserComments(courseId, username));
|
||||
}, [courseId, username]);
|
||||
|
||||
return (
|
||||
<div className="mx-3 my-3">
|
||||
{comments.map(
|
||||
(comment) => <Comment key={comment.id} comment={comment} showFullThread={false} postType="discussion" />,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommentsTabContent.propTypes = {
|
||||
tab: PropType.string.isRequired,
|
||||
};
|
||||
|
||||
export default CommentsTabContent;
|
||||
@@ -4,10 +4,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Edit, Error,
|
||||
QuestionAnswer,
|
||||
} from '@edx/paragon/icons';
|
||||
import { Edit, QuestionAnswer, Report } from '@edx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { learnerShape } from './proptypes';
|
||||
@@ -48,7 +45,7 @@ function LearnerFooter({
|
||||
)}
|
||||
>
|
||||
<div className="d-flex">
|
||||
<Icon src={Error} className="mx-2 my-0 text-danger" />
|
||||
<Icon src={Report} className="mx-2 my-0 text-danger" />
|
||||
<span style={{ minWidth: '2rem' }}>
|
||||
{activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`}
|
||||
</span>
|
||||
|
||||
36
src/discussions/learners/learner/PostsTabContent.jsx
Normal file
36
src/discussions/learners/learner/PostsTabContent.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { Post } from '../../posts';
|
||||
import { selectUserPosts } from '../data/selectors';
|
||||
import { fetchUserPosts } from '../data/thunks';
|
||||
|
||||
function PostsTabContent() {
|
||||
const dispatch = useDispatch();
|
||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
||||
const posts = useSelector(selectUserPosts(username));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUserPosts(courseId, username));
|
||||
}, [courseId, username]);
|
||||
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PostsTabContent.propTypes = {};
|
||||
|
||||
export default PostsTabContent;
|
||||
21
src/discussions/learners/messages.js
Normal file
21
src/discussions/learners/messages.js
Normal file
@@ -0,0 +1,21 @@
|
||||
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",
|
||||
},
|
||||
responsesTab: {
|
||||
id: 'discussions.learner.tab.responses',
|
||||
defaultMessage: 'Responses',
|
||||
description: "Label for the learner's responses tab",
|
||||
},
|
||||
commentsTab: {
|
||||
id: 'discussions.learner.tab.comments',
|
||||
defaultMessage: 'Comments',
|
||||
description: "Label for the learner's comments tab",
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -59,6 +59,30 @@ PostsList.defaultProps = {
|
||||
posts: [],
|
||||
};
|
||||
|
||||
function AllPostsList() {
|
||||
const posts = useSelector(selectAllThreads);
|
||||
return <PostsList posts={posts} />;
|
||||
}
|
||||
|
||||
function TopicPostsList({ topicId }) {
|
||||
const posts = useSelector(selectTopicThreads([topicId]));
|
||||
return <PostsList posts={posts} />;
|
||||
}
|
||||
|
||||
TopicPostsList.propTypes = {
|
||||
topicId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function CategoryPostsList({ category }) {
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(category);
|
||||
const posts = useSelector(selectTopicThreads(topicIds));
|
||||
return <PostsList posts={posts} />;
|
||||
}
|
||||
|
||||
CategoryPostsList.propTypes = {
|
||||
category: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function PostsView({ showOwnPosts }) {
|
||||
const {
|
||||
courseId,
|
||||
@@ -67,23 +91,21 @@ function PostsView({ showOwnPosts }) {
|
||||
} = useContext(DiscussionContext);
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
let topicIds = null;
|
||||
const orderBy = useSelector(selectThreadSorting());
|
||||
const filters = useSelector(selectThreadFilters());
|
||||
const nextPage = useSelector(selectThreadNextPage());
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
|
||||
const topicIds = null;
|
||||
let postsListComponent = null;
|
||||
let posts = [];
|
||||
|
||||
if (topicId) {
|
||||
posts = useSelector(selectTopicThreads([topicId]));
|
||||
postsListComponent = <TopicPostsList topicId={topicId} />;
|
||||
} else if (category) {
|
||||
topicIds = useSelector(selectTopicsUnderCategory)(category);
|
||||
posts = useSelector(selectTopicThreads(topicIds));
|
||||
postsListComponent = <CategoryPostsList category={category} />;
|
||||
} else {
|
||||
posts = useSelector(selectAllThreads);
|
||||
postsListComponent = <AllPostsList />;
|
||||
}
|
||||
postsListComponent = <PostsList posts={posts} />;
|
||||
|
||||
useEffect(() => {
|
||||
// The courseId from the URL is the course we WANT to load.
|
||||
dispatch(fetchThreads(courseId, {
|
||||
|
||||
@@ -196,3 +196,20 @@ 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
|
||||
* @returns API Response object in the format
|
||||
* {
|
||||
* results: [array of posts],
|
||||
* pagination: {count, num_pages, next, previous}
|
||||
* }
|
||||
*/
|
||||
export async function getUserPosts(courseId, username) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(threadsApiUrl, { params: { course_id: courseId, author: username } });
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user