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.