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
125 changes: 125 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,131 @@ 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 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!' } });
});

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

addCommentBtn = screen.queryAllByText('Add comment');
await act(async () => {
fireEvent.click(addCommentBtn[0]);
});

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

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

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

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 = async () => {
const { updatedResponses, updatedComments } = removeDraftContent(parentId, id, threadId);
if (parentId) {
await dispatch(setDraftComments(updatedComments));
} else {
await dispatch(setDraftResponses(updatedResponses));
}
};

sundasnoreen12 marked this conversation as resolved.
Show resolved Hide resolved
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
71 changes: 70 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 @@ import {
} 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 { 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';

Expand Down Expand Up @@ -102,3 +104,70 @@ 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 newObject = {
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);
}
} 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;
16 changes: 16 additions & 0 deletions src/discussions/post-comments/data/slices.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const commentsSlice = createSlice({
pagination: {},
responsesPagination: {},
sortOrder: true,
draftResponses: {},
draftComments: {},
},
reducers: {
fetchCommentsRequest: (state) => (
Expand Down Expand Up @@ -257,6 +259,18 @@ const commentsSlice = createSlice({
sortOrder: payload,
}
),
setDraftComments: (state, { payload }) => (
{
...state,
draftComments: payload,
}
),
setDraftResponses: (state, { payload }) => (
{
...state,
draftResponses: payload,
}
),
},
});

Expand All @@ -282,6 +296,8 @@ export const {
deleteCommentRequest,
deleteCommentSuccess,
setCommentSortOrder,
setDraftComments,
setDraftResponses,
} = commentsSlice.actions;

export const commentsReducer = commentsSlice.reducer;
7 changes: 7 additions & 0 deletions src/discussions/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,10 @@
}

export const isCourseStatusValid = (courseStatus) => [DENIED, LOADED].includes(courseStatus);

export const extractContent = (content) => {
if (typeof content === 'object') {
return content.target.getContent();

Check warning on line 322 in src/discussions/utils.js

View check run for this annotation

Codecov / codecov/patch

src/discussions/utils.js#L322

Added line #L322 was not covered by tests
}
return content;
};
Loading