diff --git a/.gitignore b/.gitignore index a38427e42..d3c6f8a76 100755 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ npm-debug.log coverage module.config.js env.config.* +.env*.local dist/ src/i18n/transifex_input.json diff --git a/src/data/api/index.js b/src/data/api/index.js new file mode 100644 index 000000000..d63c1361f --- /dev/null +++ b/src/data/api/index.js @@ -0,0 +1,5 @@ +/** + * Centralized API exports for the discussions app + */ + +export * from './moderation'; diff --git a/src/data/api/moderation.js b/src/data/api/moderation.js new file mode 100644 index 000000000..ea1b3c3b8 --- /dev/null +++ b/src/data/api/moderation.js @@ -0,0 +1,86 @@ +/** + * Shared moderation API functions for ban, unban, and bulk operations + * Consolidates duplicate API calls from learners/data/api.js and posts/data/api.js + */ + +import { ensureConfig, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +ensureConfig([ + 'LMS_BASE_URL', +], 'Moderation API service'); + +/** + * Bans a user from discussions in a course or organization (standalone - no deletion) + * @param {string} courseId Course ID + * @param {string} username Username of the user to ban + * @param {string} banScope 'course' or 'organization' + * @param {string} reason Optional reason for banning + * @returns {Promise<{ban_id: number, user_id: number, username: string, scope: string}>} + */ +export const banUser = async (courseId, username, banScope, reason = '') => { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/ban-user/`; + const { data } = await getAuthenticatedHttpClient().post(url, { + username, + course_id: courseId, + scope: banScope, + reason, + }); + return data; +}; + +/** + * Unban a user from discussions. + * @param {string} courseId + * @param {string} username Username of the user to unban + * @param {string} banScope Scope of the ban ('course' or 'organization') + * @param {string} reason Optional reason for unbanning + * @returns {Promise<{ban_id: number, user_id: number, username: string, scope: string}>} + */ +export const unbanUser = async (courseId, username, banScope, reason = '') => { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/unban-user/`; + const { data } = await getAuthenticatedHttpClient().post(url, { + username, + course_id: courseId, + scope: banScope, + reason, + }); + return data; +}; + +/** + * Deletes all posts/comments by a user in a course or organization + * @param {string} courseId Course ID + * @param {string} username Username of the user + * @param {string} banScope 'course' or 'organization' + * @param {boolean} shouldBanUser Whether to ban the user after deletion + * @returns {Promise<{thread_count: number, comment_count: number}>} + */ +export const bulkDeleteUserPosts = async (courseId, username, banScope, shouldBanUser = false) => { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/bulk-delete-ban/`; + const { data } = await getAuthenticatedHttpClient().post(url, { + username, + course_id: courseId, + ban_user: shouldBanUser, + ban_scope: banScope, + reason: shouldBanUser ? 'Content removed by moderator' : '', + }); + return data; +}; + +/** + * Undeletes all posts/comments by a user in a course or organization + * @param {string} courseId Course ID + * @param {string} username Username of the user + * @param {string} banScope 'course' or 'organization' + * @returns {Promise<{thread_count: number, comment_count: number}>} + */ +export const bulkUndeleteUserPosts = async (courseId, username, banScope) => { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/bulk-undelete/`; + const { data } = await getAuthenticatedHttpClient().post(url, { + username, + course_id: courseId, + ban_scope: banScope, + }); + return data; +}; diff --git a/src/data/constants.js b/src/data/constants.js index 4919694c3..aa29c4064 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -63,6 +63,20 @@ export const ContentActions = { DELETE_ORG_POSTS: 'delete-org-posts', RESTORE_COURSE_POSTS: 'restore-course-posts', RESTORE_ORG_POSTS: 'restore-org-posts', + // Mute actions + MUTE: 'mute', + UNMUTE: 'unmute', + // Ban actions + BAN_COURSE: 'ban-course', + BAN_ORG: 'ban-org', + UNBAN_COURSE: 'unban-course', + UNBAN_ORG: 'unban-org', + // Enhanced delete actions + DELETE_POST: 'delete-post', + DELETE_USER_COURSE: 'delete-user-course', + DELETE_USER_ORG: 'delete-user-org', + UNDELETE_USER_COURSE: 'undelete-user-course', + UNDELETE_USER_ORG: 'undelete-user-org', }; /** diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index b88148ea7..261b7ad97 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -1,19 +1,19 @@ import React, { - useCallback, useMemo, useRef, useState, + useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import PropTypes from 'prop-types'; import { Button, Dropdown, Icon, IconButton, ModalPopup, useToggle, } from '@openedx/paragon'; -import { MoreHoriz } from '@openedx/paragon/icons'; +import { ChevronLeft, ChevronRight, MoreHoriz } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { logError } from '@edx/frontend-platform/logging'; import { ContentActions } from '../../data/constants'; -import { selectIsPostingEnabled } from '../data/selectors'; +import { selectIsPostingEnabled, selectUserHasModerationPrivileges } from '../data/selectors'; import messages from '../messages'; import { useActions } from '../utils'; @@ -29,8 +29,10 @@ const ActionsDropdown = ({ const intl = useIntl(); const [isOpen, open, close] = useToggle(false); const [target, setTarget] = useState(null); + const [activeSubmenu, setActiveSubmenu] = useState(null); const isPostingEnabled = useSelector(selectIsPostingEnabled); - const actions = useActions(contentType, id); + const hasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + const actions = useActions(contentType, id, hasModerationPrivileges); // Check if we're in in-context sidebar mode const isInContextSidebar = useMemo(() => ( @@ -62,40 +64,133 @@ const ActionsDropdown = ({ const onCloseModal = useCallback(() => { close(); setTarget(null); + setActiveSubmenu(null); }, [close]); - const dropdownContent = ( -
( + { + if (action.hasSubmenu) { + setActiveSubmenu(action.id); + } else { + close(); + handleActions(action.action); + } + }} + className="d-flex justify-content-start actions-dropdown-item" + data-testid={action.id} > - {actions.map(action => ( - - {(action.action === ContentActions.DELETE) && } +
+ + + {intl.formatMessage(action.label)} + +
+ {action.hasSubmenu && ( + + )} +
+ ), [close, handleActions, intl]); + + const renderSubmenu = useCallback((parentAction) => ( + <> + setActiveSubmenu(null)} + className="d-flex align-items-center actions-dropdown-item" + data-testid="submenu-back" + > + + + {intl.formatMessage(messages.backToMenu)} + + + + {parentAction.submenu.map(subAction => ( + { - close(); - if (!action.disabled) { - handleActions(action.action); + if (!subAction.disabled) { + close(); + setActiveSubmenu(null); + handleActions(subAction.action); } }} - className="d-flex justify-content-start actions-dropdown-item" - data-testId={action.id} - disabled={action.disabled} + className="d-flex justify-content-start actions-dropdown-item pl-4" + data-testid={subAction.id} > - - - {intl.formatMessage(action.label)} + + {intl.formatMessage(subAction.label)} ))} + + ), [close, handleActions, intl]); + + const activeParentAction = useMemo(() => ( + activeSubmenu ? actions.find(action => action.id === activeSubmenu) : null + ), [actions, activeSubmenu]); + + useEffect(() => { + if (activeSubmenu && !activeParentAction) { + setActiveSubmenu(null); + } + }, [activeSubmenu, activeParentAction]); + + const dropdownContent = ( +
+ {activeSubmenu && activeParentAction ? ( + renderSubmenu(activeParentAction) + ) : ( + actions.map(action => ( + + {(action.id === 'ban' || action.action === ContentActions.DELETE) && } + {action.hasSubmenu ? ( + renderMenuItem(action) + ) : ( + { + close(); + if (!action.disabled) { + handleActions(action.action); + } + }} + className="d-flex justify-content-start actions-dropdown-item" + data-testid={action.id} + disabled={action.disabled} + > + + + {intl.formatMessage(action.label)} + + + )} + + )) + )}
); diff --git a/src/discussions/common/ActionsDropdown.test.jsx b/src/discussions/common/ActionsDropdown.test.jsx index a852a4abe..0c5e0ada5 100644 --- a/src/discussions/common/ActionsDropdown.test.jsx +++ b/src/discussions/common/ActionsDropdown.test.jsx @@ -86,63 +86,67 @@ const mockThreadAndComment = async (response) => { await executeThunk(addComment(commentContent, discussionThreadId, null), store.dispatch, store.getState); }; -const canPerformActionTestData = ACTIONS_LIST.flatMap(({ - id, action, conditions, label: { defaultMessage }, -}) => { - const buildParams = { editable_fields: [action] }; - - if (conditions) { - Object.entries(conditions).forEach(([conditionKey, conditionValue]) => { - buildParams[conditionKey] = conditionValue; - }); - } - - const testContent = buildTestContent(buildParams, { label: defaultMessage, action }); - - switch (id) { - case 'answer': - case 'unanswer': - return [testContent.question]; - case 'endorse': - case 'unendorse': - return [testContent.comment, testContent.discussion]; - default: - return [testContent.discussion, testContent.question, testContent.comment]; - } -}); +const canPerformActionTestData = ACTIONS_LIST + .filter(({ action }) => action !== undefined) // Filter out submenu parent items + .flatMap(({ + id, action, conditions, label: { defaultMessage }, + }) => { + const buildParams = { editable_fields: [action] }; -const canNotPerformActionTestData = ACTIONS_LIST.flatMap(({ action, conditions, label: { defaultMessage } }) => { - const label = defaultMessage; + if (conditions) { + Object.entries(conditions).forEach(([conditionKey, conditionValue]) => { + buildParams[conditionKey] = conditionValue; + }); + } - if (!conditions) { - const content = buildTestContent({ editable_fields: [] }, { reason: 'field is not editable', label: defaultMessage }); - return [content.discussion, content.question, content.comment]; - } + const testContent = buildTestContent(buildParams, { label: defaultMessage, action }); + + switch (id) { + case 'answer': + case 'unanswer': + return [testContent.question]; + case 'endorse': + case 'unendorse': + return [testContent.comment, testContent.discussion]; + default: + return [testContent.discussion, testContent.question, testContent.comment]; + } + }); - const reversedConditions = Object.fromEntries(Object.entries(conditions).map(([key, value]) => [key, !value])); +const canNotPerformActionTestData = ACTIONS_LIST + .filter(({ action }) => action !== undefined) // Filter out submenu parent items + .flatMap(({ action, conditions, label: { defaultMessage } }) => { + const label = defaultMessage; - const content = { + if (!conditions) { + const content = buildTestContent({ editable_fields: [] }, { reason: 'field is not editable', label: defaultMessage }); + return [content.discussion, content.question, content.comment]; + } + + const reversedConditions = Object.fromEntries(Object.entries(conditions).map(([key, value]) => [key, !value])); + + const content = { // can edit field, but doesn't pass conditions - ...buildTestContent({ - editable_fields: [action], - ...reversedConditions, - }, { reason: 'field is editable but does not pass condition', label, action }), - - // passes conditions, but can't edit field - ...(action === ContentActions.DELETE ? {} : buildTestContent({ - editable_fields: [], - ...conditions, - }, { reason: 'passes conditions but field is not editable', label, action })), - - // can't edit field, and doesn't pass conditions - ...buildTestContent({ - editable_fields: [], - ...reversedConditions, - }, { reason: 'can not edit field and does not match conditions', label, action }), - }; + ...buildTestContent({ + editable_fields: [action], + ...reversedConditions, + }, { reason: 'field is editable but does not pass condition', label, action }), + + // passes conditions, but can't edit field + ...(action === ContentActions.DELETE ? {} : buildTestContent({ + editable_fields: [], + ...conditions, + }, { reason: 'passes conditions but field is not editable', label, action })), + + // can't edit field, and doesn't pass conditions + ...buildTestContent({ + editable_fields: [], + ...reversedConditions, + }, { reason: 'can not edit field and does not match conditions', label, action }), + }; - return [content.discussion, content.question, content.comment]; -}); + return [content.discussion, content.question, content.comment]; + }); const renderComponent = ({ id = '', diff --git a/src/discussions/common/AuthorLabel.jsx b/src/discussions/common/AuthorLabel.jsx index aef80dd82..1219acf90 100644 --- a/src/discussions/common/AuthorLabel.jsx +++ b/src/discussions/common/AuthorLabel.jsx @@ -2,6 +2,7 @@ import React, { useCallback, useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon'; +import { Block } from '@openedx/paragon/icons'; import classNames from 'classnames'; import { generatePath, Link } from 'react-router-dom'; import * as timeago from 'timeago.js'; @@ -31,7 +32,7 @@ const AuthorLabel = ({ const { courseId, enableInContextSidebar } = useContext(DiscussionContext); const { icon, authorLabelMessage } = useMemo( () => getAuthorLabel(intl, authorLabel), - [authorLabel], + [authorLabel, intl], ); const { isNewLearner, isRegularLearner } = useLearnerStatus( postData, @@ -47,12 +48,22 @@ const AuthorLabel = ({ labelColor, ); + // Check if user is banned from postData + const isAuthorBanned = postData?.is_author_banned || postData?.isAuthorBanned || false; + const showUserNameAsLink = ( linkToProfile && author && author !== intl.formatMessage(messages.anonymous) && !enableInContextSidebar ); + const canDisplayLearnerRole = Boolean( + author + && !isRetiredUser + && author !== intl.formatMessage(messages.anonymous), + ); + + const hasRecognizedAuthorRole = Boolean(authorLabelMessage && icon); const authorName = useMemo( () => ( @@ -92,38 +103,60 @@ const AuthorLabel = ({ return null; }, [isNewLearner, isRegularLearner, createLearnerMessage]); - const labelContents = useMemo( - () => ( - <> - - <> - {authorToolTip ? author : authorLabel} -
- {intl.formatMessage(messages.authorAdminDescription)} - - - )} - trigger={['hover', 'focus']} - > -
- - {authorLabelMessage && ( + // Banned indicator - shown for all users (learners and staff) + const bannedIndicator = useMemo( + () => isAuthorBanned && ( + + + {intl.formatMessage(messages.authorLabelBanned)} + + ), + [isAuthorBanned, intl], + ); + + const roleContents = useMemo( + () => { + if (hasRecognizedAuthorRole) { + return ( + + <> + {authorToolTip ? author : authorLabel} +
+ {intl.formatMessage(messages.authorAdminDescription)} + + + )} + trigger={['hover', 'focus']} + > +
+ {authorLabelMessage} - )} -
-
- {postCreatedAt && ( +
+
+ ); + } + + if (canDisplayLearnerRole) { + return ( - {timeago.format(postCreatedAt, 'time-locale')} + {intl.formatMessage(messages.learnerMessage)} - )} - - ), + ); + } + + return null; + }, [ author, + authorLabel, authorLabelMessage, authorToolTip, + canDisplayLearnerRole, + hasRecognizedAuthorRole, icon, + intl, isRetiredUser, - postCreatedAt, showTextPrimary, alert, ], ); + const timestamp = useMemo(() => ( + postCreatedAt ? ( + + {timeago.format(postCreatedAt, 'time-locale')} + + ) : null + ), [postCreatedAt, alert]); + const learnerPostsLink = author ? (
- {!authorLabel ? ( - - <> - {intl.formatMessage(messages.authorLearnerTitle)} -
- {intl.formatMessage(messages.authorLearnerDescription)} - - - )} - trigger={['hover', 'focus']} - > - {learnerPostsLink} -
- ) : ( - learnerPostsLink - )} - {labelContents} + {learnerPostsLink} + {timestamp} +
+
+ {roleContents} + {bannedIndicator}
{postOrComment && learnerMessageComponent}
@@ -217,7 +252,11 @@ const AuthorLabel = ({
{authorName} - {labelContents} + {timestamp} +
+
+ {roleContents} + {bannedIndicator}
{postOrComment && learnerMessageComponent}
diff --git a/src/discussions/common/AuthorLabel.test.jsx b/src/discussions/common/AuthorLabel.test.jsx index 702c16077..c6b656fc2 100644 --- a/src/discussions/common/AuthorLabel.test.jsx +++ b/src/discussions/common/AuthorLabel.test.jsx @@ -114,17 +114,19 @@ describe('Author label', () => { `it has "${!linkToProfile && 'not'}" label text and label color when linkToProfile is ${!!linkToProfile}`, async () => { renderComponent(author, authorLabel, linkToProfile, labelColor); - const authorElement = container.querySelector('[role=heading]'); - const labelParentNode = authorElement.parentNode.parentNode; - const labelElement = labelParentNode.lastChild.lastChild; - const label = ['CTA', 'TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent; - - if (linkToProfile) { - expect(labelParentNode).toHaveClass(labelColor); - expect(labelElement).toHaveTextContent(label); + const roleLabelByAuthorLabel = { + 'Community TA': 'CTA', + Moderator: 'TA', + Staff: 'Staff', + }; + const expectedRoleLabel = roleLabelByAuthorLabel[authorLabel]; + + if (linkToProfile && expectedRoleLabel) { + expect(screen.getByText(expectedRoleLabel)).toBeInTheDocument(); } else { - expect(authorElement.parentNode.lastChild).not.toHaveTextContent(label, { exact: true }); - expect(authorElement.parentNode).not.toHaveClass(labelColor, { exact: true }); + expect(screen.queryByText('CTA')).not.toBeInTheDocument(); + expect(screen.queryByText('TA')).not.toBeInTheDocument(); + expect(screen.queryByText('Staff')).not.toBeInTheDocument(); } }, ); @@ -236,7 +238,8 @@ describe('Author label', () => { it('should not display regular learner message in sidebar', () => { const postData = { learner_status: 'regular' }; renderComponent('testuser', null, false, '', false, postData, false); - expect(screen.queryByText('Learner')).not.toBeInTheDocument(); + expect(screen.getByText('Learner')).toBeInTheDocument(); + expect(screen.queryByText('👋 Hi, I am a new learner')).not.toBeInTheDocument(); }); it('should display learner messages in post view (postOrComment=true)', () => { diff --git a/src/discussions/common/BanModerationModals.jsx b/src/discussions/common/BanModerationModals.jsx new file mode 100644 index 000000000..62366bd3c --- /dev/null +++ b/src/discussions/common/BanModerationModals.jsx @@ -0,0 +1,205 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import discussionMessages from '../messages'; +import DeleteWithBanConfirmation from '../posts/post/DeleteWithBanConfirmation'; +import Confirmation from './Confirmation'; + +const noop = () => null; + +/** + * Reusable component that renders all ban/unban/delete moderation modals + * Used by Post and Comment components to reduce code duplication + */ +const BanModerationModals = ({ + author, + activeModal, + onClose, + onDeleteWithBan, + onDeleteUserCourse, + onDeleteUserOrg, + onUndeleteUserCourse, + onUndeleteUserOrg, + onBanCourse, + onBanOrg, + onUnbanCourse, + onUnbanOrg, + isProcessing, + enableDiscussionBan, + showBanCheckboxOnDelete, + deleteTitle, + deleteDescription, + deleteConfirmText, +}) => { + const intl = useIntl(); + + return ( + <> + {/* Single content delete with ban option */} + + + {/* Delete all user content - Course scope */} + + + {/* Delete all user content - Organization scope */} + + + {/* Undelete user content - Course scope */} + + + {/* Undelete user content - Organization scope */} + + + {/* Ban user - Course scope */} + + + {/* Ban user - Organization scope */} + + + {/* Unban user - Course scope */} + + + {/* Unban user - Organization scope */} + + + ); +}; + +BanModerationModals.propTypes = { + author: PropTypes.string.isRequired, + activeModal: PropTypes.string, + onClose: PropTypes.func.isRequired, + onDeleteWithBan: PropTypes.func, + onDeleteUserCourse: PropTypes.func, + onDeleteUserOrg: PropTypes.func, + onUndeleteUserCourse: PropTypes.func, + onUndeleteUserOrg: PropTypes.func, + onBanCourse: PropTypes.func, + onBanOrg: PropTypes.func, + onUnbanCourse: PropTypes.func, + onUnbanOrg: PropTypes.func, + isProcessing: PropTypes.bool, + enableDiscussionBan: PropTypes.bool, + showBanCheckboxOnDelete: PropTypes.bool, + // Custom messages for single delete modal (used when modal is active) + deleteTitle: PropTypes.string, + deleteDescription: PropTypes.string, + deleteConfirmText: PropTypes.string, +}; + +BanModerationModals.defaultProps = { + activeModal: null, + onDeleteWithBan: noop, + onDeleteUserCourse: noop, + onDeleteUserOrg: noop, + onUndeleteUserCourse: noop, + onUndeleteUserOrg: noop, + onBanCourse: noop, + onBanOrg: noop, + onUnbanCourse: noop, + onUnbanOrg: noop, + isProcessing: false, + enableDiscussionBan: false, + showBanCheckboxOnDelete: false, + deleteTitle: '', + deleteDescription: '', + deleteConfirmText: '', +}; + +export default BanModerationModals; diff --git a/src/discussions/common/Confirmation.jsx b/src/discussions/common/Confirmation.jsx index b663c87df..19bf9669b 100644 --- a/src/discussions/common/Confirmation.jsx +++ b/src/discussions/common/Confirmation.jsx @@ -62,7 +62,7 @@ const Confirmation = ({ }} state={isConfirmButtonPending ? 'pending' : confirmButtonState} variant={confirmButtonVariant} - onClick={confirmAction} + onClick={() => confirmAction()} /> )} diff --git a/src/discussions/common/HoverCard.jsx b/src/discussions/common/HoverCard.jsx index 55e673115..8100dd59b 100644 --- a/src/discussions/common/HoverCard.jsx +++ b/src/discussions/common/HoverCard.jsx @@ -30,6 +30,7 @@ const HoverCard = ({ following, endorseIcons, isDeleted, + isUserBanned, }) => { const intl = useIntl(); const { enableInContextSidebar } = useContext(DiscussionContext); @@ -43,7 +44,7 @@ const HoverCard = ({ data-testid={`hover-card-${id}`} id={`hover-card-${id}`} > - {isUserPrivilegedInPostingRestriction && ( + {isUserPrivilegedInPostingRestriction && !isUserBanned && (
@@ -172,6 +174,7 @@ HoverCard.propTypes = { onFollow: PropTypes.func, following: PropTypes.bool, isDeleted: PropTypes.bool, + isUserBanned: PropTypes.bool, }; HoverCard.defaultProps = { @@ -179,6 +182,7 @@ HoverCard.defaultProps = { endorseIcons: null, following: undefined, isDeleted: false, + isUserBanned: false, }; export default React.memo(HoverCard); diff --git a/src/discussions/common/context.js b/src/discussions/common/context.js index 65664fcd7..d32f84121 100644 --- a/src/discussions/common/context.js +++ b/src/discussions/common/context.js @@ -8,6 +8,7 @@ const DiscussionContext = React.createContext({ enableInContextSidebar: false, category: null, learnerUsername: null, + enableDiscussionBan: false, }); export default DiscussionContext; diff --git a/src/discussions/common/index.js b/src/discussions/common/index.js index ffc87a2b9..8502cd461 100644 --- a/src/discussions/common/index.js +++ b/src/discussions/common/index.js @@ -2,5 +2,6 @@ export { default as ActionsDropdown } from './ActionsDropdown'; export { default as AlertBanner } from './AlertBanner'; export { default as AuthorLabel } from './AuthorLabel'; export { default as AutoSpamAlertBanner } from './AutoSpamAlertBanner'; +export { default as BanModerationModals } from './BanModerationModals'; export { default as Confirmation } from './Confirmation'; export { default as EndorsedAlertBanner } from './EndorsedAlertBanner'; diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js index 1ae6a50ad..c8c17de52 100644 --- a/src/discussions/data/selectors.js +++ b/src/discussions/data/selectors.js @@ -9,7 +9,14 @@ export const selectAnonymousPostingConfig = state => ({ allowAnonymousToPeers: state.config.allowAnonymousToPeers, }); -export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges; +// Moderation privileges include: forum moderators, community TAs, course staff, course admins, and global staff +// This matches the edX pattern where different privilege checks are combined at point of use +export const selectUserHasModerationPrivileges = state => ( + state.config.hasModerationPrivileges + || state.config.isUserAdmin + || state.config.isCourseStaff + || state.config.isCourseAdmin +); export const selectUserHasBulkDeletePrivileges = state => state.config.hasBulkDeletePrivileges; @@ -43,6 +50,10 @@ export const selectContentCreationRateLimited = state => state.config.contentCre export const selectOnlyVerifiedUsersCanPost = state => state.config.onlyVerifiedUsersCanPost; +export const selectIsUserBanned = state => state.config.isUserBanned; + +export const selectEnableDiscussionBan = state => state.config.enableDiscussionBan; + export const selectConfirmEmailStatus = state => state.threads.confirmEmailStatus; export const selectPostStatus = state => state.comments.postStatus; diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js index b6bc2e7ff..8ba7c4054 100644 --- a/src/discussions/data/slices.js +++ b/src/discussions/data/slices.js @@ -32,6 +32,8 @@ const configSlice = createSlice({ enableInContext: false, isEmailVerified: false, contentCreationRateLimited: false, + isUserBanned: false, + enableDiscussionBan: false, }, reducers: { fetchConfigRequest: (state) => ( diff --git a/src/discussions/discussions-home/BannedUserBanner.jsx b/src/discussions/discussions-home/BannedUserBanner.jsx new file mode 100644 index 000000000..c4f0e246c --- /dev/null +++ b/src/discussions/discussions-home/BannedUserBanner.jsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; + +import './BannedUserBanner.scss'; + +const BannedUserBanner = () => { + const intl = useIntl(); + + return ( +
+ {intl.formatMessage(messages.bannedUserBannerMessage)} +
+ ); +}; + +export default BannedUserBanner; diff --git a/src/discussions/discussions-home/BannedUserBanner.scss b/src/discussions/discussions-home/BannedUserBanner.scss new file mode 100644 index 000000000..2727f1648 --- /dev/null +++ b/src/discussions/discussions-home/BannedUserBanner.scss @@ -0,0 +1,19 @@ +.banned-user-banner { + --banned-user-banner-bg-color: var(--pgn-color-warning-300, #FFC107); + --banned-user-banner-text-color: var(--pgn-color-gray-900, #000000); + --banned-user-banner-border-color: var(--pgn-color-warning-500, #E0A800); + + background-color: var(--banned-user-banner-bg-color); + color: var(--banned-user-banner-text-color); + padding: 12px 24px; + font-size: 14px; + font-weight: 500; + position: relative; + z-index: 1; + border-bottom: 1px solid var(--banned-user-banner-border-color); + min-height: 48px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} diff --git a/src/discussions/discussions-home/BannedUserBanner.test.jsx b/src/discussions/discussions-home/BannedUserBanner.test.jsx new file mode 100644 index 000000000..21b59e087 --- /dev/null +++ b/src/discussions/discussions-home/BannedUserBanner.test.jsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; + +import messages from '../messages'; +import BannedUserBanner from './BannedUserBanner'; + +describe('BannedUserBanner', () => { + it('renders as an ARIA alert and announces politely', () => { + render( + + + , + ); + + const banner = screen.getByRole('alert'); + expect(banner).toBeInTheDocument(); + expect(banner).toHaveAttribute('aria-live', 'polite'); + expect(banner).toHaveTextContent(messages.bannedUserBannerMessage.defaultMessage); + }); +}); diff --git a/src/discussions/discussions-home/DiscussionsHome.jsx b/src/discussions/discussions-home/DiscussionsHome.jsx index b0031dc01..30d215a3b 100644 --- a/src/discussions/discussions-home/DiscussionsHome.jsx +++ b/src/discussions/discussions-home/DiscussionsHome.jsx @@ -18,7 +18,13 @@ import ContentUnavailable from '../content-unavailable/ContentUnavailable'; import { useCourseBlockData, useCourseDiscussionData, useIsOnTablet, useRedirectToThread, useSidebarVisible, } from '../data/hooks'; -import { selectDiscussionProvider, selectEnableInContext, selectIsUserLearner } from '../data/selectors'; +import { + selectDiscussionProvider, + selectEnableDiscussionBan, + selectEnableInContext, + selectIsUserBanned, + selectIsUserLearner, +} from '../data/selectors'; import { EmptyLearners, EmptyTopics } from '../empty-posts'; import EmptyPosts from '../empty-posts/EmptyPosts'; import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components'; @@ -37,6 +43,7 @@ const DiscussionsRestrictionBanner = lazy(() => import('./DiscussionsRestriction const DiscussionContent = lazy(() => import('./DiscussionContent')); const DiscussionSidebar = lazy(() => import('./DiscussionSidebar')); const DiscussionsConfirmEmailBanner = lazy(() => import('./DiscussionsConfirmEmailBanner')); +const BannedUserBanner = lazy(() => import('./BannedUserBanner')); const DiscussionsHome = () => { const location = useLocation(); @@ -48,6 +55,8 @@ const DiscussionsHome = () => { courseNumber, courseTitle, org, courseStatus, isEnrolled, } = useSelector(selectCourseTabs); const isUserLearner = useSelector(selectIsUserLearner); + const isUserBanned = useSelector(selectIsUserBanned); + const enableDiscussionBan = useSelector(selectEnableDiscussionBan); const pageParams = useMatch(ROUTES.COMMENTS.PAGE)?.params; const page = pageParams?.page || null; const matchPattern = ALL_ROUTES.find((route) => matchPath({ path: route }, location.pathname)); @@ -77,7 +86,8 @@ const DiscussionsHome = () => { enableInContextSidebar, category, learnerUsername, - })); + enableDiscussionBan, + }), [page, courseId, postId, topicId, enableInContextSidebar, category, learnerUsername, enableDiscussionBan]); return ( )}> @@ -88,8 +98,9 @@ const DiscussionsHome = () => {
)} -
+
{!enableInContextSidebar && } + {isUserBanned && ()} {(isEnrolled || !isUserLearner) && (
{ renderComponent(); const sectionGroups = await screen.getAllByTestId('section-group'); - coursewareTopics.forEach(async (topic, index) => { - await waitFor(async () => { - const stats = await sectionGroups[index].querySelectorAll('.icon-size:not([data-testid="subsection-group"].icon-size)'); - const subsectionGroups = await within(sectionGroups[index]).getAllByTestId('subsection-group'); + // Run assertions in parallel - each topic's assertions are independent + await Promise.all( + coursewareTopics.map((topic, index) => waitFor(() => { + const allStats = sectionGroups[index].querySelectorAll('.icon-size'); + const stats = Array.from(allStats).filter( + el => !el.closest('[data-testid="subsection-group"]'), + ); + const subsectionGroups = within(sectionGroups[index]).getAllByTestId('subsection-group'); expect(within(sectionGroups[index]).queryByText(topic.displayName)).toBeInTheDocument(); expect(stats).toHaveLength(0); expect(subsectionGroups).toHaveLength(2); - }); - }); + })), + ); }); it('The subsection should have a title name, be clickable, and have the stats', async () => { diff --git a/src/discussions/learners/AuditTrailInfo.jsx b/src/discussions/learners/AuditTrailInfo.jsx new file mode 100644 index 000000000..c11e5ba99 --- /dev/null +++ b/src/discussions/learners/AuditTrailInfo.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +/** + * AuditTrailInfo component displays ban audit information + * Shows who banned the user, when, and the scope (course-wide or org-wide) + * Only visible to staff members + */ +const AuditTrailInfo = ({ bannedByUsername, bannedAt, scope }) => { + const intl = useIntl(); + + if (!bannedByUsername || !bannedAt) { + return null; + } + + const scopeText = intl.formatMessage(messages.auditTrailBanScope, { scope }); + + // Format the date and time in the viewer's local timezone + const bannedDate = new Date(bannedAt); + const formattedDate = intl.formatDate(bannedDate, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + const formattedTime = intl.formatTime(bannedDate, { + hour: 'numeric', + minute: 'numeric', + }); + + return ( +
+
+ {intl.formatMessage(messages.auditTrailInfoTitle)} +
+
+ Banned by {bannedByUsername}, {scopeText}, on {formattedDate} at {formattedTime} +
+
+ {intl.formatMessage(messages.auditTrailStaffOnly)} +
+
+ ); +}; + +AuditTrailInfo.propTypes = { + bannedByUsername: PropTypes.string, + bannedAt: PropTypes.string, + scope: PropTypes.oneOf(['course', 'organization']), +}; + +AuditTrailInfo.defaultProps = { + bannedByUsername: null, + bannedAt: null, + scope: null, +}; + +export default AuditTrailInfo; diff --git a/src/discussions/learners/AuditTrailInfo.test.jsx b/src/discussions/learners/AuditTrailInfo.test.jsx new file mode 100644 index 000000000..d6d75f543 --- /dev/null +++ b/src/discussions/learners/AuditTrailInfo.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { BAN_SCOPES } from './data/constants'; +import AuditTrailInfo from './AuditTrailInfo'; + +const mockIntl = { + locale: 'en', +}; + +const renderWithIntl = (component) => render( + + {component} + , +); + +describe('AuditTrailInfo', () => { + it('renders correctly with course-wide ban', () => { + const props = { + bannedByUsername: 'moderator1', + bannedAt: '2025-12-10T15:30:00Z', + scope: BAN_SCOPES.COURSE, + }; + + renderWithIntl(); + + expect(screen.getByTestId('audit-trail-info')).toBeInTheDocument(); + expect(screen.getByText(/Audit trail info/i)).toBeInTheDocument(); + expect(screen.getByText(/Banned by moderator1/i)).toBeInTheDocument(); + expect(screen.getByText(/course-wide/i)).toBeInTheDocument(); + expect(screen.getByText(/Visible to staff only/i)).toBeInTheDocument(); + }); + + it('renders correctly with org-wide ban', () => { + const props = { + bannedByUsername: 'admin', + bannedAt: '2025-12-10T15:30:00Z', + scope: BAN_SCOPES.ORGANIZATION, + }; + + renderWithIntl(); + + expect(screen.getByTestId('audit-trail-info')).toBeInTheDocument(); + expect(screen.getByText(/Banned by admin/i)).toBeInTheDocument(); + expect(screen.getByText(/org-wide/i)).toBeInTheDocument(); + }); + + it('returns null when bannedByUsername is missing', () => { + const props = { + bannedByUsername: null, + bannedAt: '2025-12-10T15:30:00Z', + scope: BAN_SCOPES.COURSE, + }; + + const { container } = renderWithIntl(); + + expect(container.firstChild).toBeNull(); + }); + + it('returns null when bannedAt is missing', () => { + const props = { + bannedByUsername: 'moderator1', + bannedAt: null, + scope: BAN_SCOPES.COURSE, + }; + + const { container } = renderWithIntl(); + + expect(container.firstChild).toBeNull(); + }); + + it('formats date and time in viewer timezone', () => { + const props = { + bannedByUsername: 'moderator1', + bannedAt: '2025-12-10T15:30:00Z', + scope: BAN_SCOPES.COURSE, + }; + + renderWithIntl(); + + // The component should display the date and time + const auditTrailInfo = screen.getByTestId('audit-trail-info'); + expect(auditTrailInfo).toBeInTheDocument(); + expect(auditTrailInfo.textContent).toMatch(/on.*at/); + }); +}); diff --git a/src/discussions/learners/LearnerActionsDropdown.jsx b/src/discussions/learners/LearnerActionsDropdown.jsx index 65ee08fe6..5ed076b8a 100644 --- a/src/discussions/learners/LearnerActionsDropdown.jsx +++ b/src/discussions/learners/LearnerActionsDropdown.jsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useRef, useState, } from 'react'; -import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import { @@ -11,27 +10,47 @@ import { ChevronRight, MoreHoriz } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useLearnerActionsMenu } from './utils'; +import { useLearnerActions } from './utils'; const LearnerActionsDropdown = ({ actionHandlers, dropDownIconSize, userHasBulkDeletePrivileges, + learnerBanInfo, }) => { const buttonRef = useRef(); const intl = useIntl(); const [isOpen, open, close] = useToggle(false); const [target, setTarget] = useState(null); - const [activeSubmenu, setActiveSubmenu] = useState(null); - const menuItems = useLearnerActionsMenu(intl, userHasBulkDeletePrivileges); + const [submenuOpen, setSubmenuOpen] = useState(null); + const [submenuTarget, setSubmenuTarget] = useState(null); + const submenuRef = useRef({}); + const actions = useLearnerActions(userHasBulkDeletePrivileges, learnerBanInfo); + + // Cleanup refs when actions change to prevent memory leaks + useEffect(() => { + const currentActionIds = new Set(actions.filter(a => a.submenu).map(a => a.id)); + const storedRefs = Object.keys(submenuRef.current); + + // Remove refs for actions that no longer exist + storedRefs.forEach(refId => { + if (!currentActionIds.has(refId)) { + delete submenuRef.current[refId]; + } + }); + + // Cleanup function to clear all refs on unmount + return () => { + submenuRef.current = {}; + }; + }, [actions]); const handleActions = useCallback((action) => { const actionFunction = actionHandlers[action]; if (actionFunction) { actionFunction(); - close(); } - }, [actionHandlers, close]); + }, [actionHandlers]); const onClickButton = useCallback((event) => { event.preventDefault(); @@ -42,14 +61,19 @@ const LearnerActionsDropdown = ({ const onCloseModal = useCallback(() => { close(); setTarget(null); - setActiveSubmenu(null); + setSubmenuOpen(null); + setSubmenuTarget(null); }, [close]); - // Cleanup portal on unmount to prevent memory leaks and orphaned DOM nodes - useEffect(() => () => { - setTarget(null); - setActiveSubmenu(null); - }, []); + const handleSubmenuToggle = useCallback((actionId, ref) => { + if (submenuOpen === actionId) { + setSubmenuOpen(null); + setSubmenuTarget(null); + } else { + setSubmenuOpen(actionId); + setSubmenuTarget(ref); + } + }, [submenuOpen]); return ( <> @@ -63,69 +87,107 @@ const LearnerActionsDropdown = ({ iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''} />
- {isOpen && ReactDOM.createPortal( - +
-
- {menuItems.map(item => ( -
setActiveSubmenu(item.id)} - onMouseLeave={() => setActiveSubmenu(null)} - style={{ zIndex: 2 }} - > + {actions.map(action => ( + + {action.submenu ? ( + <> + handleSubmenuToggle(action.id, submenuRef.current[action.id])} + className="d-flex justify-content-between align-items-center actions-dropdown-item" + data-testid={action.id} + ref={el => { submenuRef.current[action.id] = el; }} + > +
+ + + {action.label.defaultMessage} + +
+ +
+ {submenuOpen === action.id && ( + setSubmenuOpen(null)} + positionRef={submenuTarget} + isOpen={submenuOpen === action.id} + placement="right-start" + container={document.body} + > +
+ {action.submenu.map(subAction => ( + { + if (!subAction.disabled) { + close(); + setSubmenuOpen(null); + handleActions(subAction.action); + } + }} + className="d-flex justify-content-start actions-dropdown-item" + data-testid={subAction.id} + > + + {subAction.label.defaultMessage} + + + ))} +
+
+ )} + + ) : ( { + close(); + handleActions(action.action); + }} + className="d-flex justify-content-start actions-dropdown-item" + data-testid={action.id} > -
- - {item.label} - -
+ + {action.label.defaultMessage} +
- {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, - )} + )} + + ))} +
+
); @@ -135,11 +197,16 @@ LearnerActionsDropdown.propTypes = { actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired, dropDownIconSize: PropTypes.bool, userHasBulkDeletePrivileges: PropTypes.bool, + learnerBanInfo: PropTypes.shape({ + isAuthorBanned: PropTypes.bool, + authorBanScope: PropTypes.string, + }), }; LearnerActionsDropdown.defaultProps = { dropDownIconSize: false, userHasBulkDeletePrivileges: false, + learnerBanInfo: {}, }; export default LearnerActionsDropdown; diff --git a/src/discussions/learners/LearnerActionsDropdown.test.jsx b/src/discussions/learners/LearnerActionsDropdown.test.jsx index 466276fb7..fdaa26204 100644 --- a/src/discussions/learners/LearnerActionsDropdown.test.jsx +++ b/src/discussions/learners/LearnerActionsDropdown.test.jsx @@ -90,10 +90,10 @@ describe('LearnerActionsDropdown', () => { fireEvent.click(openButton); }); - // Hover over the delete-activity menu item to show submenu + // Click the delete-activity menu item to show submenu const deleteActivityItem = await screen.findByTestId('delete-activity'); await act(async () => { - fireEvent.mouseEnter(deleteActivityItem); + fireEvent.click(deleteActivityItem); }); await waitFor(() => { @@ -122,10 +122,10 @@ describe('LearnerActionsDropdown', () => { await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument()); - // Hover over the delete-activity menu item to show submenu + // Click the delete-activity menu item to show submenu const deleteActivityItem = await screen.findByTestId('delete-activity'); await act(async () => { - fireEvent.mouseEnter(deleteActivityItem); + fireEvent.click(deleteActivityItem); }); const deleteCourseItem = await screen.findByTestId('delete-course-posts'); @@ -156,10 +156,10 @@ describe('LearnerActionsDropdown', () => { await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument()); - // Hover over the delete-activity menu item to show submenu + // Click the delete-activity menu item to show submenu const deleteActivityItem = await screen.findByTestId('delete-activity'); await act(async () => { - fireEvent.mouseEnter(deleteActivityItem); + fireEvent.click(deleteActivityItem); }); const deleteOrgItem = await screen.findByTestId('delete-org-posts'); diff --git a/src/discussions/learners/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx index 98c0f9d46..8077243a6 100644 --- a/src/discussions/learners/LearnerPostsView.jsx +++ b/src/discussions/learners/LearnerPostsView.jsx @@ -3,13 +3,14 @@ import React, { } from 'react'; import { - Button, Icon, IconButton, Spinner, useToggle, + Button, Icon, IconButton, Spinner, } from '@openedx/paragon'; -import { ArrowBack } from '@openedx/paragon/icons'; +import { ArrowBack, Block, Institution } from '@openedx/paragon/icons'; import capitalize from 'lodash/capitalize'; import { useDispatch, useSelector } from 'react-redux'; import { useLocation, useNavigate } from 'react-router-dom'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -25,6 +26,7 @@ import { selectUserHasModerationPrivileges, selectUserIsStaff, } from '../data/selectors'; +import discussionMessages from '../messages'; import usePostList from '../posts/data/hooks'; import { selectAllThreadsIds, @@ -35,37 +37,84 @@ import { clearPostsPages } from '../posts/data/slices'; import { fetchThread } from '../posts/data/thunks'; import NoResults from '../posts/NoResults'; import { PostLink } from '../posts/post'; +import DeleteWithBanConfirmation from '../posts/post/DeleteWithBanConfirmation'; import { discussionsPath } from '../utils'; -import { BulkDeleteType } from './data/constants'; -import { learnersLoadingStatus, selectBulkDeleteStats } from './data/selectors'; -import { deleteUserPosts, fetchUserPosts, undeleteUserPosts } from './data/thunks'; +import { BAN_SCOPES, BulkDeleteType } from './data/constants'; +import { learnersLoadingStatus, selectBannedUsers, selectBulkDeleteStats } from './data/selectors'; +import { + banUser, + deleteUserActivity, + deleteUserPosts, + fetchBannedUsers, + fetchUserPosts, + unbanUser, + undeleteUserPosts, +} from './data/thunks'; import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar'; import LearnerActionsDropdown from './LearnerActionsDropdown'; import messages from './messages'; +// Modal type constants +const MODAL_TYPES = { + DELETE: 'delete', + RESTORE: 'restore', + BAN: 'ban', + UNBAN: 'unban', + DELETE_USER: 'deleteUser', +}; + const LearnerPostsView = () => { const intl = useIntl(); const location = useLocation(); const navigate = useNavigate(); const dispatch = useDispatch(); + const authenticatedUser = getAuthenticatedUser(); const [bulkDeleting, dispatchDelete] = useDispatchWithState(); const postsIds = useSelector(selectAllThreadsIds); const loadingStatus = useSelector(threadsLoadingStatus()); const learnerLoadingStatus = useSelector(learnersLoadingStatus()); const postFilter = useSelector(state => state.learners.postFilter); - const { courseId, learnerUsername: username, postId } = useContext(DiscussionContext); + const { + courseId, learnerUsername: username, postId, enableDiscussionBan, + } = useContext(DiscussionContext); const nextPage = useSelector(selectThreadNextPage()); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsStaff = useSelector(selectUserIsStaff); const userHasBulkDeletePrivileges = useSelector(selectUserHasBulkDeletePrivileges); const bulkDeleteStats = useSelector(selectBulkDeleteStats()); + const bannedUsers = useSelector(selectBannedUsers); const sortedPostsIds = usePostList(postsIds); - const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); - const [isRestoring, showRestoreConfirmation, hideRestoreConfirmation] = useToggle(false); + + // Consolidated modal state management + const [activeModal, setActiveModal] = useState({ type: null, scope: null }); const [isDeletingCourseOrOrg, setIsDeletingCourseOrOrg] = useState(BulkDeleteType.COURSE); const [isRestoringCourseOrOrg, setIsRestoringCourseOrOrg] = useState(BulkDeleteType.COURSE); const [isLoadingRestoreData, setIsLoadingRestoreData] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + + // Unified modal control functions + const showModal = useCallback((type, scope = null) => { + setActiveModal({ type, scope }); + }, []); + + const hideModal = useCallback(() => { + setActiveModal({ type: null, scope: null }); + }, []); + + // Computed modal state + const isDeleting = activeModal.type === MODAL_TYPES.DELETE; + const isRestoring = activeModal.type === MODAL_TYPES.RESTORE; + const isBanningCourse = activeModal.type === MODAL_TYPES.BAN && activeModal.scope === BAN_SCOPES.COURSE; + const isBanningOrg = activeModal.type === MODAL_TYPES.BAN && activeModal.scope === BAN_SCOPES.ORGANIZATION; + const isUnbanningCourse = activeModal.type === MODAL_TYPES.UNBAN && activeModal.scope === BAN_SCOPES.COURSE; + const isUnbanningOrg = activeModal.type === MODAL_TYPES.UNBAN && activeModal.scope === BAN_SCOPES.ORGANIZATION; + const isDeletingUserCourse = ( + activeModal.type === MODAL_TYPES.DELETE_USER && activeModal.scope === BAN_SCOPES.COURSE + ); + const isDeletingUserOrg = ( + activeModal.type === MODAL_TYPES.DELETE_USER && activeModal.scope === BAN_SCOPES.ORGANIZATION + ); const loadMorePosts = useCallback((pageNum = undefined) => { const params = { @@ -81,15 +130,15 @@ const LearnerPostsView = () => { const handleShowDeleteConfirmation = useCallback(async (courseOrOrg) => { setIsDeletingCourseOrOrg(courseOrOrg); - showDeleteConfirmation(); + showModal(MODAL_TYPES.DELETE); await dispatch(deleteUserPosts(courseId, username, courseOrOrg, false)); - }, [courseId, username, showDeleteConfirmation, dispatch]); + }, [courseId, username, showModal, dispatch]); const handleDeletePosts = useCallback(async (courseOrOrg) => { await dispatchDelete(deleteUserPosts(courseId, username, courseOrOrg, true)); dispatch(clearPostsPages()); loadMorePosts(); - hideDeleteConfirmation(); + hideModal(); // If viewing a post, refresh it to show deleted state if (postId) { await dispatch(fetchThread(postId, courseId)); @@ -97,33 +146,102 @@ const LearnerPostsView = () => { // Navigate back to learners list after deletion navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) }); } - }, [courseId, username, hideDeleteConfirmation, dispatchDelete, navigate, location, postId, dispatch, loadMorePosts]); + }, [courseId, username, hideModal, dispatchDelete, navigate, location, postId, dispatch, loadMorePosts]); const handleShowRestoreConfirmation = useCallback(async (courseOrOrg) => { setIsRestoringCourseOrOrg(courseOrOrg); setIsLoadingRestoreData(true); - showRestoreConfirmation(); + showModal(MODAL_TYPES.RESTORE); await dispatch(undeleteUserPosts(courseId, username, courseOrOrg, false)); setIsLoadingRestoreData(false); - }, [courseId, username, showRestoreConfirmation, dispatch]); + }, [courseId, username, showModal, dispatch]); const handleRestorePosts = useCallback(async (courseOrOrg) => { await dispatch(undeleteUserPosts(courseId, username, courseOrOrg, true)); - hideRestoreConfirmation(); + hideModal(); // If viewing a post, refresh it to show restored state if (postId) { await dispatch(fetchThread(postId, courseId)); } // Navigate back to learners list after restoration navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) }); - }, [courseId, username, hideRestoreConfirmation, dispatch, navigate, location, postId]); + }, [courseId, username, hideModal, dispatch, navigate, location, postId]); + + const handleBanUser = useCallback(async (scope) => { + // Defensive check - feature must be enabled + if (!enableDiscussionBan) { + hideModal(); + return; + } + // Defensive check - username must be defined + if (!username) { + hideModal(); + return; + } + setIsProcessing(true); + await dispatch(banUser(courseId, username, scope)); + hideModal(); + setIsProcessing(false); + }, [courseId, username, dispatch, hideModal, enableDiscussionBan]); + + const handleUnbanUser = useCallback(async (scope) => { + // Defensive check - feature must be enabled + if (!enableDiscussionBan) { + hideModal(); + return; + } + // Defensive check - username must be defined + if (!username) { + hideModal(); + return; + } + setIsProcessing(true); + await dispatch(unbanUser(courseId, username, scope)); + hideModal(); + setIsProcessing(false); + }, [courseId, username, dispatch, hideModal, enableDiscussionBan]); + + const handleDeleteActivity = useCallback(async (scope, shouldBan = false) => { + setIsProcessing(true); + // Only ban if flag is enabled (defensive check) + await dispatch(deleteUserActivity(courseId, username, scope, shouldBan && enableDiscussionBan)); + hideModal(); + // Refresh the posts list after deletion + loadMorePosts(1); + setIsProcessing(false); + }, [courseId, username, dispatch, enableDiscussionBan, loadMorePosts, hideModal]); const actionHandlers = useMemo(() => ({ [ContentActions.DELETE_COURSE_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.COURSE), [ContentActions.DELETE_ORG_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.ORG), [ContentActions.RESTORE_COURSE_POSTS]: () => handleShowRestoreConfirmation(BulkDeleteType.COURSE), [ContentActions.RESTORE_ORG_POSTS]: () => handleShowRestoreConfirmation(BulkDeleteType.ORG), - }), [handleShowDeleteConfirmation, handleShowRestoreConfirmation]); + [ContentActions.BAN_COURSE]: () => showModal(MODAL_TYPES.BAN, BAN_SCOPES.COURSE), + [ContentActions.BAN_ORG]: () => showModal(MODAL_TYPES.BAN, BAN_SCOPES.ORGANIZATION), + [ContentActions.UNBAN_COURSE]: () => showModal(MODAL_TYPES.UNBAN, BAN_SCOPES.COURSE), + [ContentActions.UNBAN_ORG]: () => showModal(MODAL_TYPES.UNBAN, BAN_SCOPES.ORGANIZATION), + [ContentActions.DELETE_USER_COURSE]: () => showModal(MODAL_TYPES.DELETE_USER, BAN_SCOPES.COURSE), + [ContentActions.DELETE_USER_ORG]: () => showModal(MODAL_TYPES.DELETE_USER, BAN_SCOPES.ORGANIZATION), + }), [handleShowDeleteConfirmation, handleShowRestoreConfirmation, showModal]); + + const learnerBanInfo = useMemo(() => { + // Find the current learner in the bannedUsers list + const bannedUser = bannedUsers.find(user => user.username === username && user.isActive); + if (bannedUser) { + return { + isAuthorBanned: true, + authorBanScope: bannedUser.scope, + bannedByUsername: bannedUser.bannedByUsername, + bannedAt: bannedUser.bannedAt, + }; + } + return { + isAuthorBanned: false, + authorBanScope: null, + bannedByUsername: null, + bannedAt: null, + }; + }, [bannedUsers, username]); const postInstances = useMemo(() => ( sortedPostsIds?.map((threadId, idx) => ( @@ -139,10 +257,51 @@ const LearnerPostsView = () => { useEffect(() => { dispatch(clearPostsPages()); loadMorePosts(); - }, [courseId, postFilter, username]); + // Fetch banned users list to show ban banner if this user is banned + if (userHasModerationPrivileges && enableDiscussionBan) { + dispatch(fetchBannedUsers(courseId)); + } + }, [courseId, postFilter, username, userHasModerationPrivileges, enableDiscussionBan]); return (
+ {learnerBanInfo.isAuthorBanned && enableDiscussionBan && ( +
+
+ + + {intl.formatMessage(messages.learnerBanBannerBanned)} + + + {intl.formatMessage(messages.auditTrailBanScope, { scope: learnerBanInfo.authorBanScope })} + + {learnerBanInfo.bannedByUsername && ( + <> + + {intl.formatMessage(messages.learnerBanBannerBy)} + + + {learnerBanInfo.bannedByUsername} + + + + {intl.formatMessage(messages.learnerBanBannerStaff)} + + + )} +
+
+ {learnerBanInfo.bannedAt && new Date(learnerBanInfo.bannedAt).toLocaleString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + })} +
+
+ )}
{
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
- {userHasBulkDeletePrivileges ? ( + {userHasBulkDeletePrivileges && username !== authenticatedUser?.username ? (
@@ -194,7 +354,7 @@ const LearnerPostsView = () => { count: bulkDeleteStats.threadCount + bulkDeleteStats.commentCount, bulkType: isDeletingCourseOrOrg, })} - onClose={hideDeleteConfirmation} + onClose={hideModal} confirmAction={() => handleDeletePosts(isDeletingCourseOrOrg)} confirmButtonText={intl.formatMessage(messages.deletePostsConfirm)} confirmButtonVariant="danger" @@ -209,12 +369,82 @@ const LearnerPostsView = () => { count: bulkDeleteStats.threadCount + bulkDeleteStats.commentCount, bulkType: isRestoringCourseOrOrg, })} - onClose={hideRestoreConfirmation} + onClose={hideModal} confirmAction={() => handleRestorePosts(isRestoringCourseOrOrg)} confirmButtonText={intl.formatMessage(messages.restorePostsConfirm)} confirmButtonVariant="primary" isDataLoading={isLoadingRestoreData} /> + handleDeleteActivity(BAN_SCOPES.COURSE, shouldBan)} + closeButtonVariant="tertiary" + confirmButtonVariant="danger" + confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} + showBanCheckbox={enableDiscussionBan} + banCheckboxLabel={intl.formatMessage(discussionMessages.banUserCheckbox)} + isConfirmButtonPending={isProcessing} + /> + handleDeleteActivity(BAN_SCOPES.ORGANIZATION, shouldBan)} + closeButtonVariant="tertiary" + confirmButtonVariant="danger" + confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} + showBanCheckbox={enableDiscussionBan} + banCheckboxLabel={intl.formatMessage(discussionMessages.banUserOrgCheckbox)} + isConfirmButtonPending={isProcessing} + /> + handleBanUser(BAN_SCOPES.COURSE)} + closeButtonVariant="tertiary" + confirmButtonVariant="danger" + confirmButtonText={intl.formatMessage(discussionMessages.banButtonText)} + isConfirmButtonPending={isProcessing} + /> + handleBanUser(BAN_SCOPES.ORGANIZATION)} + closeButtonVariant="tertiary" + confirmButtonVariant="danger" + confirmButtonText={intl.formatMessage(discussionMessages.banButtonText)} + isConfirmButtonPending={isProcessing} + /> + handleUnbanUser(BAN_SCOPES.COURSE)} + closeButtonVariant="tertiary" + confirmButtonVariant="primary" + confirmButtonText={intl.formatMessage(discussionMessages.unbanButtonText)} + isConfirmButtonPending={isProcessing} + /> + handleUnbanUser(BAN_SCOPES.ORGANIZATION)} + closeButtonVariant="tertiary" + confirmButtonVariant="primary" + confirmButtonText={intl.formatMessage(discussionMessages.unbanButtonText)} + isConfirmButtonPending={isProcessing} + />
); }; diff --git a/src/discussions/learners/LearnerPostsView.test.jsx b/src/discussions/learners/LearnerPostsView.test.jsx index c1c62a69f..30f84dd51 100644 --- a/src/discussions/learners/LearnerPostsView.test.jsx +++ b/src/discussions/learners/LearnerPostsView.test.jsx @@ -21,6 +21,7 @@ import { getCohortsApiUrl } from '../cohorts/data/api'; import fetchCourseCohorts from '../cohorts/data/thunks'; import DiscussionContext from '../common/context'; import { deletePostsApiUrl, learnerPostsApiUrl } from './data/api'; +import { BAN_SCOPES } from './data/constants'; import { fetchUserPosts } from './data/thunks'; import LearnerPostsView from './LearnerPostsView'; import { setUpPrivilages } from './test-utils'; @@ -68,7 +69,7 @@ describe('Learner Posts View', () => { initializeMockApp({ authenticatedUser: { userId: 3, - username, + username: 'staff123', // Changed from 'abc123' to allow viewing other users' actions administrator: true, roles: [], }, @@ -235,7 +236,7 @@ describe('Learner Posts View', () => { test('should display confirmation dialog when delete course posts is clicked', async () => { await setUpPrivilages(axiosMock, store, true, true); - axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', false)) + axiosMock.onPost(deletePostsApiUrl(courseId, username, BAN_SCOPES.COURSE, false)) .reply(202, { thread_count: 2, comment_count: 3 }); await renderComponent(); @@ -244,10 +245,10 @@ describe('Learner Posts View', () => { fireEvent.click(actionsButton); }); - // Hover over the delete-activity menu item to show submenu + // Click the delete-activity menu item to show submenu const deleteActivityItem = await screen.findByTestId('delete-activity'); await act(async () => { - fireEvent.mouseEnter(deleteActivityItem); + fireEvent.click(deleteActivityItem); }); const deleteCourseItem = await screen.findByTestId('delete-course-posts'); @@ -266,9 +267,9 @@ describe('Learner Posts View', () => { test('should complete delete course posts flow and redirect', async () => { await setUpPrivilages(axiosMock, store, true, true); - axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', false)) + axiosMock.onPost(deletePostsApiUrl(courseId, username, BAN_SCOPES.COURSE, false)) .reply(202, { thread_count: 2, comment_count: 3 }); - axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', true)) + axiosMock.onPost(deletePostsApiUrl(courseId, username, BAN_SCOPES.COURSE, true)) .reply(202, { thread_count: 0, comment_count: 0 }); await renderComponent(); @@ -277,10 +278,10 @@ describe('Learner Posts View', () => { fireEvent.click(actionsButton); }); - // Hover over the delete-activity menu item to show submenu + // Click the delete-activity menu item to show submenu const deleteActivityItem = await screen.findByTestId('delete-activity'); await act(async () => { - fireEvent.mouseEnter(deleteActivityItem); + fireEvent.click(deleteActivityItem); }); const deleteCourseItem = await screen.findByTestId('delete-course-posts'); @@ -314,10 +315,10 @@ describe('Learner Posts View', () => { fireEvent.click(actionsButton); }); - // Hover over the delete-activity menu item to show submenu + // Click the delete-activity menu item to show submenu const deleteActivityItem = await screen.findByTestId('delete-activity'); await act(async () => { - fireEvent.mouseEnter(deleteActivityItem); + fireEvent.click(deleteActivityItem); }); const deleteCourseItem = await screen.findByTestId('delete-course-posts'); @@ -350,10 +351,10 @@ describe('Learner Posts View', () => { fireEvent.click(actionsButton); }); - // Hover over the delete-activity menu item to show submenu + // Click the delete-activity menu item to show submenu const deleteActivityItem = await screen.findByTestId('delete-activity'); await act(async () => { - fireEvent.mouseEnter(deleteActivityItem); + fireEvent.click(deleteActivityItem); }); const deleteOrgItem = await screen.findByTestId('delete-org-posts'); diff --git a/src/discussions/learners/LearnersView.jsx b/src/discussions/learners/LearnersView.jsx index f80e52216..ba7ccbf89 100644 --- a/src/discussions/learners/LearnersView.jsx +++ b/src/discussions/learners/LearnersView.jsx @@ -1,4 +1,6 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { + useCallback, useContext, useEffect, useMemo, +} from 'react'; import { Button, Spinner } from '@openedx/paragon'; import { useDispatch, useSelector } from 'react-redux'; @@ -8,30 +10,36 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import SearchInfo from '../../components/SearchInfo'; import { RequestStatus } from '../../data/constants'; -import { selectConfigLoadingStatus } from '../data/selectors'; +import DiscussionContext from '../common/context'; +import { selectUserHasModerationPrivileges } from '../data/selectors'; import NoResults from '../posts/NoResults'; import { learnersLoadingStatus, + selectAllBannedUsers, selectAllLearners, selectLearnerNextPage, selectLearnerSorting, selectUsernameSearch, } from './data/selectors'; import { setUsernameSearch } from './data/slices'; -import { fetchLearners } from './data/thunks'; +import { fetchBannedUsers, fetchLearners } from './data/thunks'; +import AllOtherLearnersSection from './learner/AllOtherLearnersSection'; +import BannedUsersSection from './learner/BannedUsersSection'; import { LearnerCard, LearnerFilterBar } from './learner'; import messages from './messages'; const LearnersView = () => { const intl = useIntl(); const { courseId } = useParams(); + const { enableDiscussionBan } = useContext(DiscussionContext); const dispatch = useDispatch(); const orderBy = useSelector(selectLearnerSorting()); const nextPage = useSelector(selectLearnerNextPage()); const loadingStatus = useSelector(learnersLoadingStatus()); const usernameSearch = useSelector(selectUsernameSearch()); - const courseConfigLoadingStatus = useSelector(selectConfigLoadingStatus); const learners = useSelector(selectAllLearners); + const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + const allBannedUsers = useSelector(selectAllBannedUsers); useEffect(() => { if (usernameSearch) { @@ -39,7 +47,11 @@ const LearnersView = () => { } else { dispatch(fetchLearners(courseId, { orderBy })); } - }, [courseId, orderBy, usernameSearch]); + // Fetch banned users if user has moderation privileges and ban feature is enabled + if (userHasModerationPrivileges && enableDiscussionBan && !usernameSearch) { + dispatch(fetchBannedUsers(courseId)); + } + }, [courseId, orderBy, usernameSearch, userHasModerationPrivileges, enableDiscussionBan]); const loadPage = useCallback(async () => { if (nextPage) { @@ -56,13 +68,31 @@ const LearnersView = () => { }, []); const renderLearnersList = useMemo(() => { - if (courseConfigLoadingStatus === RequestStatus.SUCCESSFUL) { + if (loadingStatus === RequestStatus.SUCCESSFUL) { return learners.map((learner) => ( )); } return null; - }, [courseConfigLoadingStatus, learners]); + }, [loadingStatus, learners]); + + const renderLoadingAndPagination = useMemo(() => { + if (loadingStatus === RequestStatus.IN_PROGRESS) { + return ( +
+ +
+ ); + } + if (nextPage && loadingStatus === RequestStatus.SUCCESSFUL) { + return ( + + ); + } + return null; + }, [loadingStatus, nextPage, loadPage, intl]); return (
@@ -77,17 +107,37 @@ const LearnersView = () => { /> )}
- {renderLearnersList} - {loadingStatus === RequestStatus.IN_PROGRESS ? ( -
- -
- ) : ( - nextPage && loadingStatus === RequestStatus.SUCCESSFUL && ( - + {/* Banned users section - only shown when not searching */} + {!usernameSearch && userHasModerationPrivileges && enableDiscussionBan && ( + <> + + {/* Placeholder for Muted course-wide section */} + {/* */} + + )} + {/* All other learners section - collapsible */} + {!usernameSearch && userHasModerationPrivileges ? ( + (learners.length > 0 || loadingStatus === RequestStatus.IN_PROGRESS) && ( + + <> + {renderLearnersList} + {renderLoadingAndPagination} + + ) + ) : ( + <> + {renderLearnersList} + {renderLoadingAndPagination} + )} { usernameSearch !== '' && learners.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && }
diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js index 215868fcd..e6ae5121d 100644 --- a/src/discussions/learners/data/api.js +++ b/src/discussions/learners/data/api.js @@ -14,6 +14,7 @@ export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/ac 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}`; +export const bannedUsersApiUrl = (courseId) => `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/banned-users/${courseId}`; /** * Fetches all the learners in the given course. @@ -89,6 +90,16 @@ export async function getUserPosts(courseId, { return data; } +/** + * Get banned users for a course + * @param {string} courseId Course ID of the course + * @returns API Response object with array of banned users + */ +export async function getBannedUsers(courseId) { + const { data } = await getAuthenticatedHttpClient().get(bannedUsersApiUrl(courseId)); + return data; +} + /** * Deletes posts by a specific user in a course or organization * @param {string} courseId Course ID of the course diff --git a/src/discussions/learners/data/constants.js b/src/discussions/learners/data/constants.js index c957e2569..43d8d979d 100644 --- a/src/discussions/learners/data/constants.js +++ b/src/discussions/learners/data/constants.js @@ -2,3 +2,8 @@ export const BulkDeleteType = { COURSE: 'course', ORG: 'org', }; + +export const BAN_SCOPES = { + COURSE: 'course', + ORGANIZATION: 'organization', +}; diff --git a/src/discussions/learners/data/selectors.js b/src/discussions/learners/data/selectors.js index 107634456..e1c34fec4 100644 --- a/src/discussions/learners/data/selectors.js +++ b/src/discussions/learners/data/selectors.js @@ -1,5 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; +import { RequestStatus } from '../../../data/constants'; + export const selectAllLearners = createSelector( state => state.learners.pages, pages => pages.flat(), @@ -18,3 +20,17 @@ export const selectLearnerAvatar = author => state => ( ); export const selectBulkDeleteStats = () => state => state.learners.bulkDeleteStats; + +export const selectBannedUsers = state => state.learners.bannedUsers?.list || []; + +export const selectBannedUsersStatus = state => state.learners.bannedUsers?.status || RequestStatus.IDLE; + +export const selectAllBannedUsers = createSelector( + selectBannedUsers, + (bannedUsers) => { + if (!Array.isArray(bannedUsers)) { + return []; + } + return bannedUsers.filter(user => user.isActive); + }, +); diff --git a/src/discussions/learners/data/slices.js b/src/discussions/learners/data/slices.js index 14b50247c..31ddce7bc 100644 --- a/src/discussions/learners/data/slices.js +++ b/src/discussions/learners/data/slices.js @@ -30,6 +30,10 @@ const learnersSlice = createSlice({ commentCount: 0, threadCount: 0, }, + bannedUsers: { + status: RequestStatus.IDLE, + list: [], + }, }, reducers: { fetchLearnersSuccess: (state, { payload }) => ( @@ -133,6 +137,85 @@ const learnersSlice = createSlice({ status: RequestStatus.FAILED, } ), + fetchBannedUsersRequest: (state) => ( + { + ...state, + bannedUsers: { + ...state.bannedUsers, + status: RequestStatus.IN_PROGRESS, + }, + } + ), + fetchBannedUsersSuccess: (state, { payload }) => ( + { + ...state, + bannedUsers: { + status: RequestStatus.SUCCESSFUL, + list: payload, + }, + } + ), + fetchBannedUsersFailed: (state) => ( + { + ...state, + bannedUsers: { + ...state.bannedUsers, + status: RequestStatus.FAILED, + }, + } + ), + banUserRequest: (state) => ({ + ...state, + status: RequestStatus.IN_PROGRESS, + }), + banUserSuccess: (state) => ({ + ...state, + status: RequestStatus.SUCCESSFUL, + }), + banUserFailed: (state) => ({ + ...state, + status: RequestStatus.FAILED, + }), + unbanUserRequest: (state) => ({ + ...state, + status: RequestStatus.IN_PROGRESS, + }), + unbanUserSuccess: (state) => ({ + ...state, + status: RequestStatus.SUCCESSFUL, + }), + unbanUserFailed: (state) => ({ + ...state, + status: RequestStatus.FAILED, + }), + deleteUserActivityRequest: (state) => ({ + ...state, + status: RequestStatus.IN_PROGRESS, + }), + deleteUserActivitySuccess: (state, { payload }) => ({ + ...state, + status: RequestStatus.SUCCESSFUL, + bulkDeleteStats: payload, + pages: [], // Clear pages to force refetch with updated stats + }), + deleteUserActivityFailed: (state) => ({ + ...state, + status: RequestStatus.FAILED, + }), + undeleteUserActivityRequest: (state) => ({ + ...state, + status: RequestStatus.IN_PROGRESS, + }), + undeleteUserActivitySuccess: (state, { payload }) => ({ + ...state, + status: RequestStatus.SUCCESSFUL, + bulkDeleteStats: payload, + pages: [], // Clear pages to force refetch with updated stats + }), + undeleteUserActivityFailed: (state) => ({ + ...state, + status: RequestStatus.FAILED, + }), }, }); @@ -150,6 +233,21 @@ export const { undeleteUserPostsRequest, undeleteUserPostsSuccess, undeleteUserPostsFailed, + fetchBannedUsersRequest, + fetchBannedUsersSuccess, + fetchBannedUsersFailed, + banUserRequest, + banUserSuccess, + banUserFailed, + unbanUserRequest, + unbanUserSuccess, + unbanUserFailed, + deleteUserActivityRequest, + deleteUserActivitySuccess, + deleteUserActivityFailed, + undeleteUserActivityRequest, + undeleteUserActivitySuccess, + undeleteUserActivityFailed, } = learnersSlice.actions; export const learnersReducer = learnersSlice.reducer; diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index bc935c49a..bf085be11 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -1,6 +1,12 @@ import { camelCaseObject, snakeCaseObject } from '@edx/frontend-platform'; import { logError } from '@edx/frontend-platform/logging'; +import { + banUser as banUserApi, + bulkDeleteUserPosts as bulkDeleteUserActivity, + bulkUndeleteUserPosts as bulkUndeleteUserActivity, + unbanUser as unbanUserApi, +} from '../../../data/api/moderation'; import { PostsStatusFilter, ThreadType, } from '../../../data/constants'; @@ -14,17 +20,33 @@ import { normaliseThreads } from '../../posts/data/thunks'; import { getHttpErrorStatus } from '../../utils'; import { deleteUserPostsApi, + getBannedUsers, getLearners, getUserProfiles, } from './api'; import { + banUserFailed, + banUserRequest, + banUserSuccess, + deleteUserActivityFailed, + deleteUserActivityRequest, + deleteUserActivitySuccess, deleteUserPostsFailed, deleteUserPostsRequest, deleteUserPostsSuccess, + fetchBannedUsersFailed, + fetchBannedUsersRequest, + fetchBannedUsersSuccess, fetchLearnersDenied, fetchLearnersFailed, fetchLearnersRequest, fetchLearnersSuccess, + unbanUserFailed, + unbanUserRequest, + unbanUserSuccess, + undeleteUserActivityFailed, + undeleteUserActivityRequest, + undeleteUserActivitySuccess, undeleteUserPostsFailed, undeleteUserPostsRequest, undeleteUserPostsSuccess, @@ -182,3 +204,122 @@ export function undeleteUserPosts(courseId, username, courseOrOrg, execute) { } }; } + +/** + * Fetches the list of banned users for the course + * @param {string} courseId The course ID for the course to fetch data for. + * @returns {(function(*): Promise)|*} + */ +export function fetchBannedUsers(courseId) { + return async (dispatch) => { + try { + dispatch(fetchBannedUsersRequest({ courseId })); + const response = await getBannedUsers(courseId); + // API returns { count, results } structure + // camelCaseObject will convert the whole response including the array items + const camelCasedResponse = camelCaseObject(response); + const bannedUsers = (camelCasedResponse.results || []).map(ban => ({ + ...ban, + // Flatten the nested user object to top level for component compatibility + username: ban.user?.username, + email: ban.user?.email, + userId: ban.user?.id, + // Flatten the bannedBy object for component compatibility + bannedByUsername: ban.bannedBy?.username, + })); + dispatch(fetchBannedUsersSuccess(bannedUsers)); + } catch (error) { + dispatch(fetchBannedUsersFailed()); + logError(error); + } + }; +} + +/** + * Bans a user from discussions in a course or organization + * @param {string} courseId Course ID + * @param {string} username Username of the user to ban + * @param {string} scope 'course' or 'organization' + * @returns {(function(*): Promise)|*} + */ +export function banUser(courseId, username, scope) { + return async (dispatch) => { + try { + dispatch(banUserRequest()); + await banUserApi(courseId, username, scope); + dispatch(banUserSuccess()); + // Refresh the banned users list + dispatch(fetchBannedUsers(courseId)); + } catch (error) { + dispatch(banUserFailed()); + logError(error); + } + }; +} + +/** + * Unbans a user from discussions + * @param {string} courseId Course ID + * @param {string} username Username of the user to unban + * @param {string} scope 'course' or 'organization' + * @returns {(function(*): Promise)|*} + */ +export function unbanUser(courseId, username, scope) { + return async (dispatch) => { + try { + dispatch(unbanUserRequest()); + await unbanUserApi(courseId, username, scope); + dispatch(unbanUserSuccess()); + // Refresh the banned users list + dispatch(fetchBannedUsers(courseId)); + } catch (error) { + dispatch(unbanUserFailed()); + logError(error); + } + }; +} + +/** + * Deletes all discussion activity for a user in a course or organization + * @param {string} courseId Course ID + * @param {string} username Username of the user + * @param {string} scope 'course' or 'organization' + * @param {boolean} shouldBanUser Whether to also ban the user + * @returns {(function(*): Promise)|*} + */ +export function deleteUserActivity(courseId, username, scope, shouldBanUser = false) { + return async (dispatch) => { + try { + dispatch(deleteUserActivityRequest()); + const response = await bulkDeleteUserActivity(courseId, username, scope, shouldBanUser); + dispatch(deleteUserActivitySuccess(camelCaseObject(response))); + // Refresh banned users list if user was banned + if (shouldBanUser) { + dispatch(fetchBannedUsers(courseId)); + } + } catch (error) { + dispatch(deleteUserActivityFailed()); + logError(error); + } + }; +} + +/** + * Undeletes all discussion activity for a user in a course or organization + * @param {string} courseId Course ID + * @param {string} username Username of the user + * @param {string} scope 'course' or 'organization' + * @returns {(function(*): Promise)|*} + */ +export function undeleteUserActivity(courseId, username, scope) { + return async (dispatch) => { + try { + dispatch(undeleteUserActivityRequest()); + const response = await bulkUndeleteUserActivity(courseId, username, scope); + dispatch(undeleteUserActivitySuccess(camelCaseObject(response))); + } catch (error) { + dispatch(undeleteUserActivityFailed()); + logError(error); + } + }; +} diff --git a/src/discussions/learners/learner/AllOtherLearnersSection.jsx b/src/discussions/learners/learner/AllOtherLearnersSection.jsx new file mode 100644 index 000000000..1cc1be5d3 --- /dev/null +++ b/src/discussions/learners/learner/AllOtherLearnersSection.jsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Collapsible, Icon } from '@openedx/paragon'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; + +const AllOtherLearnersSection = ({ title, children }) => { + const [isOpen, setIsOpen] = useState(true); + + return ( + + + {title} + + + + + + + + + {children} + + + ); +}; + +AllOtherLearnersSection.propTypes = { + title: PropTypes.string.isRequired, + children: PropTypes.node, +}; + +AllOtherLearnersSection.defaultProps = { + children: null, +}; + +export default AllOtherLearnersSection; diff --git a/src/discussions/learners/learner/BannedUserCard.jsx b/src/discussions/learners/learner/BannedUserCard.jsx new file mode 100644 index 000000000..7e88f1635 --- /dev/null +++ b/src/discussions/learners/learner/BannedUserCard.jsx @@ -0,0 +1,85 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import capitalize from 'lodash/capitalize'; +import { Link } from 'react-router-dom'; + +import { Routes } from '../../../data/constants'; +import DiscussionContext from '../../common/context'; +import { discussionsPath } from '../../utils'; +import LearnerAvatar from './LearnerAvatar'; +import LearnerFooter from './LearnerFooter'; + +const BannedUserCard = ({ user }) => { + const { + username, threads = 0, inactiveFlags = 0, activeFlags = 0, responses = 0, replies = 0, + } = user; + const { enableInContextSidebar, learnerUsername, courseId } = useContext(DiscussionContext); + + const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, { + 0: enableInContextSidebar ? 'in-context' : undefined, + learnerUsername: username, + courseId, + })(); + + return ( + +
+ +
+
+
+
+ {capitalize(username)} +
+
+ {threads !== null && ( + + )} +
+
+
+ + ); +}; + +BannedUserCard.propTypes = { + user: PropTypes.shape({ + id: PropTypes.number, + username: PropTypes.string.isRequired, + email: PropTypes.string, + userId: PropTypes.number, + courseId: PropTypes.string, + organization: PropTypes.string, + scope: PropTypes.string, + reason: PropTypes.string, + bannedAt: PropTypes.string, + bannedByUsername: PropTypes.string, + isActive: PropTypes.bool, + threads: PropTypes.number, + responses: PropTypes.number, + replies: PropTypes.number, + inactiveFlags: PropTypes.number, + activeFlags: PropTypes.number, + }).isRequired, +}; + +export default BannedUserCard; diff --git a/src/discussions/learners/learner/BannedUsersSection.jsx b/src/discussions/learners/learner/BannedUsersSection.jsx new file mode 100644 index 000000000..06e3e05de --- /dev/null +++ b/src/discussions/learners/learner/BannedUsersSection.jsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { + Collapsible, Icon, OverlayTrigger, Tooltip, +} from '@openedx/paragon'; +import { ExpandLess, ExpandMore, InfoOutline } from '@openedx/paragon/icons'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import BannedUserCard from './BannedUserCard'; + +const BannedUsersSection = ({ title, users, infoIconId }) => { + const intl = useIntl(); + const [isOpen, setIsOpen] = useState(true); + + if (!users || users.length === 0) { + return null; + } + + return ( + + +
+ {title} + {infoIconId && ( + + {intl.formatMessage(messages.bannedUsersTooltip)} + + )} + > + + + )} +
+ + + + + + +
+ +
+ {users.map((user) => ( + + ))} +
+
+
+ ); +}; + +BannedUsersSection.propTypes = { + title: PropTypes.string.isRequired, + users: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number, + username: PropTypes.string.isRequired, + email: PropTypes.string, + scope: PropTypes.string, + bannedAt: PropTypes.string, + bannedByUsername: PropTypes.string, + })).isRequired, + infoIconId: PropTypes.string, +}; + +BannedUsersSection.defaultProps = { + infoIconId: null, +}; + +export default BannedUsersSection; diff --git a/src/discussions/learners/learner/index.js b/src/discussions/learners/learner/index.js index b66c800ed..cf00da7cb 100644 --- a/src/discussions/learners/learner/index.js +++ b/src/discussions/learners/learner/index.js @@ -1,3 +1,5 @@ +export { default as BannedUserCard } from './BannedUserCard'; +export { default as BannedUsersSection } from './BannedUsersSection'; export { default as LearnerCard } from './LearnerCard'; export { default as LearnerFilterBar } from './LearnerFilterBar'; export { default as LearnerFooter } from './LearnerFooter'; diff --git a/src/discussions/learners/messages.js b/src/discussions/learners/messages.js index f2e68aeeb..4bd92fab5 100644 --- a/src/discussions/learners/messages.js +++ b/src/discussions/learners/messages.js @@ -156,6 +156,147 @@ const messages = defineMessages({ defaultMessage: 'Restoring', description: 'Pending state of confirm button text for restore posts', }, + bannedUsers: { + id: 'discussions.learner.bannedUsers', + defaultMessage: 'Banned', + description: 'Section title for banned users', + }, + bannedUsersTooltip: { + id: 'discussions.learner.bannedUsersTooltip', + defaultMessage: 'Discussion activity is disabled for these learners', + description: 'Tooltip text for banned users info icon', + }, + bannedUsersOrgWide: { + id: 'discussions.learner.bannedUsersOrgWide', + defaultMessage: 'Banned (Org-wide)', + description: 'Section title for organization-wide banned users', + }, + mutedCourseWide: { + id: 'discussions.learner.mutedCourseWide', + defaultMessage: 'Muted course-wide', + description: 'Section title for course-wide muted users', + }, + mutedForMe: { + id: 'discussions.learner.mutedForMe', + defaultMessage: 'Muted (for me)', + description: 'Section title for personally muted users', + }, + allOtherLearners: { + id: 'discussions.learner.allOtherLearners', + defaultMessage: 'All other learners', + description: 'Section title for all other learners not in special categories', + }, + // Action menu items + muteUser: { + id: 'discussions.learner.actions.mute', + defaultMessage: 'Mute', + description: 'Action to mute a user', + }, + unmuteUser: { + id: 'discussions.learner.actions.unmute', + defaultMessage: 'Unmute', + description: 'Action to unmute a user', + }, + banUser: { + id: 'discussions.learner.actions.ban', + defaultMessage: 'Ban', + description: 'Action to ban a user', + }, + unbanUser: { + id: 'discussions.learner.actions.unban', + defaultMessage: 'Unban', + description: 'Action to unban a user', + }, + undeleteActivity: { + id: 'discussions.learner.actions.undeleteActivity', + defaultMessage: 'Undelete activity', + description: 'Action to undelete user activity', + }, + banUserCourse: { + id: 'discussions.learner.actions.banCourse', + defaultMessage: 'Ban user from discussions in this course', + description: 'Action to ban user from course discussions', + }, + banUserOrg: { + id: 'discussions.learner.actions.banOrg', + defaultMessage: 'Ban user from discussions in this organization', + description: 'Action to ban user from organization discussions', + }, + unbanUserCourse: { + id: 'discussions.learner.actions.unbanCourse', + defaultMessage: 'Unban user from discussions in this course', + description: 'Action to unban user from course discussions', + }, + unbanUserOrg: { + id: 'discussions.learner.actions.unbanOrg', + defaultMessage: 'Unban user from discussions in this organization', + description: 'Action to unban user from organization discussions', + }, + deleteUserCourse: { + id: 'discussions.learner.actions.deleteUserCourse', + defaultMessage: 'Delete all user discussion activity in this course', + description: 'Action to delete user activity in course', + }, + deleteUserOrg: { + id: 'discussions.learner.actions.deleteUserOrg', + defaultMessage: 'Delete all user discussion activity in this organization', + description: 'Action to delete user activity in organization', + }, + undeleteUserCourse: { + id: 'discussions.learner.actions.undeleteUserCourse', + defaultMessage: 'Undelete all user discussion activity in this course', + description: 'Action to undelete user activity in course', + }, + undeleteUserOrg: { + id: 'discussions.learner.actions.undeleteUserOrg', + defaultMessage: 'Undelete all user discussion activity in this organization', + description: 'Action to undelete user activity in organization', + }, + deleteConfirmationDelete: { + id: 'discussions.learner.delete.confirmation.button.delete', + defaultMessage: 'Delete', + description: 'Delete button shown on delete confirmation dialog', + }, + auditTrailInfoTitle: { + id: 'discussions.learner.auditTrail.title', + defaultMessage: 'Audit trail info', + description: 'Title for audit trail information section', + }, + auditTrailBannedBy: { + id: 'discussions.learner.auditTrail.bannedBy', + defaultMessage: 'Banned by {moderator}', + description: 'Shows who banned the user', + }, + auditTrailBannedAt: { + id: 'discussions.learner.auditTrail.bannedAt', + defaultMessage: 'on {date} at {time}', + description: 'Shows when the user was banned', + }, + auditTrailBanScope: { + id: 'discussions.learner.auditTrail.banScope', + defaultMessage: '{scope, select, course {course-wide} organization {org-wide} other {}}', + description: 'Shows the scope of the ban (course-wide or org-wide)', + }, + learnerBanBannerBanned: { + id: 'discussions.learner.banner.banned', + defaultMessage: 'Banned', + description: 'Label shown in learner ban status banner', + }, + learnerBanBannerBy: { + id: 'discussions.learner.banner.by', + defaultMessage: 'by', + description: 'Text before moderator username in learner ban status banner', + }, + learnerBanBannerStaff: { + id: 'discussions.learner.banner.staff', + defaultMessage: 'Staff', + description: 'Role label shown in learner ban status banner', + }, + auditTrailStaffOnly: { + id: 'discussions.learner.auditTrail.staffOnly', + defaultMessage: 'Visible to staff only', + description: 'Note that audit trail is only visible to staff', + }, }); export default messages; diff --git a/src/discussions/learners/utils.js b/src/discussions/learners/utils.js index a6885466e..1ddff7ec7 100644 --- a/src/discussions/learners/utils.js +++ b/src/discussions/learners/utils.js @@ -1,101 +1,176 @@ import { useMemo } from 'react'; -import { Delete } from '@openedx/paragon/icons'; +import { + Block, Delete, +} from '@openedx/paragon/icons'; +import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { ReactComponent as Undelete } from '../../assets/undelete.svg'; import { ContentActions } from '../../data/constants'; +import { selectEnableDiscussionBan } from '../data/selectors'; +import { checkBanActionDisabled } from '../utils/banUtils'; +import { BAN_SCOPES } from './data/constants'; import messages from './messages'; export const LEARNER_ACTIONS_LIST = [ { - id: 'delete-course-posts', - action: ContentActions.DELETE_COURSE_POSTS, - icon: Delete, - label: messages.deleteCoursePosts, + id: 'ban', + icon: Block, + label: messages.banUser, + hasSubmenu: true, + submenu: [ + { + id: 'ban-course', + action: ContentActions.BAN_COURSE, + label: messages.banUserCourse, + disabledConditions: { isAuthorBanned: true, $scope: BAN_SCOPES.COURSE }, + }, + { + id: 'ban-org', + action: ContentActions.BAN_ORG, + label: messages.banUserOrg, + disabledConditions: { isAuthorBanned: true, $scope: BAN_SCOPES.ORGANIZATION }, + }, + { + id: 'unban-course', + action: ContentActions.UNBAN_COURSE, + label: messages.unbanUserCourse, + disabledConditions: { isAuthorBanned: false, $scope: BAN_SCOPES.COURSE }, + }, + { + id: 'unban-org', + action: ContentActions.UNBAN_ORG, + label: messages.unbanUserOrg, + disabledConditions: { isAuthorBanned: false, $scope: BAN_SCOPES.ORGANIZATION }, + }, + ], }, { - id: 'delete-org-posts', - action: ContentActions.DELETE_ORG_POSTS, + id: 'delete-activity', icon: Delete, - label: messages.deleteOrgPosts, - }, - { - id: 'restore-course-posts', - action: ContentActions.RESTORE_COURSE_POSTS, - icon: Undelete, - label: messages.restoreCoursePosts, + label: messages.deleteActivity, + hasSubmenu: true, + submenu: [ + { + id: 'delete-course-posts', + action: ContentActions.DELETE_COURSE_POSTS, + label: messages.deleteUserCourse, + }, + { + id: 'delete-org-posts', + action: ContentActions.DELETE_ORG_POSTS, + label: messages.deleteUserOrg, + }, + ], }, { - id: 'restore-org-posts', - action: ContentActions.RESTORE_ORG_POSTS, - icon: Undelete, - label: messages.restoreOrgPosts, + id: 'restore-activity', + icon: Delete, + label: messages.restoreActivity, + hasSubmenu: true, + submenu: [ + { + id: 'restore-course-posts', + action: ContentActions.RESTORE_COURSE_POSTS, + label: messages.restoreCoursePosts, + }, + { + id: 'restore-org-posts', + action: ContentActions.RESTORE_ORG_POSTS, + label: messages.restoreOrgPosts, + }, + ], }, ]; -export function useLearnerActions(userHasBulkDeletePrivileges = false) { +/** + * Checks if an action should be disabled based on disabled conditions. + * Uses the banUtils module for ban-related logic. + * @param {Object} learnerBanInfo - Ban information for the learner + * @param {Object} disabledConditions - Conditions that determine if action should be disabled + * @returns {boolean} - True if the action should be disabled + */ +const checkDisabled = (learnerBanInfo, disabledConditions) => { + if (!disabledConditions) { + return false; + } + + // Handle ban status with scope awareness using dedicated utility + if ('isAuthorBanned' in disabledConditions) { + return checkBanActionDisabled(learnerBanInfo, disabledConditions); + } + + return false; +}; + +export function useLearnerActions(userHasBulkDeletePrivileges = false, learnerBanInfo = {}) { const intl = useIntl(); + const enableDiscussionBan = useSelector(selectEnableDiscussionBan); const actions = useMemo(() => { if (!userHasBulkDeletePrivileges) { return []; } - return LEARNER_ACTIONS_LIST.map(action => ({ - ...action, - label: { - id: action.label.id, - defaultMessage: intl.formatMessage(action.label), - }, - })); - }, [userHasBulkDeletePrivileges, intl]); - return actions; -} + return LEARNER_ACTIONS_LIST.filter(action => { + // Hide ban menu if feature flag is disabled + if (action.id === 'ban' && !enableDiscussionBan) { + return false; + } + return true; + }).map(action => { + // For actions with submenus, check disabled conditions + if (action.submenu) { + const processedSubmenu = action.submenu + .filter(subAction => { + // Filter ban-related actions if feature flag is disabled + if (!enableDiscussionBan && ( + subAction.action === ContentActions.BAN_COURSE + || subAction.action === ContentActions.BAN_ORG + || subAction.action === ContentActions.UNBAN_COURSE + || subAction.action === ContentActions.UNBAN_ORG + )) { + return false; + } + return true; + }) + .map(subAction => { + const disabled = checkDisabled(learnerBanInfo, subAction.disabledConditions); + return { + ...subAction, + label: { + id: subAction.label.id, + defaultMessage: intl.formatMessage(subAction.label), + }, + disabled, + }; + }); -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), + // If no submenu items remain, filter out this action + if (processedSubmenu.length === 0) { + return null; + } + + return { + ...action, + label: { + id: action.label.id, + defaultMessage: intl.formatMessage(action.label), }, - ], - }, - ]; - }, [userHasBulkDeletePrivileges, intl]); + submenu: processedSubmenu, + }; + } - return menuItems; + return { + ...action, + label: { + id: action.label.id, + defaultMessage: intl.formatMessage(action.label), + }, + }; + }).filter(Boolean); // Remove null entries (actions with empty submenus) + }, [userHasBulkDeletePrivileges, learnerBanInfo, enableDiscussionBan, intl]); + + return actions; } diff --git a/src/discussions/messages.js b/src/discussions/messages.js index 7d9ba86ca..6e443838d 100644 --- a/src/discussions/messages.js +++ b/src/discussions/messages.js @@ -36,11 +36,174 @@ const messages = defineMessages({ defaultMessage: 'Restore', description: 'Action to restore a deleted post or comment', }, + // Ban submenu items + banAction: { + id: 'discussions.actions.ban', + defaultMessage: 'Ban', + description: 'Main ban menu item', + }, + banUserCourse: { + id: 'discussions.actions.ban.course', + defaultMessage: 'Ban user in this course', + description: 'Ban user from course', + }, + banUserOrg: { + id: 'discussions.actions.ban.org', + defaultMessage: 'Ban user in this organization', + description: 'Ban user from organization', + }, + unbanUserCourse: { + id: 'discussions.actions.unban.course', + defaultMessage: 'Unban user from discussions in this course', + description: 'Unban user from course', + }, + unbanUserOrg: { + id: 'discussions.actions.unban.org', + defaultMessage: 'Unban user from discussions in this organization', + description: 'Unban user from organization', + }, + // Delete submenu items + deletePost: { + id: 'discussions.actions.delete.post', + defaultMessage: 'Delete', + description: 'Delete single post', + }, + deleteUserCourse: { + id: 'discussions.actions.delete.userCourse', + defaultMessage: "Delete all user's discussion activity in this course", + description: 'Delete all posts by user in course', + }, + deleteUserOrg: { + id: 'discussions.actions.delete.userOrg', + defaultMessage: "Delete all user's discussion activity in this organization", + description: 'Delete all posts by user in organization', + }, + undeleteUserCourse: { + id: 'discussions.actions.undelete.userCourse', + defaultMessage: "Undelete all user's discussion activity in this course", + description: 'Restore all posts by user in course', + }, + undeleteUserOrg: { + id: 'discussions.actions.undelete.userOrg', + defaultMessage: "Undelete all user's discussion activity in this organization", + description: 'Restore all posts by user in organization', + }, + backToMenu: { + id: 'discussions.actions.submenu.back', + defaultMessage: 'Back', + description: 'Back button in submenu', + }, confirmationConfirm: { id: 'discussions.confirmation.button.confirm', defaultMessage: 'Confirm', description: 'Confirm button shown on confirmation dialog', }, + banUserCheckbox: { + id: 'discussions.actions.delete.banUserCheckbox', + defaultMessage: 'Ban user from discussions in this course', + description: 'Checkbox label for banning user when deleting', + }, + deleteUserCourseTitle: { + id: 'discussions.actions.delete.userCourse.title', + defaultMessage: "Delete this user's discussion activity?", + description: 'Title for delete user course confirmation', + }, + deleteUserCourseDescription: { + id: 'discussions.actions.delete.userCourse.description', + defaultMessage: 'Are you sure you want to delete (count) posts, responses, or comments by {username} in this course?', + description: 'Description for delete user course confirmation', + }, + deleteUserOrgTitle: { + id: 'discussions.actions.delete.userOrg.title', + defaultMessage: "Delete this user's discussion activity?", + description: 'Title for delete user org confirmation', + }, + deleteUserOrgDescription: { + id: 'discussions.actions.delete.userOrg.description', + defaultMessage: 'Are you sure you want to delete (count) posts, responses, or comments by {username} across this organization?', + description: 'Description for delete user org confirmation', + }, + undeleteUserCourseTitle: { + id: 'discussions.actions.undelete.userCourse.title', + defaultMessage: "Undelete this user's discussion activity?", + description: 'Title for undelete user course confirmation', + }, + undeleteUserCourseDescription: { + id: 'discussions.actions.undelete.userCourse.description', + defaultMessage: 'Are you sure you want to undelete (count) responses, or comments by {username} in this course?', + description: 'Description for undelete user course confirmation', + }, + undeleteUserOrgTitle: { + id: 'discussions.actions.undelete.userOrg.title', + defaultMessage: "Undelete this user's discussion activity?", + description: 'Title for undelete user org confirmation', + }, + undeleteUserOrgDescription: { + id: 'discussions.actions.undelete.userOrg.description', + defaultMessage: 'Are you sure you want to undelete (count) responses, or comments by {username} across this organization?', + description: 'Description for undelete user org confirmation', + }, + banUserCourseTitle: { + id: 'discussions.actions.ban.course.title', + defaultMessage: 'Ban user in this course', + description: 'Title for ban user course confirmation', + }, + banUserCourseDescription: { + id: 'discussions.actions.ban.course.description', + defaultMessage: 'Are you sure you want to ban {username} from discussions in this course?', + description: 'Description for ban user course confirmation', + }, + banUserOrgTitle: { + id: 'discussions.actions.ban.org.title', + defaultMessage: 'Ban user in this organization', + description: 'Title for ban user org confirmation', + }, + banUserOrgDescription: { + id: 'discussions.actions.ban.org.description', + defaultMessage: 'Are you sure you want to ban {username} from discussions across this organization?', + description: 'Description for ban user org confirmation', + }, + banUserOrgCheckbox: { + id: 'discussions.actions.ban.org.checkbox', + defaultMessage: 'Ban user from discussions across this organization', + description: 'Checkbox label for org-level ban', + }, + unbanUserCourseTitle: { + id: 'discussions.actions.unban.course.title', + defaultMessage: 'Unban user in this course', + description: 'Title for unban user course confirmation', + }, + unbanUserCourseDescription: { + id: 'discussions.actions.unban.course.description', + defaultMessage: 'Are you sure you want to unban {username} from discussions in this course?', + description: 'Description for unban user course confirmation', + }, + unbanUserOrgTitle: { + id: 'discussions.actions.unban.org.title', + defaultMessage: 'Unban user in this organization', + description: 'Title for unban user org confirmation', + }, + unbanUserOrgDescription: { + id: 'discussions.actions.unban.org.description', + defaultMessage: 'Are you sure you want to unban {username} from discussions across this organization?', + description: 'Description for unban user org confirmation', + }, + // Button text for confirmation dialogs + banButtonText: { + id: 'discussions.button.ban', + defaultMessage: 'Ban', + description: 'Ban button text for confirmation dialogs', + }, + unbanButtonText: { + id: 'discussions.button.unban', + defaultMessage: 'Unban', + description: 'Unban button text for confirmation dialogs', + }, + undeleteButtonText: { + id: 'discussions.button.undelete', + defaultMessage: 'Undelete', + description: 'Undelete button text for confirmation dialogs', + }, closeAction: { id: 'discussions.actions.close', defaultMessage: 'Close', @@ -168,6 +331,21 @@ const messages = defineMessages({ defaultMessage: 'CTA', description: 'A label for community TAs displayed next to their username.', }, + authorLabelBanned: { + id: 'discussions.authors.label.banned', + defaultMessage: 'Banned', + description: 'A label for banned users displayed next to their username.', + }, + bannedUserBannerTitle: { + id: 'discussions.bannedUser.banner.title', + defaultMessage: "You've been banned from discussions in this course", + description: 'Title for banned user banner', + }, + bannedUserBannerMessage: { + id: 'discussions.bannedUser.banner.message', + defaultMessage: "You've been banned from discussions in this course", + description: 'Message shown in banned user banner', + }, loadMorePosts: { id: 'discussions.learner.loadMostPosts', defaultMessage: 'Load more posts', diff --git a/src/discussions/post-comments/PostCommentsView.test.jsx b/src/discussions/post-comments/PostCommentsView.test.jsx index 59d6272e7..d99af8834 100644 --- a/src/discussions/post-comments/PostCommentsView.test.jsx +++ b/src/discussions/post-comments/PostCommentsView.test.jsx @@ -33,7 +33,7 @@ import { fetchDiscussionTours } from '../tours/data/thunks'; import discussionTourFactory from '../tours/data/tours.factory'; import { getCommentsApiUrl } from './data/api'; import * as selectors from './data/selectors'; -import { fetchCommentResponses, removeComment } from './data/thunks'; +import { removeComment } from './data/thunks'; import '../posts/data/__factories__'; import './data/__factories__'; @@ -83,7 +83,7 @@ async function mockAxiosReturnPagedCommentsResponses() { show_deleted: false, }; - [1, 2].forEach(async (page) => { + [1, 2].forEach((page) => { axiosMock.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } }).reply( 200, Factory.build('commentsResult', null, { @@ -95,8 +95,6 @@ async function mockAxiosReturnPagedCommentsResponses() { count: 2, }), ); - - await executeThunk(fetchCommentResponses(parentId), store.dispatch, store.getState); }); } diff --git a/src/discussions/post-comments/comments/CommentsView.jsx b/src/discussions/post-comments/comments/CommentsView.jsx index b9d8b25a1..f81192a97 100644 --- a/src/discussions/post-comments/comments/CommentsView.jsx +++ b/src/discussions/post-comments/comments/CommentsView.jsx @@ -9,7 +9,11 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { ThreadType } from '../../../data/constants'; import withPostingRestrictions from '../../common/withPostingRestrictions'; import { useUserPostingEnabled } from '../../data/hooks'; -import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation } from '../../data/selectors'; +import { + selectContentCreationRateLimited, + selectIsUserBanned, + selectShouldShowEmailConfirmation, +} from '../../data/selectors'; import { isLastElementOfList } from '../../utils'; import { usePostComments } from '../data/hooks'; import messages from '../messages'; @@ -23,6 +27,7 @@ const CommentsView = ({ threadType, openRestrictionDialogue }) => { const isUserPrivilegedInPostingRestriction = useUserPostingEnabled(); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); + const isUserBanned = useSelector(selectIsUserBanned); const { endorsedCommentsIds, @@ -94,7 +99,7 @@ const CommentsView = ({ threadType, openRestrictionDialogue }) => {
)} {(isUserPrivilegedInPostingRestriction && (!!unEndorsedCommentsIds.length || !!endorsedCommentsIds.length) - && !isClosed) && ( + && !isClosed && !isUserBanned) && (
{!addingResponse && (
)} @@ -349,7 +529,7 @@ const Comment = ({ />
) : ( - !isClosed && isUserPrivilegedInPostingRestriction && (inlineReplies.length >= 5) && ( + !isClosed && isUserPrivilegedInPostingRestriction && !isUserBanned && (inlineReplies.length >= 5) && (