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:
Kshitij Sobti
2022-03-17 11:50:22 +05:30
committed by GitHub
parent ce293e53c0
commit 935d0c5e3e
4 changed files with 225 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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