From 9ba94966f1017bdd0d23cac3de8ceaf99f8fdf5b Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Thu, 27 Jan 2022 07:18:43 +0000 Subject: [PATCH] feat: delete confirmation dialogs [BD-38] [TNL-9351] (#49) Adds delete confirmation dialogs for posts, comments and replies. --- src/discussions/comments/CommentsView.jsx | 2 +- .../comments/CommentsView.test.jsx | 52 +++++++++++++++++-- src/discussions/comments/comment/Comment.jsx | 20 +++++-- src/discussions/comments/comment/Reply.jsx | 25 ++++++--- .../data/__factories__/comments.factory.js | 1 + src/discussions/comments/messages.js | 16 ++++++ src/discussions/common/DeleteConfirmation.jsx | 50 ++++++++++++++++++ src/discussions/common/index.js | 1 + src/discussions/messages.js | 10 ++++ .../data/__factories__/threads.factory.js | 1 + src/discussions/posts/post/Post.jsx | 26 +++++++--- src/discussions/posts/post/messages.js | 8 +++ src/discussions/utils.js | 12 ----- 13 files changed, 190 insertions(+), 34 deletions(-) create mode 100644 src/discussions/common/DeleteConfirmation.jsx diff --git a/src/discussions/comments/CommentsView.jsx b/src/discussions/comments/CommentsView.jsx index 6037060b..11f0971a 100644 --- a/src/discussions/comments/CommentsView.jsx +++ b/src/discussions/comments/CommentsView.jsx @@ -117,7 +117,7 @@ function CommentsView({ intl }) { const thread = usePost(postId); if (!thread) { return ( - + ); } return ( diff --git a/src/discussions/comments/CommentsView.test.jsx b/src/discussions/comments/CommentsView.test.jsx index 90ceacd0..3c11131f 100644 --- a/src/discussions/comments/CommentsView.test.jsx +++ b/src/discussions/comments/CommentsView.test.jsx @@ -1,5 +1,5 @@ import { - act, fireEvent, render, screen, waitFor, + act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { IntlProvider } from 'react-intl'; @@ -40,7 +40,7 @@ function mockAxiosReturnPagedComments() { endorsed, }, }) - .reply(200, Factory.build('commentsResult', null, { + .reply(200, Factory.build('commentsResult', { can_delete: true }, { threadId: postId, page, pageSize: 1, @@ -111,6 +111,13 @@ describe('CommentsView', () => { describe('for discussion thread', () => { const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments'); + + it("shown spinner when post isn't loaded", async () => { + renderComponent('unloaded-id'); + expect(await screen.findByTestId('loading-indicator')) + .toBeInTheDocument(); + }); + it('initially loads only the first page', async () => { renderComponent(discussionPostId); expect(await screen.findByText('comment number 1', { exact: false })) @@ -254,7 +261,46 @@ describe('CommentsView', () => { } await screen.findByText('comment number 8', { exact: false }); - await expect(findLoadMoreCommentsResponsesButton()).rejects.toThrow(); + await expect(findLoadMoreCommentsResponsesButton()) + .rejects + .toThrow(); + }); + }); + + describe.each([ + { component: 'post', testId: 'post-thread-1' }, + { component: 'comment', testId: 'comment-comment-1' }, + { component: 'reply', testId: 'reply-comment-7' }, + ])('delete confirmation modal', ({ + component, + testId, + }) => { + test(`for ${component}`, async () => { + await renderComponent(discussionPostId); + // Wait for the content to load + await screen.findByText('comment number 7', { exact: false }); + const content = screen.getByTestId(testId); + const actionsButton = within(content) + .getAllByRole('button', { name: /actions menu/i })[0]; + act(() => { + fireEvent.click(actionsButton); + }); + expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })) + .not + .toBeInTheDocument(); + const deleteButton = within(content) + .queryByRole('button', { name: /delete/i }); + act(() => { + fireEvent.click(deleteButton); + }); + expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })) + .toBeInTheDocument(); + act(() => { + fireEvent.click(screen.queryByRole('button', { name: /delete/i })); + }); + expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })) + .not + .toBeInTheDocument(); }); }); }); diff --git a/src/discussions/comments/comment/Comment.jsx b/src/discussions/comments/comment/Comment.jsx index 42693386..4601a5e2 100644 --- a/src/discussions/comments/comment/Comment.jsx +++ b/src/discussions/comments/comment/Comment.jsx @@ -4,10 +4,10 @@ import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Button } from '@edx/paragon'; +import { Button, useToggle } from '@edx/paragon'; import { ContentActions } from '../../../data/constants'; -import { AlertBanner } from '../../common'; +import { AlertBanner, DeleteConfirmation } from '../../common'; import CommentIcons from '../comment-icons/CommentIcons'; import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors'; import { editComment, fetchCommentResponses, removeComment } from '../data/thunks'; @@ -27,6 +27,7 @@ function Comment({ const isNested = Boolean(comment.parentId); const inlineReplies = useSelector(selectCommentResponses(comment.id)); const [isEditing, setEditing] = useState(false); + const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isReplying, setReplying] = useState(false); const hasMorePages = useSelector(selectCommentHasMorePages(comment.id)); const currentPage = useSelector(selectCommentCurrentPage(comment.id)); @@ -39,8 +40,7 @@ function Comment({ const actionHandlers = { [ContentActions.EDIT_CONTENT]: () => setEditing(true), [ContentActions.ENDORSE]: () => dispatch(editComment(comment.id, { endorsed: !comment.endorsed })), - // TODO: Add flow to confirm before deleting - [ContentActions.DELETE]: () => dispatch(removeComment(comment.id)), + [ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })), }; const handleLoadMoreComments = () => ( @@ -48,7 +48,17 @@ function Comment({ ); return ( -
+
+ { + dispatch(removeComment(comment.id)); + hideDeleteConfirmation(); + }} + />
diff --git a/src/discussions/comments/comment/Reply.jsx b/src/discussions/comments/comment/Reply.jsx index a2d241a6..a51806cd 100644 --- a/src/discussions/comments/comment/Reply.jsx +++ b/src/discussions/comments/comment/Reply.jsx @@ -5,14 +5,15 @@ import { useDispatch, useSelector } from 'react-redux'; import * as timeago from 'timeago.js'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Avatar } from '@edx/paragon'; +import { Avatar, useToggle } from '@edx/paragon'; import { ContentActions } from '../../../data/constants'; -import ActionsDropdown from '../../common/ActionsDropdown'; -import AlertBanner from '../../common/AlertBanner'; -import AuthorLabel from '../../common/AuthorLabel'; +import { + ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation, +} from '../../common'; import { selectAuthorAvatars } from '../../posts/data/selectors'; import { editComment, removeComment } from '../data/thunks'; +import messages from '../messages'; import CommentEditor from './CommentEditor'; import { commentShape } from './proptypes'; @@ -23,16 +24,26 @@ function Reply({ }) { const dispatch = useDispatch(); const [isEditing, setEditing] = useState(false); + const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const actionHandlers = { [ContentActions.EDIT_CONTENT]: () => setEditing(true), [ContentActions.ENDORSE]: () => dispatch(editComment(reply.id, { endorsed: !reply.endorsed })), - // TODO: Add flow to confirm before deleting - [ContentActions.DELETE]: () => dispatch(removeComment(reply.id)), + [ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })), }; const authorAvatars = useSelector(selectAuthorAvatars(reply.author)); return ( -
+
+ { + dispatch(removeComment(reply.id)); + hideDeleteConfirmation(); + }} + />
diff --git a/src/discussions/comments/data/__factories__/comments.factory.js b/src/discussions/comments/data/__factories__/comments.factory.js index 583bbe09..975663de 100644 --- a/src/discussions/comments/data/__factories__/comments.factory.js +++ b/src/discussions/comments/data/__factories__/comments.factory.js @@ -15,6 +15,7 @@ Factory.define('comment') .attrs({ author: 'edx', author_label: 'Staff', + can_delete: true, created_at: () => (new Date()).toISOString(), updated_at: () => (new Date()).toISOString(), abuse_flagged: false, diff --git a/src/discussions/comments/messages.js b/src/discussions/comments/messages.js index 7d86701e..0bceebbd 100644 --- a/src/discussions/comments/messages.js +++ b/src/discussions/comments/messages.js @@ -115,6 +115,22 @@ const messages = defineMessages({ id: 'discussions.editor.error.empty', defaultMessage: 'Post content cannot be empty.', }, + deleteResponseTitle: { + id: 'discussions.editor.delete.response.title', + defaultMessage: 'Delete response', + }, + deleteResponseDescription: { + id: 'discussions.editor.delete.response.description', + defaultMessage: 'Are you sure you want to permanently delete this response?', + }, + deleteCommentTitle: { + id: 'discussions.editor.delete.comment.title', + defaultMessage: 'Delete comment', + }, + deleteCommentDescription: { + id: 'discussions.editor.delete.comment.description', + defaultMessage: 'Are you sure you want to permanently delete this comment?', + }, }); export default messages; diff --git a/src/discussions/common/DeleteConfirmation.jsx b/src/discussions/common/DeleteConfirmation.jsx new file mode 100644 index 00000000..a9fe8140 --- /dev/null +++ b/src/discussions/common/DeleteConfirmation.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { ActionRow, Button, ModalDialog } from '@edx/paragon'; + +import messages from '../messages'; + +function DeleteConfirmation({ + intl, + isOpen, + title, + description, + onClose, + onDelete, +}) { + return ( + + + + {title} + + + + {description} + + + + + {intl.formatMessage(messages.deleteConfirmationCancel)} + + + + + + ); +} + +DeleteConfirmation.propTypes = { + intl: intlShape.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, +}; + +export default injectIntl(DeleteConfirmation); diff --git a/src/discussions/common/index.js b/src/discussions/common/index.js index f5a142d1..e80c85a7 100644 --- a/src/discussions/common/index.js +++ b/src/discussions/common/index.js @@ -1,3 +1,4 @@ export { default as ActionsDropdown } from './ActionsDropdown'; export { default as AlertBanner } from './AlertBanner'; export { default as AuthorLabel } from './AuthorLabel'; +export { default as DeleteConfirmation } from './DeleteConfirmation'; diff --git a/src/discussions/messages.js b/src/discussions/messages.js index d05e5f71..212b1c4e 100644 --- a/src/discussions/messages.js +++ b/src/discussions/messages.js @@ -66,6 +66,16 @@ const messages = defineMessages({ defaultMessage: 'Unmark as answered', description: 'Action to unmark a comment as answering a post', }, + deleteConfirmationCancel: { + id: 'discussions.delete.confirmation.button.cancel', + defaultMessage: 'Cancel', + description: 'Cancel button shown on delete confirmation dialog', + }, + deleteConfirmationDelete: { + id: 'discussions.delete.confirmation.button.delete', + defaultMessage: 'Delete', + description: 'Delete button shown on delete confirmation dialog', + }, }); export default messages; diff --git a/src/discussions/posts/data/__factories__/threads.factory.js b/src/discussions/posts/data/__factories__/threads.factory.js index ac8a322d..dca8cd92 100644 --- a/src/discussions/posts/data/__factories__/threads.factory.js +++ b/src/discussions/posts/data/__factories__/threads.factory.js @@ -24,6 +24,7 @@ Factory.define('thread') author: 'test_user', author_label: 'Staff', abuse_flagged: false, + can_delete: true, voted: false, vote_count: 1, course_id: 'course-v1:Test+TestX+Test_Course', diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 7514d59d..096eb899 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -5,11 +5,11 @@ import { useDispatch, useSelector } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Hyperlink } from '@edx/paragon'; +import { Hyperlink, useToggle } from '@edx/paragon'; import { ContentActions } from '../../../data/constants'; import { selectTopicContext } from '../../../data/selectors'; -import { AlertBanner } from '../../common'; +import { AlertBanner, DeleteConfirmation } from '../../common'; import { selectTopic } from '../../topics/data/selectors'; import { removeThread, updateExistingThread } from '../data/thunks'; import messages from './messages'; @@ -27,16 +27,30 @@ function Post({ const dispatch = useDispatch(); const topic = useSelector(selectTopic(post.topicId)); const topicContext = useSelector(selectTopicContext(post.topicId)); + const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const actionHandlers = { - [ContentActions.EDIT_CONTENT]: () => history.push({ ...location, pathname: `${location.pathname}/edit` }), - // TODO: Add flow to confirm before deleting - [ContentActions.DELETE]: () => dispatch(removeThread(post.id)), + [ContentActions.EDIT_CONTENT]: () => history.push({ + ...location, + pathname: `${location.pathname}/edit`, + }), + [ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.CLOSE]: () => dispatch(updateExistingThread(post.id, { closed: !post.closed })), [ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })), [ContentActions.REPORT]: () => dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })), }; return ( -
+
+ { + dispatch(removeThread(post.id)); + history.push('.'); + hideDeleteConfirmation(); + }} + />
diff --git a/src/discussions/posts/post/messages.js b/src/discussions/posts/post/messages.js index d12568b7..79c92e14 100644 --- a/src/discussions/posts/post/messages.js +++ b/src/discussions/posts/post/messages.js @@ -62,6 +62,14 @@ const messages = defineMessages({ defaultMessage: 'Everyone', description: 'Cohort visibility indicator for all people', }, + deletePostTitle: { + id: 'discussions.editor.delete.post.title', + defaultMessage: 'Delete post', + }, + deletePostDescription: { + id: 'discussions.editor.delete.post.description', + defaultMessage: 'Are you sure you want to permanently delete this post?', + }, }); export default messages; diff --git a/src/discussions/utils.js b/src/discussions/utils.js index 33f2a5e3..433d925d 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -9,18 +9,6 @@ import { import { ContentActions, Routes, ThreadType } from '../data/constants'; import messages from './messages'; -export function buildIntlSelectionList(options, intl, messagesData) { - return Object.values(options) - .map( - option => ( - { - label: intl.formatMessage(messagesData[option]), - value: option, - } - ), - ); -} - /** * Get HTTP Error status from generic error. * @param error Generic caught error.