feat: Add support for specifying edit reasons (#46)

Adds support for specifying an edit reason when a moderator edits a user's post.

Co-authored-by: Kshitij Sobti <kshitij@opencraft.com>
This commit is contained in:
Felipe Trzaskowski
2022-03-17 09:41:47 -03:00
committed by GitHub
parent 935d0c5e3e
commit 194a3f2485
20 changed files with 404 additions and 110 deletions

View File

@@ -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.

View File

@@ -59,7 +59,7 @@ function Comment({
hideDeleteConfirmation();
}}
/>
<AlertBanner postType={postType} content={comment} intl={intl} />
<AlertBanner postType={postType} content={comment} />
<div className="d-flex flex-column p-4">
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
{isEditing

View File

@@ -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,
}) => (
<Form onSubmit={handleSubmit}>
{(reasonCodesEnabled
&& userIsPrivileged
&& comment.author !== authenticatedUser.username) && (
<Form.Group>
<Form.Control
name="editReasonCode"
className="mt-2"
as="select"
value={values.editReasonCode}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="editReasonCodeInput"
floatingLabel={intl.formatMessage(messages.editReasonCode)}
>
<option key="empty" value="">---</option>
{editReasons.map(({
code,
label,
}) => (
<option key={code} value={code}>{label}</option>
))}
</Form.Control>
</Form.Group>
)}
<TinyMCEEditor
onInit={
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
@@ -105,6 +138,7 @@ CommentEditor.propTypes = {
threadId: PropTypes.string.isRequired,
parentId: PropTypes.string,
rawBody: PropTypes.string,
author: PropTypes.string,
}).isRequired,
onCloseEditor: PropTypes.func.isRequired,
intl: intlShape.isRequired,

View File

@@ -84,6 +84,7 @@ export async function postComment(comment, threadId, parentId = null) {
* @param {boolean=} voted
* @param {boolean=} flagged
* @param {boolean=} endorsed
* @param {string=} editReasonCode The moderation reason code for editing.
* @returns {Promise<{}>}
*/
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' } });

View File

@@ -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,

View File

@@ -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.',
},
});

View File

@@ -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)}
</Alert>
)}
{reasonCodesEnabled && userIsPrivileged && content.lastEdit?.reason && (
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
<div className="d-flex align-items-center">
{intl.formatMessage(messages.editedBy)}
<span className="ml-1 mr-3">
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
</span>
{intl.formatMessage(messages.reason)}:&nbsp;{content.lastEdit.reason}
</div>
</Alert>
)}
</>
);
}
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);

View File

@@ -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(
<IntlProvider locale="en">
<AppProvider store={store}>
<AlertBanner
content={content}
postType={ThreadType.QUESTION}
postType={postType}
/>
</AppProvider>
</IntlProvider>,
);
}
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);
});
});
});

View File

@@ -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) {

View File

@@ -18,6 +18,9 @@ const configSlice = createSlice({
dividedInlineDiscussions: [],
dividedCourseWideDiscussions: [],
},
reasonCodesEnabled: false,
editReasons: [],
postCloseReasons: [],
},
reducers: {
fetchConfigRequest: (state) => {

View File

@@ -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

View File

@@ -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

View File

@@ -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' } });

View File

@@ -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,
}));

View File

@@ -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) {

View File

@@ -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 (
<Formik
enableReinitialize
@@ -204,6 +214,9 @@ function PostEditor({
cohort: Yup.string()
.nullable()
.default(null),
editReasonCode: Yup.string()
.nullable()
.default(null),
})}
onSubmit={submitForm}
>{
@@ -289,24 +302,48 @@ function PostEditor({
)}
</div>
<div className="border-bottom my-1" />
<Form.Group
className="py-2 mt-4"
isInvalid={isFormikFieldInvalid('title', {
errors,
touched,
})}
>
<Form.Control
name="title"
type="text"
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="titleInput"
floatingLabel={intl.formatMessage(messages.postTitle)}
value={values.title}
/>
<FormikErrorFeedback name="title" />
</Form.Group>
<div className="d-flex flex-row py-2 mt-4 justify-content-between">
<Form.Group
className="d-flex flex-fill"
isInvalid={isFormikFieldInvalid('title', {
errors,
touched,
})}
>
<Form.Control
name="title"
type="text"
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="titleInput"
floatingLabel={intl.formatMessage(messages.postTitle)}
value={values.title}
/>
<FormikErrorFeedback name="title" />
</Form.Group>
{(reasonCodesEnabled
&& editExisting
&& userIsPrivileged
&& post.author !== authenticatedUser.username) && (
<Form.Group className="d-flex flex-fill">
<Form.Control
name="editReasonCode"
className="ml-4"
as="select"
value={values.editReasonCode}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="editReasonCodeInput"
floatingLabel={intl.formatMessage(messages.editReasonCode)}
>
<option key="empty" value="">---</option>
{editReasons.map(({ code, label }) => (
<option key={code} value={code}>{label}</option>
))}
</Form.Control>
</Form.Group>
)}
</div>
<div className="py-2">
<TinyMCEEditor
onInit={

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
@@ -14,6 +15,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { API_BASE_URL, Routes } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { getCohortsApiUrl } from '../../cohorts/data/api';
import { fetchCourseTopics } from '../../topics/data/thunks';
import { threadsApiUrl } from '../data/api';
import { fetchThread } from '../data/thunks';
@@ -83,7 +85,7 @@ describe('PostEditor', () => {
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);
});
});
});

View File

@@ -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;

View File

@@ -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({

View File

@@ -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;