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
145 changes: 145 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,151 @@ describe('ThreadView', () => {
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));

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
74 changes: 73 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,73 @@

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 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,
};
};
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