diff --git a/src/discussions/comments/CommentsView.test.jsx b/src/discussions/comments/CommentsView.test.jsx
index 6e40fd57..5857a264 100644
--- a/src/discussions/comments/CommentsView.test.jsx
+++ b/src/discussions/comments/CommentsView.test.jsx
@@ -14,6 +14,8 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
+import { courseConfigApiUrl } from '../data/api';
+import { fetchCourseConfig } from '../data/thunks';
import { threadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import { commentsApiUrl } from './data/api';
@@ -254,6 +256,38 @@ describe('CommentsView', () => {
expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument();
});
});
+
+ it('should show reason codes when editing an existing comment', async () => {
+ axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
+ user_is_privileged: true,
+ reason_codes_enabled: true,
+ editReasons: [
+ { code: 'reason-1', label: 'reason 1' },
+ { code: 'reason-2', label: 'reason 2' },
+ ],
+ });
+ axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
+ await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
+ renderComponent(discussionPostId);
+ await waitFor(() => screen.findByText('comment number 1', { exact: false }));
+ await act(async () => {
+ fireEvent.click(
+ // The first edit menu is for the post, the second will be for the first comment.
+ screen.getAllByRole('button', {
+ name: /actions menu/i,
+ })[1],
+ );
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /edit/i }));
+ });
+ expect(screen.queryByRole('combobox', {
+ name: /reason for editing/i,
+ })).toBeInTheDocument();
+ expect(screen.getAllByRole('option', {
+ name: /reason \d/i,
+ })).toHaveLength(2);
+ });
});
describe('for discussion thread', () => {
@@ -346,7 +380,7 @@ describe('CommentsView', () => {
.not
.toBeInTheDocument();
- act(() => {
+ await act(async () => {
fireEvent.click(loadMoreButtonEndorsed);
});
// Endorsed comment from next page should be loaded now.
@@ -358,7 +392,7 @@ describe('CommentsView', () => {
.toBeInTheDocument();
// Now only one load more buttons should show, for unendorsed comments
expect(await findLoadMoreCommentsButtons()).toHaveLength(1);
- act(() => {
+ await act(async () => {
fireEvent.click(loadMoreButtonUnendorsed);
});
// Unendorsed comment from next page should be loaded now.
diff --git a/src/discussions/comments/comment/Comment.jsx b/src/discussions/comments/comment/Comment.jsx
index 4601a5e2..f583a6b3 100644
--- a/src/discussions/comments/comment/Comment.jsx
+++ b/src/discussions/comments/comment/Comment.jsx
@@ -59,7 +59,7 @@ function Comment({
hideDeleteConfirmation();
}}
/>
-
+
{isEditing
diff --git a/src/discussions/comments/comment/CommentEditor.jsx b/src/discussions/comments/comment/CommentEditor.jsx
index ae4b5647..df75186c 100644
--- a/src/discussions/comments/comment/CommentEditor.jsx
+++ b/src/discussions/comments/comment/CommentEditor.jsx
@@ -1,14 +1,17 @@
-import React, { useRef } from 'react';
+import React, { useContext, useRef } from 'react';
import PropTypes from 'prop-types';
import { Formik } from 'formik';
+import { useSelector } from 'react-redux';
import * as Yup from 'yup';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { AppContext } from '@edx/frontend-platform/react';
import { Button, Form, StatefulButton } from '@edx/paragon';
import { TinyMCEEditor } from '../../../components';
import { useDispatchWithState } from '../../../data/hooks';
+import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
import { addComment, editComment } from '../data/thunks';
import messages from '../messages';
@@ -18,6 +21,9 @@ function CommentEditor({
comment,
onCloseEditor,
}) {
+ const { authenticatedUser } = useContext(AppContext);
+ const userIsPrivileged = useSelector(selectUserIsPrivileged);
+ const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState();
const editorRef = useRef(null);
const saveUpdatedComment = async (values) => {
@@ -42,6 +48,9 @@ function CommentEditor({
.shape({
comment: Yup.string()
.required(),
+ editReasonCode: Yup.string()
+ .nullable()
+ .default(null),
})}
onSubmit={saveUpdatedComment}
>
@@ -54,6 +63,30 @@ function CommentEditor({
handleChange,
}) => (
+
+
+ {editReasons.map(({
+ code,
+ label,
+ }) => (
+
+ ))}
+
+
+ )}
}
*/
export async function updateComment(commentId, {
@@ -91,14 +92,16 @@ export async function updateComment(commentId, {
voted,
flagged,
endorsed,
+ editReasonCode,
}) {
const url = `${commentsApiUrl}${commentId}/`;
- const postData = {
+ const postData = snakeCaseObject({
raw_body: comment,
voted,
abuse_flagged: flagged,
endorsed,
- };
+ editReasonCode,
+ });
const { data } = await getAuthenticatedHttpClient()
.patch(url, postData, { headers: { 'Content-Type': 'application/merge-patch+json' } });
diff --git a/src/discussions/comments/data/redux.test.js b/src/discussions/comments/data/redux.test.js
index bec22f11..aa46a088 100644
--- a/src/discussions/comments/data/redux.test.js
+++ b/src/discussions/comments/data/redux.test.js
@@ -108,7 +108,7 @@ describe('Comments/Responses data layer tests', () => {
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
- axiosMock.onPost(`${commentsApiUrl}`)
+ axiosMock.onPost(commentsApiUrl)
.reply(200, Factory.build('comment', {
thread_id: threadId,
raw_body: content,
@@ -144,7 +144,7 @@ describe('Comments/Responses data layer tests', () => {
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
- axiosMock.onPost(`${commentsApiUrl}`)
+ axiosMock.onPost(commentsApiUrl)
.reply(200, Factory.build('comment', {
thread_id: threadId,
parent_id: parentId,
@@ -190,7 +190,7 @@ describe('Comments/Responses data layer tests', () => {
store.getState,
);
- axiosMock.onPost(`${commentsApiUrl}`)
+ axiosMock.onPost(commentsApiUrl)
.reply(200, Factory.build('comment', {
thread_id: threadId,
raw_body: content,
diff --git a/src/discussions/comments/messages.js b/src/discussions/comments/messages.js
index eb959b1c..edab5449 100644
--- a/src/discussions/comments/messages.js
+++ b/src/discussions/comments/messages.js
@@ -98,6 +98,7 @@ const messages = defineMessages({
submit: {
id: 'discussions.editor.submit',
defaultMessage: 'Submit',
+ description: 'Button to submit a response or comment',
},
submitting: {
id: 'discussions.editor.submitting',
@@ -106,26 +107,47 @@ const messages = defineMessages({
cancel: {
id: 'discussions.editor.cancel',
defaultMessage: 'Cancel',
+ description: 'Button to cancel posting a response or comment',
},
commentError: {
id: 'discussions.editor.error.empty',
defaultMessage: 'Post content cannot be empty.',
+ description: 'Error message displayed when post content is left empty',
},
deleteResponseTitle: {
id: 'discussions.editor.delete.response.title',
defaultMessage: 'Delete response',
+ description: 'Title of confirmation dialog shown when deleting a response',
},
deleteResponseDescription: {
id: 'discussions.editor.delete.response.description',
defaultMessage: 'Are you sure you want to permanently delete this response?',
+ description: 'Text displayed in confirmation dialog when deleting a response',
},
deleteCommentTitle: {
id: 'discussions.editor.delete.comment.title',
defaultMessage: 'Delete comment',
+ description: 'Title of confirmation dialog shown when deleting a comment',
},
deleteCommentDescription: {
id: 'discussions.editor.delete.comment.description',
defaultMessage: 'Are you sure you want to permanently delete this comment?',
+ description: 'Text displayed in confirmation dialog when deleting a comment',
+ },
+ editReasonCode: {
+ id: 'discussions.editor.comments.editReasonCode',
+ defaultMessage: 'Reason for editing',
+ description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s response',
+ },
+ editedBy: {
+ id: 'discussions.comment.comments.editedBy',
+ defaultMessage: 'Edited by',
+ description: 'Text shown to users to indicate who edited a post. Followed by the username of editor.',
+ },
+ reason: {
+ id: 'discussions.comment.comments.reason',
+ defaultMessage: 'Reason',
+ description: 'Text shown to users to indicate why a post was edited, followed by a reason.',
},
});
diff --git a/src/discussions/common/AlertBanner.jsx b/src/discussions/common/AlertBanner.jsx
index 20ec0861..5385ce66 100644
--- a/src/discussions/common/AlertBanner.jsx
+++ b/src/discussions/common/AlertBanner.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
+import { useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -12,7 +13,9 @@ import {
import { ThreadType } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes';
import messages from '../comments/messages';
+import { selectModerationSettings, selectUserIsPrivileged } from '../data/selectors';
import { postShape } from '../posts/post/proptypes';
+import AuthorLabel from './AuthorLabel';
function AlertBanner({
intl,
@@ -23,6 +26,8 @@ function AlertBanner({
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
const iconClass = isQuestion ? CheckCircle : Verified;
const endorsedByLabels = { Staff: 'Staff', 'Community TA': 'TA' };
+ const userIsPrivileged = useSelector(selectUserIsPrivileged);
+ const { reasonCodesEnabled } = useSelector(selectModerationSettings);
return (
<>
{content.endorsed && (
@@ -78,13 +83,29 @@ function AlertBanner({
{intl.formatMessage(messages.abuseFlaggedMessage)}
)}
+ {reasonCodesEnabled && userIsPrivileged && content.lastEdit?.reason && (
+
+
+ {intl.formatMessage(messages.editedBy)}
+
+
+
+ {intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
+
+
+ )}
>
);
}
+
AlertBanner.propTypes = {
intl: intlShape.isRequired,
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
- postType: PropTypes.string.isRequired,
+ postType: PropTypes.string,
+};
+
+AlertBanner.defaultProps = {
+ postType: null,
};
export default injectIntl(AlertBanner);
diff --git a/src/discussions/common/AlertBanner.test.jsx b/src/discussions/common/AlertBanner.test.jsx
index 1ad407d5..182ecc39 100644
--- a/src/discussions/common/AlertBanner.test.jsx
+++ b/src/discussions/common/AlertBanner.test.jsx
@@ -1,6 +1,4 @@
-import {
- render, screen, waitFor, within,
-} from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { Factory } from 'rosie';
@@ -8,35 +6,82 @@ import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/fronte
import { AppProvider } from '@edx/frontend-platform/react';
import { ThreadType } from '../../data/constants';
+import { initializeStore } from '../../store';
+import messages from '../comments/messages';
import AlertBanner from './AlertBanner';
import '../comments/data/__factories__';
+import '../posts/data/__factories__';
let store;
-function buildTestContent(buildParams) {
+function buildTestContent(type, buildParams) {
const buildParamsSnakeCase = snakeCaseObject(buildParams);
- return [
- Factory.build('comment', { ...buildParamsSnakeCase }, null),
- ].map(content => camelCaseObject(content));
+ return camelCaseObject(Factory.build(type, { ...buildParamsSnakeCase }, null));
}
function renderComponent(
content,
+ postType,
) {
render(
,
);
}
-describe('AlertBanner', () => {
+describe.each([
+ {
+ label: 'flagged comment',
+ type: 'comment',
+ postType: ThreadType.QUESTION,
+ props: { abuseFlagged: true },
+ expectText: [messages.abuseFlaggedMessage.defaultMessage],
+ },
+ {
+ label: 'Staff endorsed comment in a question thread',
+ type: 'comment',
+ postType: ThreadType.QUESTION,
+ props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
+ expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
+ },
+ {
+ label: 'TA endorsed comment in a question thread',
+ type: 'comment',
+ postType: ThreadType.QUESTION,
+ props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
+ expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
+ },
+ {
+ label: 'endorsed comment in a discussion thread',
+ type: 'comment',
+ postType: ThreadType.DISCUSSION,
+ props: { endorsed: true, endorsedBy: 'test-user' },
+ expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
+ },
+ {
+ label: 'flagged thread',
+ type: 'thread',
+ postType: null,
+ props: { abuseFlagged: true },
+ expectText: [messages.abuseFlaggedMessage.defaultMessage],
+ },
+ {
+ label: 'edited content',
+ type: 'thread',
+ postType: null,
+ props: { last_edit: { reason: 'test-reason', editorUsername: 'editor-user' } },
+ expectText: [messages.editedBy.defaultMessage, messages.reason.defaultMessage, 'editor-user', 'test-reason'],
+ },
+])('AlertBanner', ({
+ label, type, postType, props, expectText,
+}) => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
@@ -46,27 +91,19 @@ describe('AlertBanner', () => {
roles: [],
},
});
+ store = initializeStore({
+ config: {
+ userIsPrivileged: true,
+ reasonCodesEnabled: true,
+ },
+ });
+ const content = buildTestContent(type, props);
+ renderComponent(content, postType);
});
- it.each(buildTestContent({ endorsed: true, endorsedByLabel: 'Staff' }))(
- 'endorsed by label is Staff when endorsed by staff user',
- async (content) => {
- renderComponent(content);
-
- await waitFor(() => expect(
- within(screen.getByTestId('endorsed-by-label')).getByText('Staff'),
- ).toBeInTheDocument());
- },
- );
-
- it.each(buildTestContent({ endorsed: true, endorsedByLabel: 'Community TA' }))(
- 'endorsed by label is TA when not endorsed by staff user',
- async (content) => {
- renderComponent(content);
-
- await waitFor(() => expect(
- within(screen.getByTestId('endorsed-by-label')).getByText('TA'),
- ).toBeInTheDocument());
- },
- );
+ it(`should show correct banner for a ${label}`, async () => {
+ expectText.forEach(message => {
+ expect(screen.queryAllByText(message, { exact: false }).length).toBeGreaterThan(0);
+ });
+ });
});
diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js
index 31c57570..883253c1 100644
--- a/src/discussions/data/selectors.js
+++ b/src/discussions/data/selectors.js
@@ -10,6 +10,12 @@ export const selectUserIsPrivileged = state => state.config.userIsPrivileged;
export const selectDivisionSettings = state => state.config.settings;
+export const selectModerationSettings = state => ({
+ postCloseReasons: state.config.postCloseReasons,
+ editReasons: state.config.editReasons,
+ reasonCodesEnabled: state.config.reasonCodesEnabled,
+});
+
export const selectDiscussionProvider = state => state.config.provider;
export function selectAreThreadsFiltered(state) {
diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js
index 95fc0bb5..944466d5 100644
--- a/src/discussions/data/slices.js
+++ b/src/discussions/data/slices.js
@@ -18,6 +18,9 @@ const configSlice = createSlice({
dividedInlineDiscussions: [],
dividedCourseWideDiscussions: [],
},
+ reasonCodesEnabled: false,
+ editReasons: [],
+ postCloseReasons: [],
},
reducers: {
fetchConfigRequest: (state) => {
diff --git a/src/discussions/navigation/breadcrumb-menu/BreadcrumbMenu.test.jsx b/src/discussions/navigation/breadcrumb-menu/BreadcrumbMenu.test.jsx
index 53cadcb9..18827f80 100644
--- a/src/discussions/navigation/breadcrumb-menu/BreadcrumbMenu.test.jsx
+++ b/src/discussions/navigation/breadcrumb-menu/BreadcrumbMenu.test.jsx
@@ -1,7 +1,7 @@
import React from 'react';
import {
- act, fireEvent, render, screen,
+ act, fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
@@ -99,7 +99,9 @@ describe('BreadcrumbMenu', () => {
renderComponent(`/${courseId}/category/${chapterKey}`, null, chapterKey);
- const chapterDropdown = await screen.findByText(blocksAPIResponse.blocks[chapterKey].display_name);
+ waitFor(() => screen.findByText(blocksAPIResponse.blocks[chapterKey].display_name));
+
+ const chapterDropdown = screen.queryByText(blocksAPIResponse.blocks[chapterKey].display_name);
// Since a category is selected a subcategory dropdown should also be visible with "show all" selected by default
const sectionDropdown = screen.queryByRole('button', { name: 'Show all' });
// A show all button should show up that lists topics in the current category
diff --git a/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.test.jsx b/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.test.jsx
index 2d3466ca..221f6b0a 100644
--- a/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.test.jsx
+++ b/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.test.jsx
@@ -1,7 +1,7 @@
import React from 'react';
import {
- act, fireEvent, render, screen,
+ act, fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
@@ -77,8 +77,9 @@ describe('LegacyBreadcrumbMenu', () => {
it('shows the category dropdown with a category selected', async () => {
renderComponent(`/${courseId}/category/category-1`);
- // The current category should be visible on the page
- const categoryDropdown = await screen.findByText('category-1');
+ waitFor(() => screen.findByText('category-1'));
+ // The current category should be visible on the pagz
+ const categoryDropdown = screen.queryByText('category-1');
// Since a category is selected a subcategory dropdown should also be visible with "show all" selected by default
const topicsDropdown = screen.queryByText('Show all');
// A show all button should show up that lists topics in the current category
diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js
index 7ac6e381..0ae4d746 100644
--- a/src/discussions/posts/data/api.js
+++ b/src/discussions/posts/data/api.js
@@ -129,6 +129,7 @@ export async function postThread(
* @param {boolean} following
* @param {boolean} closed
* @param {boolean} pinned
+ * @param {string} editReasonCode
* @returns {Promise<{}>}
*/
export async function updateThread(threadId, {
@@ -142,6 +143,7 @@ export async function updateThread(threadId, {
following,
closed,
pinned,
+ editReasonCode,
} = {}) {
const url = `${threadsApiUrl}${threadId}/`;
const patchData = snakeCaseObject({
@@ -155,6 +157,7 @@ export async function updateThread(threadId, {
following,
closed,
pinned,
+ editReasonCode,
});
const { data } = await getAuthenticatedHttpClient()
.patch(url, patchData, { headers: { 'Content-Type': 'application/merge-patch+json' } });
diff --git a/src/discussions/posts/data/redux.test.js b/src/discussions/posts/data/redux.test.js
index b5303826..2a3b14fb 100644
--- a/src/discussions/posts/data/redux.test.js
+++ b/src/discussions/posts/data/redux.test.js
@@ -113,7 +113,7 @@ describe('Threads/Posts data layer tests', () => {
.reply(200, Factory.build('threadsResult'));
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
- axiosMock.onPost(`${threadsApiUrl}`)
+ axiosMock.onPost(threadsApiUrl)
.reply(200, Factory.build('thread', {
course_id: courseId, topic_id: topicId, title, raw_body: content, rendered_body: content,
}));
@@ -137,7 +137,7 @@ describe('Threads/Posts data layer tests', () => {
const title = 'A Test Thread';
const content = 'Some test content';
- axiosMock.onPost(`${threadsApiUrl}`)
+ axiosMock.onPost(threadsApiUrl)
.reply(200, Factory.build('thread', {
course_id: courseId, topic_id: topicId, title, raw_body: content, rendered_body: content,
}));
diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js
index ea57624e..bd47d346 100644
--- a/src/discussions/posts/data/thunks.js
+++ b/src/discussions/posts/data/thunks.js
@@ -213,7 +213,7 @@ export function createNewThread({
}
export function updateExistingThread(threadId, {
- flagged, voted, read, topicId, type, title, content, following, closed, pinned,
+ flagged, voted, read, topicId, type, title, content, following, closed, pinned, editReasonCode,
}) {
return async (dispatch) => {
try {
@@ -229,6 +229,7 @@ export function updateExistingThread(threadId, {
following,
closed,
pinned,
+ editReasonCode,
}));
const data = await updateThread(threadId, {
flagged,
@@ -241,6 +242,7 @@ export function updateExistingThread(threadId, {
following,
closed,
pinned,
+ editReasonCode,
});
dispatch(updateThreadSuccess(camelCaseObject(data)));
} catch (error) {
diff --git a/src/discussions/posts/post-editor/PostEditor.jsx b/src/discussions/posts/post-editor/PostEditor.jsx
index b55984d3..e815943d 100644
--- a/src/discussions/posts/post-editor/PostEditor.jsx
+++ b/src/discussions/posts/post-editor/PostEditor.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef } from 'react';
+import React, { useContext, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { Formik } from 'formik';
@@ -7,6 +7,7 @@ import { useHistory, useLocation, useParams } from 'react-router-dom';
import * as Yup from 'yup';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { AppContext } from '@edx/frontend-platform/react';
import {
Button, Card, Form, Spinner, StatefulButton,
} from '@edx/paragon';
@@ -17,7 +18,12 @@ import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
import { useDispatchWithState } from '../../../data/hooks';
import { selectCourseCohorts } from '../../cohorts/data/selectors';
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
-import { selectAnonymousPostingConfig, selectDivisionSettings, selectUserIsPrivileged } from '../../data/selectors';
+import {
+ selectAnonymousPostingConfig,
+ selectDivisionSettings,
+ selectModerationSettings,
+ selectUserIsPrivileged,
+} from '../../data/selectors';
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
import {
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
@@ -61,6 +67,7 @@ function PostEditor({
intl,
editExisting,
}) {
+ const { authenticatedUser } = useContext(AppContext);
const dispatch = useDispatch();
const editorRef = useRef(null);
const [submitting, dispatchSubmit] = useDispatchWithState();
@@ -114,6 +121,7 @@ function PostEditor({
type: values.postType,
title: values.title,
content: values.comment,
+ editReasonCode: values.editReasonCode || null,
}));
} else {
const cohort = canSelectCohort(values.topic)
@@ -179,6 +187,8 @@ function PostEditor({
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
+ const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
+
return (
{
@@ -289,24 +302,48 @@ function PostEditor({
)}
-
-
-
-
+
+
+
+
+
+ {(reasonCodesEnabled
+ && editExisting
+ && userIsPrivileged
+ && post.author !== authenticatedUser.username) && (
+
+
+
+ {editReasons.map(({ code, label }) => (
+
+ ))}
+
+
+ )}
+
{
allowAnonymous: true,
allowAnonymousToPeers: true,
},
- ])('Non-Cohorted', ({
+ ])('anonymous posting', ({
allowAnonymous,
allowAnonymousToPeers,
}) => {
@@ -93,6 +95,7 @@ describe('PostEditor', () => {
provider: 'legacy',
allowAnonymous,
allowAnonymousToPeers,
+ moderationSettings: {},
},
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
@@ -136,16 +139,22 @@ describe('PostEditor', () => {
);
});
- describe('Cohorted', () => {
+ describe('chorting', () => {
const dividedncw = ['ncw-topic-2'];
const dividedcw = ['category-1-topic-2', 'category-2-topic-1', 'category-2-topic-2'];
+ beforeEach(async () => {
+ axiosMock.onGet(getCohortsApiUrl(courseId))
+ .reply(200, Factory.buildList('cohort', 3));
+ });
+
async function setupData(config = {}, settings = {}) {
store = initializeStore({
config: {
provider: 'legacy',
userRoles: ['Student', 'Moderator'],
userIsPrivileged: true,
+ moderationSettings: {},
settings: {
dividedInlineDiscussions: dividedcw,
dividedCourseWideDiscussions: dividedncw,
@@ -159,7 +168,7 @@ describe('PostEditor', () => {
test('test privileged user', async () => {
await setupData();
- renderComponent();
+ await renderComponent();
// Initially the user can't select a cohort
expect(screen.queryByRole('combobox', {
name: /cohort visibility/i,
@@ -168,12 +177,14 @@ describe('PostEditor', () => {
.toBeInTheDocument();
// If a cohorted topic is selected, the cohort visibility selector is displayed
['ncw-topic 2', 'category-1-topic 2', 'category-2-topic 1', 'category-2-topic 2'].forEach((topicName) => {
- userEvent.selectOptions(
- screen.getByRole('combobox', {
- name: /topic area/i,
- }),
- screen.getByRole('option', { name: topicName }),
- );
+ act(() => {
+ userEvent.selectOptions(
+ screen.getByRole('combobox', {
+ name: /topic area/i,
+ }),
+ screen.getByRole('option', { name: topicName }),
+ );
+ });
expect(screen.queryByRole('combobox', {
name: /cohort visibility/i,
@@ -182,12 +193,14 @@ describe('PostEditor', () => {
});
// Now if a non-cohorted topic is selected, the cohort visibility selector is hidden
['ncw-topic 1', 'category-1-topic 1', 'category-2-topic 4'].forEach((topicName) => {
- userEvent.selectOptions(
- screen.getByRole('combobox', {
- name: /topic area/i,
- }),
- screen.getByRole('option', { name: topicName }),
- );
+ act(() => {
+ userEvent.selectOptions(
+ screen.getByRole('combobox', {
+ name: /topic area/i,
+ }),
+ screen.getByRole('option', { name: topicName }),
+ );
+ });
expect(screen.queryByRole('combobox', {
name: /cohort visibility/i,
}))
@@ -197,7 +210,7 @@ describe('PostEditor', () => {
});
test('test always divided inline', async () => {
await setupData({}, { alwaysDivideInlineDiscussions: true });
- renderComponent();
+ await renderComponent();
// Initially the user can't select a cohort
expect(screen.queryByRole('combobox', {
name: /cohort visibility/i,
@@ -207,12 +220,14 @@ describe('PostEditor', () => {
// All coursweare topics are divided
[1, 2].forEach(catId => {
[1, 2, 3, 4].forEach((topicId) => {
- userEvent.selectOptions(
- screen.getByRole('combobox', {
- name: /topic area/i,
- }),
- screen.getByRole('option', { name: `category-${catId}-topic ${topicId}` }),
- );
+ act(() => {
+ userEvent.selectOptions(
+ screen.getByRole('combobox', {
+ name: /topic area/i,
+ }),
+ screen.getByRole('option', { name: `category-${catId}-topic ${topicId}` }),
+ );
+ });
expect(screen.queryByRole('combobox', {
name: /cohort visibility/i,
@@ -223,12 +238,14 @@ describe('PostEditor', () => {
// Non-courseware topics can still have cohort visibility hidden
['ncw-topic 1', 'ncw-topic 3'].forEach((topicName) => {
- userEvent.selectOptions(
- screen.getByRole('combobox', {
- name: /topic area/i,
- }),
- screen.getByRole('option', { name: topicName }),
- );
+ act(() => {
+ userEvent.selectOptions(
+ screen.getByRole('combobox', {
+ name: /topic area/i,
+ }),
+ screen.getByRole('option', { name: topicName }),
+ );
+ });
expect(screen.queryByRole('combobox', {
name: /cohort visibility/i,
}))
@@ -238,14 +255,16 @@ describe('PostEditor', () => {
});
test('test unprivileged user', async () => {
await setupData({ userIsPrivileged: false });
- renderComponent();
+ await renderComponent();
['ncw-topic 1', 'ncw-topic 2', 'category-1-topic 1', 'category-2-topic 1'].forEach((topicName) => {
- userEvent.selectOptions(
- screen.getByRole('combobox', {
- name: /topic area/i,
- }),
- screen.getByRole('option', { name: topicName }),
- );
+ act(() => {
+ userEvent.selectOptions(
+ screen.getByRole('combobox', {
+ name: /topic area/i,
+ }),
+ screen.getByRole('option', { name: topicName }),
+ );
+ });
// If a cohorted topic is selected, the cohort visibility selector is displayed
expect(screen.queryByRole('combobox', {
name: /cohort visibility/i,
@@ -263,12 +282,14 @@ describe('PostEditor', () => {
await renderComponent(true, `/${courseId}/posts/${threadId}/edit`);
['ncw-topic 1', 'ncw-topic 2', 'category-1-topic 1', 'category-2-topic 1'].forEach((topicName) => {
- userEvent.selectOptions(
- screen.getByRole('combobox', {
- name: /topic area/i,
- }),
- screen.getByRole('option', { name: topicName }),
- );
+ act(() => {
+ userEvent.selectOptions(
+ screen.getByRole('combobox', {
+ name: /topic area/i,
+ }),
+ screen.getByRole('option', { name: topicName }),
+ );
+ });
// If a cohorted topic is selected, the cohort visibility selector is displayed
expect(screen.queryByRole('combobox', {
name: /cohort visibility/i,
@@ -277,5 +298,57 @@ describe('PostEditor', () => {
.toBeInTheDocument();
});
});
+ test('cancel posting of existing post', async () => {
+ const threadId = 'thread-1';
+ await setupData();
+ axiosMock.onGet(`${threadsApiUrl}${threadId}/`)
+ .reply(200, Factory.build('thread'));
+ await executeThunk(fetchThread(threadId), store.dispatch, store.getState);
+ await renderComponent(true, `/${courseId}/posts/${threadId}/edit`);
+
+ const cancelButton = screen.getByRole('button', { name: /cancel/i });
+ await act(async () => {
+ fireEvent.click(cancelButton);
+ });
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Edit codes', () => {
+ const threadId = 'thread-1';
+ beforeEach(async () => {
+ store = initializeStore({
+ config: {
+ provider: 'legacy',
+ userIsPrivileged: true,
+ reasonCodesEnabled: true,
+ editReasons: [
+ {
+ code: 'reason-1',
+ label: 'Reason 1',
+ },
+ {
+ code: 'reason-2',
+ label: 'Reason 2',
+ },
+ ],
+ },
+ });
+ await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
+ axiosMock.onGet(`${threadsApiUrl}${threadId}/`)
+ .reply(200, Factory.build('thread'));
+ await executeThunk(fetchThread(threadId), store.dispatch, store.getState);
+ });
+ test('Edit post and see reasons', async () => {
+ await renderComponent(true, `/${courseId}/posts/${threadId}/edit`);
+
+ expect(screen.queryByRole('combobox', {
+ name: /reason for editing/i,
+ }))
+ .toBeInTheDocument();
+ expect(screen.getAllByRole('option', {
+ name: /reason \d/i,
+ })).toHaveLength(2);
+ });
});
});
diff --git a/src/discussions/posts/post-editor/messages.js b/src/discussions/posts/post-editor/messages.js
index 7fa6ec5a..224c7522 100644
--- a/src/discussions/posts/post-editor/messages.js
+++ b/src/discussions/posts/post-editor/messages.js
@@ -101,6 +101,11 @@ const messages = defineMessages({
id: 'discussions.editor.cancel',
defaultMessage: 'Cancel',
},
+ editReasonCode: {
+ id: 'discussions.editor.posts.editReasonCode',
+ defaultMessage: 'Reason for editing',
+ description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s post',
+ },
});
export default messages;
diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx
index 6e2844e1..26957d53 100644
--- a/src/discussions/posts/post/Post.jsx
+++ b/src/discussions/posts/post/Post.jsx
@@ -28,6 +28,7 @@ function Post({
const topic = useSelector(selectTopic(post.topicId));
const getTopicSubsection = useSelector(selectorForUnitSubsection);
const topicContext = useSelector(selectTopicContext(post.topicId));
+
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => history.push({
diff --git a/src/discussions/posts/post/messages.js b/src/discussions/posts/post/messages.js
index d677dfa7..e5749503 100644
--- a/src/discussions/posts/post/messages.js
+++ b/src/discussions/posts/post/messages.js
@@ -76,6 +76,16 @@ const messages = defineMessages({
defaultMessage: '{count} new',
description: 'Label shown on the badge indicating new comments/posts like "3 new"',
},
+ editedBy: {
+ id: 'discussions.post.editedBy',
+ defaultMessage: 'Edited by',
+ description: 'Message shown to user to inform them who edited a post',
+ },
+ reason: {
+ id: 'discussions.post.editReason',
+ defaultMessage: 'Reason',
+ description: 'Message shown to user to inform them why a post was edited',
+ },
});
export default messages;