Compare commits

..

3 Commits

Author SHA1 Message Date
Jawayria
32a100ea41 fix: try old package-lock 2022-04-18 17:43:14 +05:00
Jawayria
876211388a fix: regenerate package-lock 2022-04-18 14:57:51 +05:00
Jawayria
c409514766 fix: Updated dependencies for Node 16 compatibility 2022-04-18 14:24:19 +05:00
49 changed files with 517 additions and 26233 deletions

View File

@@ -1,13 +0,0 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: edx/.github/.github/workflows/lockfileversion-check.yml@master

25767
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -92,8 +92,6 @@ export default function TinyMCEEditor(props) {
content_css: false, content_css: false,
content_style: contentStyle, content_style: contentStyle,
body_class: 'm-2', body_class: 'm-2',
default_link_target: '_blank',
target_list: false,
images_upload_handler: uploadHandler, images_upload_handler: uploadHandler,
setup, setup,
}} }}

View File

@@ -1,16 +0,0 @@
import * as React from 'react';
import { _extends } from './common';
export default function QuestionAnswer(props) {
return /* #__PURE__ */React.createElement('svg', _extends({
width: 20,
height: 20,
viewBox: '0 0 20 20',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
}, props), /* #__PURE__ */React.createElement('path', {
d: 'M16.7371 4.00002H14.2371V11.5H3.4038V14H13.4038L16.7371 17.3334V4.00002ZM12.5705 9.83335V0.666687H0.0704651V13.1667L3.4038 9.83335H12.5705Z',
fill: 'currentColor',
}));
}

View File

@@ -1,16 +0,0 @@
import * as React from 'react';
import { _extends } from './common';
export default function QuestionAnswerOutline(props) {
return /* #__PURE__ */React.createElement('svg', _extends({
width: 20,
height: 20,
viewBox: '0 0 20 20',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
}, props), /* #__PURE__ */React.createElement('path', {
d: 'M 16.7371 4 H 14.2371 V 11.5 H 3.4038 V 14 H 13.4038 L 16.7371 17.3334 V 4 Z M 12.5705 9.8333 V 0.6667 H 0.0705 V 13.1667 L 3.4038 9.8333 H 12.5705 Z M 11.465 8.618 H 1.038 V 1.683 H 11.465Z',
fill: 'currentColor',
}));
}

View File

@@ -1,16 +0,0 @@
import * as React from 'react';
import { _extends } from './common';
export default function StarFilled(props) {
return /* #__PURE__ */React.createElement('svg', _extends({
width: 20,
height: 20,
viewBox: '0 0 20 20',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
}, props), /* #__PURE__ */React.createElement('path', {
d: 'M8.4038 13.3917L13.5538 16.5L12.1871 10.6417L16.7371 6.70002L10.7455 6.19169L8.4038 0.666687L6.06213 6.19169L0.0704651 6.70002L4.62047 10.6417L3.2538 16.5L8.4038 13.3917Z',
fill: 'currentColor',
}));
}

View File

@@ -1,16 +0,0 @@
import * as React from 'react';
import { _extends } from './common';
export default function StarOutline(props) {
return /* #__PURE__ */React.createElement('svg', _extends({
width: 20,
height: 20,
viewBox: '0 0 20 20',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
}, props), /* #__PURE__ */React.createElement('path', {
d: 'M16.7371 6.69999L10.7455 6.18332L8.4038 0.666656L6.06213 6.19166L0.0704651 6.69999L4.62047 10.6417L3.2538 16.5L8.4038 13.3917L13.5538 16.5L12.1955 10.6417L16.7371 6.69999ZM8.4038 11.8333L5.27047 13.725L6.1038 10.1583L3.33713 7.75832L6.98713 7.44166L8.4038 4.08332L9.8288 7.44999L13.4788 7.76666L10.7121 10.1667L11.5455 13.7333L8.4038 11.8333Z',
fill: 'currentColor',
}));
}

View File

@@ -1,16 +0,0 @@
import * as React from 'react';
import { _extends } from './common';
export default function ThumbUpFilled(props) {
return /* #__PURE__ */React.createElement('svg', _extends({
width: 20,
height: 20,
viewBox: '0 0 20 20',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
}, props), /* #__PURE__ */React.createElement('path', {
d: 'M11.2122 0.833344L5.23715 6.81668V17.5H15.4955L18.5705 10.3333V6.66668H11.6455L12.5788 2.18334L11.2122 0.833344ZM0.237152 7.50001H3.57049V17.5H0.237152V7.50001Z',
fill: 'currentColor',
}));
}

View File

@@ -1,16 +0,0 @@
import * as React from 'react';
import { _extends } from './common';
export default function ThumbUpOutline(props) {
return /* #__PURE__ */React.createElement('svg', _extends({
width: 20,
height: 20,
viewBox: '0 0 20 20',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
}, props), /* #__PURE__ */React.createElement('path', {
d: 'M18.5705 6.66668V10.3333L15.4955 17.5L5.23715 17.5L5.23715 6.81668L11.2122 0.833344L12.5788 2.18334L11.6455 6.66668L18.5705 6.66668ZM6.90382 7.50834L6.90382 15.8333L14.3955 15.8333L16.9038 9.99168V8.33334L9.59548 8.33334L10.5205 3.88334L6.90382 7.50834Z M3.57049 17.5H0.237152L0.237152 7.50001H3.57049L3.57049 17.5Z',
fill: 'currentColor',
}));
}

View File

@@ -1,21 +0,0 @@
/* eslint-disable no-underscore-dangle */
/* eslint-disable no-func-assign */
/* eslint-disable prefer-object-spread */
/* eslint-disable prefer-rest-params */
/* eslint-disable no-param-reassign */
/* eslint-disable no-restricted-syntax */
// eslint-disable-next-line import/prefer-default-export
export function _extends() {
_extends = Object.assign || function (target) {
for (let i = 1; i < arguments.length; i++) {
const source = arguments[i];
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}

View File

@@ -1,6 +0,0 @@
export { default as QuestionAnswer } from './QuestionAnswer';
export { default as QuestionAnswerOutline } from './QuestionAnswerOutline';
export { default as StarFilled } from './StarFilled';
export { default as StarOutline } from './StarOutline';
export { default as ThumbUpFilled } from './ThumbUpFilled';
export { default as ThumbUpOutline } from './ThumbUpOutline';

View File

@@ -179,7 +179,6 @@ export const Routes = {
`${BASE_PATH}`, `${BASE_PATH}`,
], ],
EDIT_POST: [ EDIT_POST: [
`${BASE_PATH}/category/:category/posts/:postId/edit`,
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`, `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
`${BASE_PATH}/posts/:postId/edit`, `${BASE_PATH}/posts/:postId/edit`,
`${BASE_PATH}/my-posts/:postId/edit`, `${BASE_PATH}/my-posts/:postId/edit`,
@@ -187,14 +186,12 @@ export const Routes = {
}, },
COMMENTS: { COMMENTS: {
PATH: [ PATH: [
`${BASE_PATH}/category/:category/posts/:postId`,
`${BASE_PATH}/topics/:topicId/posts/:postId`, `${BASE_PATH}/topics/:topicId/posts/:postId`,
`${BASE_PATH}/posts/:postId`, `${BASE_PATH}/posts/:postId`,
`${BASE_PATH}/my-posts/:postId`, `${BASE_PATH}/my-posts/:postId`,
], ],
PAGE: `${BASE_PATH}/:page`, PAGE: `${BASE_PATH}/:page`,
PAGES: { PAGES: {
category: `${BASE_PATH}/category/:category/posts/:postId`,
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`, topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
posts: `${BASE_PATH}/posts/:postId`, posts: `${BASE_PATH}/posts/:postId`,
'my-posts': `${BASE_PATH}/my-posts/:postId`, 'my-posts': `${BASE_PATH}/my-posts/:postId`,
@@ -203,18 +200,15 @@ export const Routes = {
TOPICS: { TOPICS: {
PATH: [ PATH: [
`${BASE_PATH}/topics/:topicId?`, `${BASE_PATH}/topics/:topicId?`,
`${BASE_PATH}/category/:category`,
`${BASE_PATH}/topics`,
], ],
ALL: `${BASE_PATH}/topics`, ALL: `${BASE_PATH}/topics`,
CATEGORY: `${BASE_PATH}/category/:category`, CATEGORY: `${BASE_PATH}/category/:category`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
TOPIC: `${BASE_PATH}/topics/:topicId`, TOPIC: `${BASE_PATH}/topics/:topicId`,
}, },
}; };
export const ALL_ROUTES = [] export const ALL_ROUTES = []
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY]) .concat([Routes.TOPICS.CATEGORY])
.concat(Routes.COMMENTS.PATH) .concat(Routes.COMMENTS.PATH)
.concat(Routes.TOPICS.PATH) .concat(Routes.TOPICS.PATH)
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS]) .concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])

View File

@@ -58,21 +58,19 @@ function normaliseCourseBlocks({
} else { } else {
blocks[verticalId].children?.forEach(discussionId => { blocks[verticalId].children?.forEach(discussionId => {
const discussion = camelCaseObject(blocks[discussionId]); const discussion = camelCaseObject(blocks[discussionId]);
const { topicId } = discussion.studentViewData || {}; const { topicId } = discussion.studentViewData;
if (topicId) { blockData[discussionId] = discussion;
blockData[discussionId] = discussion; // Add this topic id to the list of topics for the current chapter, sequential, and vertical
// Add this topic id to the list of topics for the current chapter, sequential, and vertical chapterData.topics.push(topicId);
chapterData.topics.push(topicId); blockData[sequentialId].topics.push(topicId);
blockData[sequentialId].topics.push(topicId); blockData[verticalId].topics.push(topicId);
blockData[verticalId].topics.push(topicId); // Store the topic's context in the course in a map
// Store the topic's context in the course in a map topics[topicId] = {
topics[topicId] = { chapterName: blockData[chapterId].displayName,
chapterName: blockData[chapterId].displayName, verticalName: blockData[sequentialId].displayName,
verticalName: blockData[sequentialId].displayName, unitName: blockData[verticalId].displayName,
unitName: blockData[verticalId].displayName, unitLink: blockData[verticalId].lmsWebUrl,
unitLink: blockData[verticalId].lmsWebUrl, };
};
}
}); });
} }
}); });

View File

@@ -74,7 +74,7 @@ function Comment({
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} /> <CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} />
) )
// eslint-disable-next-line react/no-danger // eslint-disable-next-line react/no-danger
: <div className="comment-body px-2" id="comment" dangerouslySetInnerHTML={{ __html: comment.renderedBody }} />} : <div className="comment-body px-2" dangerouslySetInnerHTML={{ __html: comment.renderedBody }} />}
<CommentIcons <CommentIcons
comment={comment} comment={comment}
following={comment.following} following={comment.following}
@@ -112,7 +112,6 @@ function Comment({
threadId: comment.threadId, threadId: comment.threadId,
parentId: comment.id, parentId: comment.id,
}} }}
edit={false}
onCloseEditor={() => setReplying(false)} onCloseEditor={() => setReplying(false)}
/> />
) )

View File

@@ -10,7 +10,6 @@ import { AppContext } from '@edx/frontend-platform/react';
import { Button, Form, StatefulButton } from '@edx/paragon'; import { Button, Form, StatefulButton } from '@edx/paragon';
import { TinyMCEEditor } from '../../../components'; import { TinyMCEEditor } from '../../../components';
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
import { useDispatchWithState } from '../../../data/hooks'; import { useDispatchWithState } from '../../../data/hooks';
import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors'; import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils'; import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
@@ -21,40 +20,15 @@ function CommentEditor({
intl, intl,
comment, comment,
onCloseEditor, onCloseEditor,
edit,
}) { }) {
const editorRef = useRef(null);
const { authenticatedUser } = useContext(AppContext); const { authenticatedUser } = useContext(AppContext);
const userIsPrivileged = useSelector(selectUserIsPrivileged); const userIsPrivileged = useSelector(selectUserIsPrivileged);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings); const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState(); const [submitting, dispatch] = useDispatchWithState();
const editorRef = useRef(null);
const canDisplayEditReason = (reasonCodesEnabled && userIsPrivileged
&& edit && comment.author !== authenticatedUser.username
);
const editReasonCodeValidation = canDisplayEditReason && {
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const validationSchema = Yup.object().shape({
comment: Yup.string()
.required(),
...editReasonCodeValidation,
});
const initialValues = {
comment: comment.rawBody,
editReasonCode: comment?.lastEdit?.reasonCode || '',
};
const saveUpdatedComment = async (values) => { const saveUpdatedComment = async (values) => {
if (comment.id) { if (comment.id) {
const payload = { await dispatch(editComment(comment.id, values));
...values,
editReasonCode: values.editReasonCode || undefined,
};
await dispatch(editComment(comment.id, payload));
} else { } else {
await dispatch(addComment(values.comment, comment.threadId, comment.parentId)); await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
} }
@@ -67,11 +41,17 @@ function CommentEditor({
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to // The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread. // the current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`; const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`;
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={{ comment: comment.rawBody }}
validationSchema={validationSchema} validationSchema={Yup.object()
.shape({
comment: Yup.string()
.required(),
editReasonCode: Yup.string()
.nullable()
.default(undefined),
})}
onSubmit={saveUpdatedComment} onSubmit={saveUpdatedComment}
> >
{({ {({
@@ -83,13 +63,10 @@ function CommentEditor({
handleChange, handleChange,
}) => ( }) => (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
{canDisplayEditReason && ( {(reasonCodesEnabled
<Form.Group && userIsPrivileged
isInvalid={isFormikFieldInvalid('editReasonCode', { && comment.author !== authenticatedUser.username) && (
errors, <Form.Group>
touched,
})}
>
<Form.Control <Form.Control
name="editReasonCode" name="editReasonCode"
className="mt-2" className="mt-2"
@@ -108,7 +85,6 @@ function CommentEditor({
<option key={code} value={code}>{label}</option> <option key={code} value={code}>{label}</option>
))} ))}
</Form.Control> </Form.Control>
<FormikErrorFeedback name="editReasonCode" />
</Form.Group> </Form.Group>
)} )}
<TinyMCEEditor <TinyMCEEditor
@@ -163,15 +139,9 @@ CommentEditor.propTypes = {
parentId: PropTypes.string, parentId: PropTypes.string,
rawBody: PropTypes.string, rawBody: PropTypes.string,
author: PropTypes.string, author: PropTypes.string,
lastEdit: PropTypes.object,
}).isRequired, }).isRequired,
onCloseEditor: PropTypes.func.isRequired, onCloseEditor: PropTypes.func.isRequired,
intl: intlShape.isRequired, intl: intlShape.isRequired,
edit: PropTypes.bool,
};
CommentEditor.defaultProps = {
edit: true,
}; };
export default injectIntl(CommentEditor); export default injectIntl(CommentEditor);

View File

@@ -45,17 +45,11 @@ function Reply({
hideDeleteConfirmation(); hideDeleteConfirmation();
}} }}
/> />
<div className="d-flex flex-fill ml-6">
<div className="d-flex"> <AlertBanner postType={null} content={reply} intl={intl} />
<div className="d-flex mx-3 invisible">
<Avatar className="m-2" />
</div>
<div className="w-100">
<AlertBanner postType={null} content={reply} intl={intl} />
</div>
</div> </div>
<div className="d-flex"> <div className="d-flex">
<div className="d-flex m-3"> <div className="d-flex m-3">
<Avatar <Avatar
className={`m-2 ${colorClass && `border-${colorClass}`}`} className={`m-2 ${colorClass && `border-${colorClass}`}`}
@@ -78,8 +72,9 @@ function Reply({
{isEditing {isEditing
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} /> ? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
// eslint-disable-next-line react/no-danger // eslint-disable-next-line react/no-danger
: <div id="reply" dangerouslySetInnerHTML={{ __html: reply.renderedBody }} />} : <div dangerouslySetInnerHTML={{ __html: reply.renderedBody }} />}
</div> </div>
</div> </div>
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}> <div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
{timeago.format(reply.createdAt, intl.locale)} {timeago.format(reply.createdAt, intl.locale)}

View File

@@ -14,11 +14,7 @@ function ResponseEditor({
const [addingResponse, setAddingResponse] = useState(false); const [addingResponse, setAddingResponse] = useState(false);
return addingResponse return addingResponse
? ( ? (
<CommentEditor <CommentEditor comment={{ threadId: postId }} onCloseEditor={() => setAddingResponse(false)} />
comment={{ threadId: postId }}
edit={false}
onCloseEditor={() => setAddingResponse(false)}
/>
) : ( ) : (
<div className="actions d-flex"> <div className="actions d-flex">
<Button variant="primary" onClick={() => setAddingResponse(true)}> <Button variant="primary" onClick={() => setAddingResponse(true)}>

View File

@@ -132,13 +132,12 @@ export async function deleteComment(commentId) {
* } * }
*/ */
export async function getUserComments(courseId, username, { page }) { export async function getUserComments(courseId, username) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.get(commentsApiUrl, { .get(commentsApiUrl, {
params: { params: {
course_id: courseId, course_id: courseId,
username, username,
page,
}, },
}); });
return data; return data;

View File

@@ -26,7 +26,7 @@ const messages = defineMessages({
}, },
endorsedResponseCount: { endorsedResponseCount: {
id: 'discussions.comments.comment.endorsedResponseCount', id: 'discussions.comments.comment.endorsedResponseCount',
defaultMessage: `{num, plural, defaultMessage: `{num, plural,
=0 {No endorsed responses} =0 {No endorsed responses}
one {Showing # endorsed response} one {Showing # endorsed response}
other {Showing # endorsed responses} other {Showing # endorsed responses}
@@ -147,11 +147,6 @@ const messages = defineMessages({
defaultMessage: 'Reason for editing', defaultMessage: 'Reason for editing',
description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s response', description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s response',
}, },
editReasonCodeError: {
id: 'discussions.editor.posts.editReasonCode.error',
defaultMessage: 'Select reason for editing',
description: 'Error message visible to moderators when they submit the post/response/comment without select reason for editing',
},
editedBy: { editedBy: {
id: 'discussions.comment.comments.editedBy', id: 'discussions.comment.comments.editedBy',
defaultMessage: 'Edited by', defaultMessage: 'Edited by',

View File

@@ -56,7 +56,7 @@ function AlertBanner({
</Alert> </Alert>
)} )}
{content.abuseFlagged && ( {content.abuseFlagged && (
<Alert icon={Error} variant="danger" className="p-3 m-0 shadow-none my-1 flex-fill"> <Alert icon={Error} variant="danger" className="p-3 m-0 shadow-none mb-1 flex-fill">
{intl.formatMessage(messages.abuseFlaggedMessage)} {intl.formatMessage(messages.abuseFlaggedMessage)}
</Alert> </Alert>
)} )}

View File

@@ -2,11 +2,10 @@
import React from 'react'; import React from 'react';
export const DiscussionContext = React.createContext({ export const DiscussionContext = React.createContext({
page: null,
courseId: null, courseId: null,
postId: null, postId: null,
topicId: null,
inContext: false,
category: null, category: null,
commentId: null,
learnerUsername: null, learnerUsername: null,
inContext: false,
}); });

View File

@@ -7,7 +7,6 @@ import { Routes } from '../../data/constants';
import { CommentsView } from '../comments'; import { CommentsView } from '../comments';
import { useContainerSizeForParent } from '../data/hooks'; import { useContainerSizeForParent } from '../data/hooks';
import { LearnersContentView } from '../learners'; import { LearnersContentView } from '../learners';
import LearnerPageHeader from '../learners/LearnerPageHeader';
import { PostEditor } from '../posts'; import { PostEditor } from '../posts';
export default function DiscussionContent() { export default function DiscussionContent() {
@@ -17,9 +16,6 @@ export default function DiscussionContent() {
return ( 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"> <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}> <div className="d-flex flex-column w-100 mw-xl" ref={refContainer}>
{postEditorVisible ? ( {postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}> <Route path={Routes.POSTS.NEW_POST}>

View File

@@ -24,8 +24,11 @@ export default function DiscussionSidebar({ displaySidebar }) {
data-testid="sidebar" data-testid="sidebar"
> >
<Switch> <Switch>
<Route path={Routes.POSTS.MY_POSTS}>
<PostsView showOwnPosts />
</Route>
<Route <Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]} path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]}
component={PostsView} component={PostsView}
/> />
<Route path={Routes.TOPICS.PATH} component={TopicsView} /> <Route path={Routes.TOPICS.PATH} component={TopicsView} />

View File

@@ -1,70 +0,0 @@
import React, { useContext } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { generatePath, NavLink } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Avatar, ButtonGroup, Icon } from '@edx/paragon';
import { Report } from '@edx/paragon/icons';
import { Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectLearner, selectLearnerAvatar, selectLearnerProfile } from './data/selectors';
import messages from './messages';
function LearnerPageHeader({ intl }) {
const { courseId, learnerUsername } = useContext(DiscussionContext);
const params = { courseId, learnerUsername };
const learner = useSelector(selectLearner(learnerUsername));
const profile = useSelector(selectLearnerProfile(learnerUsername));
const avatar = useSelector(selectLearnerAvatar(learnerUsername));
const activeTabClass = (active) => classNames('btn', { 'btn-primary': active, 'btn-outline-primary': !active });
return (
<div className="d-flex flex-column w-100 bg-white shadow-sm">
<div className="d-flex flex-row align-items-center m-4">
<Avatar src={avatar} alt={learnerUsername} />
<span className="font-weight-bold mx-3">
{profile.username}
</span>
</div>
<div className="d-flex pb-0 bg-light-200 justify-content-center p-2 flex-fill">
<ButtonGroup className="my-2 bg-white">
<NavLink
className={activeTabClass}
to={generatePath(Routes.LEARNERS.TABS.posts, params)}
>
{intl.formatMessage(messages.postsTab)} <span className="ml-3">{learner.threads}</span>
{
learner.activeFlags ? (
<span className="ml-3">
<Icon src={Report} />
</span>
) : null
}
</NavLink>
<NavLink
className={activeTabClass}
to={generatePath(Routes.LEARNERS.TABS.responses, params)}
>
{intl.formatMessage(messages.responsesTab)} <span className="ml-3">{learner.responses}</span>
</NavLink>
<NavLink
className={activeTabClass}
to={generatePath(Routes.LEARNERS.TABS.comments, params)}
>
{intl.formatMessage(messages.commentsTab)} <span className="ml-3">{learner.replies}</span>
</NavLink>
</ButtonGroup>
</div>
</div>
);
}
LearnerPageHeader.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LearnerPageHeader);

View File

@@ -1,25 +1,81 @@
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import {
generatePath, Redirect, Route, Switch, generatePath, NavLink, Redirect, Route, Switch,
} from 'react-router-dom'; } from 'react-router-dom';
import { Spinner } from '@edx/paragon'; 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 { LearnerTabs, RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context'; import { DiscussionContext } from '../common/context';
import { learnersLoadingStatus } from './data/selectors'; import {
learnersLoadingStatus, selectLearner, selectLearnerAvatar, selectLearnerProfile,
} from './data/selectors';
import CommentsTabContent from './learner/CommentsTabContent'; import CommentsTabContent from './learner/CommentsTabContent';
import PostsTabContent from './learner/PostsTabContent'; import PostsTabContent from './learner/PostsTabContent';
import messages from './messages';
function LearnersContentView() { function LearnersContentView({ intl }) {
const { courseId, learnerUsername } = useContext(DiscussionContext); const { courseId, learnerUsername } = useContext(DiscussionContext);
const params = { courseId, learnerUsername }; const params = { courseId, learnerUsername };
const apiStatus = useSelector(learnersLoadingStatus()); 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 ( return (
<div className="learner-content d-flex flex-column"> <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> <Switch>
<Route path={Routes.LEARNERS.LEARNER} exact> <Route path={Routes.LEARNERS.LEARNER} exact>
<Redirect to={generatePath(Routes.LEARNERS.TABS.posts, params)} /> <Redirect to={generatePath(Routes.LEARNERS.TABS.posts, params)} />
@@ -48,6 +104,7 @@ function LearnersContentView() {
} }
LearnersContentView.propTypes = { LearnersContentView.propTypes = {
intl: intlShape.isRequired,
}; };
export default LearnersContentView; export default injectIntl(LearnersContentView);

View File

@@ -16,14 +16,14 @@ import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils'; import { executeThunk } from '../../test-utils';
import { commentsApiUrl } from '../comments/data/api'; import { commentsApiUrl } from '../comments/data/api';
import { DiscussionContext } from '../common/context'; import { DiscussionContext } from '../common/context';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { threadsApiUrl } from '../posts/data/api'; import { threadsApiUrl } from '../posts/data/api';
import { coursesApiUrl, userProfileApiUrl } from './data/api'; import { coursesApiUrl, userProfileApiUrl } from './data/api';
import { fetchLearners } from './data/thunks'; import { fetchLearners, fetchUserComments } from './data/thunks';
import LearnersContentView from './LearnersContentView';
import './data/__factories__';
import '../comments/data/__factories__'; import '../comments/data/__factories__';
import '../posts/data/__factories__'; import '../posts/data/__factories__';
import './data/__factories__';
let store; let store;
let axiosMock; let axiosMock;
@@ -37,7 +37,7 @@ function renderComponent(username = testUsername) {
<DiscussionContext.Provider value={{ learnerUsername: username, courseId }}> <DiscussionContext.Provider value={{ learnerUsername: username, courseId }}>
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/${LearnerTabs.POSTS}`]}> <MemoryRouter initialEntries={[`/${courseId}/learners/${username}/${LearnerTabs.POSTS}`]}>
<Route path="/:courseId/learners/:learnerUsername"> <Route path="/:courseId/learners/:learnerUsername">
<DiscussionContent /> <LearnersContentView />
</Route> </Route>
</MemoryRouter> </MemoryRouter>
</DiscussionContext.Provider> </DiscussionContext.Provider>
@@ -64,34 +64,28 @@ describe('LearnersContentView', () => {
Factory.resetAll(); Factory.resetAll();
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`) axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
.reply( .reply(() => [200, Factory.build('learnersResult', {}, {
200, count: learnerCount,
Factory.build('learnersResult', {}, { pageSize: 5,
count: learnerCount, })]);
pageSize: 5,
}),
);
axiosMock.onGet(`${userProfileApiUrl}?username=${testUsername}`) axiosMock.onGet(`${userProfileApiUrl}?username=${testUsername}`)
.reply( .reply(() => [200, Factory.build('learnersProfile', {}, {
200, username: [testUsername],
Factory.build('learnersProfile', {}, { }).profiles]);
username: [testUsername],
}).profiles,
);
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState); await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
axiosMock.onGet(threadsApiUrl) axiosMock.onGet(threadsApiUrl, { params: { course_id: courseId, author: testUsername } })
.reply(200, Factory.build('threadsResult', {}, { .reply(200, Factory.build('threadsResult', {}, {
topicId: undefined, topicId: undefined,
count: 6, count: 5,
pageSize: 5, pageSize: 6,
})); }));
axiosMock.onGet(commentsApiUrl) axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } })
.reply(200, Factory.build('commentsResult', {}, { .reply(200, Factory.build('commentsResult', {}, {
count: 9, count: 8,
pageSize: 8, pageSize: 10,
})); }));
}); });
@@ -115,17 +109,19 @@ describe('LearnersContentView', () => {
}); });
test('it renders all the comments with parent id in comments tab', async () => { test('it renders all the comments with parent id in comments tab', async () => {
axiosMock.onGet(commentsApiUrl) axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } })
.reply(200, Factory.build('commentsResult', {}, { .reply(200, Factory.build('commentsResult', {}, {
count: 4, count: 4,
parentId: 'test_parent_id', parentId: 'test_parent_id',
})); }));
executeThunk(fetchUserComments(courseId, testUsername), store.dispatch, store.state);
await act(async () => { await act(async () => {
await renderComponent(); await renderComponent();
}); });
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole('link', { name: /Comments \d+/i })); fireEvent.click(screen.getByText('Comments', { exact: false }));
}); });
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(4); expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(4);
}); });
@@ -134,12 +130,12 @@ describe('LearnersContentView', () => {
await renderComponent(); await renderComponent();
}); });
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole('link', { name: /Responses \d+/i })); fireEvent.click(screen.getByText('Responses', { exact: false }));
}); });
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8); expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole('link', { name: /Posts \d+/i })); fireEvent.click(screen.getByText('Posts', { exact: false }));
}); });
expect(screen.queryAllByTestId('post')).toHaveLength(5); expect(screen.queryAllByTestId('post')).toHaveLength(5);
}); });
@@ -149,7 +145,7 @@ describe('LearnersContentView', () => {
await act(async () => { await act(async () => {
await renderComponent('leaner-2'); await renderComponent('leaner-2');
}); });
const button = screen.getByRole('link', { name: /Posts/i }); const button = screen.getByText('Posts', { exact: false });
expect(button.innerHTML).not.toContain('svg'); expect(button.innerHTML).not.toContain('svg');
}); });
@@ -169,7 +165,7 @@ describe('LearnersContentView', () => {
await act(async () => { await act(async () => {
await renderComponent('leaner-2'); await renderComponent('leaner-2');
}); });
const button = screen.getByRole('link', { name: /Posts/i }); const button = screen.getByText('Posts', { exact: false });
expect(button.innerHTML).toContain('svg'); expect(button.innerHTML).toContain('svg');
}); });
}); });

View File

@@ -46,8 +46,8 @@ function LearnersView() {
} }
}; };
return ( return (
<div className="d-flex flex-column border-right border-light-300 h-100"> <div className="d-flex flex-column">
<div className="list-group list-group-flush "> <div className="list-group list-group-flush">
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && ( {courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
<Redirect <Redirect
to={{ to={{

View File

@@ -10,20 +10,19 @@ const apiBaseUrl = getConfig().LMS_BASE_URL;
export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`; export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`; 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. * Fetches all the learners in the given course.
* @param {string} courseId * @param {string} courseId
* @param {number} page
* @param {string} orderBy
* @returns {Promise<{}>} * @returns {Promise<{}>}
*/ */
export async function getLearners( export async function getLearners(
courseId, { page, orderBy }, courseId,
) { ) {
const params = { page, orderBy };
const url = `${coursesApiUrl}${courseId}/activity_stats/`; const url = `${coursesApiUrl}${courseId}/activity_stats/`;
const { data } = await getAuthenticatedHttpClient().get(url, { params }); const { data } = await getAuthenticatedHttpClient().get(url);
return data; return data;
} }

View File

@@ -5,8 +5,8 @@ import { createSelector } from '@reduxjs/toolkit';
import { LearnerTabs } from '../../../data/constants'; import { LearnerTabs } from '../../../data/constants';
export const selectAllLearners = createSelector( export const selectAllLearners = createSelector(
state => state.learners.pages, state => state.learners,
pages => pages.flat(), learners => learners.learners,
); );
export const learnersLoadingStatus = () => state => state.learners.status; export const learnersLoadingStatus = () => state => state.learners.status;
@@ -17,14 +17,6 @@ export const selectLearnerFilters = () => state => state.learners.filters;
export const selectLearnerNextPage = () => state => state.learners.nextPage; 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 => ( export const selectLearnerAvatar = author => state => (
state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall
); );
@@ -40,7 +32,7 @@ export const selectLearner = (username) => createSelector(
export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {}; export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {};
export const selectUserPosts = username => state => (state.learners.postsByUser[username] || []).flat(); export const selectUserPosts = username => state => state.learners.postsByUser[username] || [];
/** /**
* Get the comments of a post. * Get the comments of a post.
@@ -50,8 +42,8 @@ export const selectUserPosts = username => state => (state.learners.postsByUser[
*/ */
export const selectUserComments = (username, commentType) => state => ( export const selectUserComments = (username, commentType) => state => (
commentType === LearnerTabs.COMMENTS commentType === LearnerTabs.COMMENTS
? (state.learners.commentsByUser[username] || []).flat().filter(c => c.parentId) ? (state.learners.commentsByUser[username] || []).filter(c => c.parentId)
: (state.learners.commentsByUser[username] || []).flat().filter(c => !c.parentId) : (state.learners.commentsByUser[username] || []).filter(c => !c.parentId)
); );
export const flaggedCommentCount = (username) => state => state.learners.flaggedCommentsByUser[username] || 0; export const flaggedCommentCount = (username) => state => state.learners.flaggedCommentsByUser[username] || 0;

View File

@@ -11,34 +11,35 @@ const learnersSlice = createSlice({
initialState: { initialState: {
status: RequestStatus.IN_PROGRESS, status: RequestStatus.IN_PROGRESS,
avatars: {}, avatars: {},
learners: [],
learnerProfiles: {}, learnerProfiles: {},
pages: [], pages: [],
nextPage: null, nextPage: null,
totalPages: null, totalPages: null,
totalLearners: null, totalLearners: null,
sortedBy: LearnersOrdering.BY_LAST_ACTIVITY, sortedBy: LearnersOrdering.BY_LAST_ACTIVITY,
commentPaginationByUser: {
},
commentsByUser: { commentsByUser: {
// Map username to comments // Map username to comments
},
postPaginationByUser: {
}, },
postsByUser: { postsByUser: {
// Map username to posts // Map username to posts
}, },
commentCountByUser: {
// Map of username and comment count
},
postCountByUser: {
// Map of username and post count
},
}, },
reducers: { reducers: {
fetchLearnersSuccess: (state, { payload }) => { fetchLearnersSuccess: (state, { payload }) => {
state.status = RequestStatus.SUCCESSFUL; state.status = RequestStatus.SUCCESSFUL;
state.pages[payload.page - 1] = payload.results; state.learners = payload.results;
state.learnerProfiles = { state.learnerProfiles = {
...state.learnerProfiles, ...state.learnerProfiles,
...(payload.learnerProfiles || {}), ...(payload.learnerProfiles || {}),
}; };
state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null; state.nextPage = payload.pagination.next;
state.totalPages = payload.pagination.numPages; state.totalPages = payload.pagination.numPages;
state.totalLearners = payload.pagination.count; state.totalLearners = payload.pagination.count;
}, },
@@ -53,18 +54,15 @@ const learnersSlice = createSlice({
}, },
setSortedBy: (state, { payload }) => { setSortedBy: (state, { payload }) => {
state.sortedBy = payload; state.sortedBy = payload;
state.pages = [];
}, },
fetchUserCommentsRequest: (state) => { fetchUserCommentsRequest: (state) => {
state.status = RequestStatus.IN_PROGRESS; state.status = RequestStatus.IN_PROGRESS;
}, },
fetchUserCommentsSuccess: (state, { payload }) => { fetchUserCommentsSuccess: (state, { payload }) => {
state.commentsByUser[payload.username] ??= []; state.commentsByUser[payload.username] = payload.comments;
state.commentsByUser[payload.username][payload.page - 1] = payload.comments; state.commentCountByUser[payload.username] = payload.pagination.count;
state.commentPaginationByUser[payload.username] = { state.status = RequestStatus.SUCCESS;
nextPage: (payload.page < payload.pagination.numPages) ? payload.page + 1 : null,
totalPages: payload.pagination.numPages,
};
state.status = RequestStatus.SUCCESSFUL;
}, },
fetchUserCommentsDenied: (state) => { fetchUserCommentsDenied: (state) => {
state.status = RequestStatus.DENIED; state.status = RequestStatus.DENIED;
@@ -73,12 +71,8 @@ const learnersSlice = createSlice({
state.status = RequestStatus.IN_PROGRESS; state.status = RequestStatus.IN_PROGRESS;
}, },
fetchUserPostsSuccess: (state, { payload }) => { fetchUserPostsSuccess: (state, { payload }) => {
state.postsByUser[payload.username] ??= []; state.postsByUser[payload.username] = payload.posts;
state.postsByUser[payload.username][payload.page - 1] = payload.posts; state.postCountByUser[payload.username] = payload.pagination.count;
state.postPaginationByUser[payload.username] = {
nextPage: (payload.page < payload.pagination.numPages) ? payload.page + 1 : null,
totalPages: payload.pagination.numPages,
};
state.status = RequestStatus.SUCCESS; state.status = RequestStatus.SUCCESS;
}, },
fetchUserPostsDenied: (state) => { fetchUserPostsDenied: (state) => {

View File

@@ -24,19 +24,17 @@ import {
/** /**
* Fetches the learners for the course courseId. * Fetches the learners for the course courseId.
* @param {string} courseId The course ID for the course to fetch data for. * @param {string} courseId The course ID for the course to fetch data for.
* @param {string} orderBy
* @param {number} page
* @returns {(function(*): Promise<void>)|*} * @returns {(function(*): Promise<void>)|*}
*/ */
export function fetchLearners(courseId, { export function fetchLearners(courseId, {
orderBy, orderBy,
page = 1, page = 1,
} = {}) { } = {}) {
const options = {
orderBy,
page,
};
return async (dispatch) => { return async (dispatch) => {
const options = {
orderBy,
page,
};
try { try {
dispatch(fetchLearnersRequest({ courseId })); dispatch(fetchLearnersRequest({ courseId }));
const learnerStats = await getLearners(courseId, options); const learnerStats = await getLearners(courseId, options);
@@ -47,7 +45,7 @@ export function fetchLearners(courseId, {
learnerProfiles[learnerProfile.username] = camelCaseObject(learnerProfile); learnerProfiles[learnerProfile.username] = camelCaseObject(learnerProfile);
}, },
); );
dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles, page })); dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles }));
} catch (error) { } catch (error) {
if (getHttpErrorStatus(error) === 403) { if (getHttpErrorStatus(error) === 403) {
dispatch(fetchLearnersDenied()); dispatch(fetchLearnersDenied());
@@ -65,16 +63,14 @@ export function fetchLearners(courseId, {
* *
* @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z * @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z
* @param {string} username Username of the learner * @param {string} username Username of the learner
* @param {number} page
* @returns a promise that will update the state with the learner's comments * @returns a promise that will update the state with the learner's comments
*/ */
export function fetchUserComments(courseId, username, { page = 1 } = {}) { export function fetchUserComments(courseId, username) {
return async (dispatch) => { return async (dispatch) => {
try { try {
dispatch(fetchUserCommentsRequest()); dispatch(fetchUserCommentsRequest());
const data = await getUserComments(courseId, username, { page }); const data = await getUserComments(courseId, username);
dispatch(fetchUserCommentsSuccess(camelCaseObject({ dispatch(fetchUserCommentsSuccess(camelCaseObject({
page,
username, username,
comments: data.results, comments: data.results,
pagination: data.pagination, pagination: data.pagination,
@@ -91,21 +87,17 @@ export function fetchUserComments(courseId, username, { page = 1 } = {}) {
* Fetch the posts of a user for the specified course and update the * Fetch the posts of a user for the specified course and update the
* redux state * redux state
* *
* @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z * @param {sting} courseId Course ID of the course eg., course-v1:X+Y+Z
* @param {string} username Username of the learner * @param {string} username Username of the learner
* @param page
* @returns a promise that will update the state with the learner's posts * @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) {
return async (dispatch) => { return async (dispatch) => {
try { try {
dispatch(fetchUserPostsRequest()); dispatch(fetchUserPostsRequest());
const data = await getUserPosts(courseId, username, { page }); const data = await getUserPosts(courseId, username, true);
dispatch(fetchUserPostsSuccess(camelCaseObject({ dispatch(fetchUserPostsSuccess(camelCaseObject({
page, username, posts: data.results, pagination: data.pagination,
username,
posts: data.results,
pagination: data.pagination,
}))); })));
} catch (error) { } catch (error) {
if (getHttpErrorStatus(error) === 403) { if (getHttpErrorStatus(error) === 403) {

View File

@@ -1,51 +1,33 @@
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import PropType from 'prop-types'; import PropType from 'prop-types';
import { useSelector } from 'react-redux'; import { useDispatch, 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 Comment from '../../comments/comment/Comment';
import messages from '../../comments/messages';
import { DiscussionContext } from '../../common/context'; import { DiscussionContext } from '../../common/context';
import { selectLearnerCommentsNextPage, selectUserComments } from '../data/selectors'; import { selectUserComments } from '../data/selectors';
import { fetchUserComments } from '../data/thunks'; import { fetchUserComments } from '../data/thunks';
function CommentsTabContent({ tab, intl }) { function CommentsTabContent({ tab }) {
const [loading, dispatch] = useDispatchWithState(); const dispatch = useDispatch();
const { courseId, learnerUsername: username } = useContext(DiscussionContext); const { courseId, learnerUsername: username } = useContext(DiscussionContext);
const comments = useSelector(selectUserComments(username, tab)); const comments = useSelector(selectUserComments(username, tab));
const nextPage = useSelector(selectLearnerCommentsNextPage(username));
useEffect(() => { useEffect(() => {
dispatch(fetchUserComments(courseId, username)); dispatch(fetchUserComments(courseId, username));
}, [courseId, username]); }, [courseId, username]);
const handleLoadMoreComments = () => dispatch(fetchUserComments(courseId, username, { page: nextPage }));
return ( return (
<div className="mx-3 my-3"> <div className="mx-3 my-3">
{comments.map( {comments.map(
(comment) => <Comment key={comment.id} comment={comment} showFullThread={false} postType="discussion" />, (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> </div>
); );
} }
CommentsTabContent.propTypes = { CommentsTabContent.propTypes = {
intl: intlShape.isRequired,
tab: PropType.string.isRequired, tab: PropType.string.isRequired,
}; };
export default injectIntl(CommentsTabContent); export default CommentsTabContent;

View File

@@ -6,6 +6,10 @@ import { Link } from 'react-router-dom';
import * as timeago from 'timeago.js'; import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Icon, IconButton,
} from '@edx/paragon';
import { MoreVert } from '@edx/paragon/icons';
import { Routes } from '../../../data/constants'; import { Routes } from '../../../data/constants';
import { DiscussionContext } from '../../common/context'; import { DiscussionContext } from '../../common/context';
@@ -59,6 +63,12 @@ function LearnerCard({
</div> </div>
<LearnerFooter learner={learner} /> <LearnerFooter learner={learner} />
</div> </div>
<IconButton
src={MoreVert}
iconAs={Icon}
alt={learner.username}
disabled
/>
</div> </div>
</Link> </Link>
); );

View File

@@ -29,7 +29,7 @@ function LearnerFooter({
&& ( && (
<OverlayTrigger <OverlayTrigger
overlay={( overlay={(
<Tooltip id={`learner-${learner.username}`}> <Tooltip>
<div className="d-flex flex-column align-items-start"> <div className="d-flex flex-column align-items-start">
<span> <span>
{intl.formatMessage(messages.reported, { reported: activeFlags })} {intl.formatMessage(messages.reported, { reported: activeFlags })}

View File

@@ -1,28 +1,20 @@
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import { useSelector } from 'react-redux'; import { useDispatch, 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 { DiscussionContext } from '../../common/context';
import { Post } from '../../posts'; import { Post } from '../../posts';
import { selectLearnerPostsNextPage, selectUserPosts } from '../data/selectors'; import { selectUserPosts } from '../data/selectors';
import { fetchUserPosts } from '../data/thunks'; import { fetchUserPosts } from '../data/thunks';
import messages from './messages';
function PostsTabContent({ intl }) { function PostsTabContent() {
const [loading, dispatch] = useDispatchWithState(); const dispatch = useDispatch();
const { courseId, learnerUsername: username } = useContext(DiscussionContext); const { courseId, learnerUsername: username } = useContext(DiscussionContext);
const posts = useSelector(selectUserPosts(username)); const posts = useSelector(selectUserPosts(username));
const nextPage = useSelector(selectLearnerPostsNextPage(username));
useEffect(() => { useEffect(() => {
dispatch(fetchUserPosts(courseId, username)); dispatch(fetchUserPosts(courseId, username));
}, [courseId, username]); }, [courseId, username]);
// console.log({ posts });
const handleLoadMorePosts = () => dispatch(fetchUserPosts(courseId, username, { page: nextPage }));
return ( return (
<div className="d-flex flex-column my-3 mx-3 bg-white rounded"> <div className="d-flex flex-column my-3 mx-3 bg-white rounded">
@@ -35,22 +27,10 @@ function PostsTabContent({ intl }) {
<Post post={post} /> <Post post={post} />
</div> </div>
))} ))}
{nextPage && !loading && (
<Button
onClick={handleLoadMorePosts}
variant="link"
block="true"
className="card p-4"
>
{intl.formatMessage(messages.loadMorePosts)}
</Button>
)}
</div> </div>
); );
} }
PostsTabContent.propTypes = { PostsTabContent.propTypes = {};
intl: intlShape.isRequired,
};
export default injectIntl(PostsTabContent); export default PostsTabContent;

View File

@@ -13,11 +13,6 @@ const messages = defineMessages({
id: 'discussions.learner.lastLogin', id: 'discussions.learner.lastLogin',
defaultMessage: 'Last active {lastActiveTime}', 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; export default messages;

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { matchPath, useParams } from 'react-router'; import { useParams } from 'react-router';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -27,7 +27,6 @@ function NavigationBar({ intl }) {
}, },
{ {
route: Routes.TOPICS.ALL, route: Routes.TOPICS.ALL,
isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })),
labelMessage: messages.allTopics, labelMessage: messages.allTopics,
}, },
]; ];
@@ -42,12 +41,7 @@ function NavigationBar({ intl }) {
<Nav variant="pills" className="py-2"> <Nav variant="pills" className="py-2">
{navLinks.map(link => ( {navLinks.map(link => (
<Nav.Item key={link.route}> <Nav.Item key={link.route}>
<Nav.Link <Nav.Link as={NavLink} to={discussionsPath(link.route, { courseId })} className="border">
as={NavLink}
to={discussionsPath(link.route, { courseId })}
className="border"
isActive={link.isActive}
>
{intl.formatMessage(link.labelMessage)} {intl.formatMessage(link.labelMessage)}
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>

View File

@@ -23,32 +23,7 @@ import PostFilterBar from './post-filter-bar/PostFilterBar';
import NoResults from './NoResults'; import NoResults from './NoResults';
import { PostLink } from './post'; import { PostLink } from './post';
function PostsList({ posts, topics }) { function PostsList({ posts }) {
const dispatch = useDispatch();
const {
courseId,
page,
} = useContext(DiscussionContext);
const loadingStatus = useSelector(threadsLoadingStatus());
const { authenticatedUser } = useContext(AppContext);
const orderBy = useSelector(selectThreadSorting());
const filters = useSelector(selectThreadFilters());
const nextPage = useSelector(selectThreadNextPage());
const showOwnPosts = page === 'my-posts';
const loadThreads = (topicIds, pageNum = undefined) => dispatch(fetchThreads(courseId, {
topicIds,
orderBy,
filters,
page: pageNum,
author: showOwnPosts ? authenticatedUser.username : null,
}));
useEffect(() => {
if (topics !== undefined) {
loadThreads(topics);
}
}, [courseId, orderBy, filters, page, JSON.stringify(topics)]);
let lastPinnedIdx = null; let lastPinnedIdx = null;
const postInstances = posts && posts.map((post, idx) => { const postInstances = posts && posts.map((post, idx) => {
if (post.pinned && lastPinnedIdx !== false) { if (post.pinned && lastPinnedIdx !== false) {
@@ -58,7 +33,7 @@ function PostsList({ posts, topics }) {
// Add a spacing after the group of pinned posts // Add a spacing after the group of pinned posts
return ( return (
<React.Fragment key={post.id}> <React.Fragment key={post.id}>
<div className="p-1 bg-light-400" /> <div className="p-1 bg-light-300" />
<PostLink post={post} key={post.id} /> <PostLink post={post} key={post.id} />
</React.Fragment> </React.Fragment>
); );
@@ -69,18 +44,6 @@ function PostsList({ posts, topics }) {
<> <>
{postInstances} {postInstances}
{posts && posts.length === 0 && <NoResults />} {posts && 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={() => {
loadThreads(topics, nextPage);
}}
/>
)
)}
</> </>
); );
} }
@@ -90,22 +53,20 @@ PostsList.propTypes = {
pinned: PropTypes.bool.isRequired, pinned: PropTypes.bool.isRequired,
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
})), })),
topics: PropTypes.arrayOf(PropTypes.string),
}; };
PostsList.defaultProps = { PostsList.defaultProps = {
posts: [], posts: [],
topics: undefined,
}; };
function AllPostsList() { function AllPostsList() {
const posts = useSelector(selectAllThreads); const posts = useSelector(selectAllThreads);
return <PostsList posts={posts} topics={null} />; return <PostsList posts={posts} />;
} }
function TopicPostsList({ topicId }) { function TopicPostsList({ topicId }) {
const posts = useSelector(selectTopicThreads([topicId])); const posts = useSelector(selectTopicThreads([topicId]));
return <PostsList posts={posts} topics={[topicId]} />; return <PostsList posts={posts} />;
} }
TopicPostsList.propTypes = { TopicPostsList.propTypes = {
@@ -115,22 +76,27 @@ TopicPostsList.propTypes = {
function CategoryPostsList({ category }) { function CategoryPostsList({ category }) {
const topicIds = useSelector(selectTopicsUnderCategory)(category); const topicIds = useSelector(selectTopicsUnderCategory)(category);
const posts = useSelector(selectTopicThreads(topicIds)); const posts = useSelector(selectTopicThreads(topicIds));
return <PostsList posts={posts} topics={topicIds} />; return <PostsList posts={posts} />;
} }
CategoryPostsList.propTypes = { CategoryPostsList.propTypes = {
category: PropTypes.string.isRequired, category: PropTypes.string.isRequired,
}; };
function PostsView() { function PostsView({ showOwnPosts }) {
const { const {
courseId,
topicId, topicId,
category, category,
page,
} = useContext(DiscussionContext); } = useContext(DiscussionContext);
const dispatch = useDispatch();
let postsListComponent; const { authenticatedUser } = useContext(AppContext);
const showOwnPosts = page === 'my-posts'; const orderBy = useSelector(selectThreadSorting());
const filters = useSelector(selectThreadFilters());
const nextPage = useSelector(selectThreadNextPage());
const loadingStatus = useSelector(threadsLoadingStatus());
const topicIds = null;
let postsListComponent = null;
if (topicId) { if (topicId) {
postsListComponent = <TopicPostsList topicId={topicId} />; postsListComponent = <TopicPostsList topicId={topicId} />;
@@ -140,17 +106,53 @@ function PostsView() {
postsListComponent = <AllPostsList />; postsListComponent = <AllPostsList />;
} }
useEffect(() => {
// The courseId from the URL is the course we WANT to load.
dispatch(fetchThreads(courseId, {
topicIds,
orderBy,
filters,
author: showOwnPosts ? authenticatedUser.username : null,
}));
}, [courseId, orderBy, filters, showOwnPosts, topicId, category]);
const loadMorePosts = async () => {
if (nextPage) {
dispatch(fetchThreads(courseId, {
topicIds,
orderBy,
filters,
page: nextPage,
author: showOwnPosts ? authenticatedUser.username : null,
}));
}
};
return ( return (
<div className="discussion-posts d-flex flex-column"> <div className="discussion-posts d-flex flex-column">
<PostFilterBar filterSelfPosts={showOwnPosts} /> <PostFilterBar filterSelfPosts={showOwnPosts} />
<div className="list-group list-group-flush"> <div className="list-group list-group-flush">
{postsListComponent} {postsListComponent}
{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>
</div> </div>
); );
} }
PostsView.propTypes = { PostsView.propTypes = {
showOwnPosts: PropTypes.bool,
};
PostsView.defaultProps = {
showOwnPosts: false,
}; };
export default PostsView; export default PostsView;

View File

@@ -29,19 +29,16 @@ async function renderComponent({
postId, topicId, category, myPosts, postId, topicId, category, myPosts,
} = { myPosts: false }) { } = { myPosts: false }) {
let path = generatePath(Routes.POSTS.ALL_POSTS, { courseId }); let path = generatePath(Routes.POSTS.ALL_POSTS, { courseId });
let page; let showOwnPosts = false;
if (postId) { if (postId) {
path = generatePath(Routes.POSTS.ALL_POSTS, { courseId, postId }); path = generatePath(Routes.POSTS.ALL_POSTS, { courseId, postId });
page = 'posts';
} else if (topicId) { } else if (topicId) {
path = generatePath(Routes.POSTS.PATH, { courseId, topicId }); path = generatePath(Routes.POSTS.PATH, { courseId, topicId });
page = 'posts';
} else if (category) { } else if (category) {
path = generatePath(Routes.TOPICS.CATEGORY, { courseId, category }); path = generatePath(Routes.TOPICS.CATEGORY, { courseId, category });
page = 'category';
} else if (myPosts) { } else if (myPosts) {
path = generatePath(Routes.POSTS.MY_POSTS, { courseId }); path = generatePath(Routes.POSTS.MY_POSTS, { courseId });
page = 'my-posts'; showOwnPosts = myPosts;
} }
await render( await render(
<IntlProvider locale="en"> <IntlProvider locale="en">
@@ -52,12 +49,11 @@ async function renderComponent({
postId, postId,
topicId, topicId,
category, category,
page,
}} }}
> >
<Switch> <Switch>
<Route path={Routes.POSTS.MY_POSTS}> <Route path={Routes.POSTS.MY_POSTS}>
<PostsView /> <PostsView showOwnPosts={showOwnPosts} />
</Route> </Route>
<Route <Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]} path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]}
@@ -85,7 +81,6 @@ describe('PostsView', () => {
store = initializeStore({ store = initializeStore({
blocks: { blocks: { 'test-usage-key': { topics: ['some-topic-2', 'some-topic-0'] } } }, blocks: { blocks: { 'test-usage-key': { topics: ['some-topic-2', 'some-topic-0'] } } },
config: { userIsPrivileged: true },
}); });
Factory.resetAll(); Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());

View File

@@ -202,15 +202,14 @@ export async function uploadFile(blob, filename, courseId, threadKey) {
* *
* @param {string} courseId Course ID of the course * @param {string} courseId Course ID of the course
* @param {string} username Username of the user * @param {string} username Username of the user
* @param {number} page
* @returns API Response object in the format * @returns API Response object in the format
* { * {
* results: [array of posts], * results: [array of posts],
* pagination: {count, num_pages, next, previous} * pagination: {count, num_pages, next, previous}
* } * }
*/ */
export async function getUserPosts(courseId, username, { page }) { export async function getUserPosts(courseId, username) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.get(threadsApiUrl, { params: { course_id: courseId, author: username, page } }); .get(threadsApiUrl, { params: { course_id: courseId, author: username } });
return data; return data;
} }

View File

@@ -2,7 +2,6 @@ import React, { useContext, useEffect, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { isEmpty } from 'lodash';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation, useParams } from 'react-router-dom'; import { useHistory, useLocation, useParams } from 'react-router-dom';
import * as Yup from 'yup'; import * as Yup from 'yup';
@@ -84,21 +83,14 @@ function PostEditor({
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics); const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(selectNonCoursewareIds); const nonCoursewareIds = useSelector(selectNonCoursewareIds);
const coursewareTopics = useSelector(selectCoursewareTopics); const coursewareTopics = useSelector(selectCoursewareTopics);
const {
allowAnonymous,
allowAnonymousToPeers,
} = useSelector(selectAnonymousPostingConfig);
const cohorts = useSelector(selectCourseCohorts); const cohorts = useSelector(selectCourseCohorts);
const post = useSelector(selectThread(postId)); const post = useSelector(selectThread(postId));
const userIsPrivileged = useSelector(selectUserIsPrivileged); const userIsPrivileged = useSelector(selectUserIsPrivileged);
const settings = useSelector(selectDivisionSettings); const settings = useSelector(selectDivisionSettings);
const { allowAnonymous, allowAnonymousToPeers } = useSelector(selectAnonymousPostingConfig);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const canDisplayEditReason = (reasonCodesEnabled && editExisting
&& userIsPrivileged && post.author !== authenticatedUser.username
);
const editReasonCodeValidation = canDisplayEditReason && {
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const canSelectCohort = (tId) => { const canSelectCohort = (tId) => {
// If the user isn't privileged, they can't edit the cohort. // If the user isn't privileged, they can't edit the cohort.
// If the topic is being edited the cohort can't be changed. // If the topic is being edited the cohort can't be changed.
@@ -172,48 +164,60 @@ function PostEditor({
</div> </div>
); );
} }
let initialValues = {
const initialValues = { postType: 'discussion',
postType: post?.type || 'discussion', topic: topicId || nonCoursewareTopics?.[0]?.id,
topic: post?.topicId || topicId || nonCoursewareTopics?.[0]?.id, title: '',
title: post?.title || '', comment: '',
comment: post?.rawBody || '', follow: true,
follow: isEmpty(post?.following) ? true : post?.following, anonymous: false,
anonymous: allowAnonymous ? false : undefined, anonymousToPeers: false,
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
editReasonCode: post?.lastEdit?.reasonCode || '',
}; };
if (editExisting) {
const validationSchema = Yup.object().shape({ initialValues = {
postType: Yup.mixed() postType: post.type,
.oneOf(['discussion', 'question']), topic: post.topicId,
topic: Yup.string() title: post.title,
.required(), comment: post.rawBody,
title: Yup.string() follow: (post.following === null || post.following === undefined) ? true : post.following,
.required(intl.formatMessage(messages.titleError)), anonymous: allowAnonymous ? false : undefined,
comment: Yup.string() anonymousToPeers: allowAnonymousToPeers ? false : undefined,
.required(intl.formatMessage(messages.commentError)), };
follow: Yup.bool() }
.default(true),
anonymous: Yup.bool()
.default(false)
.nullable(),
anonymousToPeers: Yup.bool()
.default(false)
.nullable(),
cohort: Yup.string()
.nullable()
.default(null),
...editReasonCodeValidation,
});
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`; const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
return ( return (
<Formik <Formik
enableReinitialize enableReinitialize
initialValues={initialValues} initialValues={initialValues}
validationSchema={validationSchema} validationSchema={Yup.object()
.shape({
postType: Yup.mixed()
.oneOf(['discussion', 'question']),
topic: Yup.string()
.required(),
title: Yup.string()
.required(intl.formatMessage(messages.titleError)),
comment: Yup.string()
.required(intl.formatMessage(messages.commentError)),
follow: Yup.bool()
.default(true),
anonymous: Yup.bool()
.default(false)
.nullable(),
anonymousToPeers: Yup.bool()
.default(false)
.nullable(),
cohort: Yup.string()
.nullable()
.default(null),
editReasonCode: Yup.string()
.nullable()
.default(null),
})}
onSubmit={submitForm} onSubmit={submitForm}
>{ >{
({ ({
@@ -300,7 +304,7 @@ function PostEditor({
<div className="border-bottom my-1" /> <div className="border-bottom my-1" />
<div className="d-flex flex-row py-2 mt-4 justify-content-between"> <div className="d-flex flex-row py-2 mt-4 justify-content-between">
<Form.Group <Form.Group
className="w-100" className="d-flex flex-fill"
isInvalid={isFormikFieldInvalid('title', { isInvalid={isFormikFieldInvalid('title', {
errors, errors,
touched, touched,
@@ -317,31 +321,27 @@ function PostEditor({
/> />
<FormikErrorFeedback name="title" /> <FormikErrorFeedback name="title" />
</Form.Group> </Form.Group>
{canDisplayEditReason && ( {(reasonCodesEnabled
<Form.Group && editExisting
className="w-100" && userIsPrivileged
isInvalid={isFormikFieldInvalid('editReasonCode', { && post.author !== authenticatedUser.username) && (
errors, <Form.Group className="d-flex flex-fill">
touched, <Form.Control
})} name="editReasonCode"
> className="ml-4"
<Form.Control as="select"
name="editReasonCode" value={values.editReasonCode}
className="m-0" onChange={handleChange}
as="select" onBlur={handleBlur}
value={values.editReasonCode} aria-describedby="editReasonCodeInput"
onChange={handleChange} floatingLabel={intl.formatMessage(messages.editReasonCode)}
onBlur={handleBlur} >
aria-describedby="editReasonCodeInput" <option key="empty" value="">---</option>
floatingLabel={intl.formatMessage(messages.editReasonCode)} {editReasons.map(({ code, label }) => (
> <option key={code} value={code}>{label}</option>
<option key="empty" value="">---</option> ))}
{editReasons.map(({ code, label }) => ( </Form.Control>
<option key={code} value={code}>{label}</option> </Form.Group>
))}
</Form.Control>
<FormikErrorFeedback name="editReasonCode" />
</Form.Group>
)} )}
</div> </div>
<div className="py-2"> <div className="py-2">

View File

@@ -106,11 +106,6 @@ const messages = defineMessages({
defaultMessage: 'Reason for editing', defaultMessage: 'Reason for editing',
description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s post', description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s post',
}, },
editReasonCodeError: {
id: 'discussions.editor.posts.editReasonCode.error',
defaultMessage: 'Select reason for editing',
description: 'Error message visible to moderators when they submit the post/response/comment without select reason for editing',
},
}); });
export default messages; export default messages;

View File

@@ -1,17 +1,17 @@
import React, { useState } from 'react'; import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { Collapsible, Form, Icon } from '@edx/paragon'; import { Collapsible, Form, Icon } from '@edx/paragon';
import { Check, Sort } from '@edx/paragon/icons'; import { Check, Sort } from '@edx/paragon/icons';
import { import {
PostsStatusFilter, ThreadOrdering, ThreadType, PostsStatusFilter, ThreadOrdering, ThreadType,
} from '../../../data/constants'; } from '../../../data/constants';
import { selectUserIsPrivileged } from '../../data/selectors';
import { setPostsTypeFilter, setSortedBy, setStatusFilter } from '../data'; import { setPostsTypeFilter, setSortedBy, setStatusFilter } from '../data';
import { selectThreadFilters, selectThreadSorting } from '../data/selectors'; import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
import messages from './messages'; import messages from './messages';
@@ -44,8 +44,8 @@ function PostFilterBar({
filterSelfPosts, filterSelfPosts,
intl, intl,
}) { }) {
const { authenticatedUser } = useContext(AppContext);
const dispatch = useDispatch(); const dispatch = useDispatch();
const userIsPrivileged = useSelector(selectUserIsPrivileged);
const currentSorting = useSelector(selectThreadSorting()); const currentSorting = useSelector(selectThreadSorting());
const currentFilters = useSelector(selectThreadFilters()); const currentFilters = useSelector(selectThreadFilters());
const [isOpen, setOpen] = useState(false); const [isOpen, setOpen] = useState(false);
@@ -147,7 +147,7 @@ function PostFilterBar({
value={PostsStatusFilter.FOLLOWING} value={PostsStatusFilter.FOLLOWING}
selected={currentFilters.status} selected={currentFilters.status}
/> />
{userIsPrivileged {authenticatedUser.administrator
&& ( && (
<ActionItem <ActionItem
id="status-reported" id="status-reported"

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
@@ -20,7 +20,6 @@ function ClosePostReasonModal({
onCancel, onCancel,
onConfirm, onConfirm,
}) { }) {
const scrollTo = useRef(null);
const [reasonCode, setReasonCode] = useState(null); const [reasonCode, setReasonCode] = useState(null);
const { postCloseReasons } = useSelector(selectModerationSettings); const { postCloseReasons } = useSelector(selectModerationSettings);
@@ -33,25 +32,12 @@ function ClosePostReasonModal({
} }
}; };
useEffect(() => {
/* istanbul ignore if: This API is not available in the test environment. */
if (scrollTo.current && scrollTo.current.scrollIntoView) {
// Use a timeout since the component is first given focus, which scrolls
// it into view but doesn't centrally align it. This should run after that.
setTimeout(() => {
scrollTo.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 0);
}
}, [scrollTo, isOpen]);
return ( return (
<ModalDialog <ModalDialog
title={intl.formatMessage(messages.closePostModalTitle)} title={intl.formatMessage(messages.closePostModalTitle)}
isOpen={isOpen} isOpen={isOpen}
onClose={onCancel} onClose={onCancel}
hasCloseButton={false} hasCloseButton={false}
isFullscreenOnMobile
isFullscreenScroll
> >
<ModalDialog.Header> <ModalDialog.Header>
<ModalDialog.Title> <ModalDialog.Title>
@@ -59,7 +45,7 @@ function ClosePostReasonModal({
</ModalDialog.Title> </ModalDialog.Title>
</ModalDialog.Header> </ModalDialog.Header>
<ModalDialog.Body> <ModalDialog.Body>
<p ref={scrollTo}>{intl.formatMessage(messages.closePostModalText)}</p> <p>{intl.formatMessage(messages.closePostModalText)}</p>
<Form.Group> <Form.Group>
<Form.Control <Form.Control
name="reasonCode" name="reasonCode"

View File

@@ -5,8 +5,8 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { import {
Icon, IconButton, OverlayTrigger, Tooltip, Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon'; } from '@edx/paragon';
import { ThumbUpFilled, ThumbUpOutline } from '@edx/paragon/icons';
import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons';
import messages from './messages'; import messages from './messages';
function LikeButton({ function LikeButton({
@@ -34,7 +34,7 @@ function LikeButton({
> >
<IconButton <IconButton
onClick={handleClick} onClick={handleClick}
className="p-3 mr-2 mt-1" className="p-3 mr-2"
alt="Like" alt="Like"
iconAs={Icon} iconAs={Icon}
size="inline" size="inline"

View File

@@ -75,7 +75,7 @@ function Post({
<PostHeader post={post} actionHandlers={actionHandlers} /> <PostHeader post={post} actionHandlers={actionHandlers} />
<div className="d-flex my-2 text-break"> <div className="d-flex my-2 text-break">
{/* eslint-disable-next-line react/no-danger */} {/* eslint-disable-next-line react/no-danger */}
<div id="post" dangerouslySetInnerHTML={{ __html: post.renderedBody }} /> <div dangerouslySetInnerHTML={{ __html: post.renderedBody }} />
</div> </div>
{topicContext && topic && ( {topicContext && topic && (
<div className="border p-3 rounded mb-3 mt-2 align-self-start"> <div className="border p-3 rounded mb-3 mt-2 align-self-start">

View File

@@ -9,15 +9,9 @@ import {
Badge, Icon, IconButton, OverlayTrigger, Tooltip, Badge, Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon'; } from '@edx/paragon';
import { import {
Locked, People, Locked, People, QuestionAnswer, QuestionAnswerOutline, StarFilled, StarOutline,
} from '@edx/paragon/icons'; } from '@edx/paragon/icons';
import {
QuestionAnswer,
QuestionAnswerOutline,
StarFilled,
StarOutline,
} from '../../../components/icons';
import { updateExistingThread } from '../data/thunks'; import { updateExistingThread } from '../data/thunks';
import LikeButton from './LikeButton'; import LikeButton from './LikeButton';
import messages from './messages'; import messages from './messages';
@@ -30,7 +24,7 @@ function PostFooter({
}) { }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
return ( return (
<div className="d-flex align-items-center"> <div className="d-flex align-items-center mt-2">
<LikeButton <LikeButton
count={post.voteCount} count={post.voteCount}
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))} onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
@@ -51,14 +45,14 @@ function PostFooter({
alt="Follow" alt="Follow"
iconAs={Icon} iconAs={Icon}
size="inline" size="inline"
className="mx-2.5 my-0 mt-1.5" className="mx-2.5 my-0"
src={post.following ? StarFilled : StarOutline} src={post.following ? StarFilled : StarOutline}
/> />
</OverlayTrigger> </OverlayTrigger>
{preview && post.commentCount > 1 {preview && post.commentCount > 1
&& ( && (
<> <>
<Icon src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline} className="mx-2 my-0 mt-2" /> <Icon src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline} className="mx-2 my-0" />
<span style={{ minWidth: '2rem' }}> <span style={{ minWidth: '2rem' }}>
{post.commentCount} {post.commentCount}
</span> </span>

View File

@@ -24,14 +24,12 @@ function PostLink({
page, page,
postId, postId,
inContext, inContext,
category,
} = useContext(DiscussionContext); } = useContext(DiscussionContext);
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], { const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
0: inContext ? 'in-context' : undefined, 0: inContext ? 'in-context' : undefined,
courseId: post.courseId, courseId: post.courseId,
topicId: post.topicId, topicId: post.topicId,
postId: post.id, postId: post.id,
category,
}); });
const showAnsweredBadge = post.hasEndorsed && post.type === ThreadType.QUESTION; const showAnsweredBadge = post.hasEndorsed && post.type === ThreadType.QUESTION;
const authorLabelColor = AvatarBorderAndLabelColors[post.authorLabel]; const authorLabelColor = AvatarBorderAndLabelColors[post.authorLabel];
@@ -39,7 +37,6 @@ function PostLink({
<Link <Link
className="discussion-post list-group-item list-group-item-action p-0 text-decoration-none text-gray-900 mw-100" className="discussion-post list-group-item list-group-item-action p-0 text-decoration-none text-gray-900 mw-100"
to={linkUrl} to={linkUrl}
style={{ lineHeight: '21px' }}
> >
{post.pinned && ( {post.pinned && (
<div className="d-flex flex-fill justify-content-end mr-4 text-light-500 p-0"> <div className="d-flex flex-fill justify-content-end mr-4 text-light-500 p-0">
@@ -47,7 +44,7 @@ function PostLink({
</div> </div>
)} )}
<div <div
className={classNames('d-flex flex-row flex-fill mw-100 p-2.5 border-primary-500', { 'bg-light-300': post.read })} className={classNames('d-flex flex-row flex-fill mw-100 p-3 border-primary-500', { 'bg-light-300': post.read })}
style={post.id === postId ? { style={post.id === postId ? {
borderRightWidth: '4px', borderRightWidth: '4px',
borderRightStyle: 'solid', borderRightStyle: 'solid',

View File

@@ -5,11 +5,3 @@
$fa-font-path: "~font-awesome/fonts"; $fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome"; @import "~font-awesome/scss/font-awesome";
#post, #comment, #reply {
img {
height: auto;
max-width: 100%;
}
}