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, }) => (
+ {(reasonCodesEnabled + && userIsPrivileged + && comment.author !== authenticatedUser.username) && ( + + + + {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;