From b5256de8d0e7ed4ec8ec0fae60b97c8b1c6d7827 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Mon, 29 Dec 2025 10:31:02 +0000 Subject: [PATCH 1/2] feat: added soft delete functionality --- src/assets/undelete.svg | 3 + src/components/FilterBar.jsx | 45 ++++- src/components/SpamWarningBanner.jsx | 52 +++--- src/data/constants.js | 6 + src/discussions/common/ActionsDropdown.jsx | 5 +- src/discussions/common/AuthorLabel.jsx | 4 +- src/discussions/common/AuthorLabel.test.jsx | 61 +++++-- .../common/AutoSpamAlertBanner.jsx | 82 +++++++++ src/discussions/common/HoverCard.jsx | 16 +- src/discussions/common/index.js | 1 + src/discussions/data/constants.js | 5 + src/discussions/data/selectors.js | 2 +- .../learners/LearnerActionsDropdown.jsx | 117 ++++++++---- .../learners/LearnerActionsDropdown.test.jsx | 23 ++- src/discussions/learners/LearnerPostsView.jsx | 67 ++++++- .../learners/LearnerPostsView.test.jsx | 24 +++ src/discussions/learners/data/api.js | 57 ++++++ src/discussions/learners/data/redux.test.jsx | 7 +- src/discussions/learners/data/slices.js | 30 +++- src/discussions/learners/data/thunks.js | 123 ++++++++----- .../LearnerPostFilterBar.jsx | 47 +++-- .../LearnerPostFilterBar.test.jsx | 17 +- .../learners/learner/LearnerCard.jsx | 5 + .../learners/learner/LearnerFilterBar.jsx | 11 +- .../learners/learner/LearnerFooter.jsx | 42 ++++- src/discussions/learners/learner/proptypes.js | 4 + src/discussions/learners/messages.js | 60 +++++++ src/discussions/learners/utils.js | 59 ++++++ src/discussions/messages.js | 80 +++++++++ .../post-comments/PostCommentsView.test.jsx | 1 + .../comments/comment/Comment.jsx | 94 +++++++++- .../comments/comment/CommentHeader.jsx | 2 +- .../post-comments/comments/comment/Reply.jsx | 80 ++++++++- .../data/__factories__/comments.factory.js | 1 + src/discussions/post-comments/data/api.js | 20 +++ src/discussions/post-comments/data/hooks.js | 17 +- src/discussions/post-comments/data/thunks.js | 20 ++- src/discussions/post-comments/messages.js | 34 ++++ src/discussions/posts/NoResults.jsx | 4 +- src/discussions/posts/PostsView.test.jsx | 4 +- .../data/__factories__/threads.factory.js | 1 + src/discussions/posts/data/api.js | 18 ++ src/discussions/posts/data/selectors.js | 2 + src/discussions/posts/data/slices.js | 19 +- src/discussions/posts/data/thunks.js | 19 ++ src/discussions/posts/index.js | 1 + .../posts/post-filter-bar/messages.js | 12 ++ src/discussions/posts/post/Post.jsx | 65 ++++++- src/discussions/posts/post/PostLink.jsx | 66 ++++++- src/discussions/posts/post/messages.js | 32 ++++ src/discussions/utils.js | 22 ++- src/index.scss | 169 +++++++++++++++++- 52 files changed, 1524 insertions(+), 234 deletions(-) create mode 100644 src/assets/undelete.svg create mode 100644 src/discussions/common/AutoSpamAlertBanner.jsx diff --git a/src/assets/undelete.svg b/src/assets/undelete.svg new file mode 100644 index 000000000..fa787312e --- /dev/null +++ b/src/assets/undelete.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/FilterBar.jsx b/src/components/FilterBar.jsx index da5b9427c..b7c50f6ee 100644 --- a/src/components/FilterBar.jsx +++ b/src/components/FilterBar.jsx @@ -75,6 +75,16 @@ const FilterBar = ({ label: intl.formatMessage(messages.filterUnresponded), value: PostsStatusFilter.UNRESPONDED, }, + { + id: 'status-active', + label: intl.formatMessage(messages.filterActive), + value: PostsStatusFilter.ACTIVE, + }, + { + id: 'status-deleted', + label: intl.formatMessage(messages.filterDeleted), + value: PostsStatusFilter.DELETED, + }, { id: 'sort-activity', label: intl.formatMessage(messages.lastActivityAt), @@ -124,7 +134,7 @@ const FilterBar = ({
- {filters.map((value) => ( + {filters.filter(f => !f.hasSeparator).map((value) => ( ))}
+ {filters.some(f => f.hasSeparator) && ( + <> +
+
+ {filters.filter(f => f.hasSeparator).map((value) => ( + + {value.filters.map(filterName => { + const element = allFilters.find(obj => obj.id === filterName); + if (element) { + return ( + + ); + } + return false; + })} + + ))} +
+ + )} {showCohortsFilter && ( <>
@@ -199,6 +241,7 @@ FilterBar.propTypes = { selectedFilters: PropTypes.shape({ postType: ThreadType, status: PostsStatusFilter, + contentStatus: PostsStatusFilter, orderBy: ThreadOrdering, cohort: PropTypes.string, }).isRequired, diff --git a/src/components/SpamWarningBanner.jsx b/src/components/SpamWarningBanner.jsx index aa1344da3..af19546de 100644 --- a/src/components/SpamWarningBanner.jsx +++ b/src/components/SpamWarningBanner.jsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { PageBanner } from '@openedx/paragon'; +import { Icon, PageBanner } from '@openedx/paragon'; +import { Warning } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -42,31 +43,30 @@ const SpamWarningBanner = ({ className = '' }) => { dismissible={false} className={`spam-warning-banner ${className}`} > -
- - {intl.formatMessage(messages.spamWarningHeading)}:{' '} - {(() => { - const msg = intl.formatMessage(messages.spamWarningMessage); - const boldText = 'never invite you to join external groups or ask for personal or financial information'; - const idx = msg.indexOf(boldText); - if (idx === -1) { - return msg; - } - return ( - <> - {msg.slice(0, idx)} - {boldText} - {msg.slice(idx + boldText.length)} - - ); - })()} +
+ + + + {intl.formatMessage(messages.spamWarningHeading)}:{' '} + {(() => { + const msg = intl.formatMessage(messages.spamWarningMessage); + const boldText = 'never invite you to join external groups or ask for personal or financial information'; + const idx = msg.indexOf(boldText); + if (idx === -1) { + return msg; + } + return ( + <> + {msg.slice(0, idx)} + {boldText} + {msg.slice(idx + boldText.length)} + + ); + })()} +
) : ( @@ -215,7 +215,7 @@ const AuthorLabel = ({ {authorName} {labelContents}
- {learnerMessageComponent} + {postOrComment && learnerMessageComponent}
); diff --git a/src/discussions/common/AuthorLabel.test.jsx b/src/discussions/common/AuthorLabel.test.jsx index 4ecf54206..50d5268b2 100644 --- a/src/discussions/common/AuthorLabel.test.jsx +++ b/src/discussions/common/AuthorLabel.test.jsx @@ -21,7 +21,15 @@ let store; let axiosMock; let container; -function renderComponent(author, authorLabel, linkToProfile, labelColor, enableInContextSidebar, postData = null) { +function renderComponent( + author, + authorLabel, + linkToProfile, + labelColor, + enableInContextSidebar, + postData = null, + postOrComment = false, +) { const wrapper = render( @@ -32,6 +40,7 @@ function renderComponent(author, authorLabel, linkToProfile, labelColor, enableI linkToProfile={linkToProfile} labelColor={labelColor} postData={postData} + postOrComment={postOrComment} /> @@ -125,31 +134,31 @@ describe('Author label', () => { describe('with new learner_status API field', () => { it('should display new learner message when backend provides learner_status="new"', () => { const postData = { learner_status: 'new' }; - renderComponent('testuser', null, false, '', false, postData); + renderComponent('testuser', null, false, '', false, postData, true); expect(screen.getByText('👋 Hi, I am a new learner')).toBeInTheDocument(); }); it('should not display new learner message when backend provides learner_status="regular"', () => { const postData = { learner_status: 'regular' }; - renderComponent('testuser', null, false, '', false, postData); + renderComponent('testuser', null, false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should not display new learner message when backend provides learner_status="staff"', () => { const postData = { learner_status: 'staff' }; - renderComponent('testuser', null, false, '', false, postData); + renderComponent('testuser', null, false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should not display new learner message when backend provides learner_status="anonymous"', () => { const postData = { learner_status: 'anonymous' }; - renderComponent('testuser', null, false, '', false, postData); + renderComponent('testuser', null, false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should not display new learner message for staff users even if backend says learner_status="new"', () => { const postData = { learner_status: 'new' }; - renderComponent('testuser', 'Staff', false, '', false, postData); + renderComponent('testuser', 'Staff', false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); }); @@ -157,64 +166,84 @@ describe('Author label', () => { describe('with legacy boolean API fields (backward compatibility)', () => { it('should display new learner message when backend provides is_new_learner=true', () => { const postData = { is_new_learner: true, is_regular_learner: false }; - renderComponent('testuser', null, false, '', false, postData); + renderComponent('testuser', null, false, '', false, postData, true); expect(screen.getByText('👋 Hi, I am a new learner')).toBeInTheDocument(); }); it('should not display new learner message when backend provides is_new_learner=false', () => { const postData = { is_new_learner: false, is_regular_learner: false }; - renderComponent('testuser', null, false, '', false, postData); + renderComponent('testuser', null, false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should not display new learner message for staff users even if backend says new learner', () => { const postData = { is_new_learner: true, is_regular_learner: false }; - renderComponent('testuser', 'Staff', false, '', false, postData); + renderComponent('testuser', 'Staff', false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should not display new learner message for moderators', () => { const postData = { is_new_learner: true, is_regular_learner: false }; - renderComponent('testuser', 'Moderator', false, '', false, postData); + renderComponent('testuser', 'Moderator', false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should not display new learner message for Community TAs', () => { const postData = { is_new_learner: true, is_regular_learner: false }; - renderComponent('testuser', 'Community TA', false, '', false, postData); + renderComponent('testuser', 'Community TA', false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should not display new learner message for anonymous users', () => { const postData = { is_new_learner: true, is_regular_learner: false }; - renderComponent('anonymous', null, false, '', false, postData); + renderComponent('anonymous', null, false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should not display new learner message for retired users', () => { const postData = { is_new_learner: true, is_regular_learner: false }; - renderComponent('retired__user_123', null, false, '', false, postData); + renderComponent('retired__user_123', null, false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should prioritize new learner_status field over legacy boolean fields', () => { // If both are present, learner_status should take precedence const postData = { learner_status: 'regular', is_new_learner: true }; - renderComponent('testuser', null, false, '', false, postData); + renderComponent('testuser', null, false, '', false, postData, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); }); describe('general cases', () => { it('should not display new learner message when postData is not provided', () => { - renderComponent('testuser', null, false, '', false, null); + renderComponent('testuser', null, false, '', false, null, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should not display new learner message when postData is empty object', () => { - renderComponent('testuser', null, false, '', false, {}); + renderComponent('testuser', null, false, '', false, {}, true); expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); }); + + describe('sidebar behavior', () => { + it('should not display learner messages in sidebar (postOrComment=false)', () => { + const postData = { learner_status: 'new' }; + renderComponent('testuser', null, false, '', false, postData, false); + expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); + }); + + it('should not display regular learner message in sidebar', () => { + const postData = { learner_status: 'regular' }; + renderComponent('testuser', null, false, '', false, postData, false); + expect(screen.queryByText('Learner')).not.toBeInTheDocument(); + }); + + it('should display learner messages in post view (postOrComment=true)', () => { + const postData = { learner_status: 'new' }; + renderComponent('testuser', null, false, '', false, postData, true); + expect(screen.getByText('👋 Hi, I am a new learner')).toBeInTheDocument(); + }); + }); }); }); diff --git a/src/discussions/common/AutoSpamAlertBanner.jsx b/src/discussions/common/AutoSpamAlertBanner.jsx new file mode 100644 index 000000000..5e51b8637 --- /dev/null +++ b/src/discussions/common/AutoSpamAlertBanner.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + ActionRow, + Alert, + Button, + Icon, + ModalDialog, + useToggle, +} from '@openedx/paragon'; +import { + HelpOutline, + Report, +} from '@openedx/paragon/icons'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; + +const AutoSpamAlertBanner = ({ autoSpamFlagged }) => { + const intl = useIntl(); + const [isModalOpen, showModal, hideModal] = useToggle(false); + + if (!autoSpamFlagged) { + return null; + } + + return ( + <> + +
+ {intl.formatMessage(messages.autoSpamFlaggedMessage)} + +
+
+ + + + + {intl.formatMessage(messages.autoSpamModalTitle)} + + + +

+ {intl.formatMessage(messages.autoSpamModalBodyParagraph1)} +

+

+ {intl.formatMessage(messages.autoSpamModalBodyParagraph2)} +

+
+ + + + {intl.formatMessage(messages.autoSpamModalClose)} + + + +
+ + ); +}; + +AutoSpamAlertBanner.propTypes = { + autoSpamFlagged: PropTypes.bool, +}; + +AutoSpamAlertBanner.defaultProps = { + autoSpamFlagged: false, +}; + +export default React.memo(AutoSpamAlertBanner); diff --git a/src/discussions/common/HoverCard.jsx b/src/discussions/common/HoverCard.jsx index bfb37b537..55e673115 100644 --- a/src/discussions/common/HoverCard.jsx +++ b/src/discussions/common/HoverCard.jsx @@ -29,6 +29,7 @@ const HoverCard = ({ voted, following, endorseIcons, + isDeleted, }) => { const intl = useIntl(); const { enableInContextSidebar } = useContext(DiscussionContext); @@ -50,9 +51,9 @@ const HoverCard = ({ 'px-2.5 py-2 border-0 font-style text-gray-700', { 'w-100': enableInContextSidebar }, )} - onClick={() => handleResponseCommentButton()} - disabled={isClosed} - style={{ lineHeight: '20px' }} + onClick={handleResponseCommentButton} + disabled={isClosed || isDeleted} + style={{ lineHeight: '20px', ...(isDeleted ? { opacity: 0.3, cursor: 'not-allowed' } : {}) }} > {addResponseCommentButtonMessage} @@ -78,6 +79,8 @@ const HoverCard = ({ className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'} size="sm" alt="Endorse" + disabled={isDeleted} + style={isDeleted ? { opacity: 0.3, cursor: 'not-allowed' } : {}} /> @@ -95,8 +98,9 @@ const HoverCard = ({ iconAs={Icon} size="sm" alt="Like" - disabled={!userHasLikePermission} + disabled={!userHasLikePermission || isDeleted} iconClassNames="like-icon-dimensions" + style={isDeleted ? { opacity: 0.3, cursor: 'not-allowed' } : {}} onClick={(e) => { e.preventDefault(); onLike(); @@ -119,6 +123,8 @@ const HoverCard = ({ size="sm" alt="Follow" iconClassNames="follow-icon-dimensions" + disabled={isDeleted} + style={isDeleted ? { opacity: 0.3, cursor: 'not-allowed' } : {}} onClick={(e) => { e.preventDefault(); onFollow(); @@ -165,12 +171,14 @@ HoverCard.propTypes = { )), onFollow: PropTypes.func, following: PropTypes.bool, + isDeleted: PropTypes.bool, }; HoverCard.defaultProps = { onFollow: () => null, endorseIcons: null, following: undefined, + isDeleted: false, }; export default React.memo(HoverCard); diff --git a/src/discussions/common/index.js b/src/discussions/common/index.js index f4ca72139..ffc87a2b9 100644 --- a/src/discussions/common/index.js +++ b/src/discussions/common/index.js @@ -1,5 +1,6 @@ export { default as ActionsDropdown } from './ActionsDropdown'; export { default as AlertBanner } from './AlertBanner'; export { default as AuthorLabel } from './AuthorLabel'; +export { default as AutoSpamAlertBanner } from './AutoSpamAlertBanner'; export { default as Confirmation } from './Confirmation'; export { default as EndorsedAlertBanner } from './EndorsedAlertBanner'; diff --git a/src/discussions/data/constants.js b/src/discussions/data/constants.js index d8f434f36..e57eeda42 100644 --- a/src/discussions/data/constants.js +++ b/src/discussions/data/constants.js @@ -10,3 +10,8 @@ export const ContentTypes = { POST: 'POST', COMMENT: 'COMMENT', }; + +export const THREAD_FILTER_TYPES = { + ACTIVE: 'active', + DELETED: 'deleted', +}; diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js index d9f102a71..1ae6a50ad 100644 --- a/src/discussions/data/selectors.js +++ b/src/discussions/data/selectors.js @@ -67,7 +67,7 @@ export function selectAreThreadsFiltered(state) { } return !( - filters.status === PostsStatusFilter.ALL + (filters.status === PostsStatusFilter.ALL || filters.status === PostsStatusFilter.ACTIVE) && filters.postType === ThreadType.ALL ); } diff --git a/src/discussions/learners/LearnerActionsDropdown.jsx b/src/discussions/learners/LearnerActionsDropdown.jsx index 9571ceefa..77eb916b4 100644 --- a/src/discussions/learners/LearnerActionsDropdown.jsx +++ b/src/discussions/learners/LearnerActionsDropdown.jsx @@ -1,16 +1,17 @@ import React, { - useCallback, useRef, useState, + useCallback, useEffect, useRef, useState, } from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import { Button, Dropdown, Icon, IconButton, ModalPopup, useToggle, } from '@openedx/paragon'; -import { MoreHoriz } from '@openedx/paragon/icons'; +import { ChevronRight, MoreHoriz } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useLearnerActions } from './utils'; +import { useLearnerActionsMenu } from './utils'; const LearnerActionsDropdown = ({ actionHandlers, @@ -21,14 +22,16 @@ const LearnerActionsDropdown = ({ const intl = useIntl(); const [isOpen, open, close] = useToggle(false); const [target, setTarget] = useState(null); - const actions = useLearnerActions(userHasBulkDeletePrivileges); + const [activeSubmenu, setActiveSubmenu] = useState(null); + const menuItems = useLearnerActionsMenu(intl, userHasBulkDeletePrivileges); const handleActions = useCallback((action) => { const actionFunction = actionHandlers[action]; if (actionFunction) { actionFunction(); + close(); } - }, [actionHandlers]); + }, [actionHandlers, close]); const onClickButton = useCallback((event) => { event.preventDefault(); @@ -39,8 +42,18 @@ const LearnerActionsDropdown = ({ const onCloseModal = useCallback(() => { close(); setTarget(null); + setActiveSubmenu(null); }, [close]); + // Cleanup portal on unmount to prevent memory leaks + useEffect(() => () => { + if (isOpen) { + close(); + setTarget(null); + setActiveSubmenu(null); + } + }, []); + return ( <>
- -
- {actions.map(action => ( - - { - close(); - handleActions(action.action); - }} - className="d-flex justify-content-start actions-dropdown-item" - data-testId={action.id} +
+ {menuItems.map(item => ( +
setActiveSubmenu(item.id)} + onMouseLeave={() => setActiveSubmenu(null)} + style={{ zIndex: 2 }} > - - - {action.label.defaultMessage} - - - - ))} -
- + +
+ + {item.label} + +
+ +
+ {activeSubmenu === item.id && ( +
+ {item.submenu.map(subItem => ( + handleActions(subItem.action)} + className="d-flex justify-content-start actions-dropdown-item" + data-testid={subItem.id} + > + + {subItem.label} + + + ))} +
+ )} +
+ ))} +
+
, + document.body, + )}
); diff --git a/src/discussions/learners/LearnerActionsDropdown.test.jsx b/src/discussions/learners/LearnerActionsDropdown.test.jsx index 65b7ab0f9..466276fb7 100644 --- a/src/discussions/learners/LearnerActionsDropdown.test.jsx +++ b/src/discussions/learners/LearnerActionsDropdown.test.jsx @@ -79,7 +79,10 @@ describe('LearnerActionsDropdown', () => { const mockHandler = jest.fn(); renderComponent({ userHasBulkDeletePrivileges: true, - actionHandlers: { deleteCoursePosts: mockHandler, deleteOrgPosts: mockHandler }, + actionHandlers: { + [ContentActions.DELETE_COURSE_POSTS]: mockHandler, + [ContentActions.DELETE_ORG_POSTS]: mockHandler, + }, }); const openButton = await findOpenActionsDropdownButton(); @@ -87,6 +90,12 @@ describe('LearnerActionsDropdown', () => { fireEvent.click(openButton); }); + // Hover over the delete-activity menu item to show submenu + const deleteActivityItem = await screen.findByTestId('delete-activity'); + await act(async () => { + fireEvent.mouseEnter(deleteActivityItem); + }); + await waitFor(() => { const deleteCourseItem = screen.queryByTestId('delete-course-posts'); const deleteOrgItem = screen.queryByTestId('delete-org-posts'); @@ -113,6 +122,12 @@ describe('LearnerActionsDropdown', () => { await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument()); + // Hover over the delete-activity menu item to show submenu + const deleteActivityItem = await screen.findByTestId('delete-activity'); + await act(async () => { + fireEvent.mouseEnter(deleteActivityItem); + }); + const deleteCourseItem = await screen.findByTestId('delete-course-posts'); await act(async () => { fireEvent.click(deleteCourseItem); @@ -141,6 +156,12 @@ describe('LearnerActionsDropdown', () => { await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument()); + // Hover over the delete-activity menu item to show submenu + const deleteActivityItem = await screen.findByTestId('delete-activity'); + await act(async () => { + fireEvent.mouseEnter(deleteActivityItem); + }); + const deleteOrgItem = await screen.findByTestId('delete-org-posts'); await act(async () => { fireEvent.click(deleteOrgItem); diff --git a/src/discussions/learners/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx index 846b254b3..5cd815d1a 100644 --- a/src/discussions/learners/LearnerPostsView.jsx +++ b/src/discussions/learners/LearnerPostsView.jsx @@ -32,12 +32,13 @@ import { threadsLoadingStatus, } from '../posts/data/selectors'; import { clearPostsPages } from '../posts/data/slices'; +import { fetchThread } from '../posts/data/thunks'; import NoResults from '../posts/NoResults'; import { PostLink } from '../posts/post'; import { discussionsPath } from '../utils'; import { BulkDeleteType } from './data/constants'; import { learnersLoadingStatus, selectBulkDeleteStats } from './data/selectors'; -import { deleteUserPosts, fetchUserPosts } from './data/thunks'; +import { deleteUserPosts, fetchUserPosts, undeleteUserPosts } from './data/thunks'; import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar'; import LearnerActionsDropdown from './LearnerActionsDropdown'; import messages from './messages'; @@ -53,7 +54,7 @@ const LearnerPostsView = () => { const loadingStatus = useSelector(threadsLoadingStatus()); const learnerLoadingStatus = useSelector(learnersLoadingStatus()); const postFilter = useSelector(state => state.learners.postFilter); - const { courseId, learnerUsername: username } = useContext(DiscussionContext); + const { courseId, learnerUsername: username, postId } = useContext(DiscussionContext); const nextPage = useSelector(selectThreadNextPage()); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsStaff = useSelector(selectUserIsStaff); @@ -61,7 +62,10 @@ const LearnerPostsView = () => { const bulkDeleteStats = useSelector(selectBulkDeleteStats()); const sortedPostsIds = usePostList(postsIds); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); + const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false); const [isDeletingCourseOrOrg, setIsDeletingCourseOrOrg] = useState(BulkDeleteType.COURSE); + const [isRestoringCourseOrOrg, setIsRestoringCourseOrOrg] = useState(BulkDeleteType.COURSE); + const [isLoadingRestoreData, setIsLoadingRestoreData] = useState(false); const loadMorePosts = useCallback((pageNum = undefined) => { const params = { @@ -79,25 +83,57 @@ const LearnerPostsView = () => { setIsDeletingCourseOrOrg(courseOrOrg); showDeleteConfirmation(); await dispatch(deleteUserPosts(courseId, username, courseOrOrg, false)); - }, [courseId, username, showDeleteConfirmation]); + }, [courseId, username, showDeleteConfirmation, dispatch]); const handleDeletePosts = useCallback(async (courseOrOrg) => { await dispatchDelete(deleteUserPosts(courseId, username, courseOrOrg, true)); - navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) }); + dispatch(clearPostsPages()); + loadMorePosts(); hideDeleteConfirmation(); - }, [courseId, username, hideDeleteConfirmation]); + // If viewing a post, refresh it to show deleted state + if (postId) { + await dispatch(fetchThread(postId, courseId)); + } else { + // Navigate back to learners list after deletion + navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) }); + } + }, [courseId, username, hideDeleteConfirmation, dispatchDelete, navigate, location, postId, dispatch, loadMorePosts]); + + const handleShowRestoreConfirmation = useCallback(async (courseOrOrg) => { + setIsRestoringCourseOrOrg(courseOrOrg); + setIsLoadingRestoreData(true); + showRestoreConfirmation(); + await dispatch(undeleteUserPosts(courseId, username, courseOrOrg, false)); + setIsLoadingRestoreData(false); + }, [courseId, username, showRestoreConfirmation, dispatch]); + + const handleRestorePosts = useCallback(async (courseOrOrg) => { + await dispatch(undeleteUserPosts(courseId, username, courseOrOrg, true)); + dispatch(clearPostsPages()); + loadMorePosts(); + hideRestoreConfirmation(); + // If viewing a post, refresh it to show restored state + if (postId) { + await dispatch(fetchThread(postId, courseId)); + } else { + // Navigate back to learners list after restoration + navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) }); + } + }, [courseId, username, hideRestoreConfirmation, dispatch, loadMorePosts, postId, navigate, location]); const actionHandlers = useMemo(() => ({ [ContentActions.DELETE_COURSE_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.COURSE), [ContentActions.DELETE_ORG_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.ORG), - }), [handleShowDeleteConfirmation]); + [ContentActions.RESTORE_COURSE_POSTS]: () => handleShowRestoreConfirmation(BulkDeleteType.COURSE), + [ContentActions.RESTORE_ORG_POSTS]: () => handleShowRestoreConfirmation(BulkDeleteType.ORG), + }), [handleShowDeleteConfirmation, handleShowRestoreConfirmation]); const postInstances = useMemo(() => ( - sortedPostsIds?.map((postId, idx) => ( + sortedPostsIds?.map((threadId, idx) => ( )) @@ -170,6 +206,19 @@ const LearnerPostsView = () => { isConfirmButtonPending={bulkDeleting} pendingConfirmButtonText={intl.formatMessage(messages.deletePostConfirmPending)} /> + handleRestorePosts(isRestoringCourseOrOrg)} + confirmButtonText={intl.formatMessage(messages.restorePostsConfirm)} + confirmButtonVariant="primary" + isDataLoading={isLoadingRestoreData} + /> ); }; diff --git a/src/discussions/learners/LearnerPostsView.test.jsx b/src/discussions/learners/LearnerPostsView.test.jsx index dcca0656d..188d27f81 100644 --- a/src/discussions/learners/LearnerPostsView.test.jsx +++ b/src/discussions/learners/LearnerPostsView.test.jsx @@ -244,6 +244,12 @@ describe('Learner Posts View', () => { fireEvent.click(actionsButton); }); + // Hover over the delete-activity menu item to show submenu + const deleteActivityItem = await screen.findByTestId('delete-activity'); + await act(async () => { + fireEvent.mouseEnter(deleteActivityItem); + }); + const deleteCourseItem = await screen.findByTestId('delete-course-posts'); await act(async () => { fireEvent.click(deleteCourseItem); @@ -272,6 +278,12 @@ describe('Learner Posts View', () => { fireEvent.click(actionsButton); }); + // Hover over the delete-activity menu item to show submenu + const deleteActivityItem = await screen.findByTestId('delete-activity'); + await act(async () => { + fireEvent.mouseEnter(deleteActivityItem); + }); + const deleteCourseItem = await screen.findByTestId('delete-course-posts'); await act(async () => { fireEvent.click(deleteCourseItem); @@ -303,6 +315,12 @@ describe('Learner Posts View', () => { fireEvent.click(actionsButton); }); + // Hover over the delete-activity menu item to show submenu + const deleteActivityItem = await screen.findByTestId('delete-activity'); + await act(async () => { + fireEvent.mouseEnter(deleteActivityItem); + }); + const deleteCourseItem = await screen.findByTestId('delete-course-posts'); await act(async () => { fireEvent.click(deleteCourseItem); @@ -333,6 +351,12 @@ describe('Learner Posts View', () => { fireEvent.click(actionsButton); }); + // Hover over the delete-activity menu item to show submenu + const deleteActivityItem = await screen.findByTestId('delete-activity'); + await act(async () => { + fireEvent.mouseEnter(deleteActivityItem); + }); + const deleteOrgItem = await screen.findByTestId('delete-org-posts'); await act(async () => { fireEvent.click(deleteOrgItem); diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js index 05121079e..215868fcd 100644 --- a/src/discussions/learners/data/api.js +++ b/src/discussions/learners/data/api.js @@ -11,7 +11,9 @@ export const getCoursesApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussio export const getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/v1/accounts`; export const learnerPostsApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/learner/`; export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/activity_stats/`; +export const deletedContentApiUrl = (courseId) => `${getConfig().LMS_BASE_URL}/api/discussion/v1/deleted_content/${courseId}`; export const deletePostsApiUrl = (courseId, username, courseOrOrg, execute) => `${getConfig().LMS_BASE_URL}/api/discussion/v1/bulk_delete_user_posts/${courseId}?username=${username}&course_or_org=${courseOrOrg}&execute=${execute}`; +export const restorePostsApiUrl = (courseId, username, courseOrOrg, execute) => `${getConfig().LMS_BASE_URL}/api/discussion/v1/bulk_restore_user_posts/${courseId}?username=${username}&course_or_org=${courseOrOrg}&execute=${execute}`; /** * Fetches all the learners in the given course. @@ -49,6 +51,7 @@ export async function getUserProfiles(usernames) { * @param {ThreadViewStatus} view Set to "unread" on "unanswered" to filter to only those statuses. * @param {boolean} countFlagged If true, abuseFlaggedCount will be available. * @param {number} cohort + * @param {boolean} showDeleted If true, only deleted posts will be returned. * @returns API Response object in the format * { * results: [array of posts], @@ -65,6 +68,7 @@ export async function getUserPosts(courseId, { threadType, countFlagged, cohort, + showDeleted, } = {}) { const params = snakeCaseObject({ page, @@ -77,6 +81,7 @@ export async function getUserPosts(courseId, { username: author, countFlagged, groupId: cohort, + showDeleted, }); const { data } = await getAuthenticatedHttpClient() @@ -103,3 +108,55 @@ export async function deleteUserPostsApi(courseId, username, courseOrOrg, execut ); return data; } + +/** + * Restores deleted posts by a specific user in a course or organization + * @param {string} courseId Course ID of the course + * @param {string} username Username of the user whose posts are to be restored + * @param {string} courseOrOrg Can be 'course' or 'org' to specify restoration scope + * @param {boolean} execute If true, restores posts; if false, returns count of threads and comments + * @returns API Response object in the format + * { + * thread_count: number, + * comment_count: number + * } + */ +export async function restoreUserPostsApi(courseId, username, courseOrOrg, execute) { + const { data } = await getAuthenticatedHttpClient().post( + restorePostsApiUrl(courseId, username, courseOrOrg, execute), + null, + ); + return data; +} + +/** + * Get deleted content for a course + * + * @param {string} courseId Course ID of the course + * @param {string} author Optional - filter by author username + * @param {number} page Page number for pagination + * @param {number} pageSize Number of items per page + * @param {string} contentType Optional - filter by 'thread' or 'comment' + * @returns API Response object in the format + * { + * results: [array of deleted posts], + * pagination: {count, num_pages, next, previous} + * } + */ +export async function getDeletedContent(courseId, { + author, + page, + pageSize, + contentType, +} = {}) { + const params = snakeCaseObject({ + authorId: author, // The backend expects author_id + page, + perPage: pageSize, + contentType, + }); + + const { data } = await getAuthenticatedHttpClient() + .get(deletedContentApiUrl(courseId), { params }); + return data; +} diff --git a/src/discussions/learners/data/redux.test.jsx b/src/discussions/learners/data/redux.test.jsx index 6156188fb..fee4b314d 100644 --- a/src/discussions/learners/data/redux.test.jsx +++ b/src/discussions/learners/data/redux.test.jsx @@ -52,6 +52,7 @@ describe('Learner redux test cases', () => { expect(learners.usernameSearch).toBeNull(); expect(learners.postFilter.postType).toEqual('all'); expect(learners.postFilter.status).toEqual('statusAll'); + expect(learners.postFilter.contentStatus).toEqual('statusActive'); expect(learners.postFilter.orderBy).toEqual('lastActivityAt'); expect(learners.postFilter.cohort).toEqual(''); }); @@ -97,14 +98,10 @@ describe('Learner redux test cases', () => { test('Successfully updated the post-filter data in redux', async () => { const learners = await setupLearnerMockResponse(); - const filter = { - ...learners.postFilter, - postType: 'discussion', - }; expect(learners.postFilter.postType).toEqual('all'); - await store.dispatch(setPostFilter(filter)); + await store.dispatch(setPostFilter({ postType: 'discussion' })); const updatedLearners = store.getState().learners; expect(updatedLearners.postFilter.postType).toEqual('discussion'); diff --git a/src/discussions/learners/data/slices.js b/src/discussions/learners/data/slices.js index 534fe850e..5a0e0f4dd 100644 --- a/src/discussions/learners/data/slices.js +++ b/src/discussions/learners/data/slices.js @@ -20,7 +20,8 @@ const learnersSlice = createSlice({ sortedBy: LearnersOrdering.BY_LAST_ACTIVITY, postFilter: { postType: ThreadType.ALL, - status: PostsStatusFilter.ALL, + status: PostsStatusFilter.ALL, // secondary status (Unread, etc.) + contentStatus: PostsStatusFilter.ACTIVE, // main content status (Active/Deleted) orderBy: ThreadOrdering.BY_LAST_ACTIVITY, cohort: '', }, @@ -85,7 +86,10 @@ const learnersSlice = createSlice({ { ...state, pages: [], - postFilter: payload, + postFilter: { + ...state.postFilter, + ...payload, + }, } ), deleteUserPostsRequest: (state) => ( @@ -107,6 +111,25 @@ const learnersSlice = createSlice({ status: RequestStatus.FAILED, } ), + undeleteUserPostsRequest: (state) => ( + { + ...state, + status: RequestStatus.IN_PROGRESS, + } + ), + undeleteUserPostsSuccess: (state, { payload }) => ( + { + ...state, + status: RequestStatus.SUCCESSFUL, + bulkDeleteStats: payload, + } + ), + undeleteUserPostsFailed: (state) => ( + { + ...state, + status: RequestStatus.FAILED, + } + ), }, }); @@ -121,6 +144,9 @@ export const { deleteUserPostsRequest, deleteUserPostsSuccess, deleteUserPostsFailed, + undeleteUserPostsRequest, + undeleteUserPostsSuccess, + undeleteUserPostsFailed, } = learnersSlice.actions; export const learnersReducer = learnersSlice.reducer; diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index afc554b63..c41c14a22 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -14,9 +14,11 @@ import { normaliseThreads } from '../../posts/data/thunks'; import { getHttpErrorStatus } from '../../utils'; import { deleteUserPostsApi, + getDeletedContent, getLearners, getUserPosts, getUserProfiles, + restoreUserPostsApi, } from './api'; import { deleteUserPostsFailed, @@ -26,6 +28,9 @@ import { fetchLearnersFailed, fetchLearnersRequest, fetchLearnersSuccess, + undeleteUserPostsFailed, + undeleteUserPostsRequest, + undeleteUserPostsSuccess, } from './slices'; /** @@ -84,38 +89,60 @@ export function fetchUserPosts(courseId, { author = null, countFlagged, } = {}) { - const options = { - orderBy, - page, - author, - countFlagged, - }; - if (filters.status === PostsStatusFilter.UNREAD) { - options.status = 'unread'; - } - if (filters.status === PostsStatusFilter.UNANSWERED) { - options.status = 'unanswered'; - } - if (filters.status === PostsStatusFilter.REPORTED) { - options.status = 'flagged'; - } - if (filters.status === PostsStatusFilter.UNRESPONDED) { - options.status = 'unresponded'; - } - if (filters.postType !== ThreadType.ALL) { - options.threadType = filters.postType; - } - if (filters.search) { - options.textSearch = filters.search; - } - if (filters.cohort) { - options.cohort = filters.cohort; - } return async (dispatch) => { try { dispatch(fetchLearnerThreadsRequest({ courseId, author })); - const data = await getUserPosts(courseId, options); + let data; + + // Use dedicated deleted content endpoint when viewing deleted posts + if (filters.contentStatus === PostsStatusFilter.DELETED) { + data = await getDeletedContent(courseId, { + author, + page, + pageSize: 10, + }); + } else { + // Use regular learner posts endpoint for active content + const options = { + orderBy, + page, + author, + countFlagged, + }; + + // Only show active content (not deleted) + if (filters.contentStatus === PostsStatusFilter.ACTIVE) { + options.showDeleted = false; + } + + // Map of status filters to their API values + const statusMap = { + [PostsStatusFilter.UNREAD]: 'unread', + [PostsStatusFilter.UNANSWERED]: 'unanswered', + [PostsStatusFilter.REPORTED]: 'flagged', + [PostsStatusFilter.UNRESPONDED]: 'unresponded', + }; + + // Apply status filter if it exists in the map + if (statusMap[filters.status]) { + options.status = statusMap[filters.status]; + } + + // Apply additional filters + if (filters.postType !== ThreadType.ALL) { + options.threadType = filters.postType; + } + if (filters.search) { + options.textSearch = filters.search; + } + if (filters.cohort) { + options.cohort = filters.cohort; + } + + data = await getUserPosts(courseId, options); + } + const normalisedData = normaliseThreads(camelCaseObject(data)); dispatch(fetchThreadsSuccess({ ...normalisedData, page, author })); @@ -130,18 +157,28 @@ export function fetchUserPosts(courseId, { }; } -export const deleteUserPosts = ( - courseId, - username, - courseOrOrg, - execute, -) => async (dispatch) => { - try { - dispatch(deleteUserPostsRequest({ courseId, username })); - const response = await deleteUserPostsApi(courseId, username, courseOrOrg, execute); - dispatch(deleteUserPostsSuccess(camelCaseObject(response))); - } catch (error) { - dispatch(deleteUserPostsFailed()); - logError(error); - } -}; +export function deleteUserPosts(courseId, username, courseOrOrg, execute) { + return async (dispatch) => { + try { + dispatch(deleteUserPostsRequest({ courseId, username })); + const response = await deleteUserPostsApi(courseId, username, courseOrOrg, execute); + dispatch(deleteUserPostsSuccess(camelCaseObject(response))); + } catch (error) { + dispatch(deleteUserPostsFailed()); + logError(error); + } + }; +} + +export function undeleteUserPosts(courseId, username, courseOrOrg, execute) { + return async (dispatch) => { + try { + dispatch(undeleteUserPostsRequest({ courseId, username })); + const response = await restoreUserPostsApi(courseId, username, courseOrOrg, execute); + dispatch(undeleteUserPostsSuccess(camelCaseObject(response))); + } catch (error) { + dispatch(undeleteUserPostsFailed()); + logError(error); + } + }; +} diff --git a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx index fafdc8799..f44a1a31b 100644 --- a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx +++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx @@ -7,10 +7,9 @@ import { useParams } from 'react-router-dom'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import FilterBar from '../../../components/FilterBar'; -import { PostsStatusFilter, ThreadType } from '../../../data/constants'; import selectCourseCohorts from '../../cohorts/data/selectors'; import fetchCourseCohorts from '../../cohorts/data/thunks'; -import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors'; +import { selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff } from '../../data/selectors'; import { setPostFilter } from '../data/slices'; const LearnerPostFilterBar = () => { @@ -18,6 +17,7 @@ const LearnerPostFilterBar = () => { const { courseId } = useParams(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); + const userIsStaff = useSelector(selectUserIsStaff); const cohorts = useSelector(selectCourseCohorts); const postFilter = useSelector(state => state.learners.postFilter); @@ -27,7 +27,7 @@ const LearnerPostFilterBar = () => { filters: ['type-all', 'type-discussions', 'type-questions'], }, { - name: 'status', + name: 'status', // secondary status filters: ['status-any', 'status-unread', 'status-unanswered', 'status-unresponded'], }, { @@ -36,7 +36,17 @@ const LearnerPostFilterBar = () => { }, ]; + // Add content status filter only for staff, moderators, and TAs + if (userHasModerationPrivileges || userIsGroupTa || userIsStaff) { + filtersToShow.push({ + name: 'contentStatus', // main content status + filters: ['status-active', 'status-deleted'], + hasSeparator: true, + }); + } + if (userHasModerationPrivileges || userIsGroupTa) { + // Add reported filter only for group TA and moderators filtersToShow[1].filters.splice(2, 0, 'status-reported'); } @@ -51,40 +61,27 @@ const LearnerPostFilterBar = () => { }; if (name === 'postType') { if (postFilter.postType !== value) { - dispatch(setPostFilter({ - ...postFilter, - postType: value, - })); + dispatch(setPostFilter({ postType: value })); filterContentEventProperties.threadTypeFilter = value; } } else if (name === 'status') { if (postFilter.status !== value) { - const postType = (value === PostsStatusFilter.UNANSWERED && ThreadType.QUESTION) - || (value === PostsStatusFilter.UNRESPONDED && ThreadType.DISCUSSION) - || postFilter.postType; - - dispatch(setPostFilter({ - ...postFilter, - postType, - status: value, - })); - + dispatch(setPostFilter({ status: value })); filterContentEventProperties.statusFilter = value; } + } else if (name === 'contentStatus') { + if (postFilter.contentStatus !== value) { + dispatch(setPostFilter({ contentStatus: value })); + filterContentEventProperties.contentStatusFilter = value; + } } else if (name === 'orderBy') { if (postFilter.orderBy !== value) { - dispatch(setPostFilter({ - ...postFilter, - orderBy: value, - })); + dispatch(setPostFilter({ orderBy: value })); filterContentEventProperties.sortFilter = value; } } else if (name === 'cohort') { if (postFilter.cohort !== value) { - dispatch(setPostFilter({ - ...postFilter, - cohort: value, - })); + dispatch(setPostFilter({ cohort: value })); filterContentEventProperties.cohortFilter = value; } } diff --git a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.test.jsx b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.test.jsx index 4c62f2753..235cba254 100644 --- a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.test.jsx +++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.test.jsx @@ -68,7 +68,7 @@ describe('LearnerPostFilterBar', () => { fireEvent.click(queryAllByRole('button')[0]); }); await waitFor(() => { - expect(queryAllByRole('radiogroup')).toHaveLength(4); + expect(queryAllByRole('radiogroup')).toHaveLength(5); }); }); @@ -78,17 +78,24 @@ describe('LearnerPostFilterBar', () => { fireEvent.click(queryAllByRole('button')[0]); }); await waitFor(() => { + const radiogroups = queryAllByRole('radiogroup'); + // Radiogroup 0: postType filter - default is 'all' expect( - queryAllByRole('radiogroup')[0].querySelector('input[value="all"]'), + radiogroups[0].querySelector('input[value="all"]'), ).toBeChecked(); + // Radiogroup 1: status filter (any/unread/reported/unanswered/unresponded) + // - not checked since default is statusActive + // Radiogroup 2: orderBy filter - default is 'lastActivityAt' expect( - queryAllByRole('radiogroup')[1].querySelector('input[value="statusAll"]'), + radiogroups[2].querySelector('input[value="lastActivityAt"]'), ).toBeChecked(); + // Radiogroup 3: active/deleted status filter - default is 'statusActive' expect( - queryAllByRole('radiogroup')[2].querySelector('input[value="lastActivityAt"]'), + radiogroups[3].querySelector('input[value="statusActive"]'), ).toBeChecked(); + // Radiogroup 4: cohort filter - default is empty string expect( - queryAllByRole('radiogroup')[3].querySelector('input[value=""]'), + radiogroups[4].querySelector('input[value=""]'), ).toBeChecked(); }); }); diff --git a/src/discussions/learners/learner/LearnerCard.jsx b/src/discussions/learners/learner/LearnerCard.jsx index 8554b3855..7100921b8 100644 --- a/src/discussions/learners/learner/LearnerCard.jsx +++ b/src/discussions/learners/learner/LearnerCard.jsx @@ -13,6 +13,7 @@ import learnerShape from './proptypes'; const LearnerCard = ({ learner }) => { const { username, threads, inactiveFlags, activeFlags, responses, replies, + deletedCount, deletedThreads, deletedResponses, deletedReplies, } = learner; const { enableInContextSidebar, learnerUsername, courseId } = useContext(DiscussionContext); const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, { @@ -51,6 +52,10 @@ const LearnerCard = ({ learner }) => { responses={responses} replies={replies} username={username} + deletedCount={deletedCount} + deletedThreads={deletedThreads} + deletedResponses={deletedResponses} + deletedReplies={deletedReplies} /> )} diff --git a/src/discussions/learners/learner/LearnerFilterBar.jsx b/src/discussions/learners/learner/LearnerFilterBar.jsx index 32f91218f..fd372ac93 100644 --- a/src/discussions/learners/learner/LearnerFilterBar.jsx +++ b/src/discussions/learners/learner/LearnerFilterBar.jsx @@ -10,7 +10,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import { LearnersOrdering } from '../../../data/constants'; -import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors'; +import { selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff } from '../../data/selectors'; import { setSortedBy } from '../data'; import { selectLearnerSorting } from '../data/selectors'; import messages from '../messages'; @@ -52,6 +52,7 @@ const LearnerFilterBar = () => { const dispatch = useDispatch(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); + const userIsStaff = useSelector(selectUserIsStaff); const currentSorting = useSelector(selectLearnerSorting()); const [isOpen, setOpen] = useState(false); @@ -118,6 +119,14 @@ const LearnerFilterBar = () => { value={LearnersOrdering.BY_RECENCY} selected={currentSorting} /> + {(userHasModerationPrivileges || userIsGroupTa || userIsStaff) && ( + + )} diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx index ce4adc9e0..63f7b3622 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -3,22 +3,28 @@ import PropTypes from 'prop-types'; import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon'; import { - Edit, QuestionAnswerOutline, Report, ReportGmailerrorred, + DeleteOutline, Edit, QuestionAnswerOutline, Report, ReportGmailerrorred, } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors'; +import { selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff } from '../../data/selectors'; import messages from '../messages'; const LearnerFooter = ({ inactiveFlags, activeFlags, threads, responses, replies, username, + deletedThreads, deletedResponses, deletedReplies, }) => { const intl = useIntl(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); + const userIsStaff = useSelector(selectUserIsStaff); const canSeeLearnerReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); + const canSeeDeletedStats = userHasModerationPrivileges || userIsGroupTa || userIsStaff; + + // Calculate deleted count (sum of all deleted content) + const totalDeletedCount = (deletedThreads || 0) + (deletedResponses || 0) + (deletedReplies || 0); return (
@@ -54,20 +60,38 @@ const LearnerFooter = ({ {threads}
- {Boolean(canSeeLearnerReportedStats) && ( + {canSeeDeletedStats && ( + +
+ {intl.formatMessage(messages.deletedActivity)} +
+ + )} + > +
+ + {totalDeletedCount} +
+
+ )} + {canSeeLearnerReportedStats && (
- {Boolean(activeFlags) + {activeFlags > 0 && ( {intl.formatMessage(messages.reported, { reported: activeFlags })} )} - {Boolean(inactiveFlags) + {inactiveFlags > 0 && ( {intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })} @@ -79,7 +103,7 @@ const LearnerFooter = ({ >
- {activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`} + {activeFlags} {inactiveFlags > 0 && `/ ${inactiveFlags}`}
)} @@ -94,6 +118,9 @@ LearnerFooter.propTypes = { responses: PropTypes.number, replies: PropTypes.number, username: PropTypes.string, + deletedThreads: PropTypes.number, + deletedResponses: PropTypes.number, + deletedReplies: PropTypes.number, }; LearnerFooter.defaultProps = { @@ -103,6 +130,9 @@ LearnerFooter.defaultProps = { responses: 0, replies: 0, username: '', + deletedThreads: 0, + deletedResponses: 0, + deletedReplies: 0, }; export default React.memo(LearnerFooter); diff --git a/src/discussions/learners/learner/proptypes.js b/src/discussions/learners/learner/proptypes.js index ab6cdb6a7..89b884e03 100644 --- a/src/discussions/learners/learner/proptypes.js +++ b/src/discussions/learners/learner/proptypes.js @@ -7,6 +7,10 @@ const learnerShape = PropTypes.shape({ replies: PropTypes.number, responses: PropTypes.number, threads: PropTypes.number, + deletedCount: PropTypes.number, + deletedThreads: PropTypes.number, + deletedResponses: PropTypes.number, + deletedReplies: PropTypes.number, }); export default learnerShape; diff --git a/src/discussions/learners/messages.js b/src/discussions/learners/messages.js index 38403e56e..188c46b14 100644 --- a/src/discussions/learners/messages.js +++ b/src/discussions/learners/messages.js @@ -48,6 +48,7 @@ const messages = defineMessages({ defaultMessage: `All learners sorted by {sort, select, flagged {reported activity} activity {most activity} + deleted {deleted activity} other {{sort}} }`, description: 'Text for current selected learners filter', @@ -62,6 +63,31 @@ const messages = defineMessages({ defaultMessage: 'Posts', description: 'Tooltip text for all posts icon', }, + deletedActivity: { + id: 'discussion.learner.deletedActivity', + defaultMessage: 'Deleted activity', + description: 'Tooltip text for deleted activity icon', + }, + deleteActivity: { + id: 'discussions.learner.actions.deleteActivity', + defaultMessage: 'Delete activity', + description: 'Main menu option for deleting user activity', + }, + restoreActivity: { + id: 'discussions.learner.actions.restoreActivity', + defaultMessage: 'Restore activity', + description: 'Main menu option for restoring user activity', + }, + withinCourse: { + id: 'discussions.learner.actions.withinCourse', + defaultMessage: 'Within course', + description: 'Submenu option for actions within the current course', + }, + withinOrg: { + id: 'discussions.learner.actions.withinOrg', + defaultMessage: 'Within organization', + description: 'Submenu option for actions within the organization', + }, deleteCoursePosts: { id: 'discussions.learner.actions.deleteCoursePosts', defaultMessage: 'Delete user posts within this course', @@ -72,6 +98,16 @@ const messages = defineMessages({ defaultMessage: 'Delete user posts within this organization', description: 'Action to delete user posts within the organization', }, + restoreCoursePosts: { + id: 'discussions.learner.actions.restoreCoursePosts', + defaultMessage: 'Restore user posts within this course', + description: 'Action to restore deleted user posts within a specific course', + }, + restoreOrgPosts: { + id: 'discussions.learner.actions.restoreOrgPosts', + defaultMessage: 'Restore user posts within this organization', + description: 'Action to restore deleted user posts within the organization', + }, deletePostsTitle: { id: 'discussions.learner.deletePosts.title', defaultMessage: 'Are you sure you want to delete this user\'s discussion contributions?', @@ -101,6 +137,30 @@ const messages = defineMessages({ defaultMessage: 'This action cannot be undone.', description: 'Bold disclaimer description for delete confirmation dialog', }, + restorePostsTitle: { + id: 'discussions.learner.restorePosts.title', + defaultMessage: 'Restore this user\'s discussion contributions?', + description: 'Title for restore course posts confirmation dialog', + }, + restorePostsDescription: { + id: 'discussions.learner.restorePosts.description', + defaultMessage: `{bulkType, select, + course {You are about to restore {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user in this course. This includes all deleted discussion threads, responses, and comments authored by them.} + org {You are about to restore {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user across the organization. This includes all deleted discussion threads, responses, and comments authored by them.} + other {You are about to restore {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user. This includes all deleted discussion threads, responses, and comments authored by them.} + }`, + description: 'Description for restore posts confirmation dialog', + }, + restorePostsConfirm: { + id: 'discussions.learner.restorePosts.confirm', + defaultMessage: 'Restore', + description: 'Confirm button text for restore posts', + }, + restorePostConfirmPending: { + id: 'discussions.learner.restorePosts.confirm.pending', + defaultMessage: 'Restoring', + description: 'Pending state of confirm button text for restore posts', + }, }); export default messages; diff --git a/src/discussions/learners/utils.js b/src/discussions/learners/utils.js index 7f4cf4e79..a6885466e 100644 --- a/src/discussions/learners/utils.js +++ b/src/discussions/learners/utils.js @@ -4,6 +4,7 @@ import { Delete } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { ReactComponent as Undelete } from '../../assets/undelete.svg'; import { ContentActions } from '../../data/constants'; import messages from './messages'; @@ -20,6 +21,18 @@ export const LEARNER_ACTIONS_LIST = [ icon: Delete, label: messages.deleteOrgPosts, }, + { + id: 'restore-course-posts', + action: ContentActions.RESTORE_COURSE_POSTS, + icon: Undelete, + label: messages.restoreCoursePosts, + }, + { + id: 'restore-org-posts', + action: ContentActions.RESTORE_ORG_POSTS, + icon: Undelete, + label: messages.restoreOrgPosts, + }, ]; export function useLearnerActions(userHasBulkDeletePrivileges = false) { @@ -40,3 +53,49 @@ export function useLearnerActions(userHasBulkDeletePrivileges = false) { return actions; } + +export function useLearnerActionsMenu(intl, userHasBulkDeletePrivileges = false) { + const menuItems = useMemo(() => { + if (!userHasBulkDeletePrivileges) { + return []; + } + return [ + { + id: 'delete-activity', + icon: Delete, + label: intl.formatMessage(messages.deleteActivity), + submenu: [ + { + id: 'delete-course-posts', + action: ContentActions.DELETE_COURSE_POSTS, + label: intl.formatMessage(messages.deleteCoursePosts), + }, + { + id: 'delete-org-posts', + action: ContentActions.DELETE_ORG_POSTS, + label: intl.formatMessage(messages.deleteOrgPosts), + }, + ], + }, + { + id: 'restore-activity', + icon: Undelete, + label: intl.formatMessage(messages.restoreActivity), + submenu: [ + { + id: 'restore-course-posts', + action: ContentActions.RESTORE_COURSE_POSTS, + label: intl.formatMessage(messages.restoreCoursePosts), + }, + { + id: 'restore-org-posts', + action: ContentActions.RESTORE_ORG_POSTS, + label: intl.formatMessage(messages.restoreOrgPosts), + }, + ], + }, + ]; + }, [userHasBulkDeletePrivileges, intl]); + + return menuItems; +} diff --git a/src/discussions/messages.js b/src/discussions/messages.js index 3af911e76..7d9ba86ca 100644 --- a/src/discussions/messages.js +++ b/src/discussions/messages.js @@ -31,6 +31,11 @@ const messages = defineMessages({ defaultMessage: 'Delete', description: 'Action to delete a post or comment', }, + restoreAction: { + id: 'discussions.actions.restore', + defaultMessage: 'Restore', + description: 'Action to restore a deleted post or comment', + }, confirmationConfirm: { id: 'discussions.confirmation.button.confirm', defaultMessage: 'Confirm', @@ -243,6 +248,81 @@ const messages = defineMessages({ defaultMessage: 'Faculty and staff will never invite you to join external groups or ask for personal or financial information in the discussions. Stay safe, and if you see suspicious activity, please report it.', description: 'Warning message about spam and impersonation in discussion forums', }, + activeThreads: { + id: 'discussions.filter.activeThreads', + defaultMessage: 'Active Threads', + description: 'Label for active threads filter button', + }, + deletedThreads: { + id: 'discussions.filter.deletedThreads', + defaultMessage: 'Deleted Threads', + description: 'Label for deleted threads filter button', + }, + deletedBadge: { + id: 'discussions.thread.deletedBadge', + defaultMessage: 'Deleted', + description: 'Badge shown on deleted threads', + }, + selectedCount: { + id: 'discussions.bulk.selectedCount', + defaultMessage: '{count} selected', + description: 'Count of selected threads for bulk actions', + }, + deleteSelected: { + id: 'discussions.bulk.deleteSelected', + defaultMessage: 'Delete Selected', + description: 'Button text for bulk delete action', + }, + restoreSelected: { + id: 'discussions.bulk.restoreSelected', + defaultMessage: 'Restore Selected', + description: 'Button text for bulk restore action', + }, + deleting: { + id: 'discussions.bulk.deleting', + defaultMessage: 'Deleting...', + description: 'Loading text when bulk deleting threads', + }, + restoring: { + id: 'discussions.bulk.restoring', + defaultMessage: 'Restoring...', + description: 'Loading text when bulk restoring threads', + }, + loadingThreads: { + id: 'discussions.threads.loading', + defaultMessage: 'Loading threads...', + description: 'Loading text when fetching threads', + }, + autoSpamFlaggedMessage: { + id: 'discussions.autoSpamFlaggedMessage', + defaultMessage: 'Content automatically reported as possible spam pending staff review.', + description: 'Message shown when a post is automatically flagged as potential spam', + }, + autoSpamModalTitle: { + id: 'discussions.autoSpamModalTitle', + defaultMessage: 'What does "automatically reported" mean?', + description: 'Title for the modal that explains automatic spam flagging', + }, + autoSpamModalBodyParagraph1: { + id: 'discussions.autoSpamModalBodyParagraph1', + defaultMessage: 'Some content is flagged by an automated system when it matches patterns commonly associated with spam. This helps reduce harmful or misleading posts in discussions.', + description: 'First paragraph of explanation about automatic spam flagging process shown in modal', + }, + autoSpamModalBodyParagraph2: { + id: 'discussions.autoSpamModalBodyParagraph2', + defaultMessage: 'Automatically reported content is only visible to course staff and remains hidden from learners until action is taken.', + description: 'Second paragraph of explanation about automatic spam flagging process shown in modal', + }, + autoSpamModalClose: { + id: 'discussions.autoSpamModalClose', + defaultMessage: 'Understand', + description: 'Button text to close the automatic spam explanation modal', + }, + autoSpamModalIconAlt: { + id: 'discussions.autoSpamModalIconAlt', + defaultMessage: 'Show more information about automatic flagging', + description: 'Alt text for the icon that opens the automatic spam explanation modal', + }, }); export default messages; diff --git a/src/discussions/post-comments/PostCommentsView.test.jsx b/src/discussions/post-comments/PostCommentsView.test.jsx index 2de528732..59d6272e7 100644 --- a/src/discussions/post-comments/PostCommentsView.test.jsx +++ b/src/discussions/post-comments/PostCommentsView.test.jsx @@ -80,6 +80,7 @@ async function mockAxiosReturnPagedCommentsResponses() { page_size: undefined, requested_fields: 'profile_image', reverse_order: true, + show_deleted: false, }; [1, 2].forEach(async (page) => { diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 966cf492b..7330d626b 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -1,23 +1,34 @@ import React, { - useCallback, useContext, useEffect, useMemo, useState, + useCallback, + useContext, + useEffect, + useMemo, + useState, } from 'react'; import PropTypes from 'prop-types'; import { Button, useToggle } from '@openedx/paragon'; +import { DeleteOutline } from '@openedx/paragon/icons'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; import HTMLLoader from '../../../../components/HTMLLoader'; -import { ContentActions, EndorsementStatus } from '../../../../data/constants'; -import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common'; +import { + AvatarOutlineAndLabelColors, ContentActions, EndorsementStatus, PostsStatusFilter, +} from '../../../../data/constants'; +import { + AlertBanner, AuthorLabel, AutoSpamAlertBanner, Confirmation, EndorsedAlertBanner, +} from '../../../common'; import DiscussionContext from '../../../common/context'; import HoverCard from '../../../common/HoverCard'; import withPostingRestrictions from '../../../common/withPostingRestrictions'; import { ContentTypes } from '../../../data/constants'; import { useUserPostingEnabled } from '../../../data/hooks'; import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation } from '../../../data/selectors'; +import { selectThread } from '../../../posts/data/selectors'; import { fetchThread } from '../../../posts/data/thunks'; import LikeButton from '../../../posts/post/LikeButton'; import { useActions } from '../../../utils'; @@ -46,17 +57,21 @@ const Comment = ({ const { id, parentId, childCount, abuseFlagged, endorsed, threadId, endorsedAt, endorsedBy, endorsedByLabel, renderedBody, voted, following, voteCount, authorLabel, author, createdAt, lastEdit, rawBody, closed, closedBy, closeReason, - editByLabel, closedByLabel, users: postUsers, + editByLabel, closedByLabel, users: postUsers, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam, } = comment; const intl = useIntl(); const hasChildren = childCount > 0; const isNested = Boolean(parentId); const dispatch = useDispatch(); - const { courseId } = useContext(DiscussionContext); + const { courseId, learnerUsername } = useContext(DiscussionContext); const { isClosed } = useContext(PostCommentsContext); + // Get the post's isDeleted state for priority rules + const post = useSelector(selectThread(threadId)); + const postIsDeleted = post?.isDeleted || false; const [isEditing, setEditing] = useState(false); const [isReplying, setReplying] = useState(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); + const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); const inlineReplies = useSelector(selectCommentResponses(id)); const inlineRepliesIds = useSelector(selectCommentResponsesIds(id)); @@ -67,16 +82,23 @@ const Comment = ({ const isUserPrivilegedInPostingRestriction = useUserPostingEnabled(); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); - + const postFilter = useSelector(state => state.learners?.postFilter); + // Use contentStatus for deleted section + const showDeleted = Boolean( + learnerUsername && postFilter?.contentStatus === PostsStatusFilter.DELETED, + ); + // If isSpam is not provided in the API response, default to false + const isSpamFlagged = isSpam || false; useEffect(() => { // If the comment has a parent comment, it won't have any children, so don't fetch them. if (hasChildren && showFullThread) { dispatch(fetchCommentResponses(id, { page: 1, reverseOrder: sortedOrder, + showDeleted, })); } - }, [id, sortedOrder]); + }, [id, sortedOrder, showDeleted]); const endorseIcons = useMemo(() => ( actions.find(({ action }) => action === EndorsementStatus.ENDORSED) @@ -113,19 +135,39 @@ const Comment = ({ await dispatch(editComment(id, { voted: !voted })); }, [id, voted]); + const handleRestore = useCallback(() => { + showRestoreConfirmation(); + }, [showRestoreConfirmation]); + + const handleRestoreConfirmation = useCallback(async () => { + try { + const { performRestoreComment } = await import('../../data/thunks'); + const result = await dispatch(performRestoreComment(id, courseId)); + if (result.success) { + // Refresh the thread to reflect the change + await dispatch(fetchThread(threadId, courseId)); + } + } catch (error) { + logError(error); + } + hideRestoreConfirmation(); + }, [id, courseId, threadId, dispatch, hideRestoreConfirmation]); + const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handleEditContent, [ContentActions.ENDORSE]: handleCommentEndorse, [ContentActions.DELETE]: showDeleteConfirmation, + [ContentActions.RESTORE]: handleRestore, [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleAbusedFlag]); + }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleRestore, handleAbusedFlag]); const handleLoadMoreComments = useCallback(() => ( dispatch(fetchCommentResponses(id, { page: currentPage + 1, reverseOrder: sortedOrder, + showDeleted, })) - ), [id, currentPage, sortedOrder]); + ), [id, currentPage, sortedOrder, showDeleted]); const handleAddCommentButton = useCallback(() => { if (isUserPrivilegedInPostingRestriction) { @@ -163,6 +205,18 @@ const Comment = ({ closeButtonVariant="tertiary" confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} /> + {!abuseFlagged && ( + {isDeleted && deletedBy && ( +
+ +
+ {intl.formatMessage(messages.deletedBy)} + + + +
+
+ )} + {isEditing ? ( diff --git a/src/discussions/post-comments/comments/comment/CommentHeader.jsx b/src/discussions/post-comments/comments/comment/CommentHeader.jsx index 7674292f2..c51420d6c 100644 --- a/src/discussions/post-comments/comments/comment/CommentHeader.jsx +++ b/src/discussions/post-comments/comments/comment/CommentHeader.jsx @@ -40,7 +40,7 @@ const CommentHeader = ({ 'mt-2': hasAnyAlert, })} > -
+
{ const commentData = useSelector(selectCommentOrResponseById(responseId)); const { id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy, - closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel, + closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, + closedByLabel, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam, } = commentData; const intl = useIntl(); const dispatch = useDispatch(); + const { courseId } = useContext(DiscussionContext); const [isEditing, setEditing] = useState(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); + const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); const colorClass = AvatarOutlineAndLabelColors[authorLabel]; + // If isSpam is not provided in the API response, default to false + const isSpamFlagged = isSpam || false; const hasAnyAlert = useAlertBannerVisible({ author, abuseFlagged, @@ -68,6 +79,22 @@ const Reply = ({ responseId }) => { } }, [abuseFlagged, id, showReportConfirmation]); + const handleRestore = useCallback(() => { + showRestoreConfirmation(); + }, [showRestoreConfirmation]); + + const handleRestoreConfirmation = useCallback(async () => { + try { + const result = await dispatch(performRestoreComment(id, courseId)); + if (result.success) { + await dispatch(fetchThread(threadId, courseId)); + } + } catch (error) { + logError(error); + } + hideRestoreConfirmation(); + }, [id, courseId, threadId, dispatch, hideRestoreConfirmation]); + const handleCloseEditor = useCallback(() => { setEditing(false); }, []); @@ -76,8 +103,9 @@ const Reply = ({ responseId }) => { [ContentActions.EDIT_CONTENT]: handleEditContent, [ContentActions.ENDORSE]: handleReplyEndorse, [ContentActions.DELETE]: showDeleteConfirmation, + [ContentActions.RESTORE]: handleRestore, [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleAbusedFlag]); + }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleRestore, handleAbusedFlag]); return (
@@ -90,6 +118,14 @@ const Reply = ({ responseId }) => { closeButtonVariant="tertiary" confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} /> + {!abuseFlagged && ( {
)} + {isSpamFlagged && ( +
+
+ +
+
+ +
+
+ )} + {isDeleted && deletedBy && ( +
+
+ +
+
+
+ +
+ {intl.formatMessage(messages.deletedBy)} + + + +
+
+
+
+ )}
{ const params = snakeCaseObject({ @@ -35,6 +36,7 @@ export const getThreadComments = async (threadId, { requestedFields: 'profile_image', enableInContextSidebar, mergeQuestionTypeResponses: threadType === ThreadType.QUESTION ? true : null, + showDeleted, }); const { data } = await getAuthenticatedHttpClient().get(getCommentsApiUrl(), { params: { ...params, signal } }); @@ -52,6 +54,7 @@ export const getCommentResponses = async (commentId, { page, pageSize, reverseOrder, + showDeleted = false, } = {}) => { const url = `${getCommentsApiUrl()}${commentId}/`; const params = snakeCaseObject({ @@ -59,6 +62,7 @@ export const getCommentResponses = async (commentId, { pageSize, requestedFields: 'profile_image', reverseOrder, + showDeleted, }); const { data } = await getAuthenticatedHttpClient() .get(url, { params }); @@ -127,3 +131,19 @@ export const deleteComment = async (commentId) => { await getAuthenticatedHttpClient() .delete(url); }; + +/** + * Restores a deleted comment. + * @param {string} commentId ID of comment to restore + * @param {string} courseId Course ID + * @returns {Promise<{}>} + */ +export const restoreComment = async (commentId, courseId) => { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/restore_content`; + const { data } = await getAuthenticatedHttpClient().post(url, { + content_type: 'comment', + content_id: commentId, + course_id: courseId, + }); + return data; +}; diff --git a/src/discussions/post-comments/data/hooks.js b/src/discussions/post-comments/data/hooks.js index 33f71172d..acd0df11f 100644 --- a/src/discussions/post-comments/data/hooks.js +++ b/src/discussions/post-comments/data/hooks.js @@ -7,7 +7,7 @@ import { v4 as uuidv4 } from 'uuid'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; -import { EndorsementStatus } from '../../../data/constants'; +import { EndorsementStatus, PostsStatusFilter } from '../../../data/constants'; import useDispatchWithState from '../../../data/hooks'; import DiscussionContext from '../../common/context'; import { selectThread } from '../../posts/data/selectors'; @@ -42,6 +42,14 @@ export function usePost(postId) { return thread || {}; } +const useShowDeletedContent = () => { + const { learnerUsername } = useContext(DiscussionContext); + const postFilter = useSelector(state => state.learners?.postFilter); + + // Show deleted content if we're in learner view and the deleted filter is active (contentStatus) + return learnerUsername && postFilter?.contentStatus === PostsStatusFilter.DELETED; +}; + export function usePostComments(threadType) { const { enableInContextSidebar, postId } = useContext(DiscussionContext); const [isLoading, dispatch] = useDispatchWithState(); @@ -49,6 +57,7 @@ export function usePostComments(threadType) { const reverseOrder = useSelector(selectCommentSortOrder); const hasMorePages = useSelector(selectThreadHasMorePages(postId)); const currentPage = useSelector(selectThreadCurrentPage(postId)); + const showDeleted = useShowDeletedContent(); const endorsedCommentsIds = useMemo(() => ( [...filterPosts(comments, 'endorsed')].map(comment => comment.id) @@ -63,10 +72,11 @@ export function usePostComments(threadType) { threadType, page: currentPage + 1, reverseOrder, + showDeleted, }; await dispatch(fetchThreadComments(postId, params)); trackLoadMoreEvent(postId, params); - }, [currentPage, threadType, postId, reverseOrder]); + }, [currentPage, threadType, postId, reverseOrder, showDeleted]); useEffect(() => { const abortController = new AbortController(); @@ -76,13 +86,14 @@ export function usePostComments(threadType) { page: 1, reverseOrder, enableInContextSidebar, + showDeleted, signal: abortController.signal, })); return () => { abortController.abort(); }; - }, [postId, threadType, reverseOrder, enableInContextSidebar]); + }, [postId, threadType, reverseOrder, enableInContextSidebar, showDeleted]); return { endorsedCommentsIds, diff --git a/src/discussions/post-comments/data/thunks.js b/src/discussions/post-comments/data/thunks.js index 1650e7e51..1792e8330 100644 --- a/src/discussions/post-comments/data/thunks.js +++ b/src/discussions/post-comments/data/thunks.js @@ -79,6 +79,7 @@ export function fetchThreadComments( reverseOrder, threadType, enableInContextSidebar, + showDeleted = false, signal, } = {}, ) { @@ -86,7 +87,7 @@ export function fetchThreadComments( try { dispatch(fetchCommentsRequest()); const data = await getThreadComments(threadId, { - page, reverseOrder, threadType, enableInContextSidebar, signal, + page, reverseOrder, threadType, enableInContextSidebar, showDeleted, signal, }); dispatch(fetchCommentsSuccess({ ...normaliseComments(camelCaseObject(data)), @@ -104,11 +105,11 @@ export function fetchThreadComments( }; } -export function fetchCommentResponses(commentId, { page = 1, reverseOrder = true } = {}) { +export function fetchCommentResponses(commentId, { page = 1, reverseOrder = true, showDeleted = false } = {}) { return async (dispatch) => { try { dispatch(fetchCommentResponsesRequest({ commentId })); - const data = await getCommentResponses(commentId, { page, reverseOrder }); + const data = await getCommentResponses(commentId, { page, reverseOrder, showDeleted }); dispatch(fetchCommentResponsesSuccess({ ...normaliseComments(camelCaseObject(data)), page, @@ -185,3 +186,16 @@ export function removeComment(commentId, threadId) { } }; } + +export function performRestoreComment(commentId, courseId) { + return async () => { + try { + const { restoreComment } = await import('./api'); + await restoreComment(commentId, courseId); + return { success: true }; + } catch (error) { + logError(error); + return { success: false, error: error.message }; + } + }; +} diff --git a/src/discussions/post-comments/messages.js b/src/discussions/post-comments/messages.js index 2b9a85422..1dd6a9cb2 100644 --- a/src/discussions/post-comments/messages.js +++ b/src/discussions/post-comments/messages.js @@ -11,6 +11,20 @@ const messages = defineMessages({ defaultMessage: 'Add a response', description: 'Button to add a response to a response', }, + deletedBy: { + id: 'discussions.comments.comment.deletedBy', + defaultMessage: 'Deleted by', + }, + deletedResponse: { + id: 'discussions.comments.comment.deletedResponse', + defaultMessage: 'Deleted Response', + description: 'Badge showing that the response has been deleted', + }, + deletedComment: { + id: 'discussions.comments.comment.deletedComment', + defaultMessage: 'Deleted Comment', + description: 'Badge showing that the comment has been deleted', + }, abuseFlaggedMessage: { id: 'discussions.comments.comment.abuseFlaggedMessage', defaultMessage: 'Content reported for staff to review', @@ -138,6 +152,16 @@ const messages = defineMessages({ defaultMessage: 'Are you sure you want to permanently delete this response?', description: 'Text displayed in confirmation dialog when deleting a response', }, + undeleteResponseTitle: { + id: 'discussions.editor.undelete.response.title', + defaultMessage: 'Restore response', + description: 'Title of confirmation dialog shown when restoring a response', + }, + undeleteResponseDescription: { + id: 'discussions.editor.undelete.response.description', + defaultMessage: 'Are you sure you want to restore this response?', + description: 'Text displayed in confirmation dialog when restoring a response', + }, deleteCommentTitle: { id: 'discussions.editor.delete.comment.title', defaultMessage: 'Delete comment', @@ -148,6 +172,16 @@ const messages = defineMessages({ defaultMessage: 'Are you sure you want to permanently delete this comment?', description: 'Text displayed in confirmation dialog when deleting a comment', }, + undeleteCommentTitle: { + id: 'discussions.editor.undelete.comment.title', + defaultMessage: 'Restore comment', + description: 'Title of confirmation dialog shown when restoring a comment', + }, + undeleteCommentDescription: { + id: 'discussions.editor.undelete.comment.description', + defaultMessage: 'Are you sure you want to restore this comment?', + description: 'Text displayed in confirmation dialog when restoring a comment', + }, deleteConfirmationDelete: { id: 'discussions.delete.confirmation.button.delete', defaultMessage: 'Delete', diff --git a/src/discussions/posts/NoResults.jsx b/src/discussions/posts/NoResults.jsx index 73654d0dd..138aa8bce 100644 --- a/src/discussions/posts/NoResults.jsx +++ b/src/discussions/posts/NoResults.jsx @@ -15,9 +15,9 @@ const NoResults = () => { const inContextTopicsFilter = useSelector(selectTopicFilter); const topicsFilter = useSelector(({ topics }) => topics.filter); const filters = useSelector((state) => state.threads.filters); - const learnersFilter = useSelector(({ learners }) => learners.usernameSearch); + const learnersFilter = useSelector(({ learners }) => learners?.usernameSearch); const isFiltered = postsFiltered || (topicsFilter !== '') - || (learnersFilter !== null) || (inContextTopicsFilter !== ''); + || (learnersFilter) || (inContextTopicsFilter !== ''); let helpMessage = messages.removeFilters; diff --git a/src/discussions/posts/PostsView.test.jsx b/src/discussions/posts/PostsView.test.jsx index 8e5f01083..6e754367e 100644 --- a/src/discussions/posts/PostsView.test.jsx +++ b/src/discussions/posts/PostsView.test.jsx @@ -215,7 +215,7 @@ describe('PostsView', () => { await renderComponent(); }); dropDownButton = screen.getByRole('button', { - name: /all posts sorted by recent activity/i, + name: /all active posts sorted by recent activity/i, }); await act(async () => { fireEvent.click(dropDownButton); @@ -236,7 +236,7 @@ describe('PostsView', () => { }); dropDownButton = screen.getByRole('button', { - name: /All posts in Cohort 1 sorted by recent activity/i, + name: /All active posts in Cohort 1 sorted by recent activity/i, }); expect(dropDownButton).toBeInTheDocument(); diff --git a/src/discussions/posts/data/__factories__/threads.factory.js b/src/discussions/posts/data/__factories__/threads.factory.js index b072860c8..67286ff4f 100644 --- a/src/discussions/posts/data/__factories__/threads.factory.js +++ b/src/discussions/posts/data/__factories__/threads.factory.js @@ -45,6 +45,7 @@ Factory.define('thread') non_endorsed_comment_list_url: null, read: false, has_endorsed: false, + is_deleted: false, }); Factory.define('threadsResult') diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js index e91044bc1..57e0f0096 100644 --- a/src/discussions/posts/data/api.js +++ b/src/discussions/posts/data/api.js @@ -40,6 +40,7 @@ export const getThreads = async (courseId, { threadType, countFlagged, cohort, + isDeleted, } = {}) => { const params = snakeCaseObject({ courseId, @@ -56,6 +57,7 @@ export const getThreads = async (courseId, { flagged, countFlagged, groupId: cohort, + isDeleted, }); const { data } = await getAuthenticatedHttpClient().get(getThreadsApiUrl(), { params }); return data; @@ -214,3 +216,19 @@ export const sendEmailForAccountActivation = async () => { .post(url); return data; }; + +/** + * Restore a deleted thread. + * @param {string} threadId + * @param {string} courseId + * @returns {Promise<{}>} + */ +export const restoreThread = async (threadId, courseId) => { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/restore_content`; + const { data } = await getAuthenticatedHttpClient().post(url, { + content_type: 'thread', + content_id: threadId, + course_id: courseId, + }); + return data; +}; diff --git a/src/discussions/posts/data/selectors.js b/src/discussions/posts/data/selectors.js index b825f93a6..dfddf8ab1 100644 --- a/src/discussions/posts/data/selectors.js +++ b/src/discussions/posts/data/selectors.js @@ -61,3 +61,5 @@ export const selectThreadNextPage = () => state => state.threads.nextPage; export const selectAuthorAvatar = author => state => ( state.threads.avatars?.[camelCase(author)]?.profile.image ); + +export const selectIsDeletedView = () => state => state.threads.isDeletedView; diff --git a/src/discussions/posts/data/slices.js b/src/discussions/posts/data/slices.js index d17b8a8d3..710e04a63 100644 --- a/src/discussions/posts/data/slices.js +++ b/src/discussions/posts/data/slices.js @@ -46,7 +46,7 @@ const threadsSlice = createSlice({ textSearchRewrite: null, postStatus: RequestStatus.SUCCESSFUL, filters: { - status: PostsStatusFilter.ALL, + status: PostsStatusFilter.ACTIVE, postType: ThreadType.ALL, cohort: '', search: '', @@ -55,6 +55,7 @@ const threadsSlice = createSlice({ redirectToThread: null, sortedBy: ThreadOrdering.BY_LAST_ACTIVITY, confirmEmailStatus: RequestStatus.IDLE, + isDeletedView: false, }, reducers: { fetchLearnerThreadsRequest: (state, { payload }) => ( @@ -399,6 +400,20 @@ const threadsSlice = createSlice({ confirmEmailStatus: RequestStatus.DENIED, } ), + toggleDeletedView: (state) => ( + { + ...state, + isDeletedView: !state.isDeletedView, + pages: [], // Clear pages when switching views + } + ), + setDeletedView: (state, { payload }) => ( + { + ...state, + isDeletedView: payload, + pages: [], // Clear pages when switching views + } + ), }, }); @@ -441,6 +456,8 @@ export const { sendAccountActivationEmailFailed, sendAccountActivationEmailRequest, sendAccountActivationEmailSuccess, + toggleDeletedView, + setDeletedView, } = threadsSlice.actions; export const threadsReducer = threadsSlice.reducer; diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index d905502b2..49647d911 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -141,6 +141,12 @@ export function fetchThreads(courseId, { if (filters.cohort) { options.cohort = filters.cohort; } + if (filters.status === PostsStatusFilter.ACTIVE) { + options.isDeleted = false; + } + if (filters.status === PostsStatusFilter.DELETED) { + options.isDeleted = true; + } return async (dispatch) => { try { dispatch(fetchThreadsRequest({ courseId })); @@ -314,6 +320,19 @@ export function removeThread(threadId) { }; } +export function performRestoreThread(threadId, courseId) { + return async () => { + try { + const { restoreThread } = await import('./api'); + await restoreThread(threadId, courseId); + return { success: true }; + } catch (error) { + logError(error); + return { success: false, error: error.message }; + } + }; +} + export function sendAccountActivationEmail() { return async (dispatch) => { try { diff --git a/src/discussions/posts/index.js b/src/discussions/posts/index.js index f7d3c75dc..acda620ac 100644 --- a/src/discussions/posts/index.js +++ b/src/discussions/posts/index.js @@ -1,4 +1,5 @@ export { showPostEditor } from './data'; export { default as Post } from './post/Post'; +export { default as PostLink } from './post/PostLink'; export { default as messages } from './post-actions-bar/messages'; export { default as PostsView } from './PostsView'; diff --git a/src/discussions/posts/post-filter-bar/messages.js b/src/discussions/posts/post-filter-bar/messages.js index eed8c084a..abad87a1b 100644 --- a/src/discussions/posts/post-filter-bar/messages.js +++ b/src/discussions/posts/post-filter-bar/messages.js @@ -51,6 +51,16 @@ const messages = defineMessages({ defaultMessage: 'Not responded', description: 'Option in dropdown to filter to unresponded posts', }, + filterActive: { + id: 'discussions.posts.status.filter.active', + defaultMessage: 'Active content', + description: 'Option in dropdown to filter to active (non-deleted) posts', + }, + filterDeleted: { + id: 'discussions.posts.status.filter.deleted', + defaultMessage: 'Deleted content', + description: 'Option in dropdown to filter to deleted posts', + }, myPosts: { id: 'discussions.posts.filter.myPosts', defaultMessage: 'My posts', @@ -99,6 +109,8 @@ const messages = defineMessages({ statusReported {reported} statusUnanswered {unanswered} statusUnresponded {unresponded} + statusActive {active} + statusDeleted {deleted} other {{status}} } {type, select, discussion {discussions} diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 5fed345c0..6343d17b8 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -2,6 +2,7 @@ import React, { useCallback, useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import { Hyperlink, useToggle } from '@openedx/paragon'; +import { DeleteOutline } from '@openedx/paragon/icons'; import classNames from 'classnames'; import { toString } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; @@ -9,11 +10,14 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; import HTMLLoader from '../../../components/HTMLLoader'; -import { ContentActions, getFullUrl } from '../../../data/constants'; +import { AvatarOutlineAndLabelColors, ContentActions, getFullUrl } from '../../../data/constants'; import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors'; -import { AlertBanner, Confirmation } from '../../common'; +import { + AlertBanner, AuthorLabel, AutoSpamAlertBanner, Confirmation, +} from '../../common'; import DiscussionContext from '../../common/context'; import HoverCard from '../../common/HoverCard'; import withPostingRestrictions from '../../common/withPostingRestrictions'; @@ -29,28 +33,31 @@ import PostFooter from './PostFooter'; import PostHeader from './PostHeader'; const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { - const { enableInContextSidebar, postId } = useContext(DiscussionContext); + const { enableInContextSidebar, postId, courseId } = useContext(DiscussionContext); + const threadData = useSelector(selectThread(postId)); const { topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName, closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel, - closedByLabel, users: postUsers, + closedByLabel, users: postUsers, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam, } = threadData; + const intl = useIntl(); const location = useLocation(); const navigate = useNavigate(); const dispatch = useDispatch(); - const { courseId } = useContext(DiscussionContext); const topic = useSelector(selectTopic(topicId)); const getTopicSubsection = useSelector(selectorForUnitSubsection); const topicContext = useSelector(selectTopicContext(topicId)); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); + const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); - + // If isSpam is not provided in the API response, default to false + const isSpamFlagged = isSpam || false; const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges); const handleDeleteConfirmation = useCallback(async () => { @@ -102,15 +109,34 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { } }, [abuseFlagged, postId, showReportConfirmation]); + const handleRestore = useCallback(() => { + showRestoreConfirmation(); + }, [showRestoreConfirmation]); + + const handleRestoreConfirmation = useCallback(async () => { + try { + const { performRestoreThread } = await import('../data/thunks'); + const result = await dispatch(performRestoreThread(postId, courseId)); + if (result.success) { + window.location.reload(); + } + } catch (error) { + logError(error); + } + hideRestoreConfirmation(); + }, [postId, courseId, dispatch, hideRestoreConfirmation]); + const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handlePostContentEdit, [ContentActions.DELETE]: showDeleteConfirmation, + [ContentActions.RESTORE]: handleRestore, [ContentActions.CLOSE]: handlePostClose, [ContentActions.COPY_LINK]: handlePostCopyLink, [ContentActions.PIN]: handlePostPin, [ContentActions.REPORT]: handlePostReport, }), [ handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation, + handleRestore, ]); const handleClosePostConfirmation = useCallback((closeReasonCode) => { @@ -146,6 +172,14 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { closeButtonVariant="tertiary" confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} /> + {!abuseFlagged && ( { onFollow={handlePostFollow} voted={voted} following={following} + isDeleted={isDeleted} /> + {isDeleted && deletedBy && ( +
+ +
+ {intl.formatMessage(messages.deletedBy)} + + + +
+
+ )} { closedByLabel={closedByLabel} postData={threadData} /> + { + if (type === 'response') { + return 'Response'; + } + if (type === 'comment') { + return 'Comment'; + } + return null; + }; + + // For comments/responses, show parent thread title with arrow + const displayTitle = (type === 'response' || type === 'comment') && threadTitle ? threadTitle : title; + + // Strip render_id suffix (e.g., "-thread", "-response", "-comment") for navigation + const stripRenderIdSuffix = (idValue) => { + if (typeof idValue === 'string') { + return idValue.replace(/-(thread|response|comment)$/, ''); + } + return idValue; + }; + + // For comments/responses, navigate to the parent thread instead of the comment itself + const rawNavigationId = (type === 'response' || type === 'comment') && commentThreadId ? commentThreadId : postId; + const navigationPostId = stripRenderIdSuffix(rawNavigationId); + const { pathname } = discussionsPath(Routes.COMMENTS.PAGES[page], { 0: enableInContextSidebar ? 'in-context' : undefined, courseId, topicId, - postId, + postId: navigationPostId, category, learnerUsername, })(); @@ -76,8 +103,10 @@ const PostLink = ({ 'd-flex flex-row pt-2 pb-2 px-4 border-primary-500 position-relative', { 'bg-light-300': isPostRead }, { 'post-summary-card-selected': id === selectedPostId }, + { 'bg-light-200': isDeleted }, // Gray background for deleted threads ) } + style={isDeleted ? { opacity: 0.7 } : {}} // Slightly faded for deleted threads >
+ {(type === 'response' || type === 'comment') && threadTitle && ( + <> + + + {getTypeLabel()} in + + + )} - {title} + {displayTitle} {isPostPreviewAvailable(previewBody) ? previewBody : intl.formatMessage(messages.postWithoutPreview)} @@ -121,12 +162,25 @@ const PostLink = ({ {' '}reported )} + {isDeleted && ( + + {intl.formatMessage(messages.deletedPost)} + {' '}deleted + + )} {pinned && ( )} diff --git a/src/discussions/posts/post/messages.js b/src/discussions/posts/post/messages.js index f095e1fbe..61c49c87a 100644 --- a/src/discussions/posts/post/messages.js +++ b/src/discussions/posts/post/messages.js @@ -24,6 +24,25 @@ const messages = defineMessages({ defaultMessage: 'Reported', description: 'Content reported for staff review', }, + deletedBy: { + id: 'discussions.post.deletedBy', + defaultMessage: 'Deleted by', + }, + deletedPost: { + id: 'discussions.post.deletedPost', + defaultMessage: 'Deleted', + description: 'Badge showing that the post has been deleted', + }, + deletedResponse: { + id: 'discussions.post.deletedResponse', + defaultMessage: 'Deleted', + description: 'Badge showing that the response has been deleted', + }, + deletedComment: { + id: 'discussions.post.deletedComment', + defaultMessage: 'Deleted', + description: 'Badge showing that the comment has been deleted', + }, following: { id: 'discussions.post.following', defaultMessage: 'Following', @@ -106,6 +125,14 @@ const messages = defineMessages({ defaultMessage: 'Delete', description: 'Delete button shown on delete confirmation dialog', }, + undeletePostTitle: { + id: 'discussions.editor.undelete.post.title', + defaultMessage: 'Restore post', + }, + undeletePostDescription: { + id: 'discussions.editor.undelete.post.description', + defaultMessage: 'Are you sure you want to restore this post?', + }, reportPostTitle: { id: 'discussions.editor.report.post.title', defaultMessage: 'Report inappropriate content?', @@ -171,6 +198,11 @@ const messages = defineMessages({ defaultMessage: 'you are not following this post', description: 'tell screen readers if user is not following a post', }, + deleted: { + id: 'discussions.post.deleted', + defaultMessage: 'Deleted', + description: 'Label shown on deleted threads', + }, }); export default messages; diff --git a/src/discussions/utils.js b/src/discussions/utils.js index fb139f3c6..2b6186377 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -14,6 +14,7 @@ import { import { getConfig } from '@edx/frontend-platform'; +import { ReactComponent as RestoreFromTrash } from '../assets/undelete.svg'; import { DENIED, LOADED } from '../components/NavigationBar/data/slice'; import { ContentActions, Routes, ThreadType, @@ -60,13 +61,17 @@ export function useCommentsPagePath() { * @returns {boolean} */ export function checkPermissions(content, action) { - if (content.editableFields.includes(action)) { + if (content.editableFields && content.editableFields.includes(action)) { return true; } // For delete action we check `content.canDelete` if (action === ContentActions.DELETE) { return true; } + // For restore action we check `content.canDelete` + if (action === ContentActions.RESTORE) { + return content.canDelete; + } return false; } @@ -182,7 +187,14 @@ export const ACTIONS_LIST = [ action: ContentActions.DELETE, icon: Delete, label: messages.deleteAction, - conditions: { canDelete: true }, + conditions: { canDelete: true, isDeleted: false }, + }, + { + id: 'restore', + action: ContentActions.RESTORE, + icon: RestoreFromTrash, + label: messages.restoreAction, + conditions: { canDelete: true, isDeleted: true }, }, ]; @@ -203,7 +215,11 @@ export function useActions(contentType, id) { action, conditions = null, }) => checkPermissions(content, action) && checkConditions(content, conditions), - ), [content]); + ).map(action => ({ + ...action, + // For deleted items, disable all actions except 'copy-link' and 'restore' + disabled: content.isDeleted && action.id !== 'copy-link' && action.id !== 'restore', + })), [content]); return actions; } diff --git a/src/index.scss b/src/index.scss index acecc50b9..b186c72b6 100755 --- a/src/index.scss +++ b/src/index.scss @@ -28,6 +28,19 @@ body, background-color: var(--pgn-color-card-bg-base) !important; } +// New learner message styling +.new-learner-message { + font-style: italic; + font-size: 12px; + margin-top: 0.25rem; + line-height: 1.2; + + @media (max-width: 767.98px) { + font-size: 11px; + margin-top: 0.1rem; + } +} + #post, #comment, #reply, @@ -56,6 +69,22 @@ body, outline: var(--pgn-color-success-700) solid 2px; } +.text-learner-color { + color: var(--pgn-color-primary-500); +} + +.outline-learner-color { + outline: var(--pgn-color-primary-500) solid 2px; +} + +.text-new-learner-color { + color: var(--pgn-color-accent-500); +} + +.outline-new-learner-color { + outline: var(--pgn-color-accent-500) solid 2px; +} + .outline-anonymous { outline: var(--pgn-color-light-400) solid 2px; } @@ -558,9 +587,37 @@ code { .actions-dropdown-item { padding: 12px 16px; height: 48px !important; - min-width: 195px !important + min-width: 195px !important; + border: none !important; + outline: none !important; +} + +.actions-dropdown-item:hover, +.actions-dropdown-item:focus, +.actions-dropdown-item:active { + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.learner-submenu-container { + min-width: 280px; + max-width: 320px; + z-index: 1051; + border: 1px solid var(--pgn-color-light-400) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important; + outline: none !important; } +.learner-submenu-container .actions-dropdown-item { + min-width: 280px !important; + max-width: 320px !important; + white-space: normal; + text-align: left; + border: none !important; +} + + .font-xl { font-size: 18px !important; line-height: 28px !important; @@ -702,8 +759,84 @@ th, td { } .spam-warning-banner { + letter-spacing: -0.1px; border-left: 4px solid var(--pgn-color-warning-500); margin-bottom: 0.5rem; + background-color: var(--pgn-color-warning-100); + color: var(--pgn-color-gray-900); + font-size: 15px; // Mobile default (bigger than body text) + + @media (min-width: 768px) { + font-size: 16px; // Desktop size + } + + // Target Paragon's internal classes with higher specificity + &.pgn__page-banner, + &.alert, + &.alert-warning { + background-color: var(--pgn-color-warning-100); + color: var(--pgn-color-gray-900); + } + + // Ensure nested Paragon components inherit our styling + .pgn__page-banner, + .alert, + .alert-warning { + background-color: inherit; + color: inherit; + font-size: inherit; + } + + // Target specific text elements instead of universal selector + p, span, strong, em, div { + background-color: inherit; + color: inherit; + font-size: inherit; + } + + // Content layout + .spam-warning-content { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } + + .spam-warning-text { + text-align: left; + display: flex; + align-items: center; + flex: 1; + } + + .spam-warning-message { + font-size: inherit; + color: inherit; + } + + // Warning icon styling + .spam-warning-icon { + margin-right: 0.5rem; + flex-shrink: 0; + color: var(--pgn-color-warning-600); + fill: var(--pgn-color-warning-600); + } + + // Override Paragon icon defaults if needed + .pgn__icon svg { + color: var(--pgn-color-warning-600); + fill: var(--pgn-color-warning-600); + } + + // Ensure icon and "Reminder:" stay together on all screen sizes + .spam-warning-text { + word-break: break-word; + + // Ensure proper text wrapping on small screens + @media (max-width: 767.98px) { + line-height: 1.4; + } + } .spam-warning-close-btn { background: none; @@ -744,12 +877,8 @@ th, td { } } - .alert-warning { - background-color: var(--pgn-color-warning-100); - color: var(--pgn-color-gray-900); - } - - @media (max-width: 768px) { + // Mobile responsive adjustments + @media (max-width: 767.98px) { margin: 0 0.25rem 0.5rem 0.25rem; border-radius: 0.375rem; @@ -763,3 +892,29 @@ th, td { } } } + +// Learner submenu styling +.learner-submenu-container { + position: absolute; + left: 100%; + top: 0; + min-width: 300px; + max-width: 360px; + z-index: 9999; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + border: 1px solid var(--pgn-color-light-400); + overflow: visible; +} + +// Deleted content icon styling +.deleted-content-icon { + width: 1.5rem; + height: 1.5rem; +} + +// Subdirectory arrow icon styling +.subdirectory-arrow-icon { + width: 16px; + height: 16px; + margin-right: 4px; +} From 20375710720f7834d75528d4bd61ab4112777943 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Mon, 29 Dec 2025 11:25:54 +0000 Subject: [PATCH 2/2] fix: copilot comments --- .../learners/LearnerActionsDropdown.jsx | 9 +++------ src/discussions/learners/data/slices.js | 1 + src/discussions/learners/data/thunks.js | 15 +++++++++----- .../LearnerPostFilterBar.jsx | 2 +- .../learners/learner/LearnerFooter.jsx | 10 +++++----- src/discussions/post-comments/data/hooks.js | 2 +- src/discussions/post-comments/data/thunks.js | 6 +++++- src/discussions/posts/NoResults.jsx | 2 +- src/discussions/posts/data/thunks.js | 12 ++++++++--- src/discussions/posts/post/Post.jsx | 5 +++-- src/discussions/utils.js | 20 +++++++++---------- 11 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/discussions/learners/LearnerActionsDropdown.jsx b/src/discussions/learners/LearnerActionsDropdown.jsx index 77eb916b4..65ee08fe6 100644 --- a/src/discussions/learners/LearnerActionsDropdown.jsx +++ b/src/discussions/learners/LearnerActionsDropdown.jsx @@ -45,13 +45,10 @@ const LearnerActionsDropdown = ({ setActiveSubmenu(null); }, [close]); - // Cleanup portal on unmount to prevent memory leaks + // Cleanup portal on unmount to prevent memory leaks and orphaned DOM nodes useEffect(() => () => { - if (isOpen) { - close(); - setTarget(null); - setActiveSubmenu(null); - } + setTarget(null); + setActiveSubmenu(null); }, []); return ( diff --git a/src/discussions/learners/data/slices.js b/src/discussions/learners/data/slices.js index 5a0e0f4dd..c80407f0f 100644 --- a/src/discussions/learners/data/slices.js +++ b/src/discussions/learners/data/slices.js @@ -122,6 +122,7 @@ const learnersSlice = createSlice({ ...state, status: RequestStatus.SUCCESSFUL, bulkDeleteStats: payload, + bulkUndeleteStats: payload, } ), undeleteUserPostsFailed: (state) => ( diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index c41c14a22..0a4e041a9 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -97,11 +97,16 @@ export function fetchUserPosts(courseId, { // Use dedicated deleted content endpoint when viewing deleted posts if (filters.contentStatus === PostsStatusFilter.DELETED) { - data = await getDeletedContent(courseId, { - author, - page, - pageSize: 10, - }); + try { + data = await getDeletedContent(courseId, { + author, + page, + pageSize: 10, + }); + } catch (importError) { + logError('Failed to fetch deleted content:', importError); + throw importError; + } } else { // Use regular learner posts endpoint for active content const options = { diff --git a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx index f44a1a31b..c3f698903 100644 --- a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx +++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx @@ -27,7 +27,7 @@ const LearnerPostFilterBar = () => { filters: ['type-all', 'type-discussions', 'type-questions'], }, { - name: 'status', // secondary status + name: 'status', filters: ['status-any', 'status-unread', 'status-unanswered', 'status-unresponded'], }, { diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx index 63f7b3622..b21d2db56 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -60,7 +60,7 @@ const LearnerFooter = ({ {threads}
- {canSeeDeletedStats && ( + {Boolean(canSeeDeletedStats) && ( )} - {canSeeLearnerReportedStats && ( + {Boolean(canSeeLearnerReportedStats) && (
- {activeFlags > 0 + {Boolean(activeFlags) && ( {intl.formatMessage(messages.reported, { reported: activeFlags })} )} - {inactiveFlags > 0 + {Boolean(inactiveFlags) && ( {intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })} @@ -103,7 +103,7 @@ const LearnerFooter = ({ >
- {activeFlags} {inactiveFlags > 0 && `/ ${inactiveFlags}`} + {activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`}
)} diff --git a/src/discussions/post-comments/data/hooks.js b/src/discussions/post-comments/data/hooks.js index acd0df11f..3252dfda5 100644 --- a/src/discussions/post-comments/data/hooks.js +++ b/src/discussions/post-comments/data/hooks.js @@ -44,7 +44,7 @@ export function usePost(postId) { const useShowDeletedContent = () => { const { learnerUsername } = useContext(DiscussionContext); - const postFilter = useSelector(state => state.learners?.postFilter); + const postFilter = useSelector(state => state.learners.postFilter); // Show deleted content if we're in learner view and the deleted filter is active (contentStatus) return learnerUsername && postFilter?.contentStatus === PostsStatusFilter.DELETED; diff --git a/src/discussions/post-comments/data/thunks.js b/src/discussions/post-comments/data/thunks.js index 1792e8330..2e18d7272 100644 --- a/src/discussions/post-comments/data/thunks.js +++ b/src/discussions/post-comments/data/thunks.js @@ -60,7 +60,11 @@ function normaliseComments(data) { commentsInThreads[threadId].push(id); } } - commentsById[id] = comment; + // Normalize editableFields to always be an array + commentsById[id] = { + ...comment, + editableFields: comment.editableFields || [], + }; }, ); return { diff --git a/src/discussions/posts/NoResults.jsx b/src/discussions/posts/NoResults.jsx index 138aa8bce..4c6484921 100644 --- a/src/discussions/posts/NoResults.jsx +++ b/src/discussions/posts/NoResults.jsx @@ -15,7 +15,7 @@ const NoResults = () => { const inContextTopicsFilter = useSelector(selectTopicFilter); const topicsFilter = useSelector(({ topics }) => topics.filter); const filters = useSelector((state) => state.threads.filters); - const learnersFilter = useSelector(({ learners }) => learners?.usernameSearch); + const learnersFilter = useSelector(({ learners }) => learners.usernameSearch); const isFiltered = postsFiltered || (topicsFilter !== '') || (learnersFilter) || (inContextTopicsFilter !== ''); diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index 49647d911..68ab55139 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -81,7 +81,11 @@ export function normaliseThreads(data, topicIds = null) { if (!threadsInTopic[topicId].includes(id)) { threadsInTopic[topicId].push(id); } - threadsById[id] = thread; + // Normalize editableFields to always be an array + threadsById[id] = { + ...thread, + editableFields: thread.editableFields || [], + }; avatars = { ...avatars, ...thread.users }; }, ); @@ -321,10 +325,12 @@ export function removeThread(threadId) { } export function performRestoreThread(threadId, courseId) { - return async () => { + return async (dispatch) => { try { const { restoreThread } = await import('./api'); - await restoreThread(threadId, courseId); + const data = await restoreThread(threadId, courseId); + // Update the thread in Redux state to reflect the restore + dispatch(updateThreadSuccess(camelCaseObject(data))); return { success: true }; } catch (error) { logError(error); diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 6343d17b8..702533a9c 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -117,8 +117,9 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { try { const { performRestoreThread } = await import('../data/thunks'); const result = await dispatch(performRestoreThread(postId, courseId)); - if (result.success) { - window.location.reload(); + // Check if restore failed and log the error + if (result && !result.success) { + logError(`Failed to restore thread: ${result.error || 'Unknown error'}`); } } catch (error) { logError(error); diff --git a/src/discussions/utils.js b/src/discussions/utils.js index 2b6186377..461a55021 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -61,15 +61,11 @@ export function useCommentsPagePath() { * @returns {boolean} */ export function checkPermissions(content, action) { - if (content.editableFields && content.editableFields.includes(action)) { + if (content.editableFields.includes(action)) { return true; } - // For delete action we check `content.canDelete` - if (action === ContentActions.DELETE) { - return true; - } - // For restore action we check `content.canDelete` - if (action === ContentActions.RESTORE) { + // Both delete and restore actions check `content.canDelete` + if (action === ContentActions.DELETE || action === ContentActions.RESTORE) { return content.canDelete; } return false; @@ -210,6 +206,11 @@ export function useActions(contentType, id) { : true ), []); + const isActionDisabled = useCallback((actionId, isDeleted) => ( + // For deleted items, disable all actions except 'copy-link' and 'restore' + isDeleted && actionId !== 'copy-link' && actionId !== 'restore' + ), []); + const actions = useMemo(() => ACTIONS_LIST.filter( ({ action, @@ -217,9 +218,8 @@ export function useActions(contentType, id) { }) => checkPermissions(content, action) && checkConditions(content, conditions), ).map(action => ({ ...action, - // For deleted items, disable all actions except 'copy-link' and 'restore' - disabled: content.isDeleted && action.id !== 'copy-link' && action.id !== 'restore', - })), [content]); + disabled: isActionDisabled(action.id, content.isDeleted), + })), [content, checkConditions, isActionDisabled]); return actions; }