feat: delete confirmation dialogs [BD-38] [TNL-9351] (#49)
Adds delete confirmation dialogs for posts, comments and replies.
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
src/discussions/common/DeleteConfirmation.jsx
Normal file
50
src/discussions/common/DeleteConfirmation.jsx
Normal 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);
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user