Merge branch 'master' into INF-628
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
import { useDispatchWithState } from '../../data/hooks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { useIsOnDesktop } from '../data/hooks';
|
||||
import { EmptyPage } from '../empty-posts';
|
||||
import { Post } from '../posts';
|
||||
import { selectThread } from '../posts/data/selectors';
|
||||
import { fetchThread, markThreadAsRead } from '../posts/data/thunks';
|
||||
@@ -133,9 +134,9 @@ DiscussionCommentsView.propTypes = {
|
||||
};
|
||||
|
||||
function CommentsView({ intl }) {
|
||||
const [isLoading, submitDispatch] = useDispatchWithState();
|
||||
const { postId } = useParams();
|
||||
const thread = usePost(postId);
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
@@ -143,8 +144,16 @@ function CommentsView({ intl }) {
|
||||
courseId, learnerUsername, category, topicId, page, inContext,
|
||||
} = useContext(DiscussionContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
|
||||
}, [postId]);
|
||||
|
||||
if (!thread) {
|
||||
dispatch(fetchThread(postId, true));
|
||||
if (!isLoading) {
|
||||
return (
|
||||
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ function renderComponent(postId) {
|
||||
}
|
||||
|
||||
describe('CommentsView', () => {
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
@@ -147,7 +147,7 @@ describe('CommentsView', () => {
|
||||
)];
|
||||
});
|
||||
|
||||
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
mockAxiosReturnPagedComments();
|
||||
mockAxiosReturnPagedCommentsResponses();
|
||||
});
|
||||
@@ -381,6 +381,7 @@ describe('CommentsView', () => {
|
||||
});
|
||||
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
|
||||
});
|
||||
|
||||
it('should allow pinning the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await act(async () => {
|
||||
@@ -394,6 +395,7 @@ describe('CommentsView', () => {
|
||||
});
|
||||
assertLastUpdateData({ pinned: false });
|
||||
});
|
||||
|
||||
it('should allow reporting the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await act(async () => {
|
||||
@@ -405,6 +407,11 @@ describe('CommentsView', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /report/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
assertLastUpdateData({ abuse_flagged: true });
|
||||
});
|
||||
|
||||
@@ -423,12 +430,8 @@ describe('CommentsView', () => {
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['endorsing comments', 'Endorse', { endorsed: true }],
|
||||
['reporting comments', 'Report', { abuse_flagged: true }],
|
||||
])('handles %s', async (label, buttonLabel, patchData) => {
|
||||
it('handles endorsing comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
|
||||
@@ -438,20 +441,45 @@ describe('CommentsView', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(actionButtons[1]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||
});
|
||||
|
||||
it('handles reporting comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
|
||||
// There should be three buttons, one for the post, the second for the
|
||||
// comment and the third for a response to that comment
|
||||
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(actionButtons[1]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('for discussion thread', () => {
|
||||
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||
|
||||
it('shown spinner when post isn\'t loaded', async () => {
|
||||
it('shown post not found when post id does not belong to course', async () => {
|
||||
renderComponent('unloaded-id');
|
||||
expect(await screen.findByTestId('loading-indicator'))
|
||||
expect(await screen.findByText('Thread not found', { exact: true }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -624,12 +652,8 @@ describe('CommentsView', () => {
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['endorsing comments', 'Endorse', { endorsed: true }],
|
||||
['reporting comments', 'Report', { abuse_flagged: true }],
|
||||
])('handles %s', async (label, buttonLabel, patchData) => {
|
||||
it('handles endorsing comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
|
||||
@@ -639,11 +663,36 @@ describe('CommentsView', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(actionButtons[1]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||
});
|
||||
|
||||
it('handles reporting comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
|
||||
// There should be three buttons, one for the post, the second for the
|
||||
// comment and the third for a response to that comment
|
||||
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(actionButtons[1]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -9,7 +9,8 @@ import { Button, useToggle } from '@edx/paragon';
|
||||
|
||||
import HTMLLoader from '../../../components/HTMLLoader';
|
||||
import { ContentActions } from '../../../data/constants';
|
||||
import { AlertBanner, DeleteConfirmation, EndorsedAlertBanner } from '../../common';
|
||||
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../common';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||
import { fetchThread } from '../../posts/data/thunks';
|
||||
import CommentIcons from '../comment-icons/CommentIcons';
|
||||
@@ -34,11 +35,14 @@ function Comment({
|
||||
const inlineReplies = useSelector(selectCommentResponses(comment.id));
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isReplying, setReplying] = useState(false);
|
||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
|
||||
const {
|
||||
courseId,
|
||||
} = useContext(DiscussionContext);
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
if (hasChildren && !currentPage && showFullThread) {
|
||||
@@ -46,14 +50,32 @@ function Comment({
|
||||
}
|
||||
}, [comment.id]);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
if (comment.abuseFlagged) {
|
||||
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(comment.id));
|
||||
hideDeleteConfirmation();
|
||||
};
|
||||
|
||||
const handleReportConfirmation = () => {
|
||||
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: async () => {
|
||||
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
|
||||
await dispatch(fetchThread(comment.threadId));
|
||||
await dispatch(fetchThread(comment.threadId, courseId));
|
||||
},
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
|
||||
const handleLoadMoreComments = () => (
|
||||
@@ -63,16 +85,25 @@ function Comment({
|
||||
return (
|
||||
<div className={classNames({ 'py-2 my-3': showFullThread })}>
|
||||
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
|
||||
<DeleteConfirmation
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||
description={intl.formatMessage(messages.deleteResponseDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeComment(comment.id));
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="tertiary"
|
||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
/>
|
||||
{!comment.abuseFlagged && (
|
||||
<Confirmation
|
||||
isOpen={isReporting}
|
||||
title={intl.formatMessage(messages.reportResponseTitle)}
|
||||
description={intl.formatMessage(messages.reportResponseDescription)}
|
||||
onClose={hideReportConfirmation}
|
||||
comfirmAction={handleReportConfirmation}
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
||||
<div className="d-flex flex-column p-4.5">
|
||||
<AlertBanner content={comment} />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Avatar, useToggle } from '@edx/paragon';
|
||||
import HTMLLoader from '../../../components/HTMLLoader';
|
||||
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/constants';
|
||||
import {
|
||||
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
|
||||
ActionsDropdown, AlertBanner, AuthorLabel, Confirmation,
|
||||
} from '../../common';
|
||||
import timeLocale from '../../common/time-locale';
|
||||
import { useAlertBannerVisible } from '../../data/hooks';
|
||||
@@ -29,6 +29,26 @@ function Reply({
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
if (reply.abuseFlagged) {
|
||||
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(reply.id));
|
||||
hideDeleteConfirmation();
|
||||
};
|
||||
|
||||
const handleReportConfirmation = () => {
|
||||
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: () => dispatch(editComment(
|
||||
@@ -37,7 +57,7 @@ function Reply({
|
||||
ContentActions.ENDORSE,
|
||||
)),
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
|
||||
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
|
||||
@@ -45,17 +65,25 @@ function Reply({
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
|
||||
<DeleteConfirmation
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||
description={intl.formatMessage(messages.deleteCommentDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeComment(reply.id));
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="tertiary"
|
||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
/>
|
||||
|
||||
{!reply.abuseFlagged && (
|
||||
<Confirmation
|
||||
isOpen={isReporting}
|
||||
title={intl.formatMessage(messages.reportCommentTitle)}
|
||||
description={intl.formatMessage(messages.reportCommentDescription)}
|
||||
onClose={hideReportConfirmation}
|
||||
comfirmAction={handleReportConfirmation}
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
{hasAnyAlert && (
|
||||
<div className="d-flex">
|
||||
<div className="d-flex invisible">
|
||||
|
||||
@@ -148,6 +148,31 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Are you sure you want to permanently delete this comment?',
|
||||
description: 'Text displayed in confirmation dialog when deleting a comment',
|
||||
},
|
||||
deleteConfirmationDelete: {
|
||||
id: 'discussions.delete.confirmation.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Delete button shown on delete confirmation dialog',
|
||||
},
|
||||
reportResponseTitle: {
|
||||
id: 'discussions.editor.response.response.title',
|
||||
defaultMessage: 'Report inappropriate content?',
|
||||
description: 'Title of confirmation dialog shown when reporting a response',
|
||||
},
|
||||
reportResponseDescription: {
|
||||
id: 'discussions.editor.response.description',
|
||||
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
|
||||
description: 'Text displayed in confirmation dialog when deleting a response',
|
||||
},
|
||||
reportCommentTitle: {
|
||||
id: 'discussions.editor.report.comment.title',
|
||||
defaultMessage: 'Report inappropriate content?',
|
||||
description: 'Title of confirmation dialog shown when reporting a comment',
|
||||
},
|
||||
reportCommentDescription: {
|
||||
id: 'discussions.editor.report.comment.description',
|
||||
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
|
||||
description: 'Text displayed in confirmation dialog when deleting a response',
|
||||
},
|
||||
editReasonCode: {
|
||||
id: 'discussions.editor.comments.editReasonCode',
|
||||
defaultMessage: 'Reason for editing',
|
||||
@@ -182,6 +207,11 @@ const messages = defineMessages({
|
||||
defaultMessage: '{time} ago',
|
||||
description: 'Time text for endorse banner',
|
||||
},
|
||||
noThreadFound: {
|
||||
id: 'discussion.thread.notFound',
|
||||
defaultMessage: 'Thread not found',
|
||||
description: 'message to show on screen if the request thread is not found in course',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -64,7 +64,7 @@ function ActionsDropdown({
|
||||
>
|
||||
{actions.map(action => (
|
||||
<React.Fragment key={action.id}>
|
||||
{action.action === ContentActions.DELETE
|
||||
{(action.action === ContentActions.DELETE)
|
||||
&& <Dropdown.Divider />}
|
||||
|
||||
<Dropdown.Item
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSelector } from 'react-redux';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import { Report } from '@edx/paragon/icons';
|
||||
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import messages from '../comments/messages';
|
||||
@@ -28,7 +28,7 @@ function AlertBanner({
|
||||
return (
|
||||
<>
|
||||
{canSeeReportedBanner && (
|
||||
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
||||
<Alert icon={Report} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
||||
{intl.formatMessage(messages.abuseFlaggedMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -6,13 +6,16 @@ import { ActionRow, Button, ModalDialog } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function DeleteConfirmation({
|
||||
function Confirmation({
|
||||
intl,
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
onDelete,
|
||||
comfirmAction,
|
||||
closeButtonVaraint,
|
||||
confirmButtonVariant,
|
||||
confirmButtonText,
|
||||
}) {
|
||||
return (
|
||||
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
||||
@@ -26,11 +29,11 @@ function DeleteConfirmation({
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.deleteConfirmationCancel)}
|
||||
<ModalDialog.CloseButton variant={closeButtonVaraint}>
|
||||
{intl.formatMessage(messages.confirmationCancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button variant="primary" onClick={onDelete}>
|
||||
{intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
<Button variant={confirmButtonVariant} onClick={comfirmAction}>
|
||||
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
@@ -38,13 +41,22 @@ function DeleteConfirmation({
|
||||
);
|
||||
}
|
||||
|
||||
DeleteConfirmation.propTypes = {
|
||||
Confirmation.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
comfirmAction: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
closeButtonVaraint: PropTypes.string,
|
||||
confirmButtonVariant: PropTypes.string,
|
||||
confirmButtonText: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(DeleteConfirmation);
|
||||
Confirmation.defaultProps = {
|
||||
closeButtonVaraint: 'default',
|
||||
confirmButtonVariant: 'primary',
|
||||
confirmButtonText: '',
|
||||
};
|
||||
|
||||
export default injectIntl(Confirmation);
|
||||
@@ -1,5 +1,5 @@
|
||||
export { default as ActionsDropdown } from './ActionsDropdown';
|
||||
export { default as AlertBanner } from './AlertBanner';
|
||||
export { default as AuthorLabel } from './AuthorLabel';
|
||||
export { default as DeleteConfirmation } from './DeleteConfirmation';
|
||||
export { default as Confirmation } from './Confirmation';
|
||||
export { default as EndorsedAlertBanner } from './EndorsedAlertBanner';
|
||||
|
||||
@@ -31,6 +31,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Action to delete a post or comment',
|
||||
},
|
||||
confirmationConfirm: {
|
||||
id: 'discussions.confirmation.button.confirm',
|
||||
defaultMessage: 'Confirm',
|
||||
description: 'Confirm button shown on confirmation dialog',
|
||||
},
|
||||
closeAction: {
|
||||
id: 'discussions.actions.close',
|
||||
defaultMessage: 'Close',
|
||||
@@ -71,16 +76,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Unmark as answered',
|
||||
description: 'Action to unmark a comment as answering a post',
|
||||
},
|
||||
deleteConfirmationCancel: {
|
||||
id: 'discussions.delete.confirmation.button.cancel',
|
||||
confirmationCancel: {
|
||||
id: 'discussions.modal.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',
|
||||
},
|
||||
emptyAllTopics: {
|
||||
id: 'discussions.empty.allTopics',
|
||||
defaultMessage:
|
||||
|
||||
@@ -71,8 +71,8 @@ export async function getThreads(
|
||||
* @param {string} threadId
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getThread(threadId) {
|
||||
const params = { requested_fields: 'profile_image' };
|
||||
export async function getThread(threadId, courseId) {
|
||||
const params = { requested_fields: 'profile_image', course_id: courseId };
|
||||
const url = `${threadsApiUrl}${threadId}/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url, { params });
|
||||
return data;
|
||||
|
||||
@@ -154,18 +154,18 @@ export function fetchThreads(courseId, {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchThread(threadId, isDirectLinkPost = false) {
|
||||
export function fetchThread(threadId, courseId, isDirectLinkPost = false) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchThreadRequest({ threadId }));
|
||||
const data = await getThread(threadId);
|
||||
const data = await getThread(threadId, courseId);
|
||||
if (isDirectLinkPost) {
|
||||
dispatch(fetchThreadByDirectLinkSuccess({ ...normaliseThreads(camelCaseObject(data)), page: 1 }));
|
||||
} else {
|
||||
dispatch(fetchThreadSuccess(normaliseThreads(camelCaseObject(data))));
|
||||
}
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
if (getHttpErrorStatus(error) === 403 || getHttpErrorStatus(error) === 404) {
|
||||
dispatch(fetchThreadDenied());
|
||||
} else {
|
||||
dispatch(fetchThreadFailed());
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from '../../data/selectors';
|
||||
import { EmptyPage } from '../../empty-posts';
|
||||
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
|
||||
import {
|
||||
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
|
||||
@@ -193,16 +194,25 @@ function PostEditor({
|
||||
dispatch(fetchCourseCohorts(courseId));
|
||||
}
|
||||
if (editExisting) {
|
||||
dispatch(fetchThread(postId));
|
||||
dispatchSubmit(fetchThread(postId, courseId));
|
||||
}
|
||||
}, [courseId, editExisting]);
|
||||
|
||||
if (editExisting && !post) {
|
||||
return (
|
||||
<div className="m-4 card p-4 align-items-center">
|
||||
<Spinner animation="border" variant="primary" />
|
||||
</div>
|
||||
);
|
||||
if (submitting) {
|
||||
return (
|
||||
<div className="m-4 card p-4 align-items-center">
|
||||
<Spinner animation="border" variant="primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!submitting) {
|
||||
return (
|
||||
<EmptyPage
|
||||
title={intl.formatMessage(messages.noThreadFound)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
|
||||
@@ -131,6 +131,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Unnamed subcategory',
|
||||
description: 'display string for topics with missing names',
|
||||
},
|
||||
noThreadFound: {
|
||||
id: 'discussion.thread.notFound',
|
||||
defaultMessage: 'Thread not found',
|
||||
description: 'message to show on screen if the request thread is not found in course',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
@@ -10,7 +11,8 @@ import { Hyperlink, useToggle } from '@edx/paragon';
|
||||
import HTMLLoader from '../../../components/HTMLLoader';
|
||||
import { ContentActions } from '../../../data/constants';
|
||||
import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors';
|
||||
import { AlertBanner, DeleteConfirmation } from '../../common';
|
||||
import { AlertBanner, Confirmation } from '../../common';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectModerationSettings } from '../../data/selectors';
|
||||
import { selectTopic } from '../../topics/data/selectors';
|
||||
import { removeThread, updateExistingThread } from '../data/thunks';
|
||||
@@ -34,7 +36,29 @@ function Post({
|
||||
const topicContext = useSelector(selectTopicContext(post.topicId));
|
||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
if (post.abuseFlagged) {
|
||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeThread(post.id));
|
||||
history.push('.');
|
||||
hideDeleteConfirmation();
|
||||
};
|
||||
|
||||
const handleReportConfirmation = () => {
|
||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const { inContext } = useContext(DiscussionContext);
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => history.push({
|
||||
...location,
|
||||
@@ -52,7 +76,7 @@ function Post({
|
||||
},
|
||||
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(`${window.location.origin}/${courseId}/posts/${post.id}`); },
|
||||
[ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })),
|
||||
[ContentActions.REPORT]: () => dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })),
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
|
||||
const getTopicCategoryName = topicData => (
|
||||
@@ -61,30 +85,50 @@ function Post({
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column w-100 mw-100" data-testid={`post-${post.id}`}>
|
||||
<DeleteConfirmation
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deletePostTitle)}
|
||||
description={intl.formatMessage(messages.deletePostDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeThread(post.id));
|
||||
history.push('.');
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="tertiary"
|
||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
/>
|
||||
{!post.abuseFlagged && (
|
||||
<Confirmation
|
||||
isOpen={isReporting}
|
||||
title={intl.formatMessage(messages.reportPostTitle)}
|
||||
description={intl.formatMessage(messages.reportPostDescription)}
|
||||
onClose={hideReportConfirmation}
|
||||
comfirmAction={handleReportConfirmation}
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
<AlertBanner content={post} />
|
||||
<PostHeader post={post} actionHandlers={actionHandlers} />
|
||||
<div className="d-flex mt-4 mb-2 text-break font-style-normal text-primary-500">
|
||||
<HTMLLoader htmlNode={post.renderedBody} id="post" />
|
||||
</div>
|
||||
{topicContext && topic && (
|
||||
<div className="border px-3 rounded mb-4 border-light-400 align-self-start py-2.5">
|
||||
<div className={classNames('border px-3 rounded mb-4 border-light-400 align-self-start py-2.5',
|
||||
{ 'w-100': inContext })}
|
||||
>
|
||||
<span className="text-gray-500">{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
||||
<Hyperlink
|
||||
destination={topicContext.unitLink}
|
||||
target="_top"
|
||||
>
|
||||
{`${getTopicCategoryName(topic)} / ${topic.name}`}
|
||||
{inContext
|
||||
? (
|
||||
<>
|
||||
<span className="w-auto">{topicContext.chapterName}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="w-auto">{topicContext.verticalName}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="w-auto">{topicContext.unitName}</span>
|
||||
</>
|
||||
)
|
||||
: `${getTopicCategoryName(topic)} / ${topic.name}`}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,6 +71,21 @@ const messages = defineMessages({
|
||||
id: 'discussions.editor.delete.post.description',
|
||||
defaultMessage: 'Are you sure you want to permanently delete this post?',
|
||||
},
|
||||
deleteConfirmationDelete: {
|
||||
id: 'discussions.post.delete.confirmation.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Delete button shown on delete confirmation dialog',
|
||||
},
|
||||
reportPostTitle: {
|
||||
id: 'discussions.editor.report.post.title',
|
||||
defaultMessage: 'Report inappropriate content?',
|
||||
description: 'Title of confirmation dialog shown when reporting a post',
|
||||
},
|
||||
reportPostDescription: {
|
||||
id: 'discussions.editor.report.post.description',
|
||||
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
|
||||
description: 'Text displayed in confirmation dialog when deleting a post',
|
||||
},
|
||||
closePostModalTitle: {
|
||||
id: 'discussions.post.closePostModal.title',
|
||||
defaultMessage: 'Close post',
|
||||
|
||||
@@ -242,11 +242,11 @@ header {
|
||||
}
|
||||
|
||||
.zindex-5000 {
|
||||
z-index: 5000;
|
||||
z-index: 5000;
|
||||
}
|
||||
|
||||
#paragon-portal-root .pgn__modal-layer {
|
||||
z-index: 1500 !important;
|
||||
z-index: 5000 !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 767px) {
|
||||
|
||||
Reference in New Issue
Block a user