Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added draft functionality for comment and responses #727

Merged
merged 13 commits into from
Jul 24, 2024
1 change: 1 addition & 0 deletions src/discussions/common/ActionsDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const ActionsDropdown = ({
handleActions(action.action);
}}
className="d-flex justify-content-start actions-dropdown-item"
data-testId={action.id}
>
<Icon
src={action.icon}
Expand Down
170 changes: 170 additions & 0 deletions src/discussions/post-comments/PostCommentsView.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,176 @@ describe('ThreadView', () => {
expect(screen.queryByTestId('reply-comment-3')).not.toBeInTheDocument();
});

it('successfully added comment in the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));

let addCommentBtn = screen.queryByText('Add comment');

await act(async () => {
fireEvent.click(addCommentBtn);
});

await waitFor(() => {
const editor = screen.queryByTestId('tinymce-editor');
fireEvent.change(editor, { target: { value: 'Draft comment!' } });
});

const cancelBtn = screen.queryByText('Cancel');
await act(async () => {
fireEvent.click(cancelBtn);
});

addCommentBtn = screen.queryByText('Add comment');
await act(async () => {
fireEvent.click(addCommentBtn);
});

expect(screen.queryByText('Draft comment!')).toBeInTheDocument();
});
sundasnoreen12 marked this conversation as resolved.
Show resolved Hide resolved

it('successfully updated comment in the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));

const comment = screen.queryByTestId('reply-comment-2');
const actionBtn = comment.querySelector('button[aria-label="Actions menu"]');

await act(async () => {
fireEvent.click(actionBtn);
});

let editBtn = screen.queryByTestId('edit');

await act(async () => {
fireEvent.click(editBtn);
});

await waitFor(() => {
const editor = screen.queryByTestId('tinymce-editor');
fireEvent.change(editor, { target: { value: 'Draft comment!' } });
});

const cancelBtn = screen.queryByText('Cancel');
await act(async () => {
fireEvent.click(cancelBtn);
});

await act(async () => {
fireEvent.click(actionBtn);
});

editBtn = screen.queryByTestId('edit');

await act(async () => {
fireEvent.click(editBtn);
});

const submitBtn = screen.queryByText('Submit');
await act(async () => {
fireEvent.click(submitBtn);
});

await waitFor(() => expect(screen.queryByText('Draft comment!')).toBeInTheDocument());
});

it('successfully removed comment from the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));

let addCommentBtn = screen.queryByText('Add comment');

await act(async () => {
fireEvent.click(addCommentBtn);
});

await waitFor(() => {
const editor = screen.queryByTestId('tinymce-editor');
fireEvent.change(editor, { target: { value: 'Draft comment 123!' } });
});

const submitBtn = screen.queryByText('Submit');
await act(async () => {
fireEvent.click(submitBtn);
});

addCommentBtn = screen.queryAllByText('Add comment');
await act(async () => {
fireEvent.click(addCommentBtn[0]);
});
const editor = screen.queryByTestId('tinymce-editor');
expect(editor.value).toBe('');
});

it('successfully added response in the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));

let addResponseBtn = screen.queryByText('Add response');
await act(async () => {
fireEvent.click(addResponseBtn);
});

await waitFor(() => {
const editor = screen.queryByTestId('tinymce-editor');
fireEvent.change(editor, { target: { value: 'Draft Response!' } });
});
const cancelBtn = screen.queryByText('Cancel');
await act(async () => {
fireEvent.click(cancelBtn);
});
addResponseBtn = screen.queryByText('Add response');
await act(async () => {
fireEvent.click(addResponseBtn);
});

expect(screen.queryByText('Draft Response!')).toBeInTheDocument();
});

it('successfully removed response from the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));

let addResponseBtn = screen.queryByText('Add response');
await act(async () => {
fireEvent.click(addResponseBtn);
});

await waitFor(() => {
const editor = screen.queryByTestId('tinymce-editor');
fireEvent.change(editor, { target: { value: 'Draft Response!' } });
});
const submitBtn = screen.queryByText('Submit');
await act(async () => {
fireEvent.click(submitBtn);
});
addResponseBtn = screen.queryByText('Add response');
await act(async () => {
fireEvent.click(addResponseBtn);
});

const editor = screen.queryByTestId('tinymce-editor');
expect(editor.value).toBe('');
});

it('successfully maintain response for the specific post in the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));

let addResponseBtn = screen.queryByText('Add response');
await act(async () => {
fireEvent.click(addResponseBtn);
});

await waitFor(() => {
const editor = screen.queryByTestId('tinymce-editor');
fireEvent.change(editor, { target: { value: 'Hello, world!' } });
});

await waitFor(() => renderComponent('thread-2'));
await waitFor(() => renderComponent(discussionPostId));
addResponseBtn = screen.queryAllByText('Add response');
await act(async () => {
fireEvent.click(addResponseBtn[0]);
});

expect(screen.queryByText('Hello, world!')).toBeInTheDocument();
});

it('pressing load more button will load next page of replies', async () => {
await waitFor(() => renderComponent(discussionPostId));

Expand Down
47 changes: 43 additions & 4 deletions src/discussions/post-comments/comments/comment/CommentEditor.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {
useCallback, useContext, useEffect, useRef,
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';

Expand All @@ -22,7 +22,9 @@ import {
selectUserIsGroupTa,
selectUserIsStaff,
} from '../../../data/selectors';
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
import { extractContent, formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
import { useDraftContent } from '../../data/hooks';
import { setDraftComments, setDraftResponses } from '../../data/slices';
import { addComment, editComment } from '../../data/thunks';
import messages from '../../messages';

Expand All @@ -45,6 +47,8 @@ const CommentEditor = ({
const userIsStaff = useSelector(selectUserIsStaff);
const { editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState();
const [editorContent, setEditorContent] = useState();
const { addDraftContent, getDraftContent, removeDraftContent } = useDraftContent();

const canDisplayEditReason = (edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
Expand All @@ -62,7 +66,7 @@ const CommentEditor = ({
});

const initialValues = {
comment: rawBody,
comment: editorContent,
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
};

Expand All @@ -71,6 +75,15 @@ const CommentEditor = ({
onCloseEditor();
}, [onCloseEditor, initialValues]);

const deleteEditorContent = useCallback(async () => {
const { updatedResponses, updatedComments } = removeDraftContent(parentId, id, threadId);
if (parentId) {
await dispatch(setDraftComments(updatedComments));
} else {
await dispatch(setDraftResponses(updatedResponses));
}
}, [parentId, id, threadId, setDraftComments, setDraftResponses]);

const saveUpdatedComment = useCallback(async (values, { resetForm }) => {
if (id) {
const payload = {
Expand All @@ -86,6 +99,7 @@ const CommentEditor = ({
editorRef.current.plugins.autosave.removeDraft();
}
handleCloseEditor(resetForm);
deleteEditorContent();
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]);
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread.
Expand All @@ -97,11 +111,33 @@ const CommentEditor = ({
}
}, [formRef]);

useEffect(() => {
const draftHtml = getDraftContent(parentId, threadId, id) || rawBody;
setEditorContent(draftHtml);
}, [parentId, threadId, id]);

const saveDraftContent = async (content) => {
const draftDataContent = extractContent(content);

const { updatedResponses, updatedComments } = addDraftContent(
draftDataContent,
parentId,
id,
threadId,
);
if (parentId) {
await dispatch(setDraftComments(updatedComments));
} else {
await dispatch(setDraftResponses(updatedResponses));
}
};

sundasnoreen12 marked this conversation as resolved.
Show resolved Hide resolved
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={saveUpdatedComment}
enableReinitialize
>
{({
values,
Expand Down Expand Up @@ -151,7 +187,10 @@ const CommentEditor = ({
id={editorId}
value={values.comment}
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
onBlur={(content) => {
formikCompatibleHandler(handleChange, 'comment');
saveDraftContent(content);
}}
/>
{isFormikFieldInvalid('comment', {
errors,
Expand Down
81 changes: 80 additions & 1 deletion src/discussions/post-comments/data/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
} from 'react';

import { useDispatch, useSelector } from 'react-redux';
import { v4 as uuidv4 } from 'uuid';

import { sendTrackEvent } from '@edx/frontend-platform/analytics';

Expand All @@ -13,7 +14,8 @@
import { markThreadAsRead } from '../../posts/data/thunks';
import { filterPosts } from '../../utils';
import {
selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
selectCommentSortOrder, selectDraftComments, selectDraftResponses,
selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
} from './selectors';
import { fetchThreadComments } from './thunks';

Expand Down Expand Up @@ -102,3 +104,80 @@

return commentsLength;
}

export const useDraftContent = () => {
const comments = useSelector(selectDraftComments);
const responses = useSelector(selectDraftResponses);

const getObjectByParentId = (data, parentId, isComment, id) => Object.values(data)
.find(draft => (isComment ? draft.parentId === parentId && (id ? draft.id === id : draft.isNewContent === true)
: draft.threadId === parentId && (id ? draft.id === id : draft.isNewContent === true)));

const updateDraftData = (draftData, newDraftObject) => ({
...draftData,
[newDraftObject.id]: newDraftObject,
});

const addDraftContent = (content, parentId, id, threadId) => {
const data = parentId ? comments : responses;
const draftParentId = parentId || threadId;
const isComment = !!parentId;
const existingObj = getObjectByParentId(data, draftParentId, isComment, id);
const newObject = existingObj
? { ...existingObj, content }

Check warning on line 127 in src/discussions/post-comments/data/hooks.js

View check run for this annotation

Codecov / codecov/patch

src/discussions/post-comments/data/hooks.js#L127

Added line #L127 was not covered by tests
: {
threadId,
content,
parentId,
id: id || uuidv4(),
isNewContent: !id,
};

const updatedComments = parentId ? updateDraftData(comments, newObject) : comments;
const updatedResponses = !parentId ? updateDraftData(responses, newObject) : responses;

return { updatedComments, updatedResponses };
};

const getDraftContent = (parentId, threadId, id) => {
if (id) {
return parentId ? comments?.[id]?.content : responses?.[id]?.content;
}

const data = parentId ? comments : responses;
const draftParentId = parentId || threadId;
const isComment = !!parentId;

return getObjectByParentId(data, draftParentId, isComment, id)?.content;
};

const removeItem = (draftData, objId) => {
const { [objId]: _, ...newDraftData } = draftData;
return newDraftData;
};

const removeDraftContent = (parentId, id, threadId) => {
let updatedResponses = responses;
let updatedComments = comments;

if (!parentId) {
const responseObj = id ? responses[id] : getObjectByParentId(responses, threadId, false, id);
if (responseObj) {
updatedResponses = removeItem(responses, responseObj.id);

Check warning on line 166 in src/discussions/post-comments/data/hooks.js

View check run for this annotation

Codecov / codecov/patch

src/discussions/post-comments/data/hooks.js#L166

Added line #L166 was not covered by tests
}
} else {
const commentObj = id ? comments[id] : getObjectByParentId(comments, parentId, true, id);
if (commentObj) {
updatedComments = removeItem(comments, commentObj.id);
}
}
sundasnoreen12 marked this conversation as resolved.
Show resolved Hide resolved

return { updatedResponses, updatedComments };
};

return {
addDraftContent,
getDraftContent,
removeDraftContent,
};
};
4 changes: 4 additions & 0 deletions src/discussions/post-comments/data/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ export const selectCommentCurrentPage = commentId => (
export const selectCommentsStatus = state => state.comments.status;

export const selectCommentSortOrder = state => state.comments.sortOrder;

export const selectDraftComments = state => state.comments.draftComments;

export const selectDraftResponses = state => state.comments.draftResponses;
Loading