From db883ca7cd8d8cae7dfe0efe62e0aba0a4781779 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 <72802712+sundasnoreen12@users.noreply.github.com> Date: Wed, 24 Jul 2024 17:24:23 +0500 Subject: [PATCH] feat: added draft functionality for comment and responses (#727) * feat: added draft functionality for comment and responses * fix: fixed comment update issue: * test: added draft test case * test: added mock conditions for tinymce * refactor: refactor code * test: added test cases * refactor: refactor hook file * refactor: fixed review issues * refactor: memoize function * refactor: refactor code * test: added update comment test case * refactor: refactor remove hook method * test: fixed test cases issue --- src/discussions/common/ActionsDropdown.jsx | 1 + .../post-comments/PostCommentsView.test.jsx | 145 ++++++++++++++++++ .../comments/comment/CommentEditor.jsx | 47 +++++- src/discussions/post-comments/data/hooks.js | 74 ++++++++- .../post-comments/data/selectors.js | 4 + src/discussions/post-comments/data/slices.js | 16 ++ src/discussions/utils.js | 7 + src/setupTest.jsx | 16 +- 8 files changed, 302 insertions(+), 8 deletions(-) diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index 5a10695bf..3b559b399 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -94,6 +94,7 @@ const ActionsDropdown = ({ handleActions(action.action); }} className="d-flex justify-content-start actions-dropdown-item" + data-testId={action.id} > { expect(screen.queryByTestId('reply-comment-3')).not.toBeInTheDocument(); }); + it('successfully added comment in the draft.', async () => { + await waitFor(() => renderComponent(discussionPostId)); + + await act(async () => { + fireEvent.click(screen.queryByText('Add comment')); + }); + + await waitFor(() => { + fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment!' } }); + }); + + await act(async () => { + fireEvent.click(screen.queryByText('Cancel')); + }); + + await act(async () => { + fireEvent.click(screen.queryByText('Add comment')); + }); + + expect(screen.queryByText('Draft comment!')).toBeInTheDocument(); + }); + + 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); + }); + + await act(async () => { + fireEvent.click(screen.queryByTestId('edit')); + }); + + await waitFor(() => { + fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment!' } }); + }); + + await act(async () => { + fireEvent.click(screen.queryByText('Cancel')); + }); + + await act(async () => { + fireEvent.click(actionBtn); + }); + + await act(async () => { + fireEvent.click(screen.queryByTestId('edit')); + }); + + await act(async () => { + fireEvent.click(screen.queryByText('Submit')); + }); + + await waitFor(() => expect(screen.queryByText('Draft comment!')).toBeInTheDocument()); + }); + + it('successfully removed comment from the draft.', async () => { + await waitFor(() => renderComponent(discussionPostId)); + + await act(async () => { + fireEvent.click(screen.queryByText('Add comment')); + }); + + await waitFor(() => { + fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment 123!' } }); + }); + + await act(async () => { + fireEvent.click(screen.queryByText('Submit')); + }); + + await act(async () => { + fireEvent.click(screen.queryAllByText('Add comment')[0]); + }); + + expect(screen.queryByTestId('tinymce-editor').value).toBe(''); + }); + + it('successfully added response in the draft.', async () => { + await waitFor(() => renderComponent(discussionPostId)); + + await act(async () => { + fireEvent.click(screen.queryByText('Add response')); + }); + + await waitFor(() => { + fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft Response!' } }); + }); + + await act(async () => { + fireEvent.click(screen.queryByText('Cancel')); + }); + + await act(async () => { + fireEvent.click(screen.queryByText('Add response')); + }); + + expect(screen.queryByText('Draft Response!')).toBeInTheDocument(); + }); + + it('successfully removed response from the draft.', async () => { + await waitFor(() => renderComponent(discussionPostId)); + + await act(async () => { + fireEvent.click(screen.queryByText('Add response')); + }); + + await waitFor(() => { + fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft Response!' } }); + }); + + await act(async () => { + fireEvent.click(screen.queryByText('Submit')); + }); + + await act(async () => { + fireEvent.click(screen.queryByText('Add response')); + }); + + expect(screen.queryByTestId('tinymce-editor').value).toBe(''); + }); + + it('successfully maintain response for the specific post in the draft.', async () => { + await waitFor(() => renderComponent(discussionPostId)); + + await act(async () => { + fireEvent.click(screen.queryByText('Add response')); + }); + + await waitFor(() => { + fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Hello, world!' } }); + }); + + await waitFor(() => renderComponent('thread-2')); + + await act(async () => { + fireEvent.click(screen.queryAllByText('Add response')[0]); + }); + + expect(screen.queryByText('Hello, world!')).toBeInTheDocument(); + }); + it('pressing load more button will load next page of replies', async () => { await waitFor(() => renderComponent(discussionPostId)); diff --git a/src/discussions/post-comments/comments/comment/CommentEditor.jsx b/src/discussions/post-comments/comments/comment/CommentEditor.jsx index 59327c71f..cfa2b9a0a 100644 --- a/src/discussions/post-comments/comments/comment/CommentEditor.jsx +++ b/src/discussions/post-comments/comments/comment/CommentEditor.jsx @@ -1,5 +1,5 @@ import React, { - useCallback, useContext, useEffect, useRef, + useCallback, useContext, useEffect, useRef, useState, } from 'react'; import PropTypes from 'prop-types'; @@ -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'; @@ -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) @@ -62,7 +66,7 @@ const CommentEditor = ({ }); const initialValues = { - comment: rawBody, + comment: editorContent, editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined), }; @@ -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 = { @@ -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. @@ -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)); + } + }; + return ( {({ values, @@ -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, diff --git a/src/discussions/post-comments/data/hooks.js b/src/discussions/post-comments/data/hooks.js index d53c38846..c89b509a2 100644 --- a/src/discussions/post-comments/data/hooks.js +++ b/src/discussions/post-comments/data/hooks.js @@ -3,6 +3,7 @@ import { } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { v4 as uuidv4 } from 'uuid'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -13,7 +14,8 @@ import { selectThread } from '../../posts/data/selectors'; 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'; @@ -102,3 +104,73 @@ export function useCommentsCount(postId) { 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 } + : { + 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 updateContent = (items, itemId, parentId, isComment) => { + const itemObj = itemId ? items[itemId] : getObjectByParentId(items, parentId, isComment, itemId); + return itemObj ? removeItem(items, itemObj.id) : items; + }; + + const removeDraftContent = (parentId, id, threadId) => { + const updatedResponses = !parentId ? updateContent(responses, id, threadId, false) : responses; + const updatedComments = parentId ? updateContent(comments, id, parentId, true) : comments; + + return { updatedResponses, updatedComments }; + }; + + return { + addDraftContent, + getDraftContent, + removeDraftContent, + }; +}; diff --git a/src/discussions/post-comments/data/selectors.js b/src/discussions/post-comments/data/selectors.js index 538690463..f048733a6 100644 --- a/src/discussions/post-comments/data/selectors.js +++ b/src/discussions/post-comments/data/selectors.js @@ -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; diff --git a/src/discussions/post-comments/data/slices.js b/src/discussions/post-comments/data/slices.js index 321662e1b..67d77e3ca 100644 --- a/src/discussions/post-comments/data/slices.js +++ b/src/discussions/post-comments/data/slices.js @@ -22,6 +22,8 @@ const commentsSlice = createSlice({ pagination: {}, responsesPagination: {}, sortOrder: true, + draftResponses: {}, + draftComments: {}, }, reducers: { fetchCommentsRequest: (state) => ( @@ -257,6 +259,18 @@ const commentsSlice = createSlice({ sortOrder: payload, } ), + setDraftComments: (state, { payload }) => ( + { + ...state, + draftComments: payload, + } + ), + setDraftResponses: (state, { payload }) => ( + { + ...state, + draftResponses: payload, + } + ), }, }); @@ -282,6 +296,8 @@ export const { deleteCommentRequest, deleteCommentSuccess, setCommentSortOrder, + setDraftComments, + setDraftResponses, } = commentsSlice.actions; export const commentsReducer = commentsSlice.reducer; diff --git a/src/discussions/utils.js b/src/discussions/utils.js index 711670407..fb139f3c6 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -316,3 +316,10 @@ export function getAuthorLabel(intl, authorLabel) { } export const isCourseStatusValid = (courseStatus) => [DENIED, LOADED].includes(courseStatus); + +export const extractContent = (content) => { + if (typeof content === 'object') { + return content.target.getContent(); + } + return content; +}; diff --git a/src/setupTest.jsx b/src/setupTest.jsx index 261398e07..aaa04b1bc 100755 --- a/src/setupTest.jsx +++ b/src/setupTest.jsx @@ -19,25 +19,35 @@ Object.defineProperty(window, 'matchMedia', { })), }); +global.MathJax = { + typeset: jest.fn(callback => { + if (callback) { callback(); } + }), + startup: { + defaultPageReady: jest.fn(() => Promise.resolve()), + }, +}; + // Provides a mock editor component that functions like tinyMCE without the overhead const MockEditor = ({ onBlur, onEditorChange, + value, }) => (