Compare commits
3 Commits
jenkins/ve
...
jawayria/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32a100ea41 | ||
|
|
876211388a | ||
|
|
c409514766 |
13
.github/workflows/lockfileversion-check.yml
vendored
13
.github/workflows/lockfileversion-check.yml
vendored
@@ -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
25767
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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,
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user