Skip to content

Commit db883ca

Browse files
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
1 parent 422fbf6 commit db883ca

File tree

8 files changed

+302
-8
lines changed

8 files changed

+302
-8
lines changed

src/discussions/common/ActionsDropdown.jsx

+1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ const ActionsDropdown = ({
9494
handleActions(action.action);
9595
}}
9696
className="d-flex justify-content-start actions-dropdown-item"
97+
data-testId={action.id}
9798
>
9899
<Icon
99100
src={action.icon}

src/discussions/post-comments/PostCommentsView.test.jsx

+145
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,151 @@ describe('ThreadView', () => {
671671
expect(screen.queryByTestId('reply-comment-3')).not.toBeInTheDocument();
672672
});
673673

674+
it('successfully added comment in the draft.', async () => {
675+
await waitFor(() => renderComponent(discussionPostId));
676+
677+
await act(async () => {
678+
fireEvent.click(screen.queryByText('Add comment'));
679+
});
680+
681+
await waitFor(() => {
682+
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment!' } });
683+
});
684+
685+
await act(async () => {
686+
fireEvent.click(screen.queryByText('Cancel'));
687+
});
688+
689+
await act(async () => {
690+
fireEvent.click(screen.queryByText('Add comment'));
691+
});
692+
693+
expect(screen.queryByText('Draft comment!')).toBeInTheDocument();
694+
});
695+
696+
it('successfully updated comment in the draft.', async () => {
697+
await waitFor(() => renderComponent(discussionPostId));
698+
699+
const comment = screen.queryByTestId('reply-comment-2');
700+
const actionBtn = comment.querySelector('button[aria-label="Actions menu"]');
701+
702+
await act(async () => {
703+
fireEvent.click(actionBtn);
704+
});
705+
706+
await act(async () => {
707+
fireEvent.click(screen.queryByTestId('edit'));
708+
});
709+
710+
await waitFor(() => {
711+
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment!' } });
712+
});
713+
714+
await act(async () => {
715+
fireEvent.click(screen.queryByText('Cancel'));
716+
});
717+
718+
await act(async () => {
719+
fireEvent.click(actionBtn);
720+
});
721+
722+
await act(async () => {
723+
fireEvent.click(screen.queryByTestId('edit'));
724+
});
725+
726+
await act(async () => {
727+
fireEvent.click(screen.queryByText('Submit'));
728+
});
729+
730+
await waitFor(() => expect(screen.queryByText('Draft comment!')).toBeInTheDocument());
731+
});
732+
733+
it('successfully removed comment from the draft.', async () => {
734+
await waitFor(() => renderComponent(discussionPostId));
735+
736+
await act(async () => {
737+
fireEvent.click(screen.queryByText('Add comment'));
738+
});
739+
740+
await waitFor(() => {
741+
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment 123!' } });
742+
});
743+
744+
await act(async () => {
745+
fireEvent.click(screen.queryByText('Submit'));
746+
});
747+
748+
await act(async () => {
749+
fireEvent.click(screen.queryAllByText('Add comment')[0]);
750+
});
751+
752+
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
753+
});
754+
755+
it('successfully added response in the draft.', async () => {
756+
await waitFor(() => renderComponent(discussionPostId));
757+
758+
await act(async () => {
759+
fireEvent.click(screen.queryByText('Add response'));
760+
});
761+
762+
await waitFor(() => {
763+
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft Response!' } });
764+
});
765+
766+
await act(async () => {
767+
fireEvent.click(screen.queryByText('Cancel'));
768+
});
769+
770+
await act(async () => {
771+
fireEvent.click(screen.queryByText('Add response'));
772+
});
773+
774+
expect(screen.queryByText('Draft Response!')).toBeInTheDocument();
775+
});
776+
777+
it('successfully removed response from the draft.', async () => {
778+
await waitFor(() => renderComponent(discussionPostId));
779+
780+
await act(async () => {
781+
fireEvent.click(screen.queryByText('Add response'));
782+
});
783+
784+
await waitFor(() => {
785+
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft Response!' } });
786+
});
787+
788+
await act(async () => {
789+
fireEvent.click(screen.queryByText('Submit'));
790+
});
791+
792+
await act(async () => {
793+
fireEvent.click(screen.queryByText('Add response'));
794+
});
795+
796+
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
797+
});
798+
799+
it('successfully maintain response for the specific post in the draft.', async () => {
800+
await waitFor(() => renderComponent(discussionPostId));
801+
802+
await act(async () => {
803+
fireEvent.click(screen.queryByText('Add response'));
804+
});
805+
806+
await waitFor(() => {
807+
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Hello, world!' } });
808+
});
809+
810+
await waitFor(() => renderComponent('thread-2'));
811+
812+
await act(async () => {
813+
fireEvent.click(screen.queryAllByText('Add response')[0]);
814+
});
815+
816+
expect(screen.queryByText('Hello, world!')).toBeInTheDocument();
817+
});
818+
674819
it('pressing load more button will load next page of replies', async () => {
675820
await waitFor(() => renderComponent(discussionPostId));
676821

src/discussions/post-comments/comments/comment/CommentEditor.jsx

+43-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, {
2-
useCallback, useContext, useEffect, useRef,
2+
useCallback, useContext, useEffect, useRef, useState,
33
} from 'react';
44
import PropTypes from 'prop-types';
55

@@ -22,7 +22,9 @@ import {
2222
selectUserIsGroupTa,
2323
selectUserIsStaff,
2424
} from '../../../data/selectors';
25-
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
25+
import { extractContent, formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
26+
import { useDraftContent } from '../../data/hooks';
27+
import { setDraftComments, setDraftResponses } from '../../data/slices';
2628
import { addComment, editComment } from '../../data/thunks';
2729
import messages from '../../messages';
2830

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

4953
const canDisplayEditReason = (edit
5054
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -62,7 +66,7 @@ const CommentEditor = ({
6266
});
6367

6468
const initialValues = {
65-
comment: rawBody,
69+
comment: editorContent,
6670
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
6771
};
6872

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

78+
const deleteEditorContent = useCallback(async () => {
79+
const { updatedResponses, updatedComments } = removeDraftContent(parentId, id, threadId);
80+
if (parentId) {
81+
await dispatch(setDraftComments(updatedComments));
82+
} else {
83+
await dispatch(setDraftResponses(updatedResponses));
84+
}
85+
}, [parentId, id, threadId, setDraftComments, setDraftResponses]);
86+
7487
const saveUpdatedComment = useCallback(async (values, { resetForm }) => {
7588
if (id) {
7689
const payload = {
@@ -86,6 +99,7 @@ const CommentEditor = ({
8699
editorRef.current.plugins.autosave.removeDraft();
87100
}
88101
handleCloseEditor(resetForm);
102+
deleteEditorContent();
89103
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]);
90104
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
91105
// the current comment id, or the current comment parent or the curren thread.
@@ -97,11 +111,33 @@ const CommentEditor = ({
97111
}
98112
}, [formRef]);
99113

114+
useEffect(() => {
115+
const draftHtml = getDraftContent(parentId, threadId, id) || rawBody;
116+
setEditorContent(draftHtml);
117+
}, [parentId, threadId, id]);
118+
119+
const saveDraftContent = async (content) => {
120+
const draftDataContent = extractContent(content);
121+
122+
const { updatedResponses, updatedComments } = addDraftContent(
123+
draftDataContent,
124+
parentId,
125+
id,
126+
threadId,
127+
);
128+
if (parentId) {
129+
await dispatch(setDraftComments(updatedComments));
130+
} else {
131+
await dispatch(setDraftResponses(updatedResponses));
132+
}
133+
};
134+
100135
return (
101136
<Formik
102137
initialValues={initialValues}
103138
validationSchema={validationSchema}
104139
onSubmit={saveUpdatedComment}
140+
enableReinitialize
105141
>
106142
{({
107143
values,
@@ -151,7 +187,10 @@ const CommentEditor = ({
151187
id={editorId}
152188
value={values.comment}
153189
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
154-
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
190+
onBlur={(content) => {
191+
formikCompatibleHandler(handleChange, 'comment');
192+
saveDraftContent(content);
193+
}}
155194
/>
156195
{isFormikFieldInvalid('comment', {
157196
errors,

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

+73-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
} from 'react';
44

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

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

@@ -13,7 +14,8 @@ import { selectThread } from '../../posts/data/selectors';
1314
import { markThreadAsRead } from '../../posts/data/thunks';
1415
import { filterPosts } from '../../utils';
1516
import {
16-
selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
17+
selectCommentSortOrder, selectDraftComments, selectDraftResponses,
18+
selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
1719
} from './selectors';
1820
import { fetchThreadComments } from './thunks';
1921

@@ -102,3 +104,73 @@ export function useCommentsCount(postId) {
102104

103105
return commentsLength;
104106
}
107+
108+
export const useDraftContent = () => {
109+
const comments = useSelector(selectDraftComments);
110+
const responses = useSelector(selectDraftResponses);
111+
112+
const getObjectByParentId = (data, parentId, isComment, id) => Object.values(data)
113+
.find(draft => (isComment ? draft.parentId === parentId && (id ? draft.id === id : draft.isNewContent === true)
114+
: draft.threadId === parentId && (id ? draft.id === id : draft.isNewContent === true)));
115+
116+
const updateDraftData = (draftData, newDraftObject) => ({
117+
...draftData,
118+
[newDraftObject.id]: newDraftObject,
119+
});
120+
121+
const addDraftContent = (content, parentId, id, threadId) => {
122+
const data = parentId ? comments : responses;
123+
const draftParentId = parentId || threadId;
124+
const isComment = !!parentId;
125+
const existingObj = getObjectByParentId(data, draftParentId, isComment, id);
126+
const newObject = existingObj
127+
? { ...existingObj, content }
128+
: {
129+
threadId,
130+
content,
131+
parentId,
132+
id: id || uuidv4(),
133+
isNewContent: !id,
134+
};
135+
136+
const updatedComments = parentId ? updateDraftData(comments, newObject) : comments;
137+
const updatedResponses = !parentId ? updateDraftData(responses, newObject) : responses;
138+
139+
return { updatedComments, updatedResponses };
140+
};
141+
142+
const getDraftContent = (parentId, threadId, id) => {
143+
if (id) {
144+
return parentId ? comments?.[id]?.content : responses?.[id]?.content;
145+
}
146+
147+
const data = parentId ? comments : responses;
148+
const draftParentId = parentId || threadId;
149+
const isComment = !!parentId;
150+
151+
return getObjectByParentId(data, draftParentId, isComment, id)?.content;
152+
};
153+
154+
const removeItem = (draftData, objId) => {
155+
const { [objId]: _, ...newDraftData } = draftData;
156+
return newDraftData;
157+
};
158+
159+
const updateContent = (items, itemId, parentId, isComment) => {
160+
const itemObj = itemId ? items[itemId] : getObjectByParentId(items, parentId, isComment, itemId);
161+
return itemObj ? removeItem(items, itemObj.id) : items;
162+
};
163+
164+
const removeDraftContent = (parentId, id, threadId) => {
165+
const updatedResponses = !parentId ? updateContent(responses, id, threadId, false) : responses;
166+
const updatedComments = parentId ? updateContent(comments, id, parentId, true) : comments;
167+
168+
return { updatedResponses, updatedComments };
169+
};
170+
171+
return {
172+
addDraftContent,
173+
getDraftContent,
174+
removeDraftContent,
175+
};
176+
};

src/discussions/post-comments/data/selectors.js

+4
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,7 @@ export const selectCommentCurrentPage = commentId => (
4747
export const selectCommentsStatus = state => state.comments.status;
4848

4949
export const selectCommentSortOrder = state => state.comments.sortOrder;
50+
51+
export const selectDraftComments = state => state.comments.draftComments;
52+
53+
export const selectDraftResponses = state => state.comments.draftResponses;

0 commit comments

Comments
 (0)