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 = ( -