From 871164005685d5153b91e3a41358f2db84325da6 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Wed, 5 Nov 2025 13:29:33 +0000 Subject: [PATCH 01/19] feat: added soft delete functionality --- src/assets/undelete.svg | 3 + src/components/FilterBar.jsx | 44 ++- src/data/constants.js | 7 + src/discussions/common/ActionsDropdown.jsx | 6 +- src/discussions/common/HoverCard.jsx | 30 +- src/discussions/components/FilterBar.jsx | 160 +++++++++ src/discussions/components/FilterBar.scss | 335 ++++++++++++++++++ src/discussions/components/FilterBar.test.jsx | 301 ++++++++++++++++ src/discussions/components/index.js | 1 + src/discussions/data/constants.js | 5 + src/discussions/data/selectors.js | 15 +- .../data/services/softDeleteService.js | 126 +++++++ src/discussions/data/slices.js | 62 ++++ src/discussions/data/thunks.js | 62 +++- src/discussions/learners/LearnerPostsView.jsx | 45 ++- .../learners/LearnerPostsView.test.jsx | 4 +- src/discussions/learners/data/api.js | 28 ++ src/discussions/learners/data/redux.test.jsx | 2 +- src/discussions/learners/data/slices.js | 24 +- src/discussions/learners/data/thunks.js | 54 ++- .../LearnerPostFilterBar.jsx | 14 +- .../LearnerPostFilterBar.test.jsx | 17 +- .../learners/learner/LearnerCard.jsx | 5 + .../learners/learner/LearnerFilterBar.jsx | 11 +- .../learners/learner/LearnerFooter.jsx | 43 ++- src/discussions/learners/learner/proptypes.js | 4 + src/discussions/learners/messages.js | 40 +++ src/discussions/learners/utils.js | 13 + src/discussions/messages.js | 55 +++ .../comments/comment/Comment.jsx | 101 +++++- .../comments/comment/CommentHeader.jsx | 34 +- .../post-comments/comments/comment/Reply.jsx | 98 ++++- .../data/__factories__/comments.factory.js | 1 + src/discussions/post-comments/data/thunks.js | 26 ++ 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 | 53 +++ src/discussions/posts/data/selectors.js | 2 + src/discussions/posts/data/slices.js | 19 +- src/discussions/posts/data/thunks.js | 16 +- .../posts/post-filter-bar/PostFilterBar.jsx | 5 +- .../posts/post-filter-bar/messages.js | 12 + src/discussions/posts/post/Post.jsx | 94 ++++- src/discussions/posts/post/PostLink.jsx | 22 +- src/discussions/posts/post/messages.js | 22 ++ src/discussions/utils.js | 26 +- src/index.scss | 33 +- 49 files changed, 2006 insertions(+), 117 deletions(-) create mode 100644 src/assets/undelete.svg create mode 100644 src/discussions/components/FilterBar.jsx create mode 100644 src/discussions/components/FilterBar.scss create mode 100644 src/discussions/components/FilterBar.test.jsx create mode 100644 src/discussions/components/index.js create mode 100644 src/discussions/data/services/softDeleteService.js 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..a08084e24 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 && ( <>
diff --git a/src/data/constants.js b/src/data/constants.js index 269212d89..f590292bf 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -51,6 +51,8 @@ export const ContentActions = { COPY_LINK: 'copy_link', REPORT: 'abuse_flagged', DELETE: 'delete', + SOFT_DELETE: 'soft_delete', + RESTORE: 'restore', FOLLOWING: 'following', CHANGE_GROUP: 'group_id', MARK_READ: 'read', @@ -60,6 +62,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 +113,8 @@ export const PostsStatusFilter = { REPORTED: 'statusReported', UNANSWERED: 'statusUnanswered', UNRESPONDED: 'statusUnresponded', + ACTIVE: 'statusActive', + DELETED: 'statusDeleted', }; /** @@ -132,6 +138,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..db2de5bc6 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -78,10 +78,14 @@ 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} + style={action.disabled ? { opacity: 0.3, cursor: 'not-allowed' } : {}} > { 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/components/FilterBar.jsx b/src/discussions/components/FilterBar.jsx new file mode 100644 index 000000000..9c3681e1b --- /dev/null +++ b/src/discussions/components/FilterBar.jsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Badge, Button, Spinner } from '@openedx/paragon'; +import { DeleteOutline, RestoreOutline } from '@openedx/paragon/icons'; +import classNames from 'classnames'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; + +const FilterBar = ({ + isDeletedView, + setIsDeletedView, + selectedThreadIds = [], + onBulkAction, + isLoading = false, +}) => { + const intl = useIntl(); + const [pendingAction, setPendingAction] = useState(null); + + const handleBulkSoftDelete = async () => { + if (selectedThreadIds.length === 0) { return; } + + setPendingAction('soft-delete'); + try { + await onBulkAction('soft-delete', selectedThreadIds); + } finally { + setPendingAction(null); + } + }; + + const handleBulkRestore = async () => { + if (selectedThreadIds.length === 0) { return; } + + setPendingAction('restore'); + try { + await onBulkAction('restore', selectedThreadIds); + } finally { + setPendingAction(null); + } + }; + + const hasSelectedThreads = selectedThreadIds.length > 0; + + return ( +
+ {/* Filter Toggle Buttons */} +
+ + + + {isLoading && ( + + )} +
+ + {/* Bulk Actions */} + {hasSelectedThreads && onBulkAction && ( +
+ + {intl.formatMessage(messages.selectedCount, { count: selectedThreadIds.length })} + + + {!isDeletedView && ( + + )} + + {isDeletedView && ( + + )} +
+ )} +
+ ); +}; + +FilterBar.propTypes = { + isDeletedView: PropTypes.bool.isRequired, + setIsDeletedView: PropTypes.func.isRequired, + selectedThreadIds: PropTypes.arrayOf(PropTypes.string), + onBulkAction: PropTypes.func, + isLoading: PropTypes.bool, +}; + +export default FilterBar; diff --git a/src/discussions/components/FilterBar.scss b/src/discussions/components/FilterBar.scss new file mode 100644 index 000000000..510a72ec5 --- /dev/null +++ b/src/discussions/components/FilterBar.scss @@ -0,0 +1,335 @@ +/** + * Styles for soft delete functionality in discussions + */ + +/* Filter Bar Styles */ +.filter-bar { + background: var(--bs-light-100, #f8f9fa); + border: 1px solid var(--bs-light-400, #dee2e6); + border-radius: 8px; + padding: 16px; + margin-bottom: 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.filter-bar .filter-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.filter-bar .bulk-actions { + display: flex; + align-items: center; + gap: 12px; +} + +/* Filter Button Styles */ +.filter-bar .btn { + padding: 8px 16px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + border-width: 1px; + transition: all 0.2s ease-in-out; +} + +.filter-bar .btn:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.filter-bar .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Active/Deleted Toggle Buttons */ +.filter-bar .btn-primary { + background-color: var(--bs-primary, #0066cc); + border-color: var(--bs-primary, #0066cc); + color: white; +} + +.filter-bar .btn-outline-primary { + background-color: transparent; + border-color: var(--bs-primary, #0066cc); + color: var(--bs-primary, #0066cc); +} + +.filter-bar .btn-outline-primary:hover { + background-color: var(--bs-primary, #0066cc); + color: white; +} + +/* Bulk Action Buttons */ +.filter-bar .btn-outline-danger { + background-color: transparent; + border-color: var(--bs-danger, #dc3545); + color: var(--bs-danger, #dc3545); +} + +.filter-bar .btn-outline-danger:hover { + background-color: var(--bs-danger, #dc3545); + color: white; +} + +.filter-bar .btn-outline-success { + background-color: transparent; + border-color: var(--bs-success, #198754); + color: var(--bs-success, #198754); +} + +.filter-bar .btn-outline-success:hover { + background-color: var(--bs-success, #198754); + color: white; +} + +/* Badge Styles */ +.filter-bar .badge { + padding: 6px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.filter-bar .badge-secondary { + background-color: var(--bs-secondary, #6c757d); + color: white; +} + +/* Loading Spinner */ +.filter-bar .spinner-border { + width: 20px; + height: 20px; + border-width: 2px; +} + +.filter-bar .spinner-border-sm { + width: 16px; + height: 16px; + border-width: 2px; +} + +/* Thread List Styles for Soft Delete */ +.thread-item.deleted { + background-color: var(--bs-light-200, #f1f3f5); + border-left: 4px solid var(--bs-warning, #ffc107); + opacity: 0.8; + position: relative; +} + +.thread-item.deleted::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient( + 45deg, + transparent, + transparent 10px, + rgba(255, 193, 7, 0.1) 10px, + rgba(255, 193, 7, 0.1) 20px + ); + pointer-events: none; +} + +.thread-item.deleted .thread-title { + color: var(--bs-secondary, #6c757d); + text-decoration: line-through; +} + +.thread-item.deleted .thread-content { + color: var(--bs-secondary, #6c757d); +} + +/* Deleted Thread Badge */ +.deleted-badge { + background-color: var(--bs-warning, #ffc107); + color: var(--bs-dark, #212529); + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.deleted-badge::before { + content: '⚠️'; + font-size: 10px; +} + +/* Thread Selection Styles */ +.thread-item.selectable { + cursor: pointer; + transition: all 0.2s ease-in-out; + border: 2px solid transparent; +} + +.thread-item.selectable:hover { + border-color: var(--bs-primary-200, #b3d9ff); + box-shadow: 0 2px 8px rgba(0, 102, 204, 0.15); +} + +.thread-item.selected { + border-color: var(--bs-primary, #0066cc); + background-color: var(--bs-primary-50, #e6f3ff); +} + +.thread-checkbox { + position: absolute; + top: 12px; + right: 12px; + z-index: 2; +} + +.thread-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +/* Action Button Styles */ +.thread-actions .btn-soft-delete { + background-color: transparent; + border: 1px solid var(--bs-warning, #ffc107); + color: var(--bs-warning, #ffc107); + padding: 4px 8px; + font-size: 12px; + border-radius: 4px; + transition: all 0.2s ease-in-out; +} + +.thread-actions .btn-soft-delete:hover { + background-color: var(--bs-warning, #ffc107); + color: var(--bs-dark, #212529); +} + +.thread-actions .btn-restore { + background-color: transparent; + border: 1px solid var(--bs-success, #198754); + color: var(--bs-success, #198754); + padding: 4px 8px; + font-size: 12px; + border-radius: 4px; + transition: all 0.2s ease-in-out; +} + +.thread-actions .btn-restore:hover { + background-color: var(--bs-success, #198754); + color: white; +} + +/* Animation for Bulk Actions */ +.bulk-action-pending { + opacity: 0.6; + pointer-events: none; + position: relative; +} + +.bulk-action-pending::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .filter-bar { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .filter-bar .filter-controls { + justify-content: center; + } + + .filter-bar .bulk-actions { + justify-content: center; + flex-wrap: wrap; + } + + .filter-bar .btn { + min-width: 120px; + } +} + +@media (max-width: 480px) { + .filter-bar .bulk-actions { + flex-direction: column; + width: 100%; + } + + .filter-bar .btn { + width: 100%; + justify-content: center; + } +} + +/* Focus States for Accessibility */ +.filter-bar .btn:focus, +.thread-checkbox input:focus, +.thread-item.selectable:focus { + outline: 2px solid var(--bs-primary, #0066cc); + outline-offset: 2px; +} + +/* High Contrast Mode Support */ +@media (prefers-contrast: high) { + .thread-item.deleted { + border-left-width: 6px; + background-color: var(--bs-light-300, #e9ecef); + } + + .deleted-badge { + border: 2px solid var(--bs-warning, #ffc107); + } + + .filter-bar { + border-width: 2px; + } +} + +/* Reduced Motion Support */ +@media (prefers-reduced-motion: reduce) { + .filter-bar .btn, + .thread-item.selectable, + .bulk-action-pending::after { + transition: none; + animation: none; + } + + .filter-bar .btn:hover { + transform: none; + } +} \ No newline at end of file diff --git a/src/discussions/components/FilterBar.test.jsx b/src/discussions/components/FilterBar.test.jsx new file mode 100644 index 000000000..82bb939c1 --- /dev/null +++ b/src/discussions/components/FilterBar.test.jsx @@ -0,0 +1,301 @@ +/** + * Tests for FilterBar component with soft delete functionality + */ +import React from 'react'; + +import { configureStore } from '@reduxjs/toolkit'; +import { + fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; + +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import { threadsReducer } from '../posts/data/slices'; +import FilterBar from './FilterBar'; + +// Mock the soft delete service +jest.mock('../data/services/softDeleteService', () => ({ + bulkSoftDeleteThreads: jest.fn(), + bulkRestoreThreads: jest.fn(), +})); + +describe('FilterBar Component', () => { + let store; + let mockOnBulkAction; + + const createStore = (initialState = {}) => configureStore({ + reducer: { + threads: threadsReducer, + }, + preloadedState: { + threads: { + filter: 'active', + loading: false, + selectedThreadIds: [], + bulkActionStatus: 'idle', + bulkActionError: null, + ...initialState.threads, + }, + }, + }); + + const renderFilterBar = (props = {}) => render( + + + + + , + ); + + beforeEach(() => { + store = createStore(); + mockOnBulkAction = jest.fn().mockResolvedValue({ success: true }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Basic Rendering', () => { + test('renders filter buttons correctly', () => { + renderFilterBar(); + + expect(screen.getByTestId('active-threads-button')).toBeInTheDocument(); + expect(screen.getByTestId('deleted-threads-button')).toBeInTheDocument(); + expect(screen.getByText('Active Threads')).toBeInTheDocument(); + expect(screen.getByText('Deleted Threads')).toBeInTheDocument(); + }); + + test('shows active filter as primary by default', () => { + renderFilterBar(); + + const activeButton = screen.getByTestId('active-threads-button'); + const deletedButton = screen.getByTestId('deleted-threads-button'); + + expect(activeButton).toHaveClass('btn-primary'); + expect(deletedButton).toHaveClass('btn-outline-primary'); + }); + + test('shows deleted filter as primary when in deleted view', () => { + renderFilterBar({ isDeletedView: true }); + + const activeButton = screen.getByTestId('active-threads-button'); + const deletedButton = screen.getByTestId('deleted-threads-button'); + + expect(activeButton).toHaveClass('btn-outline-primary'); + expect(deletedButton).toHaveClass('btn-primary'); + }); + }); + + describe('Loading State', () => { + test('disables buttons when loading', () => { + renderFilterBar({ isLoading: true }); + + const activeButton = screen.getByTestId('active-threads-button'); + const deletedButton = screen.getByTestId('deleted-threads-button'); + + expect(activeButton).toBeDisabled(); + expect(deletedButton).toBeDisabled(); + }); + + test('shows loading spinner when loading', () => { + renderFilterBar({ isLoading: true }); + + expect(screen.getByText('Loading threads...')).toBeInTheDocument(); + }); + }); + + describe('Bulk Actions - Active View', () => { + test('shows bulk delete button when threads are selected in active view', () => { + const selectedThreadIds = ['thread1', 'thread2']; + renderFilterBar({ + selectedThreadIds, + isDeletedView: false, + }); + + expect(screen.getByText('2 selected')).toBeInTheDocument(); + expect(screen.getByTestId('bulk-delete-button')).toBeInTheDocument(); + expect(screen.getByText('Delete Selected')).toBeInTheDocument(); + }); + + test('handles bulk delete action correctly', async () => { + const selectedThreadIds = ['thread1', 'thread2']; + renderFilterBar({ + selectedThreadIds, + isDeletedView: false, + }); + + const deleteButton = screen.getByTestId('bulk-delete-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnBulkAction).toHaveBeenCalledWith('soft-delete', selectedThreadIds); + }); + }); + + test('shows loading state during bulk delete', async () => { + const selectedThreadIds = ['thread1']; + mockOnBulkAction.mockImplementation(() => new Promise((resolve) => { + setTimeout(resolve, 100); + })); + + renderFilterBar({ + selectedThreadIds, + isDeletedView: false, + }); + + const deleteButton = screen.getByTestId('bulk-delete-button'); + fireEvent.click(deleteButton); + + expect(screen.getByText('Deleting...')).toBeInTheDocument(); + expect(deleteButton).toBeDisabled(); + }); + }); + + describe('Bulk Actions - Deleted View', () => { + test('shows bulk restore button when threads are selected in deleted view', () => { + const selectedThreadIds = ['thread1', 'thread2']; + renderFilterBar({ + selectedThreadIds, + isDeletedView: true, + }); + + expect(screen.getByText('2 selected')).toBeInTheDocument(); + expect(screen.getByTestId('bulk-restore-button')).toBeInTheDocument(); + expect(screen.getByText('Restore Selected')).toBeInTheDocument(); + }); + + test('handles bulk restore action correctly', async () => { + const selectedThreadIds = ['thread1', 'thread2']; + renderFilterBar({ + selectedThreadIds, + isDeletedView: true, + }); + + const restoreButton = screen.getByTestId('bulk-restore-button'); + fireEvent.click(restoreButton); + + await waitFor(() => { + expect(mockOnBulkAction).toHaveBeenCalledWith('restore', selectedThreadIds); + }); + }); + + test('shows loading state during bulk restore', async () => { + const selectedThreadIds = ['thread1']; + mockOnBulkAction.mockImplementation(() => new Promise((resolve) => { + setTimeout(resolve, 100); + })); + + renderFilterBar({ + selectedThreadIds, + isDeletedView: true, + }); + + const restoreButton = screen.getByTestId('bulk-restore-button'); + fireEvent.click(restoreButton); + + expect(screen.getByText('Restoring...')).toBeInTheDocument(); + expect(restoreButton).toBeDisabled(); + }); + }); + + describe('No Selection State', () => { + test('does not show bulk actions when no threads are selected', () => { + renderFilterBar({ selectedThreadIds: [] }); + + expect(screen.queryByText('Delete Selected')).not.toBeInTheDocument(); + expect(screen.queryByText('Restore Selected')).not.toBeInTheDocument(); + expect(screen.queryByText('selected')).not.toBeInTheDocument(); + }); + + test('does not show bulk actions when onBulkAction is not provided', () => { + const selectedThreadIds = ['thread1', 'thread2']; + renderFilterBar({ + selectedThreadIds, + onBulkAction: undefined, + }); + + expect(screen.queryByText('Delete Selected')).not.toBeInTheDocument(); + expect(screen.queryByText('Restore Selected')).not.toBeInTheDocument(); + }); + }); + + describe('Error Handling', () => { + test('resets loading state after bulk action completion', async () => { + const selectedThreadIds = ['thread1']; + let resolveAction; + mockOnBulkAction.mockImplementation(() => new Promise((resolve) => { + resolveAction = resolve; + })); + + renderFilterBar({ + selectedThreadIds, + isDeletedView: false, + }); + + const deleteButton = screen.getByTestId('bulk-delete-button'); + fireEvent.click(deleteButton); + + // Button should be disabled during action + await waitFor(() => { + expect(deleteButton).toBeDisabled(); + }); + + // Complete the action + resolveAction(); + + // Button should be re-enabled after action completes + await waitFor(() => { + expect(deleteButton).not.toBeDisabled(); + }); + }); + }); + + describe('Accessibility', () => { + test('has proper aria labels and roles', () => { + const selectedThreadIds = ['thread1', 'thread2']; + renderFilterBar({ selectedThreadIds }); + + const activeButton = screen.getByTestId('active-threads-button'); + const deletedButton = screen.getByTestId('deleted-threads-button'); + + // Button components render as actual button elements + expect(activeButton.tagName).toBe('BUTTON'); + expect(deletedButton.tagName).toBe('BUTTON'); + }); + + test('spinner has proper screen reader text', () => { + renderFilterBar({ isLoading: true }); + + const spinner = screen.getByText('Loading threads...'); + expect(spinner).toBeInTheDocument(); + }); + }); + + describe('Internationalization', () => { + test('displays localized messages correctly', () => { + renderFilterBar(); + + // Check that messages are being used correctly + expect(screen.getByText(messages.activeThreads.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.deletedThreads.defaultMessage)).toBeInTheDocument(); + }); + + test('displays localized bulk action messages', () => { + const selectedThreadIds = ['thread1', 'thread2']; + renderFilterBar({ selectedThreadIds }); + + expect(screen.getByText('2 selected')).toBeInTheDocument(); + expect(screen.getByText('Delete Selected')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/discussions/components/index.js b/src/discussions/components/index.js new file mode 100644 index 000000000..3489d6a14 --- /dev/null +++ b/src/discussions/components/index.js @@ -0,0 +1 @@ +export { default as FilterBar } from './FilterBar'; 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..6056bcc9e 100644 --- a/src/discussions/data/selectors.js +++ b/src/discussions/data/selectors.js @@ -66,9 +66,11 @@ export function selectAreThreadsFiltered(state) { return true; } + // Treat both ALL and ACTIVE as unfiltered since ACTIVE is now the default status return !( - filters.status === PostsStatusFilter.ALL + (filters.status === PostsStatusFilter.ALL || filters.status === PostsStatusFilter.ACTIVE) && filters.postType === ThreadType.ALL + && (filters.cohort === '' || !filters.cohort) ); } @@ -111,3 +113,14 @@ export const selectIsUserLearner = createSelector( ) || false ), ); + +// Threads selectors for soft delete functionality +export const selectThreadsFilter = state => state.threads?.filter || 'active'; + +export const selectThreadsLoading = state => state.threads?.loading || false; + +export const selectSelectedThreadIds = state => state.threads?.selectedThreadIds || []; + +export const selectBulkActionStatus = state => state.threads?.bulkActionStatus; + +export const selectBulkActionError = state => state.threads?.bulkActionError; diff --git a/src/discussions/data/services/softDeleteService.js b/src/discussions/data/services/softDeleteService.js new file mode 100644 index 000000000..5790ea0b2 --- /dev/null +++ b/src/discussions/data/services/softDeleteService.js @@ -0,0 +1,126 @@ +/** + * API service for soft delete operations on threads + */ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +// Use the LMS API base URL from the platform configuration +const getDiscussionApiBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1`; + +/** + * Soft delete a single thread by calling the DELETE endpoint + * @param {string} threadId - The ID of the thread to soft delete + * @returns {Promise} API response + */ +export async function softDeleteThread(threadId) { + const url = `${getDiscussionApiBaseUrl()}/threads/${threadId}/`; + return getAuthenticatedHttpClient().delete(url); +} + +/** + * Restore a soft deleted thread + * @param {string} threadId - The ID of the thread to restore + * @returns {Promise} API response + */ +export async function restoreThread(threadId) { + const url = `${getDiscussionApiBaseUrl()}/threads/${threadId}/restore/`; + return getAuthenticatedHttpClient().post(url, {}); +} + +/** + * Bulk soft delete multiple threads + * NOTE: Currently implemented as sequential deletes. + * TODO: Implement true bulk endpoint for better performance + * @param {string[]} threadIds - Array of thread IDs to soft delete + * @returns {Promise} API response + */ +export async function bulkSoftDeleteThreads(threadIds) { + const results = await Promise.allSettled( + threadIds.map(threadId => softDeleteThread(threadId)), + ); + + return { + successful: results.filter(r => r.status === 'fulfilled').length, + failed: results.filter(r => r.status === 'rejected').length, + results, + }; +} + +/** + * Bulk restore multiple soft deleted threads + * NOTE: Currently implemented as sequential restores. + * TODO: Implement true bulk endpoint for better performance + * @param {string[]} threadIds - Array of thread IDs to restore + * @returns {Promise} API response + */ +export async function bulkRestoreThreads(threadIds) { + const results = await Promise.allSettled( + threadIds.map(threadId => restoreThread(threadId)), + ); + + return { + successful: results.filter(r => r.status === 'fulfilled').length, + failed: results.filter(r => r.status === 'rejected').length, + results, + }; +} + +/** + * Get soft deleted threads for a course using the learner API + * @param {string} courseId - The course ID + * @param {Object} options - Additional query options + * @param {string} options.userId - Optional user ID to filter by author + * @param {string} options.username - Username to filter by (required) + * @param {number} options.page - Page number for pagination + * @param {number} options.pageSize - Number of threads per page + * @param {string} options.sortKey - Sort key for ordering results (last_activity_at, comment_count, vote_count) + * @returns {Promise} API response + */ +export async function getDeletedThreads(courseId, options = {}) { + const url = `${getDiscussionApiBaseUrl()}/courses/${courseId}/learner/`; + + const params = { + username: options.username, + page: options.page || 1, + page_size: options.pageSize || 20, + show_deleted: true, // This is the key parameter to get deleted threads + ...options.sortKey && { order_by: options.sortKey }, + }; + + return getAuthenticatedHttpClient().get(url, { params }); +} + +/** + * Search threads with optional deleted filter + * @param {Object} searchParams - Search parameters including courseId + * @returns {Promise} API response + */ +export async function searchThreadsWithDeletedFilter(searchParams) { + const url = `${getDiscussionApiBaseUrl()}/threads/`; + + const params = { + ...searchParams, + }; + + return getAuthenticatedHttpClient().get(url, { params }); +} + +/** + * Soft delete a single comment by calling the DELETE endpoint + * @param {string} commentId - The ID of the comment to soft delete + * @returns {Promise} API response + */ +export async function softDeleteComment(commentId) { + const url = `${getDiscussionApiBaseUrl()}/comments/${commentId}/`; + return getAuthenticatedHttpClient().delete(url); +} + +/** + * Restore a soft deleted comment + * @param {string} commentId - The ID of the comment to restore + * @returns {Promise} API response + */ +export async function restoreComment(commentId) { + const url = `${getDiscussionApiBaseUrl()}/comments/${commentId}/restore/`; + return getAuthenticatedHttpClient().post(url, {}); +} diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js index b6bc2e7ff..e6727daed 100644 --- a/src/discussions/data/slices.js +++ b/src/discussions/data/slices.js @@ -1,3 +1,4 @@ +/* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; import { RequestStatus } from '../../data/constants'; @@ -75,3 +76,64 @@ export const { } = configSlice.actions; export const configReducer = configSlice.reducer; + +// Threads slice for soft delete functionality +const threadsSlice = createSlice({ + name: 'threads', + initialState: { + filter: 'active', // 'active' or 'deleted' + loading: false, + selectedThreadIds: [], + bulkActionStatus: RequestStatus.IDLE, + bulkActionError: null, + }, + reducers: { + setThreadsFilter: (state, { payload }) => { + state.filter = payload; + state.selectedThreadIds = []; // Clear selection when changing filter + }, + setThreadsLoading: (state, { payload }) => { + state.loading = payload; + }, + setSelectedThreadIds: (state, { payload }) => { + state.selectedThreadIds = payload; + }, + toggleThreadSelection: (state, { payload }) => { + const threadId = payload; + const index = state.selectedThreadIds.indexOf(threadId); + if (index > -1) { + state.selectedThreadIds.splice(index, 1); + } else { + state.selectedThreadIds.push(threadId); + } + }, + clearThreadSelection: (state) => { + state.selectedThreadIds = []; + }, + bulkActionRequest: (state) => { + state.bulkActionStatus = RequestStatus.IN_PROGRESS; + state.bulkActionError = null; + }, + bulkActionSuccess: (state) => { + state.bulkActionStatus = RequestStatus.SUCCESSFUL; + state.selectedThreadIds = []; // Clear selection after successful action + }, + bulkActionFailed: (state, { payload }) => { + state.bulkActionStatus = RequestStatus.FAILED; + state.bulkActionError = payload; + }, + }, +}); + +export const { + setThreadsFilter, + setThreadsLoading, + setSelectedThreadIds, + toggleThreadSelection, + clearThreadSelection, + bulkActionRequest, + bulkActionSuccess, + bulkActionFailed, +} = threadsSlice.actions; + +export const threadsReducer = threadsSlice.reducer; diff --git a/src/discussions/data/thunks.js b/src/discussions/data/thunks.js index f57b09974..3e046dae0 100644 --- a/src/discussions/data/thunks.js +++ b/src/discussions/data/thunks.js @@ -10,7 +10,8 @@ import { setStatusFilter } from '../posts/data'; import { getHttpErrorStatus } from '../utils'; import { getDiscussionsConfig, getDiscussionsSettings } from './api'; import { - fetchConfigDenied, fetchConfigFailed, fetchConfigRequest, fetchConfigSuccess, + bulkActionFailed, + bulkActionRequest, bulkActionSuccess, fetchConfigDenied, fetchConfigFailed, fetchConfigRequest, fetchConfigSuccess, } from './slices'; /** @@ -51,3 +52,62 @@ export default function fetchCourseConfig(courseId) { } }; } + +// Soft delete thunks +export function performBulkSoftDelete(threadIds, userId, courseId) { + return async (dispatch) => { + try { + dispatch(bulkActionRequest()); + const { bulkSoftDeleteThreads } = await import('./services/softDeleteService'); + await bulkSoftDeleteThreads(threadIds, userId, courseId); + dispatch(bulkActionSuccess()); + return { success: true }; + } catch (error) { + dispatch(bulkActionFailed(error.message || 'Failed to delete threads')); + logError(error); + return { success: false, error: error.message }; + } + }; +} + +export function performBulkRestore(threadIds, courseId) { + return async (dispatch) => { + try { + dispatch(bulkActionRequest()); + const { bulkRestoreThreads } = await import('./services/softDeleteService'); + await bulkRestoreThreads(threadIds, courseId); + dispatch(bulkActionSuccess()); + return { success: true }; + } catch (error) { + dispatch(bulkActionFailed(error.message || 'Failed to restore threads')); + logError(error); + return { success: false, error: error.message }; + } + }; +} + +export function performSoftDeleteThread(threadId, userId, courseId) { + return async () => { + try { + const { softDeleteThread } = await import('./services/softDeleteService'); + await softDeleteThread(threadId, userId, courseId); + return { success: true }; + } catch (error) { + logError(error); + return { success: false, error: error.message }; + } + }; +} + +export function performRestoreThread(threadId, courseId) { + return async () => { + try { + const { restoreThread } = await import('./services/softDeleteService'); + await restoreThread(threadId, courseId); + return { success: true }; + } catch (error) { + logError(error); + return { success: false, error: error.message }; + } + }; +} diff --git a/src/discussions/learners/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx index 846b254b3..7654fa3a5 100644 --- a/src/discussions/learners/LearnerPostsView.jsx +++ b/src/discussions/learners/LearnerPostsView.jsx @@ -37,7 +37,7 @@ 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'; @@ -61,7 +61,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,18 +82,37 @@ 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) }); hideDeleteConfirmation(); - }, [courseId, username, hideDeleteConfirmation]); + // Navigate back to learners list after deletion + navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) }); + }, [courseId, username, hideDeleteConfirmation, dispatchDelete, navigate, location]); + + 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)); + hideRestoreConfirmation(); + // Clear and reload the posts to reflect restored content + dispatch(clearPostsPages()); + loadMorePosts(); + }, [courseId, username, hideRestoreConfirmation, dispatch, loadMorePosts]); 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) => ( @@ -170,6 +192,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..9cb81a71b 100644 --- a/src/discussions/learners/LearnerPostsView.test.jsx +++ b/src/discussions/learners/LearnerPostsView.test.jsx @@ -128,11 +128,11 @@ describe('Learner Posts View', () => { }, ); - it('should display a post-filter bar and All posts sorted by recent activity text.', async () => { + it('should display a post-filter bar and All active posts sorted by recent activity text.', async () => { await renderComponent(); const filterBar = container.querySelector('.filter-bar'); - const recentActivity = screen.getByText('All posts sorted by recent activity'); + const recentActivity = screen.getByText('All active posts sorted by recent activity'); expect(filterBar).toBeInTheDocument(); expect(recentActivity).toBeInTheDocument(); diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js index 05121079e..edd16c0d4 100644 --- a/src/discussions/learners/data/api.js +++ b/src/discussions/learners/data/api.js @@ -12,6 +12,7 @@ export const getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/ export const learnerPostsApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/learner/`; export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/activity_stats/`; 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. @@ -65,6 +66,7 @@ export async function getUserPosts(courseId, { threadType, countFlagged, cohort, + isDeleted, } = {}) { const params = snakeCaseObject({ page, @@ -77,6 +79,7 @@ export async function getUserPosts(courseId, { username: author, countFlagged, groupId: cohort, + isDeleted, }); const { data } = await getAuthenticatedHttpClient() @@ -103,3 +106,28 @@ 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; +} + +/** + * Alias for restoreUserPostsApi for backwards compatibility + */ +export const undeleteUserPostsApi = restoreUserPostsApi; diff --git a/src/discussions/learners/data/redux.test.jsx b/src/discussions/learners/data/redux.test.jsx index 6156188fb..7e168198b 100644 --- a/src/discussions/learners/data/redux.test.jsx +++ b/src/discussions/learners/data/redux.test.jsx @@ -51,7 +51,7 @@ describe('Learner redux test cases', () => { expect(learners.sortedBy).toEqual('activity'); expect(learners.usernameSearch).toBeNull(); expect(learners.postFilter.postType).toEqual('all'); - expect(learners.postFilter.status).toEqual('statusAll'); + expect(learners.postFilter.status).toEqual('statusActive'); expect(learners.postFilter.orderBy).toEqual('lastActivityAt'); expect(learners.postFilter.cohort).toEqual(''); }); diff --git a/src/discussions/learners/data/slices.js b/src/discussions/learners/data/slices.js index 534fe850e..c968e409c 100644 --- a/src/discussions/learners/data/slices.js +++ b/src/discussions/learners/data/slices.js @@ -20,7 +20,7 @@ const learnersSlice = createSlice({ sortedBy: LearnersOrdering.BY_LAST_ACTIVITY, postFilter: { postType: ThreadType.ALL, - status: PostsStatusFilter.ALL, + status: PostsStatusFilter.ACTIVE, orderBy: ThreadOrdering.BY_LAST_ACTIVITY, cohort: '', }, @@ -107,6 +107,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 +140,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..c54e74144 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -17,6 +17,7 @@ import { getLearners, getUserPosts, getUserProfiles, + undeleteUserPostsApi, } from './api'; import { deleteUserPostsFailed, @@ -26,6 +27,9 @@ import { fetchLearnersFailed, fetchLearnersRequest, fetchLearnersSuccess, + undeleteUserPostsFailed, + undeleteUserPostsRequest, + undeleteUserPostsSuccess, } from './slices'; /** @@ -111,6 +115,12 @@ export function fetchUserPosts(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(fetchLearnerThreadsRequest({ courseId, author })); @@ -130,18 +140,32 @@ 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 undeleteUserPostsApi(courseId, username, courseOrOrg, execute); + + // Only dispatch success for actual execution, not preview + if (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..e707f0e9c 100644 --- a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx +++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx @@ -10,7 +10,7 @@ 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 +18,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); @@ -36,8 +37,15 @@ const LearnerPostFilterBar = () => { }, ]; - if (userHasModerationPrivileges || userIsGroupTa) { + if (userHasModerationPrivileges || userIsGroupTa || userIsStaff) { + // Add reported filter to the regular status filters filtersToShow[1].filters.splice(2, 0, 'status-reported'); + // Add Active/Deleted as a separate filter section at the bottom with a separator + filtersToShow.push({ + name: 'status', + filters: ['status-active', 'status-deleted'], + hasSeparator: true, // Add visual separator before this section + }); } const handleFilterChange = (event) => { @@ -102,7 +110,7 @@ const LearnerPostFilterBar = () => { filters={filtersToShow} selectedFilters={postFilter} onFilterChange={handleFilterChange} - showCohortsFilter={userHasModerationPrivileges || userIsGroupTa} + showCohortsFilter={userHasModerationPrivileges || userIsGroupTa || userIsStaff} /> ); }; 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..0ca0dab26 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -3,22 +3,31 @@ 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, + deletedCount, 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 active counts (total - deleted) + const activeThreads = (threads || 0) - (deletedThreads || 0); + const activeResponses = (responses || 0) - (deletedResponses || 0); + const activeReplies = (replies || 0) - (deletedReplies || 0); + const totalActive = activeThreads + activeResponses + activeReplies; return (
@@ -35,7 +44,7 @@ const LearnerFooter = ({ >
- {threads + responses + replies} + {totalActive}
- {threads} + {activeThreads}
+ {Boolean(canSeeDeletedStats) && ( + +
+ {intl.formatMessage(messages.deletedActivity)} +
+ + )} + > +
+ + {deletedCount || 0} +
+
+ )} {Boolean(canSeeLearnerReportedStats) && ( 0; @@ -54,9 +59,13 @@ const Comment = ({ const dispatch = useDispatch(); const { courseId } = 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)); @@ -89,7 +98,7 @@ const Comment = ({ const handleCommentEndorse = useCallback(async () => { await dispatch(editComment(id, { endorsed: !endorsed })); await dispatch(fetchThread(threadId, courseId)); - }, [id, endorsed, threadId]); + }, [id, endorsed, threadId, courseId, dispatch]); const handleAbusedFlag = useCallback(() => { if (abuseFlagged) { @@ -99,10 +108,18 @@ const Comment = ({ } }, [abuseFlagged, id, showReportConfirmation]); - const handleDeleteConfirmation = useCallback(() => { - dispatch(removeComment(id)); + const handleDeleteConfirmation = useCallback(async () => { + try { + const { performSoftDeleteComment } = await import('../../data/thunks'); + const result = await dispatch(performSoftDeleteComment(id)); + if (result.success) { + await dispatch(fetchThread(threadId, courseId)); + } + } catch (error) { + logError(error); + } hideDeleteConfirmation(); - }, [id, hideDeleteConfirmation]); + }, [id, courseId, threadId, dispatch, hideDeleteConfirmation]); const handleReportConfirmation = useCallback(() => { dispatch(editComment(id, { flagged: !abuseFlagged })); @@ -113,12 +130,35 @@ const Comment = ({ await dispatch(editComment(id, { voted: !voted })); }, [id, voted]); + const handleSoftDelete = useCallback(() => { + showDeleteConfirmation(); + }, [showDeleteConfirmation]); + + const handleRestore = useCallback(() => { + showRestoreConfirmation(); + }, [showRestoreConfirmation]); + + const handleRestoreConfirmation = useCallback(async () => { + try { + const { performRestoreComment } = await import('../../data/thunks'); + const result = await dispatch(performRestoreComment(id)); + 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.SOFT_DELETE]: handleSoftDelete, + [ContentActions.RESTORE]: handleRestore, [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleAbusedFlag]); + }), [handleEditContent, handleCommentEndorse, handleSoftDelete, handleRestore, handleAbusedFlag]); const handleLoadMoreComments = useCallback(() => ( dispatch(fetchCommentResponses(id, { @@ -156,13 +196,29 @@ const Comment = ({ > + {!abuseFlagged && ( + {isDeleted && deletedByLabel && ( +
+ +
+ {intl.formatMessage(messages.deletedBy)} + + + +
+
+ )} {isEditing ? ( { + const intl = useIntl(); const colorClass = AvatarOutlineAndLabelColors[authorLabel]; const hasAnyAlert = useAlertBannerVisible({ author, @@ -34,12 +40,20 @@ const CommentHeader = ({ ? Object.values(postUsers ?? {})[0]?.profile?.image : null; + // Determine which deleted badge to show based on priority rules + // Priority: Deleted Post > Deleted Response > Deleted Comment + const shouldShowDeletedBadge = isDeleted && !postIsDeleted; // Don't show if post is already deleted + const isResponse = !parentId; // Response has no parentId, comment has parentId + const deletedBadgeMessage = isResponse + ? messages.deletedResponse + : messages.deletedComment; + return (
-
+
+ {shouldShowDeletedBadge && ( + + {intl.formatMessage(deletedBadgeMessage)} + {' '}deleted {isResponse ? 'response' : 'comment'} + + )}
); @@ -73,12 +97,18 @@ CommentHeader.propTypes = { reason: PropTypes.string, }), postUsers: PropTypes.shape({}).isRequired, + isDeleted: PropTypes.bool, + parentId: PropTypes.string, + postIsDeleted: PropTypes.bool, }; CommentHeader.defaultProps = { authorLabel: null, closed: undefined, lastEdit: null, + isDeleted: false, + parentId: null, + postIsDeleted: false, }; export default React.memo(CommentHeader); diff --git a/src/discussions/post-comments/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index 91ecaa9fc..97274a284 100644 --- a/src/discussions/post-comments/comments/comment/Reply.jsx +++ b/src/discussions/post-comments/comments/comment/Reply.jsx @@ -1,23 +1,28 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { + useCallback, useContext, useMemo, useState, +} from 'react'; import PropTypes from 'prop-types'; -import { Avatar, useToggle } from '@openedx/paragon'; +import { Avatar, Badge, useToggle } from '@openedx/paragon'; import { useDispatch, useSelector } from 'react-redux'; import * as timeago from 'timeago.js'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; import HTMLLoader from '../../../../components/HTMLLoader'; import { AvatarOutlineAndLabelColors, ContentActions } from '../../../../data/constants'; import { ActionsDropdown, AlertBanner, AuthorLabel, Confirmation, } from '../../../common'; +import DiscussionContext from '../../../common/context'; import timeLocale from '../../../common/time-locale'; import { ContentTypes } from '../../../data/constants'; import { useAlertBannerVisible } from '../../../data/hooks'; -import { selectAuthorAvatar } from '../../../posts/data/selectors'; +import { selectAuthorAvatar, selectThread } from '../../../posts/data/selectors'; +import { fetchThread } from '../../../posts/data/thunks'; import { selectCommentOrResponseById } from '../../data/selectors'; -import { editComment, removeComment } from '../../data/thunks'; +import { editComment } from '../../data/thunks'; import messages from '../../messages'; import CommentEditor from './CommentEditor'; @@ -25,12 +30,17 @@ const Reply = ({ responseId }) => { timeago.register('time-locale', timeLocale); 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, } = useSelector(selectCommentOrResponseById(responseId)); const intl = useIntl(); const dispatch = useDispatch(); + const { courseId } = useContext(DiscussionContext); + // 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 [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); + const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); const colorClass = AvatarOutlineAndLabelColors[authorLabel]; const hasAnyAlert = useAlertBannerVisible({ @@ -41,10 +51,18 @@ const Reply = ({ responseId }) => { }); const authorAvatar = useSelector(selectAuthorAvatar(author)); - const handleDeleteConfirmation = useCallback(() => { - dispatch(removeComment(id)); + const handleDeleteConfirmation = useCallback(async () => { + try { + const { performSoftDeleteComment } = await import('../../data/thunks'); + const result = await dispatch(performSoftDeleteComment(id)); + if (result.success) { + await dispatch(fetchThread(threadId, courseId)); + } + } catch (error) { + logError(error); + } hideDeleteConfirmation(); - }, [id, hideDeleteConfirmation]); + }, [id, courseId, threadId, dispatch, hideDeleteConfirmation]); const handleReportConfirmation = useCallback(() => { dispatch(editComment(id, { flagged: !abuseFlagged })); @@ -67,6 +85,27 @@ const Reply = ({ responseId }) => { } }, [abuseFlagged, id, showReportConfirmation]); + const handleSoftDelete = useCallback(() => { + showDeleteConfirmation(); + }, [showDeleteConfirmation]); + + const handleRestore = useCallback(() => { + showRestoreConfirmation(); + }, [showRestoreConfirmation]); + + const handleRestoreConfirmation = useCallback(async () => { + try { + const { performRestoreComment } = await import('../../data/thunks'); + const result = await dispatch(performRestoreComment(id)); + if (result.success) { + await dispatch(fetchThread(threadId, courseId)); + } + } catch (error) { + logError(error); + } + hideRestoreConfirmation(); + }, [id, courseId, threadId, dispatch, hideRestoreConfirmation]); + const handleCloseEditor = useCallback(() => { setEditing(false); }, []); @@ -74,9 +113,10 @@ const Reply = ({ responseId }) => { const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handleEditContent, [ContentActions.ENDORSE]: handleReplyEndorse, - [ContentActions.DELETE]: showDeleteConfirmation, + [ContentActions.SOFT_DELETE]: handleSoftDelete, + [ContentActions.RESTORE]: handleRestore, [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleAbusedFlag]); + }), [handleEditContent, handleReplyEndorse, handleSoftDelete, handleRestore, handleAbusedFlag]); return (
@@ -89,6 +129,14 @@ const Reply = ({ responseId }) => { closeButtonVariant="tertiary" confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} /> + {!abuseFlagged && ( { className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px flex-fill" style={{ borderRadius: '0rem 0.375rem 0.375rem', maxWidth: 'calc(100% - 50px)' }} > -
- +
+
+ + {isDeleted && !postIsDeleted && ( + + {intl.formatMessage(messages.deletedComment)} + {' '}deleted comment + + )} +
{ + try { + const { softDeleteComment } = await import('../../data/services/softDeleteService'); + await softDeleteComment(commentId); + return { success: true }; + } catch (error) { + logError(error); + return { success: false, error: error.message }; + } + }; +} + +export function performRestoreComment(commentId) { + return async () => { + try { + const { restoreComment } = await import('../../data/services/softDeleteService'); + await restoreComment(commentId); + 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..07bacab57 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: 'Undelete response', + description: 'Title of confirmation dialog shown when undeleting a response', + }, + undeleteResponseDescription: { + id: 'discussions.editor.undelete.response.description', + defaultMessage: 'Are you sure you want to undelete this response?', + description: 'Text displayed in confirmation dialog when undeleting 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: 'Undelete comment', + description: 'Title of confirmation dialog shown when undeleting a comment', + }, + undeleteCommentDescription: { + id: 'discussions.editor.undelete.comment.description', + defaultMessage: 'Are you sure you want to undelete this comment?', + description: 'Text displayed in confirmation dialog when undeleting 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..c012ff41a 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,54 @@ export const sendEmailForAccountActivation = async () => { .post(url); return data; }; + +/** + * Soft delete a thread. + * @param {string} threadId + * @param {string} userId + * @returns {Promise<{}>} + */ +export const softDeleteThread = async (threadId, userId) => { + const url = `${getThreadsApiUrl()}${threadId}/soft_delete/`; + const { data } = await getAuthenticatedHttpClient().post(url, { user_id: userId }); + return data; +}; + +/** + * Restore a soft deleted thread. + * @param {string} threadId + * @returns {Promise<{}>} + */ +export const restoreThread = async (threadId) => { + const url = `${getThreadsApiUrl()}${threadId}/restore/`; + const { data } = await getAuthenticatedHttpClient().post(url); + return data; +}; + +/** + * Bulk soft delete threads. + * @param {string[]} threadIds + * @param {string} userId + * @returns {Promise<{}>} + */ +export const bulkSoftDeleteThreads = async (threadIds, userId) => { + const url = `${getThreadsApiUrl()}bulk_soft_delete/`; + const { data } = await getAuthenticatedHttpClient().post(url, { + thread_ids: threadIds.join(','), + user_id: userId, + }); + return data; +}; + +/** + * Bulk restore soft deleted threads. + * @param {string[]} threadIds + * @returns {Promise<{}>} + */ +export const bulkRestoreThreads = async (threadIds) => { + const url = `${getThreadsApiUrl()}bulk_restore/`; + const { data } = await getAuthenticatedHttpClient().post(url, { + thread_ids: threadIds.join(','), + }); + 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..5041176f5 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -7,7 +7,11 @@ import { import { setContentCreationRateLimited } from '../../data/slices'; import { getHttpErrorStatus } from '../../utils'; import { - deleteThread, getThread, getThreads, postThread, sendEmailForAccountActivation, updateThread, + getThread, + getThreads, + postThread, + sendEmailForAccountActivation, + updateThread, } from './api'; import { deleteThreadDenied, @@ -141,6 +145,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 })); @@ -301,7 +311,9 @@ export function removeThread(threadId) { return async (dispatch) => { try { dispatch(deleteThreadRequest({ threadId })); - await deleteThread(threadId); + // Use soft delete instead of hard delete + const { softDeleteThread } = await import('../../data/services/softDeleteService'); + await softDeleteThread(threadId); dispatch(deleteThreadSuccess({ threadId })); } catch (error) { if (getHttpErrorStatus(error) === 403) { diff --git a/src/discussions/posts/post-filter-bar/PostFilterBar.jsx b/src/discussions/posts/post-filter-bar/PostFilterBar.jsx index f601cb04d..6968c9f59 100644 --- a/src/discussions/posts/post-filter-bar/PostFilterBar.jsx +++ b/src/discussions/posts/post-filter-bar/PostFilterBar.jsx @@ -22,7 +22,7 @@ import { import selectCourseCohorts from '../../cohorts/data/selectors'; import fetchCourseCohorts from '../../cohorts/data/thunks'; import DiscussionContext from '../../common/context'; -import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors'; +import { selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff } from '../../data/selectors'; import { setCohortFilter, setPostsTypeFilter, setSortedBy, setStatusFilter, } from '../data'; @@ -68,6 +68,7 @@ const PostFilterBar = () => { const { page } = useContext(DiscussionContext); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); + const userIsStaff = useSelector(selectUserIsStaff); const currentSorting = useSelector(selectThreadSorting()); const currentFilters = useSelector(selectThreadFilters()); const { status } = useSelector(state => state.cohorts); @@ -257,7 +258,7 @@ const PostFilterBar = () => { selected={currentFilters.status} /> )} - {(userHasModerationPrivileges || userIsGroupTa) && ( + {(userHasModerationPrivileges || userIsGroupTa || userIsStaff) && ( { - const { enableInContextSidebar, postId } = useContext(DiscussionContext); + const { enableInContextSidebar, postId, courseId } = useContext(DiscussionContext); 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, deletedByLabel, } = useSelector(selectThread(postId)); + 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); @@ -53,15 +58,21 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges); const handleDeleteConfirmation = useCallback(async () => { - const basePath = truncatePath(location.pathname); - - await dispatch(removeThread(postId)); - navigate({ - pathname: basePath, - search: enableInContextSidebar && '?inContextSidebar', - }); + try { + const authenticatedUser = getAuthenticatedUser(); + const { performSoftDeleteThread } = await import('../../data/thunks'); + const result = await dispatch( + performSoftDeleteThread(postId, authenticatedUser.userId || authenticatedUser.id, courseId), + ); + if (result.success) { + // Refresh the thread list to reflect the change + window.location.reload(); + } + } catch (error) { + logError(error); + } hideDeleteConfirmation(); - }, [enableInContextSidebar, postId, hideDeleteConfirmation]); + }, [postId, courseId, dispatch, hideDeleteConfirmation]); const handleReportConfirmation = useCallback(() => { dispatch(updateExistingThread(postId, { flagged: !abuseFlagged })); @@ -101,15 +112,40 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { } }, [abuseFlagged, postId, showReportConfirmation]); + const handleSoftDelete = useCallback(() => { + showDeleteConfirmation(); + }, [showDeleteConfirmation]); + + 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) { + // Refresh the thread list to reflect the change + // The post will now appear in the active filter + window.location.reload(); // TODO: Replace with proper state update + } + } catch (error) { + logError(error); + } + hideRestoreConfirmation(); + }, [postId, courseId, dispatch, hideRestoreConfirmation]); + const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handlePostContentEdit, - [ContentActions.DELETE]: showDeleteConfirmation, + [ContentActions.SOFT_DELETE]: handleSoftDelete, + [ContentActions.RESTORE]: handleRestore, [ContentActions.CLOSE]: handlePostClose, [ContentActions.COPY_LINK]: handlePostCopyLink, [ContentActions.PIN]: handlePostPin, [ContentActions.REPORT]: handlePostReport, }), [ handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation, + handleSoftDelete, handleRestore, ]); const handleClosePostConfirmation = useCallback((closeReasonCode) => { @@ -145,6 +181,14 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { closeButtonVariant="tertiary" confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} /> + {!abuseFlagged && ( { onFollow={handlePostFollow} voted={voted} following={following} + isDeleted={isDeleted} /> + {isDeleted && deletedByLabel && ( +
+ +
+ {intl.formatMessage(messages.deletedBy)} + + + +
+
+ )} {title} @@ -120,12 +123,25 @@ const PostLink = ({ {' '}reported )} + {isDeleted && ( + + {intl.formatMessage(messages.deletedPost)} + {' '}deleted post + + )} {pinned && ( )} diff --git a/src/discussions/posts/post/messages.js b/src/discussions/posts/post/messages.js index f095e1fbe..19c3e42a3 100644 --- a/src/discussions/posts/post/messages.js +++ b/src/discussions/posts/post/messages.js @@ -24,6 +24,15 @@ 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 Post', + description: 'Badge showing that the post has been deleted', + }, following: { id: 'discussions.post.following', defaultMessage: 'Following', @@ -106,6 +115,14 @@ const messages = defineMessages({ defaultMessage: 'Delete', description: 'Delete button shown on delete confirmation dialog', }, + undeletePostTitle: { + id: 'discussions.editor.undelete.post.title', + defaultMessage: 'Undelete post', + }, + undeletePostDescription: { + id: 'discussions.editor.undelete.post.description', + defaultMessage: 'Are you sure you want to undelete this post?', + }, reportPostTitle: { id: 'discussions.editor.report.post.title', defaultMessage: 'Report inappropriate content?', @@ -171,6 +188,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..edf6541c0 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, @@ -67,6 +68,10 @@ export function checkPermissions(content, action) { if (action === ContentActions.DELETE) { return true; } + // For soft delete and restore actions we check `content.canDelete` + if (action === ContentActions.SOFT_DELETE || action === ContentActions.RESTORE) { + return content.canDelete; + } return false; } @@ -178,11 +183,18 @@ export const ACTIONS_LIST = [ conditions: { abuseFlagged: true }, }, { - id: 'delete', - action: ContentActions.DELETE, + id: 'soft-delete', + action: ContentActions.SOFT_DELETE, icon: Delete, - label: messages.deleteAction, - conditions: { canDelete: true }, + label: messages.softDeleteAction, + 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..ac5e496f9 100755 --- a/src/index.scss +++ b/src/index.scss @@ -1,8 +1,8 @@ @use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints; -@import "~@edx/frontend-component-footer/dist/footer"; -@import "~@edx/frontend-component-header/dist/index"; +// @import "~@edx/frontend-component-footer/dist/footer"; +// @import "~@edx/frontend-component-header/dist/index"; body, @@ -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; } From f067565cfef0a81f89e866ce1b59d6ee147d5eab Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Thu, 6 Nov 2025 06:36:30 +0000 Subject: [PATCH 02/19] feat: added soft delete functionality --- src/discussions/post-comments/comments/comment/Comment.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index e61d20e6c..dca8508a1 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -118,8 +118,7 @@ const Comment = ({ } catch (error) { logError(error); } - hideDeleteConfirmation(); - }, [id, courseId, threadId, dispatch, hideDeleteConfirmation]); + }, [id, threadId, courseId, dispatch]); const handleReportConfirmation = useCallback(() => { dispatch(editComment(id, { flagged: !abuseFlagged })); From c1c6e38a6d69e72c3d55c3e212167e7875cbdf27 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Thu, 6 Nov 2025 07:19:31 +0000 Subject: [PATCH 03/19] feat: added soft delete functionality --- .../post-comments/comments/comment/Reply.jsx | 3 +- src/discussions/posts/index.js | 1 + src/discussions/posts/post/Post.jsx | 34 +++++++------------ src/discussions/posts/post/PostLink.jsx | 2 -- 4 files changed, 15 insertions(+), 25 deletions(-) diff --git a/src/discussions/post-comments/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index 75f4b9d44..62c26023c 100644 --- a/src/discussions/post-comments/comments/comment/Reply.jsx +++ b/src/discussions/post-comments/comments/comment/Reply.jsx @@ -33,8 +33,6 @@ const Reply = ({ responseId }) => { id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy, closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel, isDeleted, } = useSelector(selectCommentOrResponseById(responseId)); - closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel, - } = commentData; const intl = useIntl(); const dispatch = useDispatch(); const { courseId } = useContext(DiscussionContext); @@ -207,6 +205,7 @@ const Reply = ({ responseId }) => { )}
+
{ const { enableInContextSidebar, postId, courseId } = useContext(DiscussionContext); - 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, isDeleted, deletedByLabel, - } = useSelector(selectThread(postId)); - const { enableInContextSidebar, postId } = 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, deletedByLabel, } = threadData; + const intl = useIntl(); const location = useLocation(); const navigate = useNavigate(); @@ -72,7 +67,6 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { performSoftDeleteThread(postId, authenticatedUser.userId || authenticatedUser.id, courseId), ); if (result.success) { - // Refresh the thread list to reflect the change window.location.reload(); } } catch (error) { @@ -84,12 +78,12 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { const handleReportConfirmation = useCallback(() => { dispatch(updateExistingThread(postId, { flagged: !abuseFlagged })); hideReportConfirmation(); - }, [abuseFlagged, postId, hideReportConfirmation]); + }, [abuseFlagged, postId, dispatch, hideReportConfirmation]); const handlePostContentEdit = useCallback(() => navigate({ ...location, pathname: `${location.pathname}/edit`, - }), [location.pathname]); + }), [navigate, location]); const handlePostClose = useCallback(() => { if (closed) { @@ -97,19 +91,19 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { } else { showClosePostModal(); } - }, [closed, postId, showClosePostModal]); + }, [closed, postId, dispatch, showClosePostModal]); const handlePostCopyLink = useCallback(() => { navigator.clipboard.writeText(getFullUrl(`${courseId}/posts/${postId}`)); - }, [window.location.origin, postId, courseId]); + }, [courseId, postId]); const handlePostPin = useCallback(() => dispatch( updateExistingThread(postId, { pinned: !pinned }), - ), [postId, pinned]); + ), [postId, pinned, dispatch]); const handlePostLike = useCallback(() => { dispatch(updateExistingThread(postId, { voted: !voted })); - }, [postId, voted]); + }, [postId, voted, dispatch]); const handlePostReport = useCallback(() => { if (abuseFlagged) { @@ -117,7 +111,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { } else { showReportConfirmation(); } - }, [abuseFlagged, postId, showReportConfirmation]); + }, [abuseFlagged, postId, dispatch, showReportConfirmation]); const handleSoftDelete = useCallback(() => { showDeleteConfirmation(); @@ -132,9 +126,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { const { performRestoreThread } = await import('../../data/thunks'); const result = await dispatch(performRestoreThread(postId, courseId)); if (result.success) { - // Refresh the thread list to reflect the change - // The post will now appear in the active filter - window.location.reload(); // TODO: Replace with proper state update + window.location.reload(); } } catch (error) { logError(error); @@ -151,18 +143,18 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { [ContentActions.PIN]: handlePostPin, [ContentActions.REPORT]: handlePostReport, }), [ - handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation, + handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, handleSoftDelete, handleRestore, ]); const handleClosePostConfirmation = useCallback((closeReasonCode) => { dispatch(updateExistingThread(postId, { closed: true, closeReasonCode })); hideClosePostModal(); - }, [postId, hideClosePostModal]); + }, [postId, dispatch, hideClosePostModal]); const handlePostFollow = useCallback(() => { dispatch(updateExistingThread(postId, { following: !following })); - }, [postId, following]); + }, [postId, following, dispatch]); const getTopicCategoryName = useCallback(topicData => ( topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId diff --git a/src/discussions/posts/post/PostLink.jsx b/src/discussions/posts/post/PostLink.jsx index 4ac9eef52..ba70a78ed 100644 --- a/src/discussions/posts/post/PostLink.jsx +++ b/src/discussions/posts/post/PostLink.jsx @@ -38,8 +38,6 @@ const PostLink = ({ topicId, hasEndorsed, type, author, authorLabel, abuseFlagged, abuseFlaggedCount, read, commentCount, unreadCommentCount, id, pinned, previewBody, title, voted, voteCount, following, groupId, groupName, createdAt, users: postUsers, isDeleted, - } = useSelector(selectThread(postId)); - users: postUsers, } = threadData; const { pathname } = discussionsPath(Routes.COMMENTS.PAGES[page], { 0: enableInContextSidebar ? 'in-context' : undefined, From d3487886fdc8141707e5bf087e3e7c47b48346d2 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Tue, 18 Nov 2025 10:46:17 +0000 Subject: [PATCH 04/19] feat: added soft delete functionality --- src/discussions/components/FilterBar.jsx | 160 --------- src/discussions/components/FilterBar.scss | 335 ------------------ src/discussions/components/FilterBar.test.jsx | 301 ---------------- src/discussions/components/index.js | 1 - src/discussions/learners/data/api.js | 5 +- src/discussions/learners/data/thunks.js | 5 +- .../LearnerPostFilterBar.jsx | 2 +- 7 files changed, 5 insertions(+), 804 deletions(-) delete mode 100644 src/discussions/components/FilterBar.jsx delete mode 100644 src/discussions/components/FilterBar.scss delete mode 100644 src/discussions/components/FilterBar.test.jsx delete mode 100644 src/discussions/components/index.js diff --git a/src/discussions/components/FilterBar.jsx b/src/discussions/components/FilterBar.jsx deleted file mode 100644 index 9c3681e1b..000000000 --- a/src/discussions/components/FilterBar.jsx +++ /dev/null @@ -1,160 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; - -import { Badge, Button, Spinner } from '@openedx/paragon'; -import { DeleteOutline, RestoreOutline } from '@openedx/paragon/icons'; -import classNames from 'classnames'; - -import { useIntl } from '@edx/frontend-platform/i18n'; - -import messages from '../messages'; - -const FilterBar = ({ - isDeletedView, - setIsDeletedView, - selectedThreadIds = [], - onBulkAction, - isLoading = false, -}) => { - const intl = useIntl(); - const [pendingAction, setPendingAction] = useState(null); - - const handleBulkSoftDelete = async () => { - if (selectedThreadIds.length === 0) { return; } - - setPendingAction('soft-delete'); - try { - await onBulkAction('soft-delete', selectedThreadIds); - } finally { - setPendingAction(null); - } - }; - - const handleBulkRestore = async () => { - if (selectedThreadIds.length === 0) { return; } - - setPendingAction('restore'); - try { - await onBulkAction('restore', selectedThreadIds); - } finally { - setPendingAction(null); - } - }; - - const hasSelectedThreads = selectedThreadIds.length > 0; - - return ( -
- {/* Filter Toggle Buttons */} -
- - - - {isLoading && ( - - )} -
- - {/* Bulk Actions */} - {hasSelectedThreads && onBulkAction && ( -
- - {intl.formatMessage(messages.selectedCount, { count: selectedThreadIds.length })} - - - {!isDeletedView && ( - - )} - - {isDeletedView && ( - - )} -
- )} -
- ); -}; - -FilterBar.propTypes = { - isDeletedView: PropTypes.bool.isRequired, - setIsDeletedView: PropTypes.func.isRequired, - selectedThreadIds: PropTypes.arrayOf(PropTypes.string), - onBulkAction: PropTypes.func, - isLoading: PropTypes.bool, -}; - -export default FilterBar; diff --git a/src/discussions/components/FilterBar.scss b/src/discussions/components/FilterBar.scss deleted file mode 100644 index 510a72ec5..000000000 --- a/src/discussions/components/FilterBar.scss +++ /dev/null @@ -1,335 +0,0 @@ -/** - * Styles for soft delete functionality in discussions - */ - -/* Filter Bar Styles */ -.filter-bar { - background: var(--bs-light-100, #f8f9fa); - border: 1px solid var(--bs-light-400, #dee2e6); - border-radius: 8px; - padding: 16px; - margin-bottom: 24px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.filter-bar .filter-controls { - display: flex; - align-items: center; - gap: 12px; -} - -.filter-bar .bulk-actions { - display: flex; - align-items: center; - gap: 12px; -} - -/* Filter Button Styles */ -.filter-bar .btn { - padding: 8px 16px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - border-width: 1px; - transition: all 0.2s ease-in-out; -} - -.filter-bar .btn:hover { - transform: translateY(-1px); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.filter-bar .btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -/* Active/Deleted Toggle Buttons */ -.filter-bar .btn-primary { - background-color: var(--bs-primary, #0066cc); - border-color: var(--bs-primary, #0066cc); - color: white; -} - -.filter-bar .btn-outline-primary { - background-color: transparent; - border-color: var(--bs-primary, #0066cc); - color: var(--bs-primary, #0066cc); -} - -.filter-bar .btn-outline-primary:hover { - background-color: var(--bs-primary, #0066cc); - color: white; -} - -/* Bulk Action Buttons */ -.filter-bar .btn-outline-danger { - background-color: transparent; - border-color: var(--bs-danger, #dc3545); - color: var(--bs-danger, #dc3545); -} - -.filter-bar .btn-outline-danger:hover { - background-color: var(--bs-danger, #dc3545); - color: white; -} - -.filter-bar .btn-outline-success { - background-color: transparent; - border-color: var(--bs-success, #198754); - color: var(--bs-success, #198754); -} - -.filter-bar .btn-outline-success:hover { - background-color: var(--bs-success, #198754); - color: white; -} - -/* Badge Styles */ -.filter-bar .badge { - padding: 6px 12px; - border-radius: 12px; - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.filter-bar .badge-secondary { - background-color: var(--bs-secondary, #6c757d); - color: white; -} - -/* Loading Spinner */ -.filter-bar .spinner-border { - width: 20px; - height: 20px; - border-width: 2px; -} - -.filter-bar .spinner-border-sm { - width: 16px; - height: 16px; - border-width: 2px; -} - -/* Thread List Styles for Soft Delete */ -.thread-item.deleted { - background-color: var(--bs-light-200, #f1f3f5); - border-left: 4px solid var(--bs-warning, #ffc107); - opacity: 0.8; - position: relative; -} - -.thread-item.deleted::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: repeating-linear-gradient( - 45deg, - transparent, - transparent 10px, - rgba(255, 193, 7, 0.1) 10px, - rgba(255, 193, 7, 0.1) 20px - ); - pointer-events: none; -} - -.thread-item.deleted .thread-title { - color: var(--bs-secondary, #6c757d); - text-decoration: line-through; -} - -.thread-item.deleted .thread-content { - color: var(--bs-secondary, #6c757d); -} - -/* Deleted Thread Badge */ -.deleted-badge { - background-color: var(--bs-warning, #ffc107); - color: var(--bs-dark, #212529); - padding: 4px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - display: inline-flex; - align-items: center; - gap: 4px; -} - -.deleted-badge::before { - content: '⚠️'; - font-size: 10px; -} - -/* Thread Selection Styles */ -.thread-item.selectable { - cursor: pointer; - transition: all 0.2s ease-in-out; - border: 2px solid transparent; -} - -.thread-item.selectable:hover { - border-color: var(--bs-primary-200, #b3d9ff); - box-shadow: 0 2px 8px rgba(0, 102, 204, 0.15); -} - -.thread-item.selected { - border-color: var(--bs-primary, #0066cc); - background-color: var(--bs-primary-50, #e6f3ff); -} - -.thread-checkbox { - position: absolute; - top: 12px; - right: 12px; - z-index: 2; -} - -.thread-checkbox input[type="checkbox"] { - width: 18px; - height: 18px; - cursor: pointer; -} - -/* Action Button Styles */ -.thread-actions .btn-soft-delete { - background-color: transparent; - border: 1px solid var(--bs-warning, #ffc107); - color: var(--bs-warning, #ffc107); - padding: 4px 8px; - font-size: 12px; - border-radius: 4px; - transition: all 0.2s ease-in-out; -} - -.thread-actions .btn-soft-delete:hover { - background-color: var(--bs-warning, #ffc107); - color: var(--bs-dark, #212529); -} - -.thread-actions .btn-restore { - background-color: transparent; - border: 1px solid var(--bs-success, #198754); - color: var(--bs-success, #198754); - padding: 4px 8px; - font-size: 12px; - border-radius: 4px; - transition: all 0.2s ease-in-out; -} - -.thread-actions .btn-restore:hover { - background-color: var(--bs-success, #198754); - color: white; -} - -/* Animation for Bulk Actions */ -.bulk-action-pending { - opacity: 0.6; - pointer-events: none; - position: relative; -} - -.bulk-action-pending::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.4), - transparent - ); - animation: shimmer 1.5s infinite; -} - -@keyframes shimmer { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(100%); - } -} - -/* Responsive Design */ -@media (max-width: 768px) { - .filter-bar { - flex-direction: column; - align-items: stretch; - gap: 16px; - } - - .filter-bar .filter-controls { - justify-content: center; - } - - .filter-bar .bulk-actions { - justify-content: center; - flex-wrap: wrap; - } - - .filter-bar .btn { - min-width: 120px; - } -} - -@media (max-width: 480px) { - .filter-bar .bulk-actions { - flex-direction: column; - width: 100%; - } - - .filter-bar .btn { - width: 100%; - justify-content: center; - } -} - -/* Focus States for Accessibility */ -.filter-bar .btn:focus, -.thread-checkbox input:focus, -.thread-item.selectable:focus { - outline: 2px solid var(--bs-primary, #0066cc); - outline-offset: 2px; -} - -/* High Contrast Mode Support */ -@media (prefers-contrast: high) { - .thread-item.deleted { - border-left-width: 6px; - background-color: var(--bs-light-300, #e9ecef); - } - - .deleted-badge { - border: 2px solid var(--bs-warning, #ffc107); - } - - .filter-bar { - border-width: 2px; - } -} - -/* Reduced Motion Support */ -@media (prefers-reduced-motion: reduce) { - .filter-bar .btn, - .thread-item.selectable, - .bulk-action-pending::after { - transition: none; - animation: none; - } - - .filter-bar .btn:hover { - transform: none; - } -} \ No newline at end of file diff --git a/src/discussions/components/FilterBar.test.jsx b/src/discussions/components/FilterBar.test.jsx deleted file mode 100644 index 82bb939c1..000000000 --- a/src/discussions/components/FilterBar.test.jsx +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Tests for FilterBar component with soft delete functionality - */ -import React from 'react'; - -import { configureStore } from '@reduxjs/toolkit'; -import { - fireEvent, render, screen, waitFor, -} from '@testing-library/react'; -import { Provider } from 'react-redux'; - -import { IntlProvider } from '@edx/frontend-platform/i18n'; - -import messages from '../messages'; -import { threadsReducer } from '../posts/data/slices'; -import FilterBar from './FilterBar'; - -// Mock the soft delete service -jest.mock('../data/services/softDeleteService', () => ({ - bulkSoftDeleteThreads: jest.fn(), - bulkRestoreThreads: jest.fn(), -})); - -describe('FilterBar Component', () => { - let store; - let mockOnBulkAction; - - const createStore = (initialState = {}) => configureStore({ - reducer: { - threads: threadsReducer, - }, - preloadedState: { - threads: { - filter: 'active', - loading: false, - selectedThreadIds: [], - bulkActionStatus: 'idle', - bulkActionError: null, - ...initialState.threads, - }, - }, - }); - - const renderFilterBar = (props = {}) => render( - - - - - , - ); - - beforeEach(() => { - store = createStore(); - mockOnBulkAction = jest.fn().mockResolvedValue({ success: true }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('Basic Rendering', () => { - test('renders filter buttons correctly', () => { - renderFilterBar(); - - expect(screen.getByTestId('active-threads-button')).toBeInTheDocument(); - expect(screen.getByTestId('deleted-threads-button')).toBeInTheDocument(); - expect(screen.getByText('Active Threads')).toBeInTheDocument(); - expect(screen.getByText('Deleted Threads')).toBeInTheDocument(); - }); - - test('shows active filter as primary by default', () => { - renderFilterBar(); - - const activeButton = screen.getByTestId('active-threads-button'); - const deletedButton = screen.getByTestId('deleted-threads-button'); - - expect(activeButton).toHaveClass('btn-primary'); - expect(deletedButton).toHaveClass('btn-outline-primary'); - }); - - test('shows deleted filter as primary when in deleted view', () => { - renderFilterBar({ isDeletedView: true }); - - const activeButton = screen.getByTestId('active-threads-button'); - const deletedButton = screen.getByTestId('deleted-threads-button'); - - expect(activeButton).toHaveClass('btn-outline-primary'); - expect(deletedButton).toHaveClass('btn-primary'); - }); - }); - - describe('Loading State', () => { - test('disables buttons when loading', () => { - renderFilterBar({ isLoading: true }); - - const activeButton = screen.getByTestId('active-threads-button'); - const deletedButton = screen.getByTestId('deleted-threads-button'); - - expect(activeButton).toBeDisabled(); - expect(deletedButton).toBeDisabled(); - }); - - test('shows loading spinner when loading', () => { - renderFilterBar({ isLoading: true }); - - expect(screen.getByText('Loading threads...')).toBeInTheDocument(); - }); - }); - - describe('Bulk Actions - Active View', () => { - test('shows bulk delete button when threads are selected in active view', () => { - const selectedThreadIds = ['thread1', 'thread2']; - renderFilterBar({ - selectedThreadIds, - isDeletedView: false, - }); - - expect(screen.getByText('2 selected')).toBeInTheDocument(); - expect(screen.getByTestId('bulk-delete-button')).toBeInTheDocument(); - expect(screen.getByText('Delete Selected')).toBeInTheDocument(); - }); - - test('handles bulk delete action correctly', async () => { - const selectedThreadIds = ['thread1', 'thread2']; - renderFilterBar({ - selectedThreadIds, - isDeletedView: false, - }); - - const deleteButton = screen.getByTestId('bulk-delete-button'); - fireEvent.click(deleteButton); - - await waitFor(() => { - expect(mockOnBulkAction).toHaveBeenCalledWith('soft-delete', selectedThreadIds); - }); - }); - - test('shows loading state during bulk delete', async () => { - const selectedThreadIds = ['thread1']; - mockOnBulkAction.mockImplementation(() => new Promise((resolve) => { - setTimeout(resolve, 100); - })); - - renderFilterBar({ - selectedThreadIds, - isDeletedView: false, - }); - - const deleteButton = screen.getByTestId('bulk-delete-button'); - fireEvent.click(deleteButton); - - expect(screen.getByText('Deleting...')).toBeInTheDocument(); - expect(deleteButton).toBeDisabled(); - }); - }); - - describe('Bulk Actions - Deleted View', () => { - test('shows bulk restore button when threads are selected in deleted view', () => { - const selectedThreadIds = ['thread1', 'thread2']; - renderFilterBar({ - selectedThreadIds, - isDeletedView: true, - }); - - expect(screen.getByText('2 selected')).toBeInTheDocument(); - expect(screen.getByTestId('bulk-restore-button')).toBeInTheDocument(); - expect(screen.getByText('Restore Selected')).toBeInTheDocument(); - }); - - test('handles bulk restore action correctly', async () => { - const selectedThreadIds = ['thread1', 'thread2']; - renderFilterBar({ - selectedThreadIds, - isDeletedView: true, - }); - - const restoreButton = screen.getByTestId('bulk-restore-button'); - fireEvent.click(restoreButton); - - await waitFor(() => { - expect(mockOnBulkAction).toHaveBeenCalledWith('restore', selectedThreadIds); - }); - }); - - test('shows loading state during bulk restore', async () => { - const selectedThreadIds = ['thread1']; - mockOnBulkAction.mockImplementation(() => new Promise((resolve) => { - setTimeout(resolve, 100); - })); - - renderFilterBar({ - selectedThreadIds, - isDeletedView: true, - }); - - const restoreButton = screen.getByTestId('bulk-restore-button'); - fireEvent.click(restoreButton); - - expect(screen.getByText('Restoring...')).toBeInTheDocument(); - expect(restoreButton).toBeDisabled(); - }); - }); - - describe('No Selection State', () => { - test('does not show bulk actions when no threads are selected', () => { - renderFilterBar({ selectedThreadIds: [] }); - - expect(screen.queryByText('Delete Selected')).not.toBeInTheDocument(); - expect(screen.queryByText('Restore Selected')).not.toBeInTheDocument(); - expect(screen.queryByText('selected')).not.toBeInTheDocument(); - }); - - test('does not show bulk actions when onBulkAction is not provided', () => { - const selectedThreadIds = ['thread1', 'thread2']; - renderFilterBar({ - selectedThreadIds, - onBulkAction: undefined, - }); - - expect(screen.queryByText('Delete Selected')).not.toBeInTheDocument(); - expect(screen.queryByText('Restore Selected')).not.toBeInTheDocument(); - }); - }); - - describe('Error Handling', () => { - test('resets loading state after bulk action completion', async () => { - const selectedThreadIds = ['thread1']; - let resolveAction; - mockOnBulkAction.mockImplementation(() => new Promise((resolve) => { - resolveAction = resolve; - })); - - renderFilterBar({ - selectedThreadIds, - isDeletedView: false, - }); - - const deleteButton = screen.getByTestId('bulk-delete-button'); - fireEvent.click(deleteButton); - - // Button should be disabled during action - await waitFor(() => { - expect(deleteButton).toBeDisabled(); - }); - - // Complete the action - resolveAction(); - - // Button should be re-enabled after action completes - await waitFor(() => { - expect(deleteButton).not.toBeDisabled(); - }); - }); - }); - - describe('Accessibility', () => { - test('has proper aria labels and roles', () => { - const selectedThreadIds = ['thread1', 'thread2']; - renderFilterBar({ selectedThreadIds }); - - const activeButton = screen.getByTestId('active-threads-button'); - const deletedButton = screen.getByTestId('deleted-threads-button'); - - // Button components render as actual button elements - expect(activeButton.tagName).toBe('BUTTON'); - expect(deletedButton.tagName).toBe('BUTTON'); - }); - - test('spinner has proper screen reader text', () => { - renderFilterBar({ isLoading: true }); - - const spinner = screen.getByText('Loading threads...'); - expect(spinner).toBeInTheDocument(); - }); - }); - - describe('Internationalization', () => { - test('displays localized messages correctly', () => { - renderFilterBar(); - - // Check that messages are being used correctly - expect(screen.getByText(messages.activeThreads.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(messages.deletedThreads.defaultMessage)).toBeInTheDocument(); - }); - - test('displays localized bulk action messages', () => { - const selectedThreadIds = ['thread1', 'thread2']; - renderFilterBar({ selectedThreadIds }); - - expect(screen.getByText('2 selected')).toBeInTheDocument(); - expect(screen.getByText('Delete Selected')).toBeInTheDocument(); - }); - }); -}); diff --git a/src/discussions/components/index.js b/src/discussions/components/index.js deleted file mode 100644 index 3489d6a14..000000000 --- a/src/discussions/components/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as FilterBar } from './FilterBar'; diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js index edd16c0d4..625df5f65 100644 --- a/src/discussions/learners/data/api.js +++ b/src/discussions/learners/data/api.js @@ -50,6 +50,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], @@ -66,7 +67,7 @@ export async function getUserPosts(courseId, { threadType, countFlagged, cohort, - isDeleted, + showDeleted, } = {}) { const params = snakeCaseObject({ page, @@ -79,7 +80,7 @@ export async function getUserPosts(courseId, { username: author, countFlagged, groupId: cohort, - isDeleted, + showDeleted, }); const { data } = await getAuthenticatedHttpClient() diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index c54e74144..7585ef71b 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -115,11 +115,8 @@ export function fetchUserPosts(courseId, { if (filters.cohort) { options.cohort = filters.cohort; } - if (filters.status === PostsStatusFilter.ACTIVE) { - options.isDeleted = false; - } if (filters.status === PostsStatusFilter.DELETED) { - options.isDeleted = true; + options.showDeleted = true; } return async (dispatch) => { try { diff --git a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx index e707f0e9c..2dceaf0f9 100644 --- a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx +++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx @@ -110,7 +110,7 @@ const LearnerPostFilterBar = () => { filters={filtersToShow} selectedFilters={postFilter} onFilterChange={handleFilterChange} - showCohortsFilter={userHasModerationPrivileges || userIsGroupTa || userIsStaff} + showCohortsFilter={userHasModerationPrivileges || userIsGroupTa} /> ); }; From e908403386af2591389ac514dd8cdc138952614c Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Tue, 18 Nov 2025 11:01:57 +0000 Subject: [PATCH 05/19] feat: added soft delete functionality --- src/discussions/messages.js | 1 + .../post-comments/comments/comment/Reply.jsx | 3 ++- src/discussions/posts/post/Post.jsx | 10 ++++++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/discussions/messages.js b/src/discussions/messages.js index e24df25eb..c8b3461de 100644 --- a/src/discussions/messages.js +++ b/src/discussions/messages.js @@ -297,6 +297,7 @@ const messages = defineMessages({ 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/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index 3a17ab213..20767454d 100644 --- a/src/discussions/post-comments/comments/comment/Reply.jsx +++ b/src/discussions/post-comments/comments/comment/Reply.jsx @@ -31,7 +31,8 @@ const Reply = ({ responseId }) => { const commentData = useSelector(selectCommentOrResponseById(responseId)); const { id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy, - closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel, isDeleted, is_spam: isSpam, + closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, + closedByLabel, isDeleted, is_spam: isSpam, } = useSelector(selectCommentOrResponseById(responseId)); const intl = useIntl(); const dispatch = useDispatch(); diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 4cf8d3924..89dc022fa 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -16,12 +16,18 @@ import { logError } from '@edx/frontend-platform/logging'; import HTMLLoader from '../../../components/HTMLLoader'; import { AvatarOutlineAndLabelColors, ContentActions, getFullUrl } from '../../../data/constants'; import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors'; -import { AlertBanner, AuthorLabel, 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'; import { ContentTypes } from '../../data/constants'; -import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation, selectUserHasModerationPrivileges } from '../../data/selectors'; +import { + selectContentCreationRateLimited, + selectShouldShowEmailConfirmation, + selectUserHasModerationPrivileges, +} from '../../data/selectors'; import { selectTopic } from '../../topics/data/selectors'; import { selectThread } from '../data/selectors'; import { From 3e68d8bc8bb61678425afd629472588e3a2b9827 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Thu, 20 Nov 2025 07:36:23 +0000 Subject: [PATCH 06/19] feat: added soft delete functionality --- .env.development | 22 +-- .../data/services/softDeleteService.js | 126 ------------------ src/discussions/data/thunks.js | 16 +-- src/discussions/post-comments/data/api.js | 16 +++ src/discussions/post-comments/data/thunks.js | 9 +- src/discussions/posts/data/api.js | 43 +++--- src/discussions/posts/data/thunks.js | 3 +- webpack.dev.config.js | 8 ++ 8 files changed, 70 insertions(+), 173 deletions(-) delete mode 100644 src/discussions/data/services/softDeleteService.js create mode 100644 webpack.dev.config.js diff --git a/.env.development b/.env.development index 2bc65aa92..79b54a7cb 100644 --- a/.env.development +++ b/.env.development @@ -1,24 +1,24 @@ NODE_ENV='development' PORT=2002 ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' -BASE_URL='http://localhost:2002' +BASE_URL='https://discussion.cosmo2742.sandbox.edx.org:2002' CREDENTIALS_BASE_URL='http://localhost:18150' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' ECOMMERCE_BASE_URL='http://localhost:18130' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' -LMS_BASE_URL='http://localhost:18000' +LMS_BASE_URL='https://cosmo2742.sandbox.edx.org' LEARNING_BASE_URL='http://localhost:2000' -LOGIN_URL='http://localhost:18000/login' -LOGOUT_URL='http://localhost:18000/logout' -LOGO_URL=https://edx-cdn.org/v3/default/logo.svg -LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg -LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg -FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico -MARKETING_SITE_BASE_URL='http://localhost:18000' +LOGIN_URL='https://cosmo2742.sandbox.edx.org/login' +LOGOUT_URL='https://cosmo2742.sandbox.edx.org/logout' +LOGO_URL='https://edx-cdn.org/v3/default/logo.svg' +LOGO_TRADEMARK_URL='https://edx-cdn.org/v3/default/logo-trademark.svg' +LOGO_WHITE_URL='https://edx-cdn.org/v3/default/logo-white.svg' +FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico' +MARKETING_SITE_BASE_URL='https://cosmo2742.sandbox.edx.org' ORDER_HISTORY_URL='http://localhost:1996/orders' -REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' +REFRESH_ACCESS_TOKEN_ENDPOINT='https://cosmo2742.sandbox.edx.org/login_refresh' SEGMENT_KEY='' -SITE_NAME=localhost +SITE_NAME=cosmo2742.sandbox.edx.org USER_INFO_COOKIE_NAME='edx-user-info' SUPPORT_URL='https://support.edx.org' LEARNER_FEEDBACK_URL='' diff --git a/src/discussions/data/services/softDeleteService.js b/src/discussions/data/services/softDeleteService.js deleted file mode 100644 index 5790ea0b2..000000000 --- a/src/discussions/data/services/softDeleteService.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * API service for soft delete operations on threads - */ -import { getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - -// Use the LMS API base URL from the platform configuration -const getDiscussionApiBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1`; - -/** - * Soft delete a single thread by calling the DELETE endpoint - * @param {string} threadId - The ID of the thread to soft delete - * @returns {Promise} API response - */ -export async function softDeleteThread(threadId) { - const url = `${getDiscussionApiBaseUrl()}/threads/${threadId}/`; - return getAuthenticatedHttpClient().delete(url); -} - -/** - * Restore a soft deleted thread - * @param {string} threadId - The ID of the thread to restore - * @returns {Promise} API response - */ -export async function restoreThread(threadId) { - const url = `${getDiscussionApiBaseUrl()}/threads/${threadId}/restore/`; - return getAuthenticatedHttpClient().post(url, {}); -} - -/** - * Bulk soft delete multiple threads - * NOTE: Currently implemented as sequential deletes. - * TODO: Implement true bulk endpoint for better performance - * @param {string[]} threadIds - Array of thread IDs to soft delete - * @returns {Promise} API response - */ -export async function bulkSoftDeleteThreads(threadIds) { - const results = await Promise.allSettled( - threadIds.map(threadId => softDeleteThread(threadId)), - ); - - return { - successful: results.filter(r => r.status === 'fulfilled').length, - failed: results.filter(r => r.status === 'rejected').length, - results, - }; -} - -/** - * Bulk restore multiple soft deleted threads - * NOTE: Currently implemented as sequential restores. - * TODO: Implement true bulk endpoint for better performance - * @param {string[]} threadIds - Array of thread IDs to restore - * @returns {Promise} API response - */ -export async function bulkRestoreThreads(threadIds) { - const results = await Promise.allSettled( - threadIds.map(threadId => restoreThread(threadId)), - ); - - return { - successful: results.filter(r => r.status === 'fulfilled').length, - failed: results.filter(r => r.status === 'rejected').length, - results, - }; -} - -/** - * Get soft deleted threads for a course using the learner API - * @param {string} courseId - The course ID - * @param {Object} options - Additional query options - * @param {string} options.userId - Optional user ID to filter by author - * @param {string} options.username - Username to filter by (required) - * @param {number} options.page - Page number for pagination - * @param {number} options.pageSize - Number of threads per page - * @param {string} options.sortKey - Sort key for ordering results (last_activity_at, comment_count, vote_count) - * @returns {Promise} API response - */ -export async function getDeletedThreads(courseId, options = {}) { - const url = `${getDiscussionApiBaseUrl()}/courses/${courseId}/learner/`; - - const params = { - username: options.username, - page: options.page || 1, - page_size: options.pageSize || 20, - show_deleted: true, // This is the key parameter to get deleted threads - ...options.sortKey && { order_by: options.sortKey }, - }; - - return getAuthenticatedHttpClient().get(url, { params }); -} - -/** - * Search threads with optional deleted filter - * @param {Object} searchParams - Search parameters including courseId - * @returns {Promise} API response - */ -export async function searchThreadsWithDeletedFilter(searchParams) { - const url = `${getDiscussionApiBaseUrl()}/threads/`; - - const params = { - ...searchParams, - }; - - return getAuthenticatedHttpClient().get(url, { params }); -} - -/** - * Soft delete a single comment by calling the DELETE endpoint - * @param {string} commentId - The ID of the comment to soft delete - * @returns {Promise} API response - */ -export async function softDeleteComment(commentId) { - const url = `${getDiscussionApiBaseUrl()}/comments/${commentId}/`; - return getAuthenticatedHttpClient().delete(url); -} - -/** - * Restore a soft deleted comment - * @param {string} commentId - The ID of the comment to restore - * @returns {Promise} API response - */ -export async function restoreComment(commentId) { - const url = `${getDiscussionApiBaseUrl()}/comments/${commentId}/restore/`; - return getAuthenticatedHttpClient().post(url, {}); -} diff --git a/src/discussions/data/thunks.js b/src/discussions/data/thunks.js index 3e046dae0..fd4ca329e 100644 --- a/src/discussions/data/thunks.js +++ b/src/discussions/data/thunks.js @@ -54,12 +54,12 @@ export default function fetchCourseConfig(courseId) { } // Soft delete thunks -export function performBulkSoftDelete(threadIds, userId, courseId) { +export function performBulkSoftDelete(threadIds) { return async (dispatch) => { try { dispatch(bulkActionRequest()); - const { bulkSoftDeleteThreads } = await import('./services/softDeleteService'); - await bulkSoftDeleteThreads(threadIds, userId, courseId); + const { bulkSoftDeleteThreads } = await import('../posts/data/api'); + await bulkSoftDeleteThreads(threadIds); dispatch(bulkActionSuccess()); return { success: true }; } catch (error) { @@ -74,7 +74,7 @@ export function performBulkRestore(threadIds, courseId) { return async (dispatch) => { try { dispatch(bulkActionRequest()); - const { bulkRestoreThreads } = await import('./services/softDeleteService'); + const { bulkRestoreThreads } = await import('../posts/data/api'); await bulkRestoreThreads(threadIds, courseId); dispatch(bulkActionSuccess()); return { success: true }; @@ -86,11 +86,11 @@ export function performBulkRestore(threadIds, courseId) { }; } -export function performSoftDeleteThread(threadId, userId, courseId) { +export function performSoftDeleteThread(threadId) { return async () => { try { - const { softDeleteThread } = await import('./services/softDeleteService'); - await softDeleteThread(threadId, userId, courseId); + const { softDeleteThread } = await import('../posts/data/api'); + await softDeleteThread(threadId); return { success: true }; } catch (error) { logError(error); @@ -102,7 +102,7 @@ export function performSoftDeleteThread(threadId, userId, courseId) { export function performRestoreThread(threadId, courseId) { return async () => { try { - const { restoreThread } = await import('./services/softDeleteService'); + const { restoreThread } = await import('../posts/data/api'); await restoreThread(threadId, courseId); return { success: true }; } catch (error) { diff --git a/src/discussions/post-comments/data/api.js b/src/discussions/post-comments/data/api.js index a67fa7312..77fe6f5af 100644 --- a/src/discussions/post-comments/data/api.js +++ b/src/discussions/post-comments/data/api.js @@ -127,3 +127,19 @@ export const deleteComment = async (commentId) => { await getAuthenticatedHttpClient() .delete(url); }; + +/** + * Restores a soft 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/thunks.js b/src/discussions/post-comments/data/thunks.js index ef079a0c0..4320ba0c3 100644 --- a/src/discussions/post-comments/data/thunks.js +++ b/src/discussions/post-comments/data/thunks.js @@ -189,8 +189,7 @@ export function removeComment(commentId, threadId) { export function performSoftDeleteComment(commentId) { return async () => { try { - const { softDeleteComment } = await import('../../data/services/softDeleteService'); - await softDeleteComment(commentId); + await deleteComment(commentId); return { success: true }; } catch (error) { logError(error); @@ -199,11 +198,11 @@ export function performSoftDeleteComment(commentId) { }; } -export function performRestoreComment(commentId) { +export function performRestoreComment(commentId, courseId) { return async () => { try { - const { restoreComment } = await import('../../data/services/softDeleteService'); - await restoreComment(commentId); + const { restoreComment } = await import('./api'); + await restoreComment(commentId, courseId); return { success: true }; } catch (error) { logError(error); diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js index c012ff41a..dac6cb09a 100644 --- a/src/discussions/posts/data/api.js +++ b/src/discussions/posts/data/api.js @@ -220,50 +220,51 @@ export const sendEmailForAccountActivation = async () => { /** * Soft delete a thread. * @param {string} threadId - * @param {string} userId * @returns {Promise<{}>} */ -export const softDeleteThread = async (threadId, userId) => { - const url = `${getThreadsApiUrl()}${threadId}/soft_delete/`; - const { data } = await getAuthenticatedHttpClient().post(url, { user_id: userId }); +export const softDeleteThread = async (threadId) => { + const url = `${getThreadsApiUrl()}${threadId}/`; + const { data } = await getAuthenticatedHttpClient().delete(url); return data; }; /** * Restore a soft deleted thread. * @param {string} threadId + * @param {string} courseId * @returns {Promise<{}>} */ -export const restoreThread = async (threadId) => { - const url = `${getThreadsApiUrl()}${threadId}/restore/`; - const { data } = await getAuthenticatedHttpClient().post(url); +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; }; /** * Bulk soft delete threads. * @param {string[]} threadIds - * @param {string} userId * @returns {Promise<{}>} */ -export const bulkSoftDeleteThreads = async (threadIds, userId) => { - const url = `${getThreadsApiUrl()}bulk_soft_delete/`; - const { data } = await getAuthenticatedHttpClient().post(url, { - thread_ids: threadIds.join(','), - user_id: userId, - }); - return data; +export const bulkSoftDeleteThreads = async (threadIds) => { + // Delete threads one by one since there's no bulk delete endpoint in v1 API + const promises = threadIds.map(threadId => softDeleteThread(threadId)); + const results = await Promise.all(promises); + return { success: true, results }; }; /** * Bulk restore soft deleted threads. * @param {string[]} threadIds + * @param {string} courseId * @returns {Promise<{}>} */ -export const bulkRestoreThreads = async (threadIds) => { - const url = `${getThreadsApiUrl()}bulk_restore/`; - const { data } = await getAuthenticatedHttpClient().post(url, { - thread_ids: threadIds.join(','), - }); - return data; +export const bulkRestoreThreads = async (threadIds, courseId) => { + // Restore threads one by one since RestoreContent handles individual items + const promises = threadIds.map(threadId => restoreThread(threadId, courseId)); + const results = await Promise.all(promises); + return { success: true, results }; }; diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index 5041176f5..c0963eefe 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -11,6 +11,7 @@ import { getThreads, postThread, sendEmailForAccountActivation, + softDeleteThread, updateThread, } from './api'; import { @@ -311,8 +312,6 @@ export function removeThread(threadId) { return async (dispatch) => { try { dispatch(deleteThreadRequest({ threadId })); - // Use soft delete instead of hard delete - const { softDeleteThread } = await import('../../data/services/softDeleteService'); await softDeleteThread(threadId); dispatch(deleteThreadSuccess({ threadId })); } catch (error) { diff --git a/webpack.dev.config.js b/webpack.dev.config.js new file mode 100644 index 000000000..03de789c5 --- /dev/null +++ b/webpack.dev.config.js @@ -0,0 +1,8 @@ +const { createConfig } = require('@openedx/frontend-build'); + +module.exports = createConfig('webpack-dev', { + devServer: { + allowedHosts: 'all', + server: 'https', + }, +}); From 57443048436fc7a81773cf0b99ebb1a8f6c5861f Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Thu, 20 Nov 2025 12:45:18 +0000 Subject: [PATCH 07/19] fix: new feature --- .env.development | 14 ++++++------ .../deleted-content/data/thunks.js | 0 .../comments/comment/Comment.jsx | 17 +++++++++----- .../post-comments/comments/comment/Reply.jsx | 22 ++++++------------- src/discussions/post-comments/data/api.js | 4 ++++ src/discussions/post-comments/data/hooks.js | 17 +++++++++++--- src/discussions/post-comments/data/thunks.js | 7 +++--- webpack.dev.config.js | 8 ------- 8 files changed, 47 insertions(+), 42 deletions(-) create mode 100644 src/discussions/deleted-content/data/thunks.js delete mode 100644 webpack.dev.config.js diff --git a/.env.development b/.env.development index 79b54a7cb..4df51caef 100644 --- a/.env.development +++ b/.env.development @@ -1,24 +1,24 @@ NODE_ENV='development' PORT=2002 ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' -BASE_URL='https://discussion.cosmo2742.sandbox.edx.org:2002' +BASE_URL='http://localhost:2002' CREDENTIALS_BASE_URL='http://localhost:18150' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' ECOMMERCE_BASE_URL='http://localhost:18130' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' -LMS_BASE_URL='https://cosmo2742.sandbox.edx.org' +LMS_BASE_URL='http://localhost:18000' LEARNING_BASE_URL='http://localhost:2000' -LOGIN_URL='https://cosmo2742.sandbox.edx.org/login' -LOGOUT_URL='https://cosmo2742.sandbox.edx.org/logout' +LOGIN_URL='http://localhost:18000/login' +LOGOUT_URL='http://localhost:18000/logout' LOGO_URL='https://edx-cdn.org/v3/default/logo.svg' LOGO_TRADEMARK_URL='https://edx-cdn.org/v3/default/logo-trademark.svg' LOGO_WHITE_URL='https://edx-cdn.org/v3/default/logo-white.svg' FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico' -MARKETING_SITE_BASE_URL='https://cosmo2742.sandbox.edx.org' +MARKETING_SITE_BASE_URL='http://localhost:18000' ORDER_HISTORY_URL='http://localhost:1996/orders' -REFRESH_ACCESS_TOKEN_ENDPOINT='https://cosmo2742.sandbox.edx.org/login_refresh' +REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' SEGMENT_KEY='' -SITE_NAME=cosmo2742.sandbox.edx.org +SITE_NAME=localhost USER_INFO_COOKIE_NAME='edx-user-info' SUPPORT_URL='https://support.edx.org' LEARNER_FEEDBACK_URL='' diff --git a/src/discussions/deleted-content/data/thunks.js b/src/discussions/deleted-content/data/thunks.js new file mode 100644 index 000000000..e69de29bb diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 846bb0f6d..1f8f2f1f5 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -16,7 +16,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { logError } from '@edx/frontend-platform/logging'; import HTMLLoader from '../../../../components/HTMLLoader'; -import { AvatarOutlineAndLabelColors, ContentActions, EndorsementStatus } from '../../../../data/constants'; +import { AvatarOutlineAndLabelColors, ContentActions, EndorsementStatus, PostsStatusFilter } from '../../../../data/constants'; import { AlertBanner, AuthorLabel, AutoSpamAlertBanner, Confirmation, EndorsedAlertBanner, } from '../../../common'; @@ -61,7 +61,7 @@ const Comment = ({ 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)); @@ -80,6 +80,8 @@ const Comment = ({ const isUserPrivilegedInPostingRestriction = useUserPostingEnabled(); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); + const postFilter = useSelector(state => state.learners?.postFilter); + const showDeleted = Boolean(learnerUsername && postFilter?.status === PostsStatusFilter.DELETED); // If isSpam is not provided in the API response, default to false const isSpamFlagged = isSpam || false; useEffect(() => { @@ -88,9 +90,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,7 +126,8 @@ const Comment = ({ } catch (error) { logError(error); } - }, [id, threadId, courseId, dispatch]); + hideDeleteConfirmation(); + }, [id, threadId, courseId, dispatch, hideDeleteConfirmation]); const handleReportConfirmation = useCallback(() => { dispatch(editComment(id, { flagged: !abuseFlagged })); @@ -145,7 +149,7 @@ const Comment = ({ const handleRestoreConfirmation = useCallback(async () => { try { const { performRestoreComment } = await import('../../data/thunks'); - const result = await dispatch(performRestoreComment(id)); + const result = await dispatch(performRestoreComment(id, courseId)); if (result.success) { // Refresh the thread to reflect the change await dispatch(fetchThread(threadId, courseId)); @@ -168,8 +172,9 @@ const Comment = ({ dispatch(fetchCommentResponses(id, { page: currentPage + 1, reverseOrder: sortedOrder, + showDeleted, })) - ), [id, currentPage, sortedOrder]); + ), [id, currentPage, sortedOrder, showDeleted]); const handleAddCommentButton = useCallback(() => { if (isUserPrivilegedInPostingRestriction) { diff --git a/src/discussions/post-comments/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index 20767454d..91d2cd4af 100644 --- a/src/discussions/post-comments/comments/comment/Reply.jsx +++ b/src/discussions/post-comments/comments/comment/Reply.jsx @@ -100,7 +100,7 @@ const Reply = ({ responseId }) => { const handleRestoreConfirmation = useCallback(async () => { try { const { performRestoreComment } = await import('../../data/thunks'); - const result = await dispatch(performRestoreComment(id)); + const result = await dispatch(performRestoreComment(id, courseId)); if (result.success) { await dispatch(fetchThread(threadId, courseId)); } @@ -197,28 +197,20 @@ const Reply = ({ responseId }) => { className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px flex-fill" style={{ borderRadius: '0rem 0.375rem 0.375rem', maxWidth: 'calc(100% - 50px)' }} > -
-
- - {isDeleted && !postIsDeleted && ( + {isDeleted && !postIsDeleted && ( +
+
{intl.formatMessage(messages.deletedComment)} {' '}deleted comment - )} +
-
+ )}
{ 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 }); diff --git a/src/discussions/post-comments/data/hooks.js b/src/discussions/post-comments/data/hooks.js index 33f71172d..88831dd60 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 + return learnerUsername && postFilter?.status === 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 4320ba0c3..ad0935de8 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, diff --git a/webpack.dev.config.js b/webpack.dev.config.js deleted file mode 100644 index 03de789c5..000000000 --- a/webpack.dev.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const { createConfig } = require('@openedx/frontend-build'); - -module.exports = createConfig('webpack-dev', { - devServer: { - allowedHosts: 'all', - server: 'https', - }, -}); From fd9406c9a146eee447d5d3aaa362892bbad76d5f Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Mon, 24 Nov 2025 09:28:11 +0000 Subject: [PATCH 08/19] feat: added soft delete functionality --- src/components/FilterBar.jsx | 1 + .../learners/LearnerActionsDropdown.jsx | 121 ++++++++++++------ src/discussions/learners/LearnerPostsView.jsx | 5 +- src/discussions/learners/data/slices.js | 8 +- src/discussions/learners/data/thunks.js | 14 +- .../LearnerPostFilterBar.jsx | 54 +++----- src/discussions/learners/messages.js | 20 +++ src/discussions/learners/utils.js | 46 +++++++ .../comments/comment/Comment.jsx | 9 +- src/discussions/post-comments/data/hooks.js | 6 +- .../posts/post-filter-bar/messages.js | 4 +- src/index.scss | 30 ++++- 12 files changed, 233 insertions(+), 85 deletions(-) diff --git a/src/components/FilterBar.jsx b/src/components/FilterBar.jsx index a08084e24..b7c50f6ee 100644 --- a/src/components/FilterBar.jsx +++ b/src/components/FilterBar.jsx @@ -241,6 +241,7 @@ FilterBar.propTypes = { selectedFilters: PropTypes.shape({ postType: ThreadType, status: PostsStatusFilter, + contentStatus: PostsStatusFilter, orderBy: ThreadOrdering, cohort: PropTypes.string, }).isRequired, diff --git a/src/discussions/learners/LearnerActionsDropdown.jsx b/src/discussions/learners/LearnerActionsDropdown.jsx index 9571ceefa..51e33bcc0 100644 --- a/src/discussions/learners/LearnerActionsDropdown.jsx +++ b/src/discussions/learners/LearnerActionsDropdown.jsx @@ -1,12 +1,13 @@ import React, { useCallback, 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'; @@ -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 = useLearnerActions(userHasBulkDeletePrivileges); const handleActions = useCallback((action) => { const actionFunction = actionHandlers[action]; if (actionFunction) { actionFunction(); + close(); } - }, [actionHandlers]); + }, [actionHandlers, close]); const onClickButton = useCallback((event) => { event.preventDefault(); @@ -39,6 +42,7 @@ const LearnerActionsDropdown = ({ const onCloseModal = useCallback(() => { close(); setTarget(null); + setActiveSubmenu(null); }, [close]); return ( @@ -53,41 +57,86 @@ const LearnerActionsDropdown = ({ iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''} />
- -
- {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/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx index 7654fa3a5..47589feba 100644 --- a/src/discussions/learners/LearnerPostsView.jsx +++ b/src/discussions/learners/LearnerPostsView.jsx @@ -86,6 +86,8 @@ const LearnerPostsView = () => { const handleDeletePosts = useCallback(async (courseOrOrg) => { await dispatchDelete(deleteUserPosts(courseId, username, courseOrOrg, true)); + dispatch(clearPostsPages()); + loadMorePosts(); hideDeleteConfirmation(); // Navigate back to learners list after deletion navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) }); @@ -101,10 +103,9 @@ const LearnerPostsView = () => { const handleRestorePosts = useCallback(async (courseOrOrg) => { await dispatch(undeleteUserPosts(courseId, username, courseOrOrg, true)); - hideRestoreConfirmation(); - // Clear and reload the posts to reflect restored content dispatch(clearPostsPages()); loadMorePosts(); + hideRestoreConfirmation(); }, [courseId, username, hideRestoreConfirmation, dispatch, loadMorePosts]); const actionHandlers = useMemo(() => ({ diff --git a/src/discussions/learners/data/slices.js b/src/discussions/learners/data/slices.js index c968e409c..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.ACTIVE, + 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) => ( diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index 7585ef71b..6bb9cf3a5 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -94,6 +94,14 @@ export function fetchUserPosts(courseId, { author, countFlagged, }; + // Main content status: Active/Deleted + if (filters.contentStatus === PostsStatusFilter.DELETED) { + options.showDeleted = true; + } else if (filters.contentStatus === PostsStatusFilter.ACTIVE) { + options.showDeleted = false; + } + + // Secondary status filters (independent) if (filters.status === PostsStatusFilter.UNREAD) { options.status = 'unread'; } @@ -106,6 +114,7 @@ export function fetchUserPosts(courseId, { if (filters.status === PostsStatusFilter.UNRESPONDED) { options.status = 'unresponded'; } + if (filters.postType !== ThreadType.ALL) { options.threadType = filters.postType; } @@ -115,9 +124,6 @@ export function fetchUserPosts(courseId, { if (filters.cohort) { options.cohort = filters.cohort; } - if (filters.status === PostsStatusFilter.DELETED) { - options.showDeleted = true; - } return async (dispatch) => { try { dispatch(fetchLearnerThreadsRequest({ courseId, author })); @@ -166,3 +172,5 @@ export function undeleteUserPosts(courseId, username, courseOrOrg, execute) { } }; } + + diff --git a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx index 2dceaf0f9..a017b167c 100644 --- a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx +++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx @@ -7,10 +7,10 @@ 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 { PostsStatusFilter, ThreadType } from '../../../data/constants'; import selectCourseCohorts from '../../cohorts/data/selectors'; import fetchCourseCohorts from '../../cohorts/data/thunks'; -import { selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff } from '../../data/selectors'; +import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors'; import { setPostFilter } from '../data/slices'; const LearnerPostFilterBar = () => { @@ -18,7 +18,7 @@ const LearnerPostFilterBar = () => { const { courseId } = useParams(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); - const userIsStaff = useSelector(selectUserIsStaff); + // const userIsStaff = useSelector(selectUserIsStaff); const cohorts = useSelector(selectCourseCohorts); const postFilter = useSelector(state => state.learners.postFilter); @@ -28,24 +28,23 @@ const LearnerPostFilterBar = () => { filters: ['type-all', 'type-discussions', 'type-questions'], }, { - name: 'status', + name: 'status', // secondary status filters: ['status-any', 'status-unread', 'status-unanswered', 'status-unresponded'], }, { name: 'orderBy', filters: ['sort-activity', 'sort-comments', 'sort-votes'], }, + { + name: 'contentStatus', // main content status + filters: ['status-active', 'status-deleted'], + hasSeparator: true, + }, ]; - if (userHasModerationPrivileges || userIsGroupTa || userIsStaff) { - // Add reported filter to the regular status filters + if (userHasModerationPrivileges || userIsGroupTa) { + // Add reported filter only for group TA and moderators filtersToShow[1].filters.splice(2, 0, 'status-reported'); - // Add Active/Deleted as a separate filter section at the bottom with a separator - filtersToShow.push({ - name: 'status', - filters: ['status-active', 'status-deleted'], - hasSeparator: true, // Add visual separator before this section - }); } const handleFilterChange = (event) => { @@ -59,40 +58,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/messages.js b/src/discussions/learners/messages.js index 5e8679ba1..188c46b14 100644 --- a/src/discussions/learners/messages.js +++ b/src/discussions/learners/messages.js @@ -68,6 +68,26 @@ const messages = defineMessages({ 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', diff --git a/src/discussions/learners/utils.js b/src/discussions/learners/utils.js index c4654f599..a6885466e 100644 --- a/src/discussions/learners/utils.js +++ b/src/discussions/learners/utils.js @@ -53,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/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 1f8f2f1f5..e62ec706e 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -16,7 +16,9 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { logError } from '@edx/frontend-platform/logging'; import HTMLLoader from '../../../../components/HTMLLoader'; -import { AvatarOutlineAndLabelColors, ContentActions, EndorsementStatus, PostsStatusFilter } from '../../../../data/constants'; +import { + AvatarOutlineAndLabelColors, ContentActions, EndorsementStatus, PostsStatusFilter, +} from '../../../../data/constants'; import { AlertBanner, AuthorLabel, AutoSpamAlertBanner, Confirmation, EndorsedAlertBanner, } from '../../../common'; @@ -81,7 +83,10 @@ const Comment = ({ const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const postFilter = useSelector(state => state.learners?.postFilter); - const showDeleted = Boolean(learnerUsername && postFilter?.status === PostsStatusFilter.DELETED); + // 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(() => { diff --git a/src/discussions/post-comments/data/hooks.js b/src/discussions/post-comments/data/hooks.js index 88831dd60..acd0df11f 100644 --- a/src/discussions/post-comments/data/hooks.js +++ b/src/discussions/post-comments/data/hooks.js @@ -45,9 +45,9 @@ export function usePost(postId) { 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 - return learnerUsername && postFilter?.status === PostsStatusFilter.DELETED; + + // 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) { diff --git a/src/discussions/posts/post-filter-bar/messages.js b/src/discussions/posts/post-filter-bar/messages.js index c4eabdfd9..abad87a1b 100644 --- a/src/discussions/posts/post-filter-bar/messages.js +++ b/src/discussions/posts/post-filter-bar/messages.js @@ -53,12 +53,12 @@ const messages = defineMessages({ }, filterActive: { id: 'discussions.posts.status.filter.active', - defaultMessage: 'Active', + defaultMessage: 'Active content', description: 'Option in dropdown to filter to active (non-deleted) posts', }, filterDeleted: { id: 'discussions.posts.status.filter.deleted', - defaultMessage: 'Deleted', + defaultMessage: 'Deleted content', description: 'Option in dropdown to filter to deleted posts', }, myPosts: { diff --git a/src/index.scss b/src/index.scss index 74a18428d..c28520989 100755 --- a/src/index.scss +++ b/src/index.scss @@ -587,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; From ebd6d13013d2ec698c37b28bceaf4d21b2b2ea5a Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Mon, 24 Nov 2025 10:40:16 +0000 Subject: [PATCH 09/19] feat: added soft delete functionality --- .env.development | 8 ++++---- src/discussions/deleted-content/data/thunks.js | 0 2 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 src/discussions/deleted-content/data/thunks.js diff --git a/.env.development b/.env.development index 4df51caef..2bc65aa92 100644 --- a/.env.development +++ b/.env.development @@ -10,10 +10,10 @@ LMS_BASE_URL='http://localhost:18000' LEARNING_BASE_URL='http://localhost:2000' LOGIN_URL='http://localhost:18000/login' LOGOUT_URL='http://localhost:18000/logout' -LOGO_URL='https://edx-cdn.org/v3/default/logo.svg' -LOGO_TRADEMARK_URL='https://edx-cdn.org/v3/default/logo-trademark.svg' -LOGO_WHITE_URL='https://edx-cdn.org/v3/default/logo-white.svg' -FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico' +LOGO_URL=https://edx-cdn.org/v3/default/logo.svg +LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg +LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg +FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico MARKETING_SITE_BASE_URL='http://localhost:18000' ORDER_HISTORY_URL='http://localhost:1996/orders' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' diff --git a/src/discussions/deleted-content/data/thunks.js b/src/discussions/deleted-content/data/thunks.js deleted file mode 100644 index e69de29bb..000000000 From 62b1e6f13ed22a9ae6e9223166f429eac99fcb90 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Mon, 24 Nov 2025 14:47:27 +0000 Subject: [PATCH 10/19] feat: added soft delete functionality --- src/discussions/learners/LearnerActionsDropdown.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/discussions/learners/LearnerActionsDropdown.jsx b/src/discussions/learners/LearnerActionsDropdown.jsx index 51e33bcc0..8d69c1e85 100644 --- a/src/discussions/learners/LearnerActionsDropdown.jsx +++ b/src/discussions/learners/LearnerActionsDropdown.jsx @@ -11,7 +11,7 @@ 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, @@ -23,7 +23,7 @@ const LearnerActionsDropdown = ({ const [isOpen, open, close] = useToggle(false); const [target, setTarget] = useState(null); const [activeSubmenu, setActiveSubmenu] = useState(null); - const menuItems = useLearnerActions(userHasBulkDeletePrivileges); + const menuItems = useLearnerActionsMenu(intl, userHasBulkDeletePrivileges); const handleActions = useCallback((action) => { const actionFunction = actionHandlers[action]; From ed1f9499cd67070a7b790d362eeec1f2f54a9597 Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Mon, 1 Dec 2025 10:08:19 +0000 Subject: [PATCH 11/19] fix: threads,response, comments in UI deleted content --- src/data/constants.js | 1 - src/discussions/common/ActionsDropdown.jsx | 6 +- src/discussions/data/selectors.js | 15 +--- src/discussions/data/slices.js | 62 ------------- src/discussions/data/thunks.js | 30 ------- src/discussions/learners/data/api.js | 33 +++++++ src/discussions/learners/data/thunks.js | 89 +++++++++++-------- src/discussions/messages.js | 7 +- .../comments/comment/Comment.jsx | 40 +++------ .../post-comments/comments/comment/Reply.jsx | 48 +++++----- src/discussions/post-comments/data/api.js | 2 +- src/discussions/post-comments/data/thunks.js | 12 --- src/discussions/posts/data/api.js | 27 +----- src/discussions/posts/data/thunks.js | 9 +- src/discussions/posts/post/Post.jsx | 67 ++++++-------- src/discussions/posts/post/PostLink.jsx | 37 ++++++-- src/discussions/posts/post/messages.js | 10 +++ src/discussions/utils.js | 12 +-- src/index.scss | 4 +- 19 files changed, 206 insertions(+), 305 deletions(-) diff --git a/src/data/constants.js b/src/data/constants.js index f590292bf..4919694c3 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -51,7 +51,6 @@ export const ContentActions = { COPY_LINK: 'copy_link', REPORT: 'abuse_flagged', DELETE: 'delete', - SOFT_DELETE: 'soft_delete', RESTORE: 'restore', FOLLOWING: 'following', CHANGE_GROUP: 'group_id', diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index db2de5bc6..359125081 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -78,14 +78,10 @@ const ActionsDropdown = ({ size="inline" onClick={() => { close(); - if (!action.disabled) { - handleActions(action.action); - } + handleActions(action.action); }} className="d-flex justify-content-start actions-dropdown-item" data-testId={action.id} - disabled={action.disabled} - style={action.disabled ? { opacity: 0.3, cursor: 'not-allowed' } : {}} > state.threads?.filter || 'active'; - -export const selectThreadsLoading = state => state.threads?.loading || false; - -export const selectSelectedThreadIds = state => state.threads?.selectedThreadIds || []; - -export const selectBulkActionStatus = state => state.threads?.bulkActionStatus; - -export const selectBulkActionError = state => state.threads?.bulkActionError; diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js index e6727daed..b6bc2e7ff 100644 --- a/src/discussions/data/slices.js +++ b/src/discussions/data/slices.js @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; import { RequestStatus } from '../../data/constants'; @@ -76,64 +75,3 @@ export const { } = configSlice.actions; export const configReducer = configSlice.reducer; - -// Threads slice for soft delete functionality -const threadsSlice = createSlice({ - name: 'threads', - initialState: { - filter: 'active', // 'active' or 'deleted' - loading: false, - selectedThreadIds: [], - bulkActionStatus: RequestStatus.IDLE, - bulkActionError: null, - }, - reducers: { - setThreadsFilter: (state, { payload }) => { - state.filter = payload; - state.selectedThreadIds = []; // Clear selection when changing filter - }, - setThreadsLoading: (state, { payload }) => { - state.loading = payload; - }, - setSelectedThreadIds: (state, { payload }) => { - state.selectedThreadIds = payload; - }, - toggleThreadSelection: (state, { payload }) => { - const threadId = payload; - const index = state.selectedThreadIds.indexOf(threadId); - if (index > -1) { - state.selectedThreadIds.splice(index, 1); - } else { - state.selectedThreadIds.push(threadId); - } - }, - clearThreadSelection: (state) => { - state.selectedThreadIds = []; - }, - bulkActionRequest: (state) => { - state.bulkActionStatus = RequestStatus.IN_PROGRESS; - state.bulkActionError = null; - }, - bulkActionSuccess: (state) => { - state.bulkActionStatus = RequestStatus.SUCCESSFUL; - state.selectedThreadIds = []; // Clear selection after successful action - }, - bulkActionFailed: (state, { payload }) => { - state.bulkActionStatus = RequestStatus.FAILED; - state.bulkActionError = payload; - }, - }, -}); - -export const { - setThreadsFilter, - setThreadsLoading, - setSelectedThreadIds, - toggleThreadSelection, - clearThreadSelection, - bulkActionRequest, - bulkActionSuccess, - bulkActionFailed, -} = threadsSlice.actions; - -export const threadsReducer = threadsSlice.reducer; diff --git a/src/discussions/data/thunks.js b/src/discussions/data/thunks.js index fd4ca329e..a8f373e35 100644 --- a/src/discussions/data/thunks.js +++ b/src/discussions/data/thunks.js @@ -53,23 +53,6 @@ export default function fetchCourseConfig(courseId) { }; } -// Soft delete thunks -export function performBulkSoftDelete(threadIds) { - return async (dispatch) => { - try { - dispatch(bulkActionRequest()); - const { bulkSoftDeleteThreads } = await import('../posts/data/api'); - await bulkSoftDeleteThreads(threadIds); - dispatch(bulkActionSuccess()); - return { success: true }; - } catch (error) { - dispatch(bulkActionFailed(error.message || 'Failed to delete threads')); - logError(error); - return { success: false, error: error.message }; - } - }; -} - export function performBulkRestore(threadIds, courseId) { return async (dispatch) => { try { @@ -86,19 +69,6 @@ export function performBulkRestore(threadIds, courseId) { }; } -export function performSoftDeleteThread(threadId) { - return async () => { - try { - const { softDeleteThread } = await import('../posts/data/api'); - await softDeleteThread(threadId); - return { success: true }; - } catch (error) { - logError(error); - return { success: false, error: error.message }; - } - }; -} - export function performRestoreThread(threadId, courseId) { return async () => { try { diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js index 625df5f65..6a291ec6c 100644 --- a/src/discussions/learners/data/api.js +++ b/src/discussions/learners/data/api.js @@ -11,6 +11,7 @@ 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}`; @@ -132,3 +133,35 @@ export async function restoreUserPostsApi(courseId, username, courseOrOrg, execu * Alias for restoreUserPostsApi for backwards compatibility */ export const undeleteUserPostsApi = restoreUserPostsApi; + +/** + * 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/thunks.js b/src/discussions/learners/data/thunks.js index 6bb9cf3a5..fda3254a6 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -88,47 +88,62 @@ export function fetchUserPosts(courseId, { author = null, countFlagged, } = {}) { - const options = { - orderBy, - page, - author, - countFlagged, - }; - // Main content status: Active/Deleted - if (filters.contentStatus === PostsStatusFilter.DELETED) { - options.showDeleted = true; - } else 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; - } 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) { + const { getDeletedContent } = await import('./api'); + data = await getDeletedContent(courseId, { + author, + page, + pageSize: 10, + }); + } else { + // Use regular learner posts endpoint for active content + const { getUserPosts } = await import('./api'); + 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 })); diff --git a/src/discussions/messages.js b/src/discussions/messages.js index c8b3461de..7d9ba86ca 100644 --- a/src/discussions/messages.js +++ b/src/discussions/messages.js @@ -31,15 +31,10 @@ const messages = defineMessages({ defaultMessage: 'Delete', description: 'Action to delete a post or comment', }, - softDeleteAction: { - id: 'discussions.actions.softDelete', - defaultMessage: 'Delete', - description: 'Action to soft delete a post or comment', - }, restoreAction: { id: 'discussions.actions.restore', defaultMessage: 'Restore', - description: 'Action to restore a soft deleted post or comment', + description: 'Action to restore a deleted post or comment', }, confirmationConfirm: { id: 'discussions.confirmation.button.confirm', diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index e62ec706e..06bb34550 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -40,7 +40,7 @@ import { selectCommentResponsesIds, selectCommentSortOrder, } from '../../data/selectors'; -import { editComment, fetchCommentResponses } from '../../data/thunks'; +import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks'; import messages from '../../messages'; import PostCommentsContext from '../../postCommentsContext'; import CommentEditor from './CommentEditor'; @@ -57,7 +57,7 @@ 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, isDeleted, deletedByLabel, is_spam: isSpam, + editByLabel, closedByLabel, users: postUsers, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam, } = comment; const intl = useIntl(); const hasChildren = childCount > 0; @@ -111,7 +111,7 @@ const Comment = ({ const handleCommentEndorse = useCallback(async () => { await dispatch(editComment(id, { endorsed: !endorsed })); await dispatch(fetchThread(threadId, courseId)); - }, [id, endorsed, threadId, courseId, dispatch]); + }, [id, endorsed, threadId]); const handleAbusedFlag = useCallback(() => { if (abuseFlagged) { @@ -121,18 +121,10 @@ const Comment = ({ } }, [abuseFlagged, id, showReportConfirmation]); - const handleDeleteConfirmation = useCallback(async () => { - try { - const { performSoftDeleteComment } = await import('../../data/thunks'); - const result = await dispatch(performSoftDeleteComment(id)); - if (result.success) { - await dispatch(fetchThread(threadId, courseId)); - } - } catch (error) { - logError(error); - } + const handleDeleteConfirmation = useCallback(() => { + dispatch(removeComment(id)); hideDeleteConfirmation(); - }, [id, threadId, courseId, dispatch, hideDeleteConfirmation]); + }, [id, hideDeleteConfirmation]); const handleReportConfirmation = useCallback(() => { dispatch(editComment(id, { flagged: !abuseFlagged })); @@ -143,10 +135,6 @@ const Comment = ({ await dispatch(editComment(id, { voted: !voted })); }, [id, voted]); - const handleSoftDelete = useCallback(() => { - showDeleteConfirmation(); - }, [showDeleteConfirmation]); - const handleRestore = useCallback(() => { showRestoreConfirmation(); }, [showRestoreConfirmation]); @@ -168,10 +156,10 @@ const Comment = ({ const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handleEditContent, [ContentActions.ENDORSE]: handleCommentEndorse, - [ContentActions.SOFT_DELETE]: handleSoftDelete, + [ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.RESTORE]: handleRestore, [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleCommentEndorse, handleSoftDelete, handleRestore, handleAbusedFlag]); + }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleRestore, handleAbusedFlag]); const handleLoadMoreComments = useCallback(() => ( dispatch(fetchCommentResponses(id, { @@ -210,12 +198,8 @@ const Comment = ({ > - {isDeleted && deletedByLabel && ( + {isDeleted && deletedBy && deletedByLabel && (
{intl.formatMessage(messages.deletedBy)} { const { id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy, closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, - closedByLabel, isDeleted, is_spam: isSpam, + closedByLabel, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam, } = useSelector(selectCommentOrResponseById(responseId)); const intl = useIntl(); const dispatch = useDispatch(); @@ -55,18 +54,10 @@ const Reply = ({ responseId }) => { }); const authorAvatar = useSelector(selectAuthorAvatar(author)); - const handleDeleteConfirmation = useCallback(async () => { - try { - const { performSoftDeleteComment } = await import('../../data/thunks'); - const result = await dispatch(performSoftDeleteComment(id)); - if (result.success) { - await dispatch(fetchThread(threadId, courseId)); - } - } catch (error) { - logError(error); - } + const handleDeleteConfirmation = useCallback(() => { + dispatch(removeComment(id)); hideDeleteConfirmation(); - }, [id, courseId, threadId, dispatch, hideDeleteConfirmation]); + }, [id, hideDeleteConfirmation]); const handleReportConfirmation = useCallback(() => { dispatch(editComment(id, { flagged: !abuseFlagged })); @@ -89,10 +80,6 @@ const Reply = ({ responseId }) => { } }, [abuseFlagged, id, showReportConfirmation]); - const handleSoftDelete = useCallback(() => { - showDeleteConfirmation(); - }, [showDeleteConfirmation]); - const handleRestore = useCallback(() => { showRestoreConfirmation(); }, [showRestoreConfirmation]); @@ -117,10 +104,10 @@ const Reply = ({ responseId }) => { const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handleEditContent, [ContentActions.ENDORSE]: handleReplyEndorse, - [ContentActions.SOFT_DELETE]: handleSoftDelete, + [ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.RESTORE]: handleRestore, [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleReplyEndorse, handleSoftDelete, handleRestore, handleAbusedFlag]); + }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleRestore, handleAbusedFlag]); return (
@@ -211,6 +198,23 @@ const Reply = ({ responseId }) => {
)} + {isDeleted && deletedBy && deletedByLabel && ( +
+ +
+ {intl.formatMessage(messages.deletedBy)} + + + +
+
+ )}
{ }; /** - * Restores a soft deleted comment. + * Restores a deleted comment. * @param {string} commentId ID of comment to restore * @param {string} courseId Course ID * @returns {Promise<{}>} diff --git a/src/discussions/post-comments/data/thunks.js b/src/discussions/post-comments/data/thunks.js index ad0935de8..1792e8330 100644 --- a/src/discussions/post-comments/data/thunks.js +++ b/src/discussions/post-comments/data/thunks.js @@ -187,18 +187,6 @@ export function removeComment(commentId, threadId) { }; } -export function performSoftDeleteComment(commentId) { - return async () => { - try { - await deleteComment(commentId); - return { success: true }; - } catch (error) { - logError(error); - return { success: false, error: error.message }; - } - }; -} - export function performRestoreComment(commentId, courseId) { return async () => { try { diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js index dac6cb09a..b31f985b6 100644 --- a/src/discussions/posts/data/api.js +++ b/src/discussions/posts/data/api.js @@ -218,18 +218,7 @@ export const sendEmailForAccountActivation = async () => { }; /** - * Soft delete a thread. - * @param {string} threadId - * @returns {Promise<{}>} - */ -export const softDeleteThread = async (threadId) => { - const url = `${getThreadsApiUrl()}${threadId}/`; - const { data } = await getAuthenticatedHttpClient().delete(url); - return data; -}; - -/** - * Restore a soft deleted thread. + * Restore a deleted thread. * @param {string} threadId * @param {string} courseId * @returns {Promise<{}>} @@ -245,19 +234,7 @@ export const restoreThread = async (threadId, courseId) => { }; /** - * Bulk soft delete threads. - * @param {string[]} threadIds - * @returns {Promise<{}>} - */ -export const bulkSoftDeleteThreads = async (threadIds) => { - // Delete threads one by one since there's no bulk delete endpoint in v1 API - const promises = threadIds.map(threadId => softDeleteThread(threadId)); - const results = await Promise.all(promises); - return { success: true, results }; -}; - -/** - * Bulk restore soft deleted threads. + * Bulk restore deleted threads. * @param {string[]} threadIds * @param {string} courseId * @returns {Promise<{}>} diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index c0963eefe..5ab8a6165 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -7,12 +7,7 @@ import { import { setContentCreationRateLimited } from '../../data/slices'; import { getHttpErrorStatus } from '../../utils'; import { - getThread, - getThreads, - postThread, - sendEmailForAccountActivation, - softDeleteThread, - updateThread, + deleteThread, getThread, getThreads, postThread, sendEmailForAccountActivation, updateThread, } from './api'; import { deleteThreadDenied, @@ -312,7 +307,7 @@ export function removeThread(threadId) { return async (dispatch) => { try { dispatch(deleteThreadRequest({ threadId })); - await softDeleteThread(threadId); + await deleteThread(threadId); dispatch(deleteThreadSuccess({ threadId })); } catch (error) { if (getHttpErrorStatus(error) === 403) { diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 89dc022fa..508b50b75 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -9,7 +9,6 @@ import { useDispatch, useSelector } from 'react-redux'; import { useLocation, useNavigate } from 'react-router-dom'; import { getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { useIntl } from '@edx/frontend-platform/i18n'; import { logError } from '@edx/frontend-platform/logging'; @@ -23,16 +22,11 @@ import DiscussionContext from '../../common/context'; import HoverCard from '../../common/HoverCard'; import withPostingRestrictions from '../../common/withPostingRestrictions'; import { ContentTypes } from '../../data/constants'; -import { - selectContentCreationRateLimited, - selectShouldShowEmailConfirmation, - selectUserHasModerationPrivileges, -} from '../../data/selectors'; +import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation, selectUserHasModerationPrivileges } from '../../data/selectors'; import { selectTopic } from '../../topics/data/selectors'; +import { truncatePath } from '../../utils'; import { selectThread } from '../data/selectors'; -import { - updateExistingThread, -} from '../data/thunks'; +import { removeThread, updateExistingThread } from '../data/thunks'; import ClosePostReasonModal from './ClosePostReasonModal'; import messages from './messages'; import PostFooter from './PostFooter'; @@ -45,7 +39,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { 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, isDeleted, deletedByLabel, is_spam: isSpam, + closedByLabel, users: postUsers, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam, } = threadData; const intl = useIntl(); @@ -67,30 +61,25 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges); const handleDeleteConfirmation = useCallback(async () => { - try { - const authenticatedUser = getAuthenticatedUser(); - const { performSoftDeleteThread } = await import('../../data/thunks'); - const result = await dispatch( - performSoftDeleteThread(postId, authenticatedUser.userId || authenticatedUser.id, courseId), - ); - if (result.success) { - window.location.reload(); - } - } catch (error) { - logError(error); - } + const basePath = truncatePath(location.pathname); + + await dispatch(removeThread(postId)); + navigate({ + pathname: basePath, + search: enableInContextSidebar && '?inContextSidebar', + }); hideDeleteConfirmation(); - }, [postId, courseId, dispatch, hideDeleteConfirmation]); + }, [enableInContextSidebar, postId, hideDeleteConfirmation]); const handleReportConfirmation = useCallback(() => { dispatch(updateExistingThread(postId, { flagged: !abuseFlagged })); hideReportConfirmation(); - }, [abuseFlagged, postId, dispatch, hideReportConfirmation]); + }, [abuseFlagged, postId, hideReportConfirmation]); const handlePostContentEdit = useCallback(() => navigate({ ...location, pathname: `${location.pathname}/edit`, - }), [navigate, location]); + }), [location.pathname]); const handlePostClose = useCallback(() => { if (closed) { @@ -98,19 +87,19 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { } else { showClosePostModal(); } - }, [closed, postId, dispatch, showClosePostModal]); + }, [closed, postId, showClosePostModal]); const handlePostCopyLink = useCallback(() => { navigator.clipboard.writeText(getFullUrl(`${courseId}/posts/${postId}`)); - }, [courseId, postId]); + }, [window.location.origin, postId, courseId]); const handlePostPin = useCallback(() => dispatch( updateExistingThread(postId, { pinned: !pinned }), - ), [postId, pinned, dispatch]); + ), [postId, pinned]); const handlePostLike = useCallback(() => { dispatch(updateExistingThread(postId, { voted: !voted })); - }, [postId, voted, dispatch]); + }, [postId, voted]); const handlePostReport = useCallback(() => { if (abuseFlagged) { @@ -118,11 +107,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { } else { showReportConfirmation(); } - }, [abuseFlagged, postId, dispatch, showReportConfirmation]); - - const handleSoftDelete = useCallback(() => { - showDeleteConfirmation(); - }, [showDeleteConfirmation]); + }, [abuseFlagged, postId, showReportConfirmation]); const handleRestore = useCallback(() => { showRestoreConfirmation(); @@ -143,25 +128,25 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handlePostContentEdit, - [ContentActions.SOFT_DELETE]: handleSoftDelete, + [ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.RESTORE]: handleRestore, [ContentActions.CLOSE]: handlePostClose, [ContentActions.COPY_LINK]: handlePostCopyLink, [ContentActions.PIN]: handlePostPin, [ContentActions.REPORT]: handlePostReport, }), [ - handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, - handleSoftDelete, handleRestore, + handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation, + handleRestore, ]); const handleClosePostConfirmation = useCallback((closeReasonCode) => { dispatch(updateExistingThread(postId, { closed: true, closeReasonCode })); hideClosePostModal(); - }, [postId, dispatch, hideClosePostModal]); + }, [postId, hideClosePostModal]); const handlePostFollow = useCallback(() => { dispatch(updateExistingThread(postId, { following: !following })); - }, [postId, following, dispatch]); + }, [postId, following]); const getTopicCategoryName = useCallback(topicData => ( topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId @@ -218,14 +203,14 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { following={following} isDeleted={isDeleted} /> - {isDeleted && deletedByLabel && ( + {isDeleted && deletedBy && deletedByLabel && (
{intl.formatMessage(messages.deletedBy)} { + if (type === 'response') { + return intl.formatMessage(messages.deletedResponse); + } + if (type === 'comment') { + return intl.formatMessage(messages.deletedComment); + } + return intl.formatMessage(messages.deletedPost); + }; + + // For comments/responses, show parent thread title with arrow + const displayTitle = (type === 'response' || type === 'comment') && threadTitle ? threadTitle : title; + + // For comments/responses, navigate to the parent thread instead of the comment itself + const navigationPostId = (type === 'response' || type === 'comment') && commentThreadId ? commentThreadId : postId; + const { pathname } = discussionsPath(Routes.COMMENTS.PAGES[page], { 0: enableInContextSidebar ? 'in-context' : undefined, courseId, topicId, - postId, + postId: navigationPostId, category, learnerUsername, })(); @@ -93,13 +111,20 @@ const PostLink = ({
+ {(type === 'response' || type === 'comment') && threadTitle && ( + + )} - {title} + {displayTitle} {isPostPreviewAvailable(previewBody) ? previewBody : intl.formatMessage(messages.postWithoutPreview)} @@ -133,8 +158,8 @@ const PostLink = ({ 'ml-auto': !canSeeReportedBadge && !showAnsweredBadge, })} > - {intl.formatMessage(messages.deletedPost)} - {' '}deleted post + {getDeletedBadgeText()} + {' '}deleted )} {pinned && ( diff --git a/src/discussions/posts/post/messages.js b/src/discussions/posts/post/messages.js index 19c3e42a3..432e328b7 100644 --- a/src/discussions/posts/post/messages.js +++ b/src/discussions/posts/post/messages.js @@ -33,6 +33,16 @@ const messages = defineMessages({ defaultMessage: 'Deleted Post', description: 'Badge showing that the post has been deleted', }, + deletedResponse: { + id: 'discussions.post.deletedResponse', + defaultMessage: 'Deleted Response', + description: 'Badge showing that the response has been deleted', + }, + deletedComment: { + id: 'discussions.post.deletedComment', + defaultMessage: 'Deleted Comment', + description: 'Badge showing that the comment has been deleted', + }, following: { id: 'discussions.post.following', defaultMessage: 'Following', diff --git a/src/discussions/utils.js b/src/discussions/utils.js index edf6541c0..2b6186377 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -61,15 +61,15 @@ 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 soft delete and restore actions we check `content.canDelete` - if (action === ContentActions.SOFT_DELETE || action === ContentActions.RESTORE) { + // For restore action we check `content.canDelete` + if (action === ContentActions.RESTORE) { return content.canDelete; } return false; @@ -183,10 +183,10 @@ export const ACTIONS_LIST = [ conditions: { abuseFlagged: true }, }, { - id: 'soft-delete', - action: ContentActions.SOFT_DELETE, + id: 'delete', + action: ContentActions.DELETE, icon: Delete, - label: messages.softDeleteAction, + label: messages.deleteAction, conditions: { canDelete: true, isDeleted: false }, }, { diff --git a/src/index.scss b/src/index.scss index c28520989..a43e47598 100755 --- a/src/index.scss +++ b/src/index.scss @@ -1,8 +1,8 @@ @use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints; -// @import "~@edx/frontend-component-footer/dist/footer"; -// @import "~@edx/frontend-component-header/dist/index"; +@import "~@edx/frontend-component-footer/dist/footer"; +@import "~@edx/frontend-component-header/dist/index"; body, From f595d2fd1be5716e558435bb3f288c0ed97132a2 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Mon, 1 Dec 2025 14:40:29 +0000 Subject: [PATCH 12/19] feat: added soft delete functionality --- src/discussions/common/ActionsDropdown.jsx | 6 +- .../learners/LearnerActionsDropdown.jsx | 6 +- .../comments/comment/Comment.jsx | 2 +- .../comments/comment/CommentHeader.jsx | 23 +------- .../post-comments/comments/comment/Reply.jsx | 57 ++++++++----------- src/discussions/post-comments/messages.js | 16 +++--- .../posts/post-filter-bar/PostFilterBar.jsx | 5 +- src/discussions/posts/post/Post.jsx | 2 +- src/discussions/posts/post/PostLink.jsx | 27 +++++---- src/discussions/posts/post/messages.js | 10 ++-- 10 files changed, 65 insertions(+), 89 deletions(-) diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index 359125081..db2de5bc6 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -78,10 +78,14 @@ 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} + style={action.disabled ? { opacity: 0.3, cursor: 'not-allowed' } : {}} >
- - + {item.label}
diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 06bb34550..3d319480b 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -247,7 +247,7 @@ const Comment = ({ endorseIcons={endorseIcons} isDeleted={isDeleted} /> - {isDeleted && deletedBy && deletedByLabel && ( + {isDeleted && deletedBy && (
diff --git a/src/discussions/post-comments/comments/comment/CommentHeader.jsx b/src/discussions/post-comments/comments/comment/CommentHeader.jsx index 0c95049d8..318558269 100644 --- a/src/discussions/post-comments/comments/comment/CommentHeader.jsx +++ b/src/discussions/post-comments/comments/comment/CommentHeader.jsx @@ -1,18 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Avatar, Badge } from '@openedx/paragon'; +import { Avatar } from '@openedx/paragon'; import classNames from 'classnames'; import { useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; -import { useIntl } from '@edx/frontend-platform/i18n'; import { AvatarOutlineAndLabelColors } from '../../../../data/constants'; import { AuthorLabel } from '../../../common'; import { useAlertBannerVisible } from '../../../data/hooks'; import { selectAuthorAvatar } from '../../../posts/data/selectors'; -import messages from '../../messages'; const CommentHeader = ({ author, @@ -27,7 +25,6 @@ const CommentHeader = ({ postIsDeleted, postData, }) => { - const intl = useIntl(); const colorClass = AvatarOutlineAndLabelColors[authorLabel]; const hasAnyAlert = useAlertBannerVisible({ author, @@ -41,14 +38,6 @@ const CommentHeader = ({ ? Object.values(postUsers ?? {})[0]?.profile?.image : null; - // Determine which deleted badge to show based on priority rules - // Priority: Deleted Post > Deleted Response > Deleted Comment - const shouldShowDeletedBadge = isDeleted && !postIsDeleted; // Don't show if post is already deleted - const isResponse = !parentId; // Response has no parentId, comment has parentId - const deletedBadgeMessage = isResponse - ? messages.deletedResponse - : messages.deletedComment; - return (
- {shouldShowDeletedBadge && ( - - {intl.formatMessage(deletedBadgeMessage)} - {' '}deleted {isResponse ? 'response' : 'comment'} - - )}
); diff --git a/src/discussions/post-comments/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index 228f5d448..817cc1660 100644 --- a/src/discussions/post-comments/comments/comment/Reply.jsx +++ b/src/discussions/post-comments/comments/comment/Reply.jsx @@ -1,7 +1,7 @@ import React, { useCallback, useMemo, useState, useContext } from 'react'; import PropTypes from 'prop-types'; -import { Avatar, Badge, useToggle } from '@openedx/paragon'; +import { Avatar, useToggle } from '@openedx/paragon'; import { DeleteOutline } from '@openedx/paragon/icons'; import { useDispatch, useSelector } from 'react-redux'; import * as timeago from 'timeago.js'; @@ -168,6 +168,30 @@ const Reply = ({ responseId }) => {
)} + {isDeleted && deletedBy && ( +
+
+ +
+
+
+ +
+ {intl.formatMessage(messages.deletedBy)} + + + +
+
+
+
+ )}
{ className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px flex-fill" style={{ borderRadius: '0rem 0.375rem 0.375rem', maxWidth: 'calc(100% - 50px)' }} > - {isDeleted && !postIsDeleted && ( -
-
- - {intl.formatMessage(messages.deletedComment)} - {' '}deleted comment - -
-
- )} - {isDeleted && deletedBy && deletedByLabel && ( -
- -
- {intl.formatMessage(messages.deletedBy)} - - - -
-
- )}
{ const { page } = useContext(DiscussionContext); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); - const userIsStaff = useSelector(selectUserIsStaff); const currentSorting = useSelector(selectThreadSorting()); const currentFilters = useSelector(selectThreadFilters()); const { status } = useSelector(state => state.cohorts); @@ -258,7 +257,7 @@ const PostFilterBar = () => { selected={currentFilters.status} /> )} - {(userHasModerationPrivileges || userIsGroupTa || userIsStaff) && ( + {(userHasModerationPrivileges || userIsGroupTa) && ( { following={following} isDeleted={isDeleted} /> - {isDeleted && deletedBy && deletedByLabel && ( + {isDeleted && deletedBy && (
diff --git a/src/discussions/posts/post/PostLink.jsx b/src/discussions/posts/post/PostLink.jsx index 03e5280c9..3332c143a 100644 --- a/src/discussions/posts/post/PostLink.jsx +++ b/src/discussions/posts/post/PostLink.jsx @@ -40,15 +40,15 @@ const PostLink = ({ users: postUsers, isDeleted, threadTitle, commentThreadId, } = threadData; - // Determine the deleted badge text based on type - const getDeletedBadgeText = () => { + // Get the type label to display + const getTypeLabel = () => { if (type === 'response') { - return intl.formatMessage(messages.deletedResponse); + return 'Response'; } if (type === 'comment') { - return intl.formatMessage(messages.deletedComment); + return 'Comment'; } - return intl.formatMessage(messages.deletedPost); + return null; }; // For comments/responses, show parent thread title with arrow @@ -112,11 +112,16 @@ const PostLink = ({
{(type === 'response' || type === 'comment') && threadTitle && ( - + <> + + + {getTypeLabel()} in + + )} - {getDeletedBadgeText()} + {intl.formatMessage(messages.deletedPost)} {' '}deleted )} diff --git a/src/discussions/posts/post/messages.js b/src/discussions/posts/post/messages.js index 432e328b7..61c49c87a 100644 --- a/src/discussions/posts/post/messages.js +++ b/src/discussions/posts/post/messages.js @@ -30,17 +30,17 @@ const messages = defineMessages({ }, deletedPost: { id: 'discussions.post.deletedPost', - defaultMessage: 'Deleted Post', + defaultMessage: 'Deleted', description: 'Badge showing that the post has been deleted', }, deletedResponse: { id: 'discussions.post.deletedResponse', - defaultMessage: 'Deleted Response', + defaultMessage: 'Deleted', description: 'Badge showing that the response has been deleted', }, deletedComment: { id: 'discussions.post.deletedComment', - defaultMessage: 'Deleted Comment', + defaultMessage: 'Deleted', description: 'Badge showing that the comment has been deleted', }, following: { @@ -127,11 +127,11 @@ const messages = defineMessages({ }, undeletePostTitle: { id: 'discussions.editor.undelete.post.title', - defaultMessage: 'Undelete post', + defaultMessage: 'Restore post', }, undeletePostDescription: { id: 'discussions.editor.undelete.post.description', - defaultMessage: 'Are you sure you want to undelete this post?', + defaultMessage: 'Are you sure you want to restore this post?', }, reportPostTitle: { id: 'discussions.editor.report.post.title', From 24bd13dbede999b1435f78860bd1632e18bf68a9 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Wed, 3 Dec 2025 09:40:07 +0000 Subject: [PATCH 13/19] feat: added soft delete functionality --- src/discussions/data/thunks.js | 32 +------------------ src/discussions/learners/data/api.js | 2 +- src/discussions/learners/data/thunks.js | 9 ++---- .../LearnerPostFilterBar.jsx | 14 +++++--- .../comments/comment/Comment.jsx | 2 +- .../comments/comment/CommentHeader.jsx | 9 ------ .../post-comments/comments/comment/Reply.jsx | 7 ++-- src/discussions/posts/post/Post.jsx | 4 +-- src/discussions/posts/post/PostLink.jsx | 8 ++--- 9 files changed, 24 insertions(+), 63 deletions(-) diff --git a/src/discussions/data/thunks.js b/src/discussions/data/thunks.js index a8f373e35..f57b09974 100644 --- a/src/discussions/data/thunks.js +++ b/src/discussions/data/thunks.js @@ -10,8 +10,7 @@ import { setStatusFilter } from '../posts/data'; import { getHttpErrorStatus } from '../utils'; import { getDiscussionsConfig, getDiscussionsSettings } from './api'; import { - bulkActionFailed, - bulkActionRequest, bulkActionSuccess, fetchConfigDenied, fetchConfigFailed, fetchConfigRequest, fetchConfigSuccess, + fetchConfigDenied, fetchConfigFailed, fetchConfigRequest, fetchConfigSuccess, } from './slices'; /** @@ -52,32 +51,3 @@ export default function fetchCourseConfig(courseId) { } }; } - -export function performBulkRestore(threadIds, courseId) { - return async (dispatch) => { - try { - dispatch(bulkActionRequest()); - const { bulkRestoreThreads } = await import('../posts/data/api'); - await bulkRestoreThreads(threadIds, courseId); - dispatch(bulkActionSuccess()); - return { success: true }; - } catch (error) { - dispatch(bulkActionFailed(error.message || 'Failed to restore threads')); - logError(error); - return { success: false, error: error.message }; - } - }; -} - -export function performRestoreThread(threadId, courseId) { - return async () => { - try { - const { restoreThread } = await import('../posts/data/api'); - await restoreThread(threadId, courseId); - return { success: true }; - } catch (error) { - logError(error); - return { success: false, error: error.message }; - } - }; -} diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js index 6a291ec6c..13b159909 100644 --- a/src/discussions/learners/data/api.js +++ b/src/discussions/learners/data/api.js @@ -155,7 +155,7 @@ export async function getDeletedContent(courseId, { contentType, } = {}) { const params = snakeCaseObject({ - authorId: author, // The backend expects author_id + authorId: author, // The backend expects author_id page, perPage: pageSize, contentType, diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index fda3254a6..acdfcee1e 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -15,7 +15,6 @@ import { getHttpErrorStatus } from '../../utils'; import { deleteUserPostsApi, getLearners, - getUserPosts, getUserProfiles, undeleteUserPostsApi, } from './api'; @@ -93,7 +92,7 @@ export function fetchUserPosts(courseId, { dispatch(fetchLearnerThreadsRequest({ courseId, author })); let data; - + // Use dedicated deleted content endpoint when viewing deleted posts if (filters.contentStatus === PostsStatusFilter.DELETED) { const { getDeletedContent } = await import('./api'); @@ -111,7 +110,7 @@ export function fetchUserPosts(courseId, { author, countFlagged, }; - + // Only show active content (not deleted) if (filters.contentStatus === PostsStatusFilter.ACTIVE) { options.showDeleted = false; @@ -140,7 +139,7 @@ export function fetchUserPosts(courseId, { if (filters.cohort) { options.cohort = filters.cohort; } - + data = await getUserPosts(courseId, options); } @@ -187,5 +186,3 @@ export function undeleteUserPosts(courseId, username, courseOrOrg, execute) { } }; } - - diff --git a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx index a017b167c..ccfe8ff12 100644 --- a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx +++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx @@ -10,7 +10,7 @@ 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,7 +18,7 @@ const LearnerPostFilterBar = () => { const { courseId } = useParams(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); - // const userIsStaff = useSelector(selectUserIsStaff); + const userIsStaff = useSelector(selectUserIsStaff); const cohorts = useSelector(selectCourseCohorts); const postFilter = useSelector(state => state.learners.postFilter); @@ -35,12 +35,16 @@ const LearnerPostFilterBar = () => { name: 'orderBy', filters: ['sort-activity', 'sort-comments', 'sort-votes'], }, - { + ]; + + // 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 diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 3d319480b..913481ef1 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -85,7 +85,7 @@ const Comment = ({ const postFilter = useSelector(state => state.learners?.postFilter); // Use contentStatus for deleted section const showDeleted = Boolean( - learnerUsername && postFilter?.contentStatus === PostsStatusFilter.DELETED + learnerUsername && postFilter?.contentStatus === PostsStatusFilter.DELETED, ); // If isSpam is not provided in the API response, default to false const isSpamFlagged = isSpam || false; diff --git a/src/discussions/post-comments/comments/comment/CommentHeader.jsx b/src/discussions/post-comments/comments/comment/CommentHeader.jsx index 318558269..c51420d6c 100644 --- a/src/discussions/post-comments/comments/comment/CommentHeader.jsx +++ b/src/discussions/post-comments/comments/comment/CommentHeader.jsx @@ -20,9 +20,6 @@ const CommentHeader = ({ createdAt, lastEdit, postUsers, - isDeleted, - parentId, - postIsDeleted, postData, }) => { const colorClass = AvatarOutlineAndLabelColors[authorLabel]; @@ -78,9 +75,6 @@ CommentHeader.propTypes = { reason: PropTypes.string, }), postUsers: PropTypes.shape({}).isRequired, - isDeleted: PropTypes.bool, - parentId: PropTypes.string, - postIsDeleted: PropTypes.bool, postData: PropTypes.shape({}), }; @@ -88,9 +82,6 @@ CommentHeader.defaultProps = { authorLabel: null, closed: undefined, lastEdit: null, - isDeleted: false, - parentId: null, - postIsDeleted: false, postData: null, }; diff --git a/src/discussions/post-comments/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index 817cc1660..86c662f4f 100644 --- a/src/discussions/post-comments/comments/comment/Reply.jsx +++ b/src/discussions/post-comments/comments/comment/Reply.jsx @@ -1,4 +1,6 @@ -import React, { useCallback, useMemo, useState, useContext } from 'react'; +import React, { + useCallback, useContext, useMemo, useState, +} from 'react'; import PropTypes from 'prop-types'; import { Avatar, useToggle } from '@openedx/paragon'; @@ -36,9 +38,6 @@ const Reply = ({ responseId }) => { const intl = useIntl(); const dispatch = useDispatch(); const { courseId } = useContext(DiscussionContext); - // 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 [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false); diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 3797fa6fd..e0725ad8e 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -135,8 +135,8 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { [ContentActions.PIN]: handlePostPin, [ContentActions.REPORT]: handlePostReport, }), [ - handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation, - handleRestore, + handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation, + handleRestore, ]); const handleClosePostConfirmation = useCallback((closeReasonCode) => { diff --git a/src/discussions/posts/post/PostLink.jsx b/src/discussions/posts/post/PostLink.jsx index 3332c143a..196255948 100644 --- a/src/discussions/posts/post/PostLink.jsx +++ b/src/discussions/posts/post/PostLink.jsx @@ -39,7 +39,7 @@ const PostLink = ({ unreadCommentCount, id, pinned, previewBody, title, voted, voteCount, following, groupId, groupName, createdAt, users: postUsers, isDeleted, threadTitle, commentThreadId, } = threadData; - + // Get the type label to display const getTypeLabel = () => { if (type === 'response') { @@ -50,13 +50,13 @@ const PostLink = ({ } return null; }; - + // For comments/responses, show parent thread title with arrow const displayTitle = (type === 'response' || type === 'comment') && threadTitle ? threadTitle : title; - + // For comments/responses, navigate to the parent thread instead of the comment itself const navigationPostId = (type === 'response' || type === 'comment') && commentThreadId ? commentThreadId : postId; - + const { pathname } = discussionsPath(Routes.COMMENTS.PAGES[page], { 0: enableInContextSidebar ? 'in-context' : undefined, courseId, From 1da2d5eae7bfb181ff07f3cbdf694f6be61fc110 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Wed, 3 Dec 2025 10:58:00 +0000 Subject: [PATCH 14/19] feat: added soft delete functionality --- .../post-comments/comments/comment/Reply.jsx | 2 +- src/discussions/posts/data/api.js | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/discussions/post-comments/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index 86c662f4f..6fd4303ab 100644 --- a/src/discussions/post-comments/comments/comment/Reply.jsx +++ b/src/discussions/post-comments/comments/comment/Reply.jsx @@ -20,7 +20,7 @@ import DiscussionContext from '../../../common/context'; import timeLocale from '../../../common/time-locale'; import { ContentTypes } from '../../../data/constants'; import { useAlertBannerVisible } from '../../../data/hooks'; -import { selectAuthorAvatar, selectThread } from '../../../posts/data/selectors'; +import { selectAuthorAvatar } from '../../../posts/data/selectors'; import { fetchThread } from '../../../posts/data/thunks'; import { selectCommentOrResponseById } from '../../data/selectors'; import { editComment, removeComment } from '../../data/thunks'; diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js index b31f985b6..57e0f0096 100644 --- a/src/discussions/posts/data/api.js +++ b/src/discussions/posts/data/api.js @@ -232,16 +232,3 @@ export const restoreThread = async (threadId, courseId) => { }); return data; }; - -/** - * Bulk restore deleted threads. - * @param {string[]} threadIds - * @param {string} courseId - * @returns {Promise<{}>} - */ -export const bulkRestoreThreads = async (threadIds, courseId) => { - // Restore threads one by one since RestoreContent handles individual items - const promises = threadIds.map(threadId => restoreThread(threadId, courseId)); - const results = await Promise.all(promises); - return { success: true, results }; -}; From cd4f4dd9e41b389fcebacebf23532cd2109def1a Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Fri, 5 Dec 2025 13:22:16 +0000 Subject: [PATCH 15/19] fix: rectified the restore functionality --- src/discussions/learners/LearnerPostsView.jsx | 26 +++++++++++++------ src/discussions/posts/data/thunks.js | 13 ++++++++++ src/discussions/posts/post/Post.jsx | 2 +- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/discussions/learners/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx index 47589feba..ffba369cd 100644 --- a/src/discussions/learners/LearnerPostsView.jsx +++ b/src/discussions/learners/LearnerPostsView.jsx @@ -32,6 +32,7 @@ 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'; @@ -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); @@ -89,9 +90,14 @@ const LearnerPostsView = () => { dispatch(clearPostsPages()); loadMorePosts(); hideDeleteConfirmation(); - // Navigate back to learners list after deletion - navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) }); - }, [courseId, username, hideDeleteConfirmation, dispatchDelete, navigate, location]); + // 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); @@ -106,7 +112,11 @@ const LearnerPostsView = () => { dispatch(clearPostsPages()); loadMorePosts(); hideRestoreConfirmation(); - }, [courseId, username, hideRestoreConfirmation, dispatch, loadMorePosts]); + // If viewing a post, refresh it to show restored state + if (postId) { + await dispatch(fetchThread(postId, courseId)); + } + }, [courseId, username, hideRestoreConfirmation, dispatch, loadMorePosts, postId]); const actionHandlers = useMemo(() => ({ [ContentActions.DELETE_COURSE_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.COURSE), @@ -116,11 +126,11 @@ const LearnerPostsView = () => { }), [handleShowDeleteConfirmation, handleShowRestoreConfirmation]); const postInstances = useMemo(() => ( - sortedPostsIds?.map((postId, idx) => ( + sortedPostsIds?.map((threadId, idx) => ( )) diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index 5ab8a6165..49647d911 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -320,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/post/Post.jsx b/src/discussions/posts/post/Post.jsx index e0725ad8e..bdbaf3574 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -115,7 +115,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { const handleRestoreConfirmation = useCallback(async () => { try { - const { performRestoreThread } = await import('../../data/thunks'); + const { performRestoreThread } = await import('../data/thunks'); const result = await dispatch(performRestoreThread(postId, courseId)); if (result.success) { window.location.reload(); From 2685f27dc346c69b79851156b27cd5f316f209cf Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Wed, 10 Dec 2025 10:26:47 +0000 Subject: [PATCH 16/19] fix: add missing things for restore and fix counts --- src/discussions/data/thunks.js | 12 ++++++++++++ src/discussions/learners/data/thunks.js | 6 +----- src/discussions/learners/learner/LearnerFooter.jsx | 13 +++++-------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/discussions/data/thunks.js b/src/discussions/data/thunks.js index f57b09974..d5cd627d3 100644 --- a/src/discussions/data/thunks.js +++ b/src/discussions/data/thunks.js @@ -51,3 +51,15 @@ export default function fetchCourseConfig(courseId) { } }; } +export function performRestoreThread(threadId, courseId) { + return async () => { + try { + const { restoreThread } = await import('../posts/data/api'); + await restoreThread(threadId, courseId); + return { success: true }; + } catch (error) { + logError(error); + return { success: false, error: error.message }; + } + }; +} \ No newline at end of file diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index acdfcee1e..79ca09ff7 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -175,11 +175,7 @@ export function undeleteUserPosts(courseId, username, courseOrOrg, execute) { try { dispatch(undeleteUserPostsRequest({ courseId, username })); const response = await undeleteUserPostsApi(courseId, username, courseOrOrg, execute); - - // Only dispatch success for actual execution, not preview - if (execute) { - dispatch(undeleteUserPostsSuccess(camelCaseObject(response))); - } + dispatch(undeleteUserPostsSuccess(camelCaseObject(response))); } catch (error) { dispatch(undeleteUserPostsFailed()); logError(error); diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx index 0ca0dab26..6e015d90f 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -23,11 +23,8 @@ const LearnerFooter = ({ const canSeeLearnerReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); const canSeeDeletedStats = userHasModerationPrivileges || userIsGroupTa || userIsStaff; - // Calculate active counts (total - deleted) - const activeThreads = (threads || 0) - (deletedThreads || 0); - const activeResponses = (responses || 0) - (deletedResponses || 0); - const activeReplies = (replies || 0) - (deletedReplies || 0); - const totalActive = activeThreads + activeResponses + activeReplies; + // Calculate deleted count (sum of all deleted content) + const totalDeletedCount = (deletedThreads || 0) + (deletedResponses || 0) + (deletedReplies || 0); return (
@@ -44,7 +41,7 @@ const LearnerFooter = ({ >
- {totalActive} + {threads + responses + replies}
- {activeThreads} + {threads}
{Boolean(canSeeDeletedStats) && ( @@ -77,7 +74,7 @@ const LearnerFooter = ({ >
- {deletedCount || 0} + {totalDeletedCount}
)} From 33811200c4cea5ef029ee4a493587f3ff5cb6c43 Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Thu, 11 Dec 2025 10:22:35 +0000 Subject: [PATCH 17/19] fix: added the unit tests --- src/discussions/data/selectors.js | 2 +- src/discussions/data/thunks.js | 2 +- .../learners/LearnerActionsDropdown.test.jsx | 23 ++++++++++++++- .../learners/LearnerPostsView.test.jsx | 28 +++++++++++++++++-- src/discussions/learners/data/redux.test.jsx | 3 +- .../learners/learner/LearnerFooter.jsx | 4 +-- .../post-comments/PostCommentsView.test.jsx | 1 + 7 files changed, 54 insertions(+), 9 deletions(-) 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/data/thunks.js b/src/discussions/data/thunks.js index d5cd627d3..8497edaf9 100644 --- a/src/discussions/data/thunks.js +++ b/src/discussions/data/thunks.js @@ -62,4 +62,4 @@ export function performRestoreThread(threadId, courseId) { return { success: false, error: error.message }; } }; -} \ No newline at end of file +} 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.test.jsx b/src/discussions/learners/LearnerPostsView.test.jsx index 9cb81a71b..188d27f81 100644 --- a/src/discussions/learners/LearnerPostsView.test.jsx +++ b/src/discussions/learners/LearnerPostsView.test.jsx @@ -128,11 +128,11 @@ describe('Learner Posts View', () => { }, ); - it('should display a post-filter bar and All active posts sorted by recent activity text.', async () => { + it('should display a post-filter bar and All posts sorted by recent activity text.', async () => { await renderComponent(); const filterBar = container.querySelector('.filter-bar'); - const recentActivity = screen.getByText('All active posts sorted by recent activity'); + const recentActivity = screen.getByText('All posts sorted by recent activity'); expect(filterBar).toBeInTheDocument(); expect(recentActivity).toBeInTheDocument(); @@ -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/redux.test.jsx b/src/discussions/learners/data/redux.test.jsx index 7e168198b..ba4e8d5ad 100644 --- a/src/discussions/learners/data/redux.test.jsx +++ b/src/discussions/learners/data/redux.test.jsx @@ -51,7 +51,8 @@ describe('Learner redux test cases', () => { expect(learners.sortedBy).toEqual('activity'); expect(learners.usernameSearch).toBeNull(); expect(learners.postFilter.postType).toEqual('all'); - expect(learners.postFilter.status).toEqual('statusActive'); + expect(learners.postFilter.status).toEqual('statusAll'); + expect(learners.postFilter.contentStatus).toEqual('statusActive'); expect(learners.postFilter.orderBy).toEqual('lastActivityAt'); expect(learners.postFilter.cohort).toEqual(''); }); diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx index 6e015d90f..b21d2db56 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -14,7 +14,7 @@ import messages from '../messages'; const LearnerFooter = ({ inactiveFlags, activeFlags, threads, responses, replies, username, - deletedCount, deletedThreads, deletedResponses, deletedReplies, + deletedThreads, deletedResponses, deletedReplies, }) => { const intl = useIntl(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); @@ -118,7 +118,6 @@ LearnerFooter.propTypes = { responses: PropTypes.number, replies: PropTypes.number, username: PropTypes.string, - deletedCount: PropTypes.number, deletedThreads: PropTypes.number, deletedResponses: PropTypes.number, deletedReplies: PropTypes.number, @@ -131,7 +130,6 @@ LearnerFooter.defaultProps = { responses: 0, replies: 0, username: '', - deletedCount: 0, deletedThreads: 0, deletedResponses: 0, deletedReplies: 0, 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) => { From 94b4ad65fea6ebec45ed78dce13262bef420ef43 Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Fri, 12 Dec 2025 06:16:06 +0000 Subject: [PATCH 18/19] fix: UI link --- src/discussions/posts/post/PostLink.jsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/discussions/posts/post/PostLink.jsx b/src/discussions/posts/post/PostLink.jsx index 196255948..be6f0d244 100644 --- a/src/discussions/posts/post/PostLink.jsx +++ b/src/discussions/posts/post/PostLink.jsx @@ -54,8 +54,17 @@ const PostLink = ({ // 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 navigationPostId = (type === 'response' || type === 'comment') && commentThreadId ? commentThreadId : postId; + 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, From fb9d581abd332decaaa00c66f1851d48d253814a Mon Sep 17 00:00:00 2001 From: Alam-2U Date: Fri, 19 Dec 2025 15:19:19 +0000 Subject: [PATCH 19/19] fix: copilot comments --- src/discussions/common/ActionsDropdown.jsx | 1 - src/discussions/data/thunks.js | 12 ------------ src/discussions/learners/LearnerActionsDropdown.jsx | 11 ++++++++++- src/discussions/learners/LearnerPostsView.jsx | 5 ++++- src/discussions/learners/data/api.js | 5 ----- src/discussions/learners/data/redux.test.jsx | 6 +----- src/discussions/learners/data/thunks.js | 8 ++++---- .../learner-post-filter-bar/LearnerPostFilterBar.jsx | 1 - src/discussions/learners/learner/LearnerFooter.jsx | 10 +++++----- .../post-comments/comments/comment/Reply.jsx | 2 +- 10 files changed, 25 insertions(+), 36 deletions(-) diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index db2de5bc6..b88148ea7 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -85,7 +85,6 @@ const ActionsDropdown = ({ className="d-flex justify-content-start actions-dropdown-item" data-testId={action.id} disabled={action.disabled} - style={action.disabled ? { opacity: 0.3, cursor: 'not-allowed' } : {}} > { - try { - const { restoreThread } = await import('../posts/data/api'); - await restoreThread(threadId, courseId); - return { success: true }; - } catch (error) { - logError(error); - return { success: false, error: error.message }; - } - }; -} diff --git a/src/discussions/learners/LearnerActionsDropdown.jsx b/src/discussions/learners/LearnerActionsDropdown.jsx index 2d3d8327a..b17eef5e5 100644 --- a/src/discussions/learners/LearnerActionsDropdown.jsx +++ b/src/discussions/learners/LearnerActionsDropdown.jsx @@ -1,5 +1,5 @@ import React, { - useCallback, useRef, useState, + useCallback, useEffect, useRef, useState, } from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; @@ -45,6 +45,15 @@ const LearnerActionsDropdown = ({ setActiveSubmenu(null); }, [close]); + // Cleanup portal on unmount to prevent memory leaks + useEffect(() => () => { + if (isOpen) { + close(); + setTarget(null); + setActiveSubmenu(null); + } + }, []); + return ( <> { // 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]); + }, [courseId, username, hideRestoreConfirmation, dispatch, loadMorePosts, postId, navigate, location]); const actionHandlers = useMemo(() => ({ [ContentActions.DELETE_COURSE_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.COURSE), diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js index 13b159909..215868fcd 100644 --- a/src/discussions/learners/data/api.js +++ b/src/discussions/learners/data/api.js @@ -129,11 +129,6 @@ export async function restoreUserPostsApi(courseId, username, courseOrOrg, execu return data; } -/** - * Alias for restoreUserPostsApi for backwards compatibility - */ -export const undeleteUserPostsApi = restoreUserPostsApi; - /** * Get deleted content for a course * diff --git a/src/discussions/learners/data/redux.test.jsx b/src/discussions/learners/data/redux.test.jsx index ba4e8d5ad..fee4b314d 100644 --- a/src/discussions/learners/data/redux.test.jsx +++ b/src/discussions/learners/data/redux.test.jsx @@ -98,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/thunks.js b/src/discussions/learners/data/thunks.js index 79ca09ff7..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, - undeleteUserPostsApi, + restoreUserPostsApi, } from './api'; import { deleteUserPostsFailed, @@ -95,7 +97,6 @@ export function fetchUserPosts(courseId, { // Use dedicated deleted content endpoint when viewing deleted posts if (filters.contentStatus === PostsStatusFilter.DELETED) { - const { getDeletedContent } = await import('./api'); data = await getDeletedContent(courseId, { author, page, @@ -103,7 +104,6 @@ export function fetchUserPosts(courseId, { }); } else { // Use regular learner posts endpoint for active content - const { getUserPosts } = await import('./api'); const options = { orderBy, page, @@ -174,7 +174,7 @@ export function undeleteUserPosts(courseId, username, courseOrOrg, execute) { return async (dispatch) => { try { dispatch(undeleteUserPostsRequest({ courseId, username })); - const response = await undeleteUserPostsApi(courseId, username, courseOrOrg, execute); + const response = await restoreUserPostsApi(courseId, username, courseOrOrg, execute); dispatch(undeleteUserPostsSuccess(camelCaseObject(response))); } catch (error) { dispatch(undeleteUserPostsFailed()); diff --git a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx index ccfe8ff12..f44a1a31b 100644 --- a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx +++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx @@ -7,7 +7,6 @@ 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, selectUserIsStaff } from '../../data/selectors'; diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx index b21d2db56..63f7b3622 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -60,7 +60,7 @@ const LearnerFooter = ({ {threads}
- {Boolean(canSeeDeletedStats) && ( + {canSeeDeletedStats && ( )} - {Boolean(canSeeLearnerReportedStats) && ( + {canSeeLearnerReportedStats && (
- {Boolean(activeFlags) + {activeFlags > 0 && ( {intl.formatMessage(messages.reported, { reported: activeFlags })} )} - {Boolean(inactiveFlags) + {inactiveFlags > 0 && ( {intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })} @@ -103,7 +103,7 @@ const LearnerFooter = ({ >
- {activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`} + {activeFlags} {inactiveFlags > 0 && `/ ${inactiveFlags}`}
)} diff --git a/src/discussions/post-comments/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index 6fd4303ab..8148a297a 100644 --- a/src/discussions/post-comments/comments/comment/Reply.jsx +++ b/src/discussions/post-comments/comments/comment/Reply.jsx @@ -34,7 +34,7 @@ const Reply = ({ responseId }) => { id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy, closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam, - } = useSelector(selectCommentOrResponseById(responseId)); + } = commentData; const intl = useIntl(); const dispatch = useDispatch(); const { courseId } = useContext(DiscussionContext);