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:
committed by
GitHub
parent
935d0c5e3e
commit
194a3f2485
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}: {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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -18,6 +18,9 @@ const configSlice = createSlice({
|
||||
dividedInlineDiscussions: [],
|
||||
dividedCourseWideDiscussions: [],
|
||||
},
|
||||
reasonCodesEnabled: false,
|
||||
editReasons: [],
|
||||
postCloseReasons: [],
|
||||
},
|
||||
reducers: {
|
||||
fetchConfigRequest: (state) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user