Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ npm-debug.log
coverage
module.config.js
env.config.*
.env*.local

dist/
src/i18n/transifex_input.json
Expand Down
5 changes: 5 additions & 0 deletions src/data/api/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Centralized API exports for the discussions app
*/

export * from './moderation';
86 changes: 86 additions & 0 deletions src/data/api/moderation.js
Original file line number Diff line number Diff line change
@@ -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;
};
14 changes: 14 additions & 0 deletions src/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

/**
Expand Down
141 changes: 118 additions & 23 deletions src/discussions/common/ActionsDropdown.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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(() => (
Expand Down Expand Up @@ -62,40 +64,133 @@ const ActionsDropdown = ({
const onCloseModal = useCallback(() => {
close();
setTarget(null);
setActiveSubmenu(null);
}, [close]);

const dropdownContent = (
<div
className="bg-white shadow d-flex flex-column mt-1"
data-testid="actions-dropdown-modal-popup"
const renderMenuItem = useCallback((action) => (
<Dropdown.Item
key={action.id}
as={Button}
variant="tertiary"
size="inline"
onClick={() => {
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 => (
<React.Fragment key={action.id}>
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
<div className="d-flex align-items-center">
<Icon
src={action.icon}
className="icon-size-24"
/>
<span className="font-weight-normal ml-2">
{intl.formatMessage(action.label)}
</span>
</div>
{action.hasSubmenu && (
<Icon src={ChevronRight} className="icon-size-20 ml-2" />
)}
</Dropdown.Item>
), [close, handleActions, intl]);

const renderSubmenu = useCallback((parentAction) => (
<>
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
onClick={() => setActiveSubmenu(null)}
className="d-flex align-items-center actions-dropdown-item"
data-testid="submenu-back"
>
<Icon src={ChevronLeft} className="icon-size-20" />
<span className="font-weight-normal ml-2">
{intl.formatMessage(messages.backToMenu)}
</span>
</Dropdown.Item>
<Dropdown.Divider />
{parentAction.submenu.map(subAction => (
<React.Fragment key={subAction.id}>
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
disabled={subAction.disabled}
onClick={() => {
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}
>
<Icon
src={action.icon}
className="icon-size-24"
/>
<span className="font-weight-normal ml-2">
{intl.formatMessage(action.label)}
<span className="font-weight-normal">
{intl.formatMessage(subAction.label)}
</span>
</Dropdown.Item>
</React.Fragment>
))}
</>
), [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 = (
<div
className="bg-white shadow d-flex flex-column mt-1"
data-testid="actions-dropdown-modal-popup"
>
{activeSubmenu && activeParentAction ? (
renderSubmenu(activeParentAction)
) : (
actions.map(action => (
<React.Fragment key={action.id}>
{(action.id === 'ban' || action.action === ContentActions.DELETE) && <Dropdown.Divider />}
{action.hasSubmenu ? (
renderMenuItem(action)
) : (
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
onClick={() => {
close();
if (!action.disabled) {
handleActions(action.action);
}
}}
className="d-flex justify-content-start actions-dropdown-item"
data-testid={action.id}
disabled={action.disabled}
>
<Icon
src={action.icon}
className="icon-size-24"
/>
<span className="font-weight-normal ml-2">
{intl.formatMessage(action.label)}
</span>
</Dropdown.Item>
)}
</React.Fragment>
))
)}
</div>
);

Expand Down
Loading