-
+
+
{activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`}
@@ -94,6 +118,9 @@ LearnerFooter.propTypes = {
responses: PropTypes.number,
replies: PropTypes.number,
username: PropTypes.string,
+ deletedThreads: PropTypes.number,
+ deletedResponses: PropTypes.number,
+ deletedReplies: PropTypes.number,
};
LearnerFooter.defaultProps = {
@@ -103,6 +130,9 @@ LearnerFooter.defaultProps = {
responses: 0,
replies: 0,
username: '',
+ deletedThreads: 0,
+ deletedResponses: 0,
+ deletedReplies: 0,
};
export default React.memo(LearnerFooter);
diff --git a/src/discussions/learners/learner/proptypes.js b/src/discussions/learners/learner/proptypes.js
index ab6cdb6a7..89b884e03 100644
--- a/src/discussions/learners/learner/proptypes.js
+++ b/src/discussions/learners/learner/proptypes.js
@@ -7,6 +7,10 @@ const learnerShape = PropTypes.shape({
replies: PropTypes.number,
responses: PropTypes.number,
threads: PropTypes.number,
+ deletedCount: PropTypes.number,
+ deletedThreads: PropTypes.number,
+ deletedResponses: PropTypes.number,
+ deletedReplies: PropTypes.number,
});
export default learnerShape;
diff --git a/src/discussions/learners/messages.js b/src/discussions/learners/messages.js
index 38403e56e..188c46b14 100644
--- a/src/discussions/learners/messages.js
+++ b/src/discussions/learners/messages.js
@@ -48,6 +48,7 @@ const messages = defineMessages({
defaultMessage: `All learners sorted by {sort, select,
flagged {reported activity}
activity {most activity}
+ deleted {deleted activity}
other {{sort}}
}`,
description: 'Text for current selected learners filter',
@@ -62,6 +63,31 @@ const messages = defineMessages({
defaultMessage: 'Posts',
description: 'Tooltip text for all posts icon',
},
+ deletedActivity: {
+ id: 'discussion.learner.deletedActivity',
+ defaultMessage: 'Deleted activity',
+ description: 'Tooltip text for deleted activity icon',
+ },
+ deleteActivity: {
+ id: 'discussions.learner.actions.deleteActivity',
+ defaultMessage: 'Delete activity',
+ description: 'Main menu option for deleting user activity',
+ },
+ restoreActivity: {
+ id: 'discussions.learner.actions.restoreActivity',
+ defaultMessage: 'Restore activity',
+ description: 'Main menu option for restoring user activity',
+ },
+ withinCourse: {
+ id: 'discussions.learner.actions.withinCourse',
+ defaultMessage: 'Within course',
+ description: 'Submenu option for actions within the current course',
+ },
+ withinOrg: {
+ id: 'discussions.learner.actions.withinOrg',
+ defaultMessage: 'Within organization',
+ description: 'Submenu option for actions within the organization',
+ },
deleteCoursePosts: {
id: 'discussions.learner.actions.deleteCoursePosts',
defaultMessage: 'Delete user posts within this course',
@@ -72,6 +98,16 @@ const messages = defineMessages({
defaultMessage: 'Delete user posts within this organization',
description: 'Action to delete user posts within the organization',
},
+ restoreCoursePosts: {
+ id: 'discussions.learner.actions.restoreCoursePosts',
+ defaultMessage: 'Restore user posts within this course',
+ description: 'Action to restore deleted user posts within a specific course',
+ },
+ restoreOrgPosts: {
+ id: 'discussions.learner.actions.restoreOrgPosts',
+ defaultMessage: 'Restore user posts within this organization',
+ description: 'Action to restore deleted user posts within the organization',
+ },
deletePostsTitle: {
id: 'discussions.learner.deletePosts.title',
defaultMessage: 'Are you sure you want to delete this user\'s discussion contributions?',
@@ -101,6 +137,30 @@ const messages = defineMessages({
defaultMessage: 'This action cannot be undone.',
description: 'Bold disclaimer description for delete confirmation dialog',
},
+ restorePostsTitle: {
+ id: 'discussions.learner.restorePosts.title',
+ defaultMessage: 'Restore this user\'s discussion contributions?',
+ description: 'Title for restore course posts confirmation dialog',
+ },
+ restorePostsDescription: {
+ id: 'discussions.learner.restorePosts.description',
+ defaultMessage: `{bulkType, select,
+ course {You are about to restore {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user in this course. This includes all deleted discussion threads, responses, and comments authored by them.}
+ org {You are about to restore {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user across the organization. This includes all deleted discussion threads, responses, and comments authored by them.}
+ other {You are about to restore {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user. This includes all deleted discussion threads, responses, and comments authored by them.}
+ }`,
+ description: 'Description for restore posts confirmation dialog',
+ },
+ restorePostsConfirm: {
+ id: 'discussions.learner.restorePosts.confirm',
+ defaultMessage: 'Restore',
+ description: 'Confirm button text for restore posts',
+ },
+ restorePostConfirmPending: {
+ id: 'discussions.learner.restorePosts.confirm.pending',
+ defaultMessage: 'Restoring',
+ description: 'Pending state of confirm button text for restore posts',
+ },
});
export default messages;
diff --git a/src/discussions/learners/utils.js b/src/discussions/learners/utils.js
index 7f4cf4e79..a6885466e 100644
--- a/src/discussions/learners/utils.js
+++ b/src/discussions/learners/utils.js
@@ -4,6 +4,7 @@ import { Delete } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
+import { ReactComponent as Undelete } from '../../assets/undelete.svg';
import { ContentActions } from '../../data/constants';
import messages from './messages';
@@ -20,6 +21,18 @@ export const LEARNER_ACTIONS_LIST = [
icon: Delete,
label: messages.deleteOrgPosts,
},
+ {
+ id: 'restore-course-posts',
+ action: ContentActions.RESTORE_COURSE_POSTS,
+ icon: Undelete,
+ label: messages.restoreCoursePosts,
+ },
+ {
+ id: 'restore-org-posts',
+ action: ContentActions.RESTORE_ORG_POSTS,
+ icon: Undelete,
+ label: messages.restoreOrgPosts,
+ },
];
export function useLearnerActions(userHasBulkDeletePrivileges = false) {
@@ -40,3 +53,49 @@ export function useLearnerActions(userHasBulkDeletePrivileges = false) {
return actions;
}
+
+export function useLearnerActionsMenu(intl, userHasBulkDeletePrivileges = false) {
+ const menuItems = useMemo(() => {
+ if (!userHasBulkDeletePrivileges) {
+ return [];
+ }
+ return [
+ {
+ id: 'delete-activity',
+ icon: Delete,
+ label: intl.formatMessage(messages.deleteActivity),
+ submenu: [
+ {
+ id: 'delete-course-posts',
+ action: ContentActions.DELETE_COURSE_POSTS,
+ label: intl.formatMessage(messages.deleteCoursePosts),
+ },
+ {
+ id: 'delete-org-posts',
+ action: ContentActions.DELETE_ORG_POSTS,
+ label: intl.formatMessage(messages.deleteOrgPosts),
+ },
+ ],
+ },
+ {
+ id: 'restore-activity',
+ icon: Undelete,
+ label: intl.formatMessage(messages.restoreActivity),
+ submenu: [
+ {
+ id: 'restore-course-posts',
+ action: ContentActions.RESTORE_COURSE_POSTS,
+ label: intl.formatMessage(messages.restoreCoursePosts),
+ },
+ {
+ id: 'restore-org-posts',
+ action: ContentActions.RESTORE_ORG_POSTS,
+ label: intl.formatMessage(messages.restoreOrgPosts),
+ },
+ ],
+ },
+ ];
+ }, [userHasBulkDeletePrivileges, intl]);
+
+ return menuItems;
+}
diff --git a/src/discussions/messages.js b/src/discussions/messages.js
index 3589aaf0d..7d9ba86ca 100644
--- a/src/discussions/messages.js
+++ b/src/discussions/messages.js
@@ -31,6 +31,11 @@ const messages = defineMessages({
defaultMessage: 'Delete',
description: 'Action to delete a post or comment',
},
+ restoreAction: {
+ id: 'discussions.actions.restore',
+ defaultMessage: 'Restore',
+ description: 'Action to restore a deleted post or comment',
+ },
confirmationConfirm: {
id: 'discussions.confirmation.button.confirm',
defaultMessage: 'Confirm',
@@ -243,6 +248,51 @@ const messages = defineMessages({
defaultMessage: 'Faculty and staff will never invite you to join external groups or ask for personal or financial information in the discussions. Stay safe, and if you see suspicious activity, please report it.',
description: 'Warning message about spam and impersonation in discussion forums',
},
+ activeThreads: {
+ id: 'discussions.filter.activeThreads',
+ defaultMessage: 'Active Threads',
+ description: 'Label for active threads filter button',
+ },
+ deletedThreads: {
+ id: 'discussions.filter.deletedThreads',
+ defaultMessage: 'Deleted Threads',
+ description: 'Label for deleted threads filter button',
+ },
+ deletedBadge: {
+ id: 'discussions.thread.deletedBadge',
+ defaultMessage: 'Deleted',
+ description: 'Badge shown on deleted threads',
+ },
+ selectedCount: {
+ id: 'discussions.bulk.selectedCount',
+ defaultMessage: '{count} selected',
+ description: 'Count of selected threads for bulk actions',
+ },
+ deleteSelected: {
+ id: 'discussions.bulk.deleteSelected',
+ defaultMessage: 'Delete Selected',
+ description: 'Button text for bulk delete action',
+ },
+ restoreSelected: {
+ id: 'discussions.bulk.restoreSelected',
+ defaultMessage: 'Restore Selected',
+ description: 'Button text for bulk restore action',
+ },
+ deleting: {
+ id: 'discussions.bulk.deleting',
+ defaultMessage: 'Deleting...',
+ description: 'Loading text when bulk deleting threads',
+ },
+ restoring: {
+ id: 'discussions.bulk.restoring',
+ defaultMessage: 'Restoring...',
+ description: 'Loading text when bulk restoring threads',
+ },
+ loadingThreads: {
+ id: 'discussions.threads.loading',
+ defaultMessage: 'Loading threads...',
+ description: 'Loading text when fetching threads',
+ },
autoSpamFlaggedMessage: {
id: 'discussions.autoSpamFlaggedMessage',
defaultMessage: 'Content automatically reported as possible spam pending staff review.',
diff --git a/src/discussions/post-comments/PostCommentsView.test.jsx b/src/discussions/post-comments/PostCommentsView.test.jsx
index 2de528732..59d6272e7 100644
--- a/src/discussions/post-comments/PostCommentsView.test.jsx
+++ b/src/discussions/post-comments/PostCommentsView.test.jsx
@@ -80,6 +80,7 @@ async function mockAxiosReturnPagedCommentsResponses() {
page_size: undefined,
requested_fields: 'profile_image',
reverse_order: true,
+ show_deleted: false,
};
[1, 2].forEach(async (page) => {
diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx
index c2d96be80..f6ab50122 100644
--- a/src/discussions/post-comments/comments/comment/Comment.jsx
+++ b/src/discussions/post-comments/comments/comment/Comment.jsx
@@ -8,18 +8,19 @@ import React, {
import PropTypes from 'prop-types';
import { Button, useToggle } from '@openedx/paragon';
+import { DeleteOutline } from '@openedx/paragon/icons';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
+import { logError } from '@edx/frontend-platform/logging';
import HTMLLoader from '../../../../components/HTMLLoader';
-import { ContentActions, EndorsementStatus } from '../../../../data/constants';
import {
- AlertBanner,
- AutoSpamAlertBanner,
- Confirmation,
- EndorsedAlertBanner,
+ AvatarOutlineAndLabelColors, ContentActions, EndorsementStatus, PostsStatusFilter,
+} from '../../../../data/constants';
+import {
+ AlertBanner, AuthorLabel, AutoSpamAlertBanner, Confirmation, EndorsedAlertBanner,
} from '../../../common';
import DiscussionContext from '../../../common/context';
import HoverCard from '../../../common/HoverCard';
@@ -27,6 +28,7 @@ import withPostingRestrictions from '../../../common/withPostingRestrictions';
import { ContentTypes } from '../../../data/constants';
import { useUserPostingEnabled } from '../../../data/hooks';
import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation } from '../../../data/selectors';
+import { selectThread } from '../../../posts/data/selectors';
import { fetchThread } from '../../../posts/data/thunks';
import LikeButton from '../../../posts/post/LikeButton';
import { useActions } from '../../../utils';
@@ -55,17 +57,21 @@ const Comment = ({
const {
id, parentId, childCount, abuseFlagged, endorsed, threadId, endorsedAt, endorsedBy, endorsedByLabel, renderedBody,
voted, following, voteCount, authorLabel, author, createdAt, lastEdit, rawBody, closed, closedBy, closeReason,
- editByLabel, closedByLabel, users: postUsers, is_spam: isSpam,
+ editByLabel, closedByLabel, users: postUsers, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam,
} = comment;
const intl = useIntl();
const hasChildren = childCount > 0;
const isNested = Boolean(parentId);
const dispatch = useDispatch();
- const { courseId } = useContext(DiscussionContext);
+ const { courseId, learnerUsername } = useContext(DiscussionContext);
const { isClosed } = useContext(PostCommentsContext);
+ // Get the post's isDeleted state for priority rules
+ const post = useSelector(selectThread(threadId));
+ const postIsDeleted = post?.isDeleted || false;
const [isEditing, setEditing] = useState(false);
const [isReplying, setReplying] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
+ const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const inlineReplies = useSelector(selectCommentResponses(id));
const inlineRepliesIds = useSelector(selectCommentResponsesIds(id));
@@ -76,6 +82,11 @@ const Comment = ({
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation);
const contentCreationRateLimited = useSelector(selectContentCreationRateLimited);
+ const postFilter = useSelector(state => state.learners?.postFilter);
+ // Use contentStatus for deleted section
+ const showDeleted = Boolean(
+ learnerUsername && postFilter?.contentStatus === PostsStatusFilter.DELETED,
+ );
// If isSpam is not provided in the API response, default to false
const isSpamFlagged = isSpam || false;
useEffect(() => {
@@ -84,9 +95,10 @@ const Comment = ({
dispatch(fetchCommentResponses(id, {
page: 1,
reverseOrder: sortedOrder,
+ showDeleted,
}));
}
- }, [id, sortedOrder]);
+ }, [id, sortedOrder, showDeleted]);
const endorseIcons = useMemo(() => (
actions.find(({ action }) => action === EndorsementStatus.ENDORSED)
@@ -123,19 +135,39 @@ const Comment = ({
await dispatch(editComment(id, { voted: !voted }));
}, [id, voted]);
+ const handleRestore = useCallback(() => {
+ showRestoreConfirmation();
+ }, [showRestoreConfirmation]);
+
+ const handleRestoreConfirmation = useCallback(async () => {
+ try {
+ const { performRestoreComment } = await import('../../data/thunks');
+ const result = await dispatch(performRestoreComment(id, courseId));
+ // Check if restore failed and log the error
+ if (result && !result.success) {
+ logError(`Failed to restore comment: ${result.error || 'Unknown error'}`);
+ }
+ } catch (error) {
+ logError(error);
+ }
+ hideRestoreConfirmation();
+ }, [id, courseId, threadId, dispatch, hideRestoreConfirmation]);
+
const actionHandlers = useMemo(() => ({
[ContentActions.EDIT_CONTENT]: handleEditContent,
[ContentActions.ENDORSE]: handleCommentEndorse,
[ContentActions.DELETE]: showDeleteConfirmation,
+ [ContentActions.RESTORE]: handleRestore,
[ContentActions.REPORT]: handleAbusedFlag,
- }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleAbusedFlag]);
+ }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleRestore, handleAbusedFlag]);
const handleLoadMoreComments = useCallback(() => (
dispatch(fetchCommentResponses(id, {
page: currentPage + 1,
reverseOrder: sortedOrder,
+ showDeleted,
}))
- ), [id, currentPage, sortedOrder]);
+ ), [id, currentPage, sortedOrder, showDeleted]);
const handleAddCommentButton = useCallback(() => {
if (isUserPrivilegedInPostingRestriction) {
@@ -173,6 +205,18 @@ const Comment = ({
closeButtonVariant="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
+
{!abuseFlagged && (
+ {isDeleted && deletedBy && (
+
+
+
+ {intl.formatMessage(messages.deletedBy)}
+
+
+
+
+
+ )}
{isEditing ? (
diff --git a/src/discussions/post-comments/comments/comment/CommentHeader.jsx b/src/discussions/post-comments/comments/comment/CommentHeader.jsx
index 7674292f2..c51420d6c 100644
--- a/src/discussions/post-comments/comments/comment/CommentHeader.jsx
+++ b/src/discussions/post-comments/comments/comment/CommentHeader.jsx
@@ -40,7 +40,7 @@ const CommentHeader = ({
'mt-2': hasAnyAlert,
})}
>
-
+
{
const commentData = useSelector(selectCommentOrResponseById(responseId));
const {
id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy,
- closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel, is_spam: isSpam,
+ closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel,
+ closedByLabel, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam,
} = commentData;
const intl = useIntl();
const dispatch = useDispatch();
+ const { courseId } = useContext(DiscussionContext);
const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
+ const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const colorClass = AvatarOutlineAndLabelColors[authorLabel];
// If isSpam is not provided in the API response, default to false
@@ -70,6 +78,24 @@ const Reply = ({ responseId }) => {
}
}, [abuseFlagged, id, showReportConfirmation]);
+ const handleRestore = useCallback(() => {
+ showRestoreConfirmation();
+ }, [showRestoreConfirmation]);
+
+ const handleRestoreConfirmation = useCallback(async () => {
+ try {
+ const { performRestoreComment } = await import('../../data/thunks');
+ const result = await dispatch(performRestoreComment(id, courseId));
+ // Check if restore failed and log the error
+ if (result && !result.success) {
+ logError(`Failed to restore comment: ${result.error || 'Unknown error'}`);
+ }
+ } catch (error) {
+ logError(error);
+ }
+ hideRestoreConfirmation();
+ }, [id, courseId, threadId, dispatch, hideRestoreConfirmation]);
+
const handleCloseEditor = useCallback(() => {
setEditing(false);
}, []);
@@ -78,8 +104,9 @@ const Reply = ({ responseId }) => {
[ContentActions.EDIT_CONTENT]: handleEditContent,
[ContentActions.ENDORSE]: handleReplyEndorse,
[ContentActions.DELETE]: showDeleteConfirmation,
+ [ContentActions.RESTORE]: handleRestore,
[ContentActions.REPORT]: handleAbusedFlag,
- }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleAbusedFlag]);
+ }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleRestore, handleAbusedFlag]);
return (
@@ -92,6 +119,14 @@ const Reply = ({ responseId }) => {
closeButtonVariant="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
+
{!abuseFlagged && (
{
)}
+ {isDeleted && deletedBy && (
+
+
+
+
+
+
+ {intl.formatMessage(messages.deletedBy)}
+
+
+
+
+
+
+
+ )}
{
const params = snakeCaseObject({
@@ -35,6 +36,7 @@ export const getThreadComments = async (threadId, {
requestedFields: 'profile_image',
enableInContextSidebar,
mergeQuestionTypeResponses: threadType === ThreadType.QUESTION ? true : null,
+ showDeleted,
});
const { data } = await getAuthenticatedHttpClient().get(getCommentsApiUrl(), { params: { ...params, signal } });
@@ -52,6 +54,7 @@ export const getCommentResponses = async (commentId, {
page,
pageSize,
reverseOrder,
+ showDeleted = false,
} = {}) => {
const url = `${getCommentsApiUrl()}${commentId}/`;
const params = snakeCaseObject({
@@ -59,6 +62,7 @@ export const getCommentResponses = async (commentId, {
pageSize,
requestedFields: 'profile_image',
reverseOrder,
+ showDeleted,
});
const { data } = await getAuthenticatedHttpClient()
.get(url, { params });
@@ -127,3 +131,19 @@ export const deleteComment = async (commentId) => {
await getAuthenticatedHttpClient()
.delete(url);
};
+
+/**
+ * Restores a deleted comment.
+ * @param {string} commentId ID of comment to restore
+ * @param {string} courseId Course ID
+ * @returns {Promise<{}>}
+ */
+export const restoreComment = async (commentId, courseId) => {
+ const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/restore_content`;
+ const { data } = await getAuthenticatedHttpClient().post(url, {
+ content_type: 'comment',
+ content_id: commentId,
+ course_id: courseId,
+ });
+ return data;
+};
diff --git a/src/discussions/post-comments/data/hooks.js b/src/discussions/post-comments/data/hooks.js
index 33f71172d..3252dfda5 100644
--- a/src/discussions/post-comments/data/hooks.js
+++ b/src/discussions/post-comments/data/hooks.js
@@ -7,7 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
-import { EndorsementStatus } from '../../../data/constants';
+import { EndorsementStatus, PostsStatusFilter } from '../../../data/constants';
import useDispatchWithState from '../../../data/hooks';
import DiscussionContext from '../../common/context';
import { selectThread } from '../../posts/data/selectors';
@@ -42,6 +42,14 @@ export function usePost(postId) {
return thread || {};
}
+const useShowDeletedContent = () => {
+ const { learnerUsername } = useContext(DiscussionContext);
+ const postFilter = useSelector(state => state.learners.postFilter);
+
+ // Show deleted content if we're in learner view and the deleted filter is active (contentStatus)
+ return learnerUsername && postFilter?.contentStatus === PostsStatusFilter.DELETED;
+};
+
export function usePostComments(threadType) {
const { enableInContextSidebar, postId } = useContext(DiscussionContext);
const [isLoading, dispatch] = useDispatchWithState();
@@ -49,6 +57,7 @@ export function usePostComments(threadType) {
const reverseOrder = useSelector(selectCommentSortOrder);
const hasMorePages = useSelector(selectThreadHasMorePages(postId));
const currentPage = useSelector(selectThreadCurrentPage(postId));
+ const showDeleted = useShowDeletedContent();
const endorsedCommentsIds = useMemo(() => (
[...filterPosts(comments, 'endorsed')].map(comment => comment.id)
@@ -63,10 +72,11 @@ export function usePostComments(threadType) {
threadType,
page: currentPage + 1,
reverseOrder,
+ showDeleted,
};
await dispatch(fetchThreadComments(postId, params));
trackLoadMoreEvent(postId, params);
- }, [currentPage, threadType, postId, reverseOrder]);
+ }, [currentPage, threadType, postId, reverseOrder, showDeleted]);
useEffect(() => {
const abortController = new AbortController();
@@ -76,13 +86,14 @@ export function usePostComments(threadType) {
page: 1,
reverseOrder,
enableInContextSidebar,
+ showDeleted,
signal: abortController.signal,
}));
return () => {
abortController.abort();
};
- }, [postId, threadType, reverseOrder, enableInContextSidebar]);
+ }, [postId, threadType, reverseOrder, enableInContextSidebar, showDeleted]);
return {
endorsedCommentsIds,
diff --git a/src/discussions/post-comments/data/thunks.js b/src/discussions/post-comments/data/thunks.js
index 1650e7e51..166747a8c 100644
--- a/src/discussions/post-comments/data/thunks.js
+++ b/src/discussions/post-comments/data/thunks.js
@@ -60,7 +60,11 @@ function normaliseComments(data) {
commentsInThreads[threadId].push(id);
}
}
- commentsById[id] = comment;
+ // Normalize editableFields to always be an array
+ commentsById[id] = {
+ ...comment,
+ editableFields: comment.editableFields || [],
+ };
},
);
return {
@@ -79,6 +83,7 @@ export function fetchThreadComments(
reverseOrder,
threadType,
enableInContextSidebar,
+ showDeleted = false,
signal,
} = {},
) {
@@ -86,7 +91,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 +109,11 @@ export function fetchThreadComments(
};
}
-export function fetchCommentResponses(commentId, { page = 1, reverseOrder = true } = {}) {
+export function fetchCommentResponses(commentId, { page = 1, reverseOrder = true, showDeleted = false } = {}) {
return async (dispatch) => {
try {
dispatch(fetchCommentResponsesRequest({ commentId }));
- const data = await getCommentResponses(commentId, { page, reverseOrder });
+ const data = await getCommentResponses(commentId, { page, reverseOrder, showDeleted });
dispatch(fetchCommentResponsesSuccess({
...normaliseComments(camelCaseObject(data)),
page,
@@ -185,3 +190,19 @@ export function removeComment(commentId, threadId) {
}
};
}
+
+export function performRestoreComment(commentId, courseId) {
+ return async (dispatch) => {
+ try {
+ const { restoreComment } = await import('./api');
+ await restoreComment(commentId, courseId);
+ // Fetch the updated comment state by calling editComment with empty object
+ // This will refresh the comment data from the backend
+ await dispatch(editComment(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..1dd6a9cb2 100644
--- a/src/discussions/post-comments/messages.js
+++ b/src/discussions/post-comments/messages.js
@@ -11,6 +11,20 @@ const messages = defineMessages({
defaultMessage: 'Add a response',
description: 'Button to add a response to a response',
},
+ deletedBy: {
+ id: 'discussions.comments.comment.deletedBy',
+ defaultMessage: 'Deleted by',
+ },
+ deletedResponse: {
+ id: 'discussions.comments.comment.deletedResponse',
+ defaultMessage: 'Deleted Response',
+ description: 'Badge showing that the response has been deleted',
+ },
+ deletedComment: {
+ id: 'discussions.comments.comment.deletedComment',
+ defaultMessage: 'Deleted Comment',
+ description: 'Badge showing that the comment has been deleted',
+ },
abuseFlaggedMessage: {
id: 'discussions.comments.comment.abuseFlaggedMessage',
defaultMessage: 'Content reported for staff to review',
@@ -138,6 +152,16 @@ const messages = defineMessages({
defaultMessage: 'Are you sure you want to permanently delete this response?',
description: 'Text displayed in confirmation dialog when deleting a response',
},
+ undeleteResponseTitle: {
+ id: 'discussions.editor.undelete.response.title',
+ defaultMessage: 'Restore response',
+ description: 'Title of confirmation dialog shown when restoring a response',
+ },
+ undeleteResponseDescription: {
+ id: 'discussions.editor.undelete.response.description',
+ defaultMessage: 'Are you sure you want to restore this response?',
+ description: 'Text displayed in confirmation dialog when restoring a response',
+ },
deleteCommentTitle: {
id: 'discussions.editor.delete.comment.title',
defaultMessage: 'Delete comment',
@@ -148,6 +172,16 @@ const messages = defineMessages({
defaultMessage: 'Are you sure you want to permanently delete this comment?',
description: 'Text displayed in confirmation dialog when deleting a comment',
},
+ undeleteCommentTitle: {
+ id: 'discussions.editor.undelete.comment.title',
+ defaultMessage: 'Restore comment',
+ description: 'Title of confirmation dialog shown when restoring a comment',
+ },
+ undeleteCommentDescription: {
+ id: 'discussions.editor.undelete.comment.description',
+ defaultMessage: 'Are you sure you want to restore this comment?',
+ description: 'Text displayed in confirmation dialog when restoring a comment',
+ },
deleteConfirmationDelete: {
id: 'discussions.delete.confirmation.button.delete',
defaultMessage: 'Delete',
diff --git a/src/discussions/posts/NoResults.jsx b/src/discussions/posts/NoResults.jsx
index 73654d0dd..4c6484921 100644
--- a/src/discussions/posts/NoResults.jsx
+++ b/src/discussions/posts/NoResults.jsx
@@ -17,7 +17,7 @@ const NoResults = () => {
const filters = useSelector((state) => state.threads.filters);
const learnersFilter = useSelector(({ learners }) => learners.usernameSearch);
const isFiltered = postsFiltered || (topicsFilter !== '')
- || (learnersFilter !== null) || (inContextTopicsFilter !== '');
+ || (learnersFilter) || (inContextTopicsFilter !== '');
let helpMessage = messages.removeFilters;
diff --git a/src/discussions/posts/PostsView.test.jsx b/src/discussions/posts/PostsView.test.jsx
index 8e5f01083..6e754367e 100644
--- a/src/discussions/posts/PostsView.test.jsx
+++ b/src/discussions/posts/PostsView.test.jsx
@@ -215,7 +215,7 @@ describe('PostsView', () => {
await renderComponent();
});
dropDownButton = screen.getByRole('button', {
- name: /all posts sorted by recent activity/i,
+ name: /all active posts sorted by recent activity/i,
});
await act(async () => {
fireEvent.click(dropDownButton);
@@ -236,7 +236,7 @@ describe('PostsView', () => {
});
dropDownButton = screen.getByRole('button', {
- name: /All posts in Cohort 1 sorted by recent activity/i,
+ name: /All active posts in Cohort 1 sorted by recent activity/i,
});
expect(dropDownButton).toBeInTheDocument();
diff --git a/src/discussions/posts/data/__factories__/threads.factory.js b/src/discussions/posts/data/__factories__/threads.factory.js
index b072860c8..67286ff4f 100644
--- a/src/discussions/posts/data/__factories__/threads.factory.js
+++ b/src/discussions/posts/data/__factories__/threads.factory.js
@@ -45,6 +45,7 @@ Factory.define('thread')
non_endorsed_comment_list_url: null,
read: false,
has_endorsed: false,
+ is_deleted: false,
});
Factory.define('threadsResult')
diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js
index e91044bc1..57e0f0096 100644
--- a/src/discussions/posts/data/api.js
+++ b/src/discussions/posts/data/api.js
@@ -40,6 +40,7 @@ export const getThreads = async (courseId, {
threadType,
countFlagged,
cohort,
+ isDeleted,
} = {}) => {
const params = snakeCaseObject({
courseId,
@@ -56,6 +57,7 @@ export const getThreads = async (courseId, {
flagged,
countFlagged,
groupId: cohort,
+ isDeleted,
});
const { data } = await getAuthenticatedHttpClient().get(getThreadsApiUrl(), { params });
return data;
@@ -214,3 +216,19 @@ export const sendEmailForAccountActivation = async () => {
.post(url);
return data;
};
+
+/**
+ * Restore a deleted thread.
+ * @param {string} threadId
+ * @param {string} courseId
+ * @returns {Promise<{}>}
+ */
+export const restoreThread = async (threadId, courseId) => {
+ const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/restore_content`;
+ const { data } = await getAuthenticatedHttpClient().post(url, {
+ content_type: 'thread',
+ content_id: threadId,
+ course_id: courseId,
+ });
+ return data;
+};
diff --git a/src/discussions/posts/data/selectors.js b/src/discussions/posts/data/selectors.js
index b825f93a6..dfddf8ab1 100644
--- a/src/discussions/posts/data/selectors.js
+++ b/src/discussions/posts/data/selectors.js
@@ -61,3 +61,5 @@ export const selectThreadNextPage = () => state => state.threads.nextPage;
export const selectAuthorAvatar = author => state => (
state.threads.avatars?.[camelCase(author)]?.profile.image
);
+
+export const selectIsDeletedView = () => state => state.threads.isDeletedView;
diff --git a/src/discussions/posts/data/slices.js b/src/discussions/posts/data/slices.js
index d17b8a8d3..710e04a63 100644
--- a/src/discussions/posts/data/slices.js
+++ b/src/discussions/posts/data/slices.js
@@ -46,7 +46,7 @@ const threadsSlice = createSlice({
textSearchRewrite: null,
postStatus: RequestStatus.SUCCESSFUL,
filters: {
- status: PostsStatusFilter.ALL,
+ status: PostsStatusFilter.ACTIVE,
postType: ThreadType.ALL,
cohort: '',
search: '',
@@ -55,6 +55,7 @@ const threadsSlice = createSlice({
redirectToThread: null,
sortedBy: ThreadOrdering.BY_LAST_ACTIVITY,
confirmEmailStatus: RequestStatus.IDLE,
+ isDeletedView: false,
},
reducers: {
fetchLearnerThreadsRequest: (state, { payload }) => (
@@ -399,6 +400,20 @@ const threadsSlice = createSlice({
confirmEmailStatus: RequestStatus.DENIED,
}
),
+ toggleDeletedView: (state) => (
+ {
+ ...state,
+ isDeletedView: !state.isDeletedView,
+ pages: [], // Clear pages when switching views
+ }
+ ),
+ setDeletedView: (state, { payload }) => (
+ {
+ ...state,
+ isDeletedView: payload,
+ pages: [], // Clear pages when switching views
+ }
+ ),
},
});
@@ -441,6 +456,8 @@ export const {
sendAccountActivationEmailFailed,
sendAccountActivationEmailRequest,
sendAccountActivationEmailSuccess,
+ toggleDeletedView,
+ setDeletedView,
} = threadsSlice.actions;
export const threadsReducer = threadsSlice.reducer;
diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js
index d905502b2..55f75a05f 100644
--- a/src/discussions/posts/data/thunks.js
+++ b/src/discussions/posts/data/thunks.js
@@ -81,7 +81,11 @@ export function normaliseThreads(data, topicIds = null) {
if (!threadsInTopic[topicId].includes(id)) {
threadsInTopic[topicId].push(id);
}
- threadsById[id] = thread;
+ // Normalize editableFields to always be an array
+ threadsById[id] = {
+ ...thread,
+ editableFields: thread.editableFields || [],
+ };
avatars = { ...avatars, ...thread.users };
},
);
@@ -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 }));
@@ -314,6 +324,21 @@ export function removeThread(threadId) {
};
}
+export function performRestoreThread(threadId, courseId) {
+ return async (dispatch) => {
+ try {
+ const { restoreThread } = await import('./api');
+ await restoreThread(threadId, courseId);
+ // Fetch the updated thread to get the current state
+ await dispatch(fetchThread(threadId, courseId, false));
+ return { success: true };
+ } catch (error) {
+ logError(error);
+ return { success: false, error: error.message };
+ }
+ };
+}
+
export function sendAccountActivationEmail() {
return async (dispatch) => {
try {
diff --git a/src/discussions/posts/index.js b/src/discussions/posts/index.js
index f7d3c75dc..acda620ac 100644
--- a/src/discussions/posts/index.js
+++ b/src/discussions/posts/index.js
@@ -1,4 +1,5 @@
export { showPostEditor } from './data';
export { default as Post } from './post/Post';
+export { default as PostLink } from './post/PostLink';
export { default as messages } from './post-actions-bar/messages';
export { default as PostsView } from './PostsView';
diff --git a/src/discussions/posts/post-filter-bar/messages.js b/src/discussions/posts/post-filter-bar/messages.js
index eed8c084a..abad87a1b 100644
--- a/src/discussions/posts/post-filter-bar/messages.js
+++ b/src/discussions/posts/post-filter-bar/messages.js
@@ -51,6 +51,16 @@ const messages = defineMessages({
defaultMessage: 'Not responded',
description: 'Option in dropdown to filter to unresponded posts',
},
+ filterActive: {
+ id: 'discussions.posts.status.filter.active',
+ defaultMessage: 'Active content',
+ description: 'Option in dropdown to filter to active (non-deleted) posts',
+ },
+ filterDeleted: {
+ id: 'discussions.posts.status.filter.deleted',
+ defaultMessage: 'Deleted content',
+ description: 'Option in dropdown to filter to deleted posts',
+ },
myPosts: {
id: 'discussions.posts.filter.myPosts',
defaultMessage: 'My posts',
@@ -99,6 +109,8 @@ const messages = defineMessages({
statusReported {reported}
statusUnanswered {unanswered}
statusUnresponded {unresponded}
+ statusActive {active}
+ statusDeleted {deleted}
other {{status}}
} {type, select,
discussion {discussions}
diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx
index 965a81c67..702533a9c 100644
--- a/src/discussions/posts/post/Post.jsx
+++ b/src/discussions/posts/post/Post.jsx
@@ -2,6 +2,7 @@ import React, { useCallback, useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import { Hyperlink, useToggle } from '@openedx/paragon';
+import { DeleteOutline } from '@openedx/paragon/icons';
import classNames from 'classnames';
import { toString } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
@@ -9,11 +10,14 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
+import { logError } from '@edx/frontend-platform/logging';
import HTMLLoader from '../../../components/HTMLLoader';
-import { ContentActions, getFullUrl } from '../../../data/constants';
+import { AvatarOutlineAndLabelColors, ContentActions, getFullUrl } from '../../../data/constants';
import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors';
-import { AlertBanner, AutoSpamAlertBanner, Confirmation } from '../../common';
+import {
+ AlertBanner, AuthorLabel, AutoSpamAlertBanner, Confirmation,
+} from '../../common';
import DiscussionContext from '../../common/context';
import HoverCard from '../../common/HoverCard';
import withPostingRestrictions from '../../common/withPostingRestrictions';
@@ -29,22 +33,24 @@ import PostFooter from './PostFooter';
import PostHeader from './PostHeader';
const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
- const { enableInContextSidebar, postId } = useContext(DiscussionContext);
+ const { enableInContextSidebar, postId, courseId } = useContext(DiscussionContext);
+
const threadData = useSelector(selectThread(postId));
const {
topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName,
closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel,
- closedByLabel, users: postUsers, is_spam: isSpam,
+ closedByLabel, users: postUsers, isDeleted, deletedBy, deletedByLabel, is_spam: isSpam,
} = threadData;
+
const intl = useIntl();
const location = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
- const { courseId } = useContext(DiscussionContext);
const topic = useSelector(selectTopic(topicId));
const getTopicSubsection = useSelector(selectorForUnitSubsection);
const topicContext = useSelector(selectTopicContext(topicId));
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
+ const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
@@ -103,15 +109,35 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
}
}, [abuseFlagged, postId, showReportConfirmation]);
+ const handleRestore = useCallback(() => {
+ showRestoreConfirmation();
+ }, [showRestoreConfirmation]);
+
+ const handleRestoreConfirmation = useCallback(async () => {
+ try {
+ const { performRestoreThread } = await import('../data/thunks');
+ const result = await dispatch(performRestoreThread(postId, courseId));
+ // Check if restore failed and log the error
+ if (result && !result.success) {
+ logError(`Failed to restore thread: ${result.error || 'Unknown error'}`);
+ }
+ } catch (error) {
+ logError(error);
+ }
+ hideRestoreConfirmation();
+ }, [postId, courseId, dispatch, hideRestoreConfirmation]);
+
const actionHandlers = useMemo(() => ({
[ContentActions.EDIT_CONTENT]: handlePostContentEdit,
[ContentActions.DELETE]: showDeleteConfirmation,
+ [ContentActions.RESTORE]: handleRestore,
[ContentActions.CLOSE]: handlePostClose,
[ContentActions.COPY_LINK]: handlePostCopyLink,
[ContentActions.PIN]: handlePostPin,
[ContentActions.REPORT]: handlePostReport,
}), [
handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation,
+ handleRestore,
]);
const handleClosePostConfirmation = useCallback((closeReasonCode) => {
@@ -147,6 +173,14 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
closeButtonVariant="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
+
{!abuseFlagged && (
{
onFollow={handlePostFollow}
voted={voted}
following={following}
+ isDeleted={isDeleted}
/>
+ {isDeleted && deletedBy && (
+
+
+
+ {intl.formatMessage(messages.deletedBy)}
+
+
+
+
+
+ )}
{
+ if (type === 'response') {
+ return 'Response';
+ }
+ if (type === 'comment') {
+ return 'Comment';
+ }
+ return null;
+ };
+
+ // For comments/responses, show parent thread title with arrow
+ const displayTitle = (type === 'response' || type === 'comment') && threadTitle ? threadTitle : title;
+
+ // Strip render_id suffix (e.g., "-thread", "-response", "-comment") for navigation
+ const stripRenderIdSuffix = (idValue) => {
+ if (typeof idValue === 'string') {
+ return idValue.replace(/-(thread|response|comment)$/, '');
+ }
+ return idValue;
+ };
+
+ // For comments/responses, navigate to the parent thread instead of the comment itself
+ const rawNavigationId = (type === 'response' || type === 'comment') && commentThreadId ? commentThreadId : postId;
+ const navigationPostId = stripRenderIdSuffix(rawNavigationId);
+
const { pathname } = discussionsPath(Routes.COMMENTS.PAGES[page], {
0: enableInContextSidebar ? 'in-context' : undefined,
courseId,
topicId,
- postId,
+ postId: navigationPostId,
category,
learnerUsername,
})();
@@ -76,8 +103,10 @@ const PostLink = ({
'd-flex flex-row pt-2 pb-2 px-4 border-primary-500 position-relative',
{ 'bg-light-300': isPostRead },
{ 'post-summary-card-selected': id === selectedPostId },
+ { 'bg-light-200': isDeleted }, // Gray background for deleted threads
)
}
+ style={isDeleted ? { opacity: 0.7 } : {}} // Slightly faded for deleted threads
>
+ {(type === 'response' || type === 'comment') && threadTitle && (
+ <>
+
+
+ {getTypeLabel()} in
+
+ >
+ )}
- {title}
+ {displayTitle}
{isPostPreviewAvailable(previewBody) ? previewBody : intl.formatMessage(messages.postWithoutPreview)}
@@ -121,12 +162,25 @@ const PostLink = ({
{' '}reported
)}
+ {isDeleted && (
+
+ {intl.formatMessage(messages.deletedPost)}
+ {' '}deleted
+
+ )}
{pinned && (
)}
diff --git a/src/discussions/posts/post/messages.js b/src/discussions/posts/post/messages.js
index f095e1fbe..61c49c87a 100644
--- a/src/discussions/posts/post/messages.js
+++ b/src/discussions/posts/post/messages.js
@@ -24,6 +24,25 @@ const messages = defineMessages({
defaultMessage: 'Reported',
description: 'Content reported for staff review',
},
+ deletedBy: {
+ id: 'discussions.post.deletedBy',
+ defaultMessage: 'Deleted by',
+ },
+ deletedPost: {
+ id: 'discussions.post.deletedPost',
+ defaultMessage: 'Deleted',
+ description: 'Badge showing that the post has been deleted',
+ },
+ deletedResponse: {
+ id: 'discussions.post.deletedResponse',
+ defaultMessage: 'Deleted',
+ description: 'Badge showing that the response has been deleted',
+ },
+ deletedComment: {
+ id: 'discussions.post.deletedComment',
+ defaultMessage: 'Deleted',
+ description: 'Badge showing that the comment has been deleted',
+ },
following: {
id: 'discussions.post.following',
defaultMessage: 'Following',
@@ -106,6 +125,14 @@ const messages = defineMessages({
defaultMessage: 'Delete',
description: 'Delete button shown on delete confirmation dialog',
},
+ undeletePostTitle: {
+ id: 'discussions.editor.undelete.post.title',
+ defaultMessage: 'Restore post',
+ },
+ undeletePostDescription: {
+ id: 'discussions.editor.undelete.post.description',
+ defaultMessage: 'Are you sure you want to restore this post?',
+ },
reportPostTitle: {
id: 'discussions.editor.report.post.title',
defaultMessage: 'Report inappropriate content?',
@@ -171,6 +198,11 @@ const messages = defineMessages({
defaultMessage: 'you are not following this post',
description: 'tell screen readers if user is not following a post',
},
+ deleted: {
+ id: 'discussions.post.deleted',
+ defaultMessage: 'Deleted',
+ description: 'Label shown on deleted threads',
+ },
});
export default messages;
diff --git a/src/discussions/utils.js b/src/discussions/utils.js
index fb139f3c6..461a55021 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,
@@ -63,9 +64,9 @@ export function checkPermissions(content, action) {
if (content.editableFields.includes(action)) {
return true;
}
- // For delete action we check `content.canDelete`
- if (action === ContentActions.DELETE) {
- return true;
+ // Both delete and restore actions check `content.canDelete`
+ if (action === ContentActions.DELETE || action === ContentActions.RESTORE) {
+ return content.canDelete;
}
return false;
}
@@ -182,7 +183,14 @@ export const ACTIONS_LIST = [
action: ContentActions.DELETE,
icon: Delete,
label: messages.deleteAction,
- conditions: { canDelete: true },
+ conditions: { canDelete: true, isDeleted: false },
+ },
+ {
+ id: 'restore',
+ action: ContentActions.RESTORE,
+ icon: RestoreFromTrash,
+ label: messages.restoreAction,
+ conditions: { canDelete: true, isDeleted: true },
},
];
@@ -198,12 +206,20 @@ export function useActions(contentType, id) {
: true
), []);
+ const isActionDisabled = useCallback((actionId, isDeleted) => (
+ // For deleted items, disable all actions except 'copy-link' and 'restore'
+ isDeleted && actionId !== 'copy-link' && actionId !== 'restore'
+ ), []);
+
const actions = useMemo(() => ACTIONS_LIST.filter(
({
action,
conditions = null,
}) => checkPermissions(content, action) && checkConditions(content, conditions),
- ), [content]);
+ ).map(action => ({
+ ...action,
+ disabled: isActionDisabled(action.id, content.isDeleted),
+ })), [content, checkConditions, isActionDisabled]);
return actions;
}
diff --git a/src/index.scss b/src/index.scss
index fdff64ff1..2e8fc30ab 100755
--- a/src/index.scss
+++ b/src/index.scss
@@ -28,6 +28,19 @@ body,
background-color: var(--pgn-color-card-bg-base) !important;
}
+// New learner message styling
+.new-learner-message {
+ font-style: italic;
+ font-size: 12px;
+ margin-top: 0.25rem;
+ line-height: 1.2;
+
+ @media (max-width: 767.98px) {
+ font-size: 11px;
+ margin-top: 0.1rem;
+ }
+}
+
#post,
#comment,
#reply,
@@ -56,6 +69,22 @@ body,
outline: var(--pgn-color-success-700) solid 2px;
}
+.text-learner-color {
+ color: var(--pgn-color-primary-500);
+}
+
+.outline-learner-color {
+ outline: var(--pgn-color-primary-500) solid 2px;
+}
+
+.text-new-learner-color {
+ color: var(--pgn-color-accent-500);
+}
+
+.outline-new-learner-color {
+ outline: var(--pgn-color-accent-500) solid 2px;
+}
+
.outline-anonymous {
outline: var(--pgn-color-light-400) solid 2px;
}
@@ -236,15 +265,18 @@ header {
}
.sidebar-desktop-width {
- max-width: 28rem !important;
+ max-width: 45rem !important;
+ flex-shrink: 0 !important;
}
.sidebar-tablet-width {
- max-width: 21rem !important;
+ max-width: 35rem !important;
+ flex-shrink: 0 !important;
}
.sidebar-XL-width {
- min-width: 29rem !important;
+ min-width: 45rem !important;
+ flex-shrink: 0 !important;
}
.filter-menu:focus-visible {
@@ -558,9 +590,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;
@@ -835,3 +895,29 @@ th, td {
}
}
}
+
+// Learner submenu styling
+.learner-submenu-container {
+ position: absolute;
+ left: 100%;
+ top: 0;
+ min-width: 300px;
+ max-width: 360px;
+ z-index: 9999;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ border: 1px solid var(--pgn-color-light-400);
+ overflow: visible;
+}
+
+// Deleted content icon styling
+.deleted-content-icon {
+ width: 1.5rem;
+ height: 1.5rem;
+}
+
+// Subdirectory arrow icon styling
+.subdirectory-arrow-icon {
+ width: 16px;
+ height: 16px;
+ margin-right: 4px;
+}