feat: delete confirmation dialogs [BD-38] [TNL-9351] (#49)

Adds delete confirmation dialogs for posts, comments and replies.
This commit is contained in:
Kshitij Sobti
2022-01-27 07:18:43 +00:00
committed by GitHub
parent f210379a39
commit 9ba94966f1
13 changed files with 190 additions and 34 deletions

View File

@@ -117,7 +117,7 @@ function CommentsView({ intl }) {
const thread = usePost(postId);
if (!thread) {
return (
<Spinner animation="border" variant="primary" />
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
);
}
return (

View File

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

View File

@@ -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 (
<div className="discussion-comment d-flex flex-column card my-3">
<div className="discussion-comment d-flex flex-column card my-3" data-testid={`comment-${comment.id}`}>
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteResponseTitle)}
description={intl.formatMessage(messages.deleteResponseDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeComment(comment.id));
hideDeleteConfirmation();
}}
/>
<AlertBanner postType={postType} content={comment} intl={intl} />
<div className="d-flex flex-column p-4">
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />

View File

@@ -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 (
<div className="d-flex my-2 flex-column">
<div className="d-flex my-2 flex-column" data-testid={`reply-${reply.id}`}>
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteCommentTitle)}
description={intl.formatMessage(messages.deleteCommentDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeComment(reply.id));
hideDeleteConfirmation();
}}
/>
<div className="d-flex flex-fill ml-6">
<AlertBanner postType={null} content={reply} intl={intl} />
</div>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 (
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose}>
<ModalDialog.Header>
<ModalDialog.Title>
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{description}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.deleteConfirmationCancel)}
</ModalDialog.CloseButton>
<Button variant="primary" onClick={onDelete}>
{intl.formatMessage(messages.deleteConfirmationDelete)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
}
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);

View File

@@ -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';

View File

@@ -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;

View File

@@ -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',

View File

@@ -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 (
<div className="d-flex flex-column p-2.5 w-100 mw-100">
<div className="d-flex flex-column p-2.5 w-100 mw-100" data-testid={`post-${post.id}`}>
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deletePostTitle)}
description={intl.formatMessage(messages.deletePostDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeThread(post.id));
history.push('.');
hideDeleteConfirmation();
}}
/>
<div className="mb-4">
<AlertBanner postType={post.type} content={post} />
</div>

View File

@@ -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;

View File

@@ -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.