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/data/constants.js b/src/data/constants.js index 269212d89..4919694c3 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -51,6 +51,7 @@ export const ContentActions = { COPY_LINK: 'copy_link', REPORT: 'abuse_flagged', DELETE: 'delete', + RESTORE: 'restore', FOLLOWING: 'following', CHANGE_GROUP: 'group_id', MARK_READ: 'read', @@ -60,6 +61,8 @@ export const ContentActions = { VOTE: 'voted', DELETE_COURSE_POSTS: 'delete-course-posts', DELETE_ORG_POSTS: 'delete-org-posts', + RESTORE_COURSE_POSTS: 'restore-course-posts', + RESTORE_ORG_POSTS: 'restore-org-posts', }; /** @@ -109,6 +112,8 @@ export const PostsStatusFilter = { REPORTED: 'statusReported', UNANSWERED: 'statusUnanswered', UNRESPONDED: 'statusUnresponded', + ACTIVE: 'statusActive', + DELETED: 'statusDeleted', }; /** @@ -132,6 +137,7 @@ export const LearnersOrdering = { BY_FLAG: 'flagged', BY_LAST_ACTIVITY: 'activity', BY_RECENCY: 'recency', + BY_DELETED: 'deleted', }; /** diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index 359125081..b88148ea7 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -78,10 +78,13 @@ const ActionsDropdown = ({ size="inline" onClick={() => { close(); - handleActions(action.action); + if (!action.disabled) { + handleActions(action.action); + } }} className="d-flex justify-content-start actions-dropdown-item" data-testId={action.id} + disabled={action.disabled} > { 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={() => !isDeleted && handleResponseCommentButton()} + disabled={isClosed || isDeleted} + style={{ lineHeight: '20px', ...(isDeleted ? { opacity: 0.3, cursor: 'not-allowed' } : {}) }} > {addResponseCommentButtonMessage} @@ -72,12 +73,16 @@ const HoverCard = ({ src={endorseIcons.icon} iconAs={Icon} onClick={() => { - const actionFunction = actionHandlers[endorseIcons.action]; - actionFunction(); + if (!isDeleted) { + const actionFunction = actionHandlers[endorseIcons.action]; + actionFunction(); + } }} 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,11 +100,14 @@ 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(); + if (!isDeleted) { + onLike(); + } }} /> @@ -119,9 +127,13 @@ 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(); + if (!isDeleted) { + onFollow(); + } }} /> @@ -165,12 +177,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/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..b17eef5e5 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..2191906ac 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; + } + + // Secondary status filters (independent) + 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; + } + + 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 3589aaf0d..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,51 @@ 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.', 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 c2d96be80..913481ef1 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -8,18 +8,19 @@ import 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, - AutoSpamAlertBanner, - Confirmation, - EndorsedAlertBanner, + 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'; @@ -27,6 +28,7 @@ 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'; @@ -55,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, is_spam: isSpam, + 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)); @@ -76,6 +82,11 @@ 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(() => { @@ -84,9 +95,10 @@ const Comment = ({ dispatch(fetchCommentResponses(id, { page: 1, reverseOrder: sortedOrder, + showDeleted, })); } - }, [id, sortedOrder]); + }, [id, sortedOrder, showDeleted]); const endorseIcons = useMemo(() => ( actions.find(({ action }) => action === EndorsementStatus.ENDORSED) @@ -123,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) { @@ -173,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, is_spam: isSpam, + 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 @@ -70,6 +79,23 @@ const Reply = ({ responseId }) => { } }, [abuseFlagged, id, showReportConfirmation]); + 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) { + await dispatch(fetchThread(threadId, courseId)); + } + } catch (error) { + logError(error); + } + hideRestoreConfirmation(); + }, [id, courseId, threadId, dispatch, hideRestoreConfirmation]); + const handleCloseEditor = useCallback(() => { setEditing(false); }, []); @@ -78,8 +104,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 (
@@ -92,6 +119,14 @@ const Reply = ({ responseId }) => { closeButtonVariant="tertiary" confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} /> + {!abuseFlagged && ( {
)} + {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 965a81c67..bdbaf3574 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, AutoSpamAlertBanner, 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,22 +33,24 @@ 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, is_spam: isSpam, + 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); @@ -103,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) => { @@ -147,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)} + + + +
+
+ )} { + 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 +163,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 fdff64ff1..a43e47598 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;