fix: clear saved post or comment on submit (#88)
If a post or comment has been submitted, clear it from autosaved drafts so that it doesn't show up anymore.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
act, fireEvent, render, screen, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
@@ -6,7 +8,7 @@ import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
@@ -26,6 +28,37 @@ const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
// Provides a mock editor component that functions like tinyMCE without the overhead
|
||||
function MockEditor({
|
||||
onBlur,
|
||||
onEditorChange,
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
data-testid="tinymce-editor"
|
||||
onChange={(event) => {
|
||||
onEditorChange(event.currentTarget.value);
|
||||
}}
|
||||
onBlur={event => {
|
||||
onBlur(event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
MockEditor.propTypes = {
|
||||
onBlur: PropTypes.func.isRequired,
|
||||
onEditorChange: PropTypes.func.isRequired,
|
||||
};
|
||||
jest.mock('@tinymce/tinymce-react', () => {
|
||||
const originalModule = jest.requireActual('@tinymce/tinymce-react');
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
Editor: MockEditor,
|
||||
};
|
||||
});
|
||||
|
||||
function mockAxiosReturnPagedComments() {
|
||||
[null, false, true].forEach(endorsed => {
|
||||
const postId = endorsed === null ? discussionPostId : questionPostId;
|
||||
@@ -103,16 +136,130 @@ describe('CommentsView', () => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(threadsApiUrl)
|
||||
.reply(200, Factory.build('threadsResult'));
|
||||
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({
|
||||
url,
|
||||
data,
|
||||
}) => {
|
||||
const commentId = url.match(/comments\/(?<id>[a-z1-9-]+)\//).groups.id;
|
||||
const {
|
||||
rawBody,
|
||||
} = camelCaseObject(JSON.parse(data));
|
||||
return [200, Factory.build('comment', {
|
||||
id: commentId,
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
})];
|
||||
});
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(({ data }) => {
|
||||
const {
|
||||
rawBody,
|
||||
threadId,
|
||||
} = camelCaseObject(JSON.parse(data));
|
||||
return [200, Factory.build(
|
||||
'comment',
|
||||
{
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
thread_id: threadId,
|
||||
},
|
||||
)];
|
||||
});
|
||||
|
||||
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
mockAxiosReturnPagedComments();
|
||||
mockAxiosReturnPagedCommentsResponses();
|
||||
});
|
||||
|
||||
describe('for all post types', () => {
|
||||
it('should show and hide the editor', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /add a response/i }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /cancel/i,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
it('should allow posting a response', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /add a response/i }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByText(/submit/i),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||
});
|
||||
it('should allow posting a comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: /add a comment/i })[0],
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByText(/submit/i),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||
});
|
||||
it('should allow editing an existing comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
act(() => {
|
||||
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],
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('for discussion thread', () => {
|
||||
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||
|
||||
it("shown spinner when post isn't loaded", async () => {
|
||||
it('shown spinner when post isn\'t loaded', async () => {
|
||||
renderComponent('unloaded-id');
|
||||
expect(await screen.findByTestId('loading-indicator'))
|
||||
.toBeInTheDocument();
|
||||
@@ -199,7 +346,7 @@ describe('CommentsView', () => {
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
|
||||
await act(() => {
|
||||
act(() => {
|
||||
fireEvent.click(loadMoreButtonEndorsed);
|
||||
});
|
||||
// Endorsed comment from next page should be loaded now.
|
||||
@@ -211,7 +358,7 @@ describe('CommentsView', () => {
|
||||
.toBeInTheDocument();
|
||||
// Now only one load more buttons should show, for unendorsed comments
|
||||
expect(await findLoadMoreCommentsButtons()).toHaveLength(1);
|
||||
await act(() => {
|
||||
act(() => {
|
||||
fireEvent.click(loadMoreButtonUnendorsed);
|
||||
});
|
||||
// Unendorsed comment from next page should be loaded now.
|
||||
@@ -227,7 +374,7 @@ describe('CommentsView', () => {
|
||||
it('initially loads only the first page', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
await waitFor(() => screen.findByText('comment number 7', { exact: false }));
|
||||
expect(screen.queryByText('comment number 8', { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -235,7 +382,9 @@ describe('CommentsView', () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
fireEvent.click(loadMoreButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
await screen.findByText('comment number 8', { exact: false });
|
||||
});
|
||||
@@ -244,7 +393,9 @@ describe('CommentsView', () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
fireEvent.click(loadMoreButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
await screen.findByText('comment number 8', { exact: false });
|
||||
// check that comments from the first page are also displayed
|
||||
@@ -252,12 +403,14 @@ describe('CommentsView', () => {
|
||||
});
|
||||
|
||||
it('load more button is hidden when no more responses pages to load', async () => {
|
||||
const totalePages = 2;
|
||||
const totalPages = 2;
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
for (let page = 1; page < totalePages; page++) {
|
||||
fireEvent.click(loadMoreButton);
|
||||
for (let page = 1; page < totalPages; page++) {
|
||||
act(() => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
}
|
||||
|
||||
await screen.findByText('comment number 8', { exact: false });
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Formik } from 'formik';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form, StatefulButton } from '@edx/paragon';
|
||||
import { Button, Form, StatefulButton } from '@edx/paragon';
|
||||
|
||||
import { TinyMCEEditor } from '../../../components';
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
|
||||
import { addComment, editComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
@@ -18,13 +18,18 @@ function CommentEditor({
|
||||
comment,
|
||||
onCloseEditor,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const [submitting, dispatch] = useDispatchWithState();
|
||||
const editorRef = useRef(null);
|
||||
const saveUpdatedComment = async (values) => {
|
||||
if (comment.id) {
|
||||
dispatch(editComment(comment.id, values));
|
||||
await dispatch(editComment(comment.id, values));
|
||||
} else {
|
||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
if (editorRef.current) {
|
||||
editorRef.current.plugins.autosave.removeDraft();
|
||||
}
|
||||
onCloseEditor();
|
||||
};
|
||||
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
|
||||
@@ -48,42 +53,47 @@ function CommentEditor({
|
||||
handleBlur,
|
||||
handleChange,
|
||||
}) => (
|
||||
<>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TinyMCEEditor
|
||||
id={editorId}
|
||||
value={values.comment}
|
||||
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
|
||||
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
|
||||
/>
|
||||
{isFormikFieldInvalid('comment', {
|
||||
errors,
|
||||
touched,
|
||||
})
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TinyMCEEditor
|
||||
onInit={
|
||||
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
|
||||
(_, editor) => {
|
||||
editorRef.current = editor;
|
||||
}
|
||||
}
|
||||
id={editorId}
|
||||
value={values.comment}
|
||||
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
|
||||
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
|
||||
/>
|
||||
{isFormikFieldInvalid('comment', {
|
||||
errors,
|
||||
touched,
|
||||
})
|
||||
&& (
|
||||
<Form.Control.Feedback type="invalid" hasIcon={false}>
|
||||
{intl.formatMessage(messages.commentError)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
<div className="d-flex py-2 justify-content-end">
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.cancel),
|
||||
}}
|
||||
variant="outline-primary"
|
||||
onClick={onCloseEditor}
|
||||
/>
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.submit),
|
||||
}}
|
||||
className="ml-2"
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
<div className="d-flex py-2 justify-content-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={onCloseEditor}
|
||||
>
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</Button>
|
||||
<StatefulButton
|
||||
state={submitting ? 'pending' : null}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.submit),
|
||||
pending: intl.formatMessage(messages.submitting),
|
||||
}}
|
||||
className="ml-2"
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
@@ -99,6 +99,10 @@ const messages = defineMessages({
|
||||
id: 'discussions.editor.submit',
|
||||
defaultMessage: 'Submit',
|
||||
},
|
||||
submitting: {
|
||||
id: 'discussions.editor.submitting',
|
||||
defaultMessage: 'Submitting',
|
||||
},
|
||||
cancel: {
|
||||
id: 'discussions.editor.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Formik } from 'formik';
|
||||
@@ -62,6 +62,7 @@ function PostEditor({
|
||||
editExisting,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const editorRef = useRef(null);
|
||||
const [submitting, dispatchSubmit] = useDispatchWithState();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
@@ -132,6 +133,10 @@ function PostEditor({
|
||||
cohort,
|
||||
}));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
if (editorRef.current) {
|
||||
editorRef.current.plugins.autosave.removeDraft();
|
||||
}
|
||||
hideEditor();
|
||||
};
|
||||
|
||||
@@ -304,6 +309,12 @@ function PostEditor({
|
||||
</Form.Group>
|
||||
<div className="py-2">
|
||||
<TinyMCEEditor
|
||||
onInit={
|
||||
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
|
||||
(_, editor) => {
|
||||
editorRef.current = editor;
|
||||
}
|
||||
}
|
||||
id={postEditorId}
|
||||
value={values.comment}
|
||||
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
|
||||
|
||||
Reference in New Issue
Block a user