feat: added bulk delete user posts feature for privileged users (#788)

* feat: added bulk delete user posts feature for privileged users
This commit is contained in:
Eemaan Amir
2025-07-22 19:09:48 +05:00
committed by GitHub
parent 3cda02be76
commit 909d133acc
18 changed files with 728 additions and 42 deletions

View File

@@ -58,6 +58,8 @@ export const ContentActions = {
CHANGE_TOPIC: 'topic_id',
CHANGE_TYPE: 'type',
VOTE: 'voted',
DELETE_COURSE_POSTS: 'delete-course-posts',
DELETE_ORG_POSTS: 'delete-org-posts',
};
/**

View File

@@ -1,7 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, Button, ModalDialog } from '@openedx/paragon';
import {
ActionRow,
ModalDialog,
Spinner, StatefulButton,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -11,34 +15,56 @@ const Confirmation = ({
isOpen,
title,
description,
boldDescription,
onClose,
confirmAction,
closeButtonVariant,
confirmButtonVariant,
confirmButtonText,
isDataLoading,
isConfirmButtonPending,
pendingConfirmButtonText,
}) => {
const intl = useIntl();
return (
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
<ModalDialog.Header>
<ModalDialog.Title>
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{description}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant={closeButtonVariant}>
{intl.formatMessage(messages.confirmationCancel)}
</ModalDialog.CloseButton>
<Button variant={confirmButtonVariant} onClick={confirmAction}>
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
</Button>
</ActionRow>
</ModalDialog.Footer>
{isDataLoading && !isConfirmButtonPending ? (
<ModalDialog.Body>
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
</ModalDialog.Body>
) : (
<>
<ModalDialog.Header>
<ModalDialog.Title>
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{description}
{boldDescription && <><br /><p className="font-weight-bold pt-2">{boldDescription}</p></>}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant={closeButtonVariant}>
{intl.formatMessage(messages.confirmationCancel)}
</ModalDialog.CloseButton>
<StatefulButton
labels={{
default: confirmButtonText || intl.formatMessage(messages.confirmationConfirm),
pending: pendingConfirmButtonText || confirmButtonText
|| intl.formatMessage(messages.confirmationConfirm),
}}
state={isConfirmButtonPending ? 'pending' : confirmButtonVariant}
variant={confirmButtonVariant}
onClick={confirmAction}
/>
</ActionRow>
</ModalDialog.Footer>
</>
)}
</ModalDialog>
);
};
@@ -49,15 +75,23 @@ Confirmation.propTypes = {
confirmAction: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
boldDescription: PropTypes.string,
closeButtonVariant: PropTypes.string,
confirmButtonVariant: PropTypes.string,
confirmButtonText: PropTypes.string,
isDataLoading: PropTypes.bool,
isConfirmButtonPending: PropTypes.bool,
pendingConfirmButtonText: PropTypes.string,
};
Confirmation.defaultProps = {
closeButtonVariant: 'default',
confirmButtonVariant: 'primary',
confirmButtonText: '',
boldDescription: '',
isDataLoading: false,
isConfirmButtonPending: false,
pendingConfirmButtonText: '',
};
export default React.memo(Confirmation);

View File

@@ -11,6 +11,8 @@ export const selectAnonymousPostingConfig = state => ({
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges;
export const selectUserHasBulkDeletePrivileges = state => state.config.hasBulkDeletePrivileges;
export const selectUserIsStaff = state => state.config.isUserAdmin;
export const selectUserIsGroupTa = state => state.config.isGroupTa;

View File

@@ -11,6 +11,7 @@ const configSlice = createSlice({
userRoles: [],
groupAtSubsection: false,
hasModerationPrivileges: false,
hasBulkDeletePrivileges: false,
isGroupTa: false,
isCourseAdmin: false,
isCourseStaff: false,

View File

@@ -0,0 +1,107 @@
import React, {
useCallback, 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 { useIntl } from '@edx/frontend-platform/i18n';
import { useLearnerActions } from './utils';
const LearnerActionsDropdown = ({
actionHandlers,
dropDownIconSize,
userHasBulkDeletePrivileges,
}) => {
const buttonRef = useRef();
const intl = useIntl();
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const actions = useLearnerActions(userHasBulkDeletePrivileges);
const handleActions = useCallback((action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
actionFunction();
}
}, [actionHandlers]);
const onClickButton = useCallback((event) => {
event.preventDefault();
setTarget(buttonRef.current);
open();
}, [open]);
const onCloseModal = useCallback(() => {
close();
setTarget(null);
}, [close]);
return (
<>
<IconButton
onClick={onClickButton}
alt={intl.formatMessage({ id: 'discussions.learner.actions.alt', defaultMessage: 'Actions menu' })}
src={MoreHoriz}
iconAs={Icon}
size="sm"
ref={buttonRef}
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''}
/>
<div className="actions-dropdown">
<ModalPopup
onClose={onCloseModal}
positionRef={target}
isOpen={isOpen}
placement="bottom-start"
>
<div
className="bg-white shadow d-flex flex-column mt-1"
data-testid="learner-actions-dropdown-modal-popup"
>
{actions.map(action => (
<React.Fragment key={action.id}>
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
onClick={() => {
close();
handleActions(action.action);
}}
className="d-flex justify-content-start actions-dropdown-item"
data-testId={action.id}
>
<Icon
src={action.icon}
className="icon-size-24"
/>
<span className="font-weight-normal ml-2">
{action.label.defaultMessage}
</span>
</Dropdown.Item>
</React.Fragment>
))}
</div>
</ModalPopup>
</div>
</>
);
};
LearnerActionsDropdown.propTypes = {
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
dropDownIconSize: PropTypes.bool,
userHasBulkDeletePrivileges: PropTypes.bool,
};
LearnerActionsDropdown.defaultProps = {
dropDownIconSize: false,
userHasBulkDeletePrivileges: false,
};
export default LearnerActionsDropdown;

View File

@@ -0,0 +1,153 @@
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { ContentActions } from '../../data/constants';
import { initializeStore } from '../../store';
import executeThunk from '../../test-utils';
import { getCourseConfigApiUrl } from '../data/api';
import fetchCourseConfig from '../data/thunks';
import LearnerActionsDropdown from './LearnerActionsDropdown';
let store;
let axiosMock;
const courseId = 'course-v1:edX+TestX+Test_Course';
const username = 'abc123';
const renderComponent = ({
contentType = 'LEARNER',
userHasBulkDeletePrivileges = false,
actionHandlers = {},
} = {}) => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<LearnerActionsDropdown
contentType={contentType}
userHasBulkDeletePrivileges={userHasBulkDeletePrivileges}
actionHandlers={actionHandlers}
/>
</AppProvider>
</IntlProvider>,
);
};
const findOpenActionsDropdownButton = async () => (
screen.findByRole('button', { name: 'Actions menu' })
);
describe('LearnerActionsDropdown', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username,
administrator: false,
roles: [],
},
});
store = initializeStore();
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`)
.reply(200, { isPostingEnabled: true });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
});
it('can open dropdown if enabled', async () => {
renderComponent({ userHasBulkDeletePrivileges: true });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument());
});
it('shows delete action for privileged users', async () => {
const mockHandler = jest.fn();
renderComponent({
userHasBulkDeletePrivileges: true,
actionHandlers: { deleteCoursePosts: mockHandler, deleteOrgPosts: mockHandler },
});
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => {
const deleteCourseItem = screen.queryByTestId('delete-course-posts');
const deleteOrgItem = screen.queryByTestId('delete-org-posts');
expect(deleteCourseItem).toBeInTheDocument();
expect(deleteOrgItem).toBeInTheDocument();
});
});
it('triggers deleteCoursePosts handler when delete-course-posts is clicked', async () => {
const mockDeleteCourseHandler = jest.fn();
const mockDeleteOrgHandler = jest.fn();
renderComponent({
userHasBulkDeletePrivileges: true,
actionHandlers: {
[ContentActions.DELETE_COURSE_POSTS]: mockDeleteCourseHandler,
[ContentActions.DELETE_ORG_POSTS]: mockDeleteOrgHandler,
},
});
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument());
const deleteCourseItem = await screen.findByTestId('delete-course-posts');
await act(async () => {
fireEvent.click(deleteCourseItem);
});
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).not.toBeInTheDocument());
expect(mockDeleteCourseHandler).toHaveBeenCalled();
expect(mockDeleteOrgHandler).not.toHaveBeenCalled();
});
it('triggers deleteOrgPosts handler when delete-org-posts is clicked', async () => {
const mockDeleteCourseHandler = jest.fn();
const mockDeleteOrgHandler = jest.fn();
renderComponent({
userHasBulkDeletePrivileges: true,
actionHandlers: {
[ContentActions.DELETE_COURSE_POSTS]: mockDeleteCourseHandler,
[ContentActions.DELETE_ORG_POSTS]: mockDeleteOrgHandler,
},
});
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument());
const deleteOrgItem = await screen.findByTestId('delete-org-posts');
await act(async () => {
fireEvent.click(deleteOrgItem);
});
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).not.toBeInTheDocument());
expect(mockDeleteOrgHandler).toHaveBeenCalled();
expect(mockDeleteCourseHandler).not.toHaveBeenCalled();
});
});

View File

@@ -1,9 +1,9 @@
import React, {
useCallback, useContext, useEffect, useMemo,
useCallback, useContext, useEffect, useMemo, useState,
} from 'react';
import {
Button, Icon, IconButton, Spinner,
Button, Icon, IconButton, Spinner, useToggle,
} from '@openedx/paragon';
import { ArrowBack } from '@openedx/paragon/icons';
import capitalize from 'lodash/capitalize';
@@ -13,11 +13,18 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ContentActions,
RequestStatus,
Routes,
} from '../../data/constants';
import useDispatchWithState from '../../data/hooks';
import { Confirmation } from '../common';
import DiscussionContext from '../common/context';
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import {
selectUserHasBulkDeletePrivileges,
selectUserHasModerationPrivileges,
selectUserIsStaff,
} from '../data/selectors';
import usePostList from '../posts/data/hooks';
import {
selectAllThreadsIds,
@@ -28,8 +35,11 @@ import { clearPostsPages } from '../posts/data/slices';
import NoResults from '../posts/NoResults';
import { PostLink } from '../posts/post';
import { discussionsPath } from '../utils';
import { fetchUserPosts } from './data/thunks';
import { BulkDeleteType } from './data/constants';
import { learnersLoadingStatus, selectBulkDeleteStats } from './data/selectors';
import { deleteUserPosts, fetchUserPosts } from './data/thunks';
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
import LearnerActionsDropdown from './LearnerActionsDropdown';
import messages from './messages';
const LearnerPostsView = () => {
@@ -38,14 +48,20 @@ const LearnerPostsView = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
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 } = useContext(DiscussionContext);
const nextPage = useSelector(selectThreadNextPage());
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsStaff = useSelector(selectUserIsStaff);
const userHasBulkDeletePrivileges = useSelector(selectUserHasBulkDeletePrivileges);
const bulkDeleteStats = useSelector(selectBulkDeleteStats());
const sortedPostsIds = usePostList(postsIds);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isDeletingCourseOrOrg, setIsDeletingCourseOrOrg] = useState(BulkDeleteType.COURSE);
const loadMorePosts = useCallback((pageNum = undefined) => {
const params = {
@@ -59,6 +75,23 @@ const LearnerPostsView = () => {
dispatch(fetchUserPosts(courseId, params));
}, [courseId, postFilter, username, userHasModerationPrivileges, userIsStaff]);
const handleShowDeleteConfirmation = useCallback(async (courseOrOrg) => {
setIsDeletingCourseOrOrg(courseOrOrg);
showDeleteConfirmation();
await dispatch(deleteUserPosts(courseId, username, courseOrOrg, false));
}, [courseId, username, showDeleteConfirmation]);
const handleDeletePosts = useCallback(async (courseOrOrg) => {
await dispatchDelete(deleteUserPosts(courseId, username, courseOrOrg, true));
navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) });
hideDeleteConfirmation();
}, [courseId, username, hideDeleteConfirmation]);
const actionHandlers = useMemo(() => ({
[ContentActions.DELETE_COURSE_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.COURSE),
[ContentActions.DELETE_ORG_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.ORG),
}), [handleShowDeleteConfirmation]);
const postInstances = useMemo(() => (
sortedPostsIds?.map((postId, idx) => (
<PostLink
@@ -77,19 +110,31 @@ const LearnerPostsView = () => {
return (
<div className="discussion-posts d-flex flex-column">
<div className="d-flex align-items-center justify-content-between px-2.5">
<IconButton
src={ArrowBack}
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) })}
alt={intl.formatMessage(messages.back)}
/>
<div className="text-primary-500 font-style font-weight-bold py-2.5">
<div className="row d-flex align-items-center justify-content-between px-2.5">
<div className="col-1">
<IconButton
src={ArrowBack}
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) })}
alt={intl.formatMessage(messages.back)}
/>
</div>
<div className=" col-auto text-primary-500 font-style font-weight-bold py-2.5">
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
</div>
<div style={{ padding: '18px' }} />
{userHasBulkDeletePrivileges ? (
<div className="col-2">
<LearnerActionsDropdown
id={username}
actionHandlers={actionHandlers}
userHasBulkDeletePrivileges={userHasBulkDeletePrivileges}
dropDownIconSize
/>
</div>
)
: (<div style={{ padding: '18px' }} />)}
</div>
<div className="bg-light-400 border border-light-300" />
<LearnerPostFilterBar />
@@ -109,6 +154,23 @@ const LearnerPostsView = () => {
)
)}
</div>
<Confirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deletePostsTitle)}
description={learnerLoadingStatus === RequestStatus.SUCCESSFUL
? intl.formatMessage(messages.deletePostsDescription, {
count: bulkDeleteStats.threadCount + bulkDeleteStats.commentCount,
bulkType: isDeletingCourseOrOrg,
}) : ''}
boldDescription={intl.formatMessage(messages.deletePostsBoldDescription)}
onClose={hideDeleteConfirmation}
confirmAction={() => handleDeletePosts(isDeletingCourseOrOrg)}
confirmButtonText={intl.formatMessage(messages.deletePostsConfirm)}
confirmButtonVariant="danger"
isDataLoading={!(learnerLoadingStatus === RequestStatus.SUCCESSFUL)}
isConfirmButtonPending={bulkDeleting}
pendingConfirmButtonText={intl.formatMessage(messages.deletePostConfirmPending)}
/>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import {
fireEvent, render, screen, waitFor,
fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
@@ -20,7 +20,7 @@ import executeThunk from '../../test-utils';
import { getCohortsApiUrl } from '../cohorts/data/api';
import fetchCourseCohorts from '../cohorts/data/thunks';
import DiscussionContext from '../common/context';
import { learnerPostsApiUrl } from './data/api';
import { deletePostsApiUrl, learnerPostsApiUrl } from './data/api';
import { fetchUserPosts } from './data/thunks';
import LearnerPostsView from './LearnerPostsView';
import { setUpPrivilages } from './test-utils';
@@ -220,4 +220,128 @@ describe('Learner Posts View', () => {
expect(loadMoreButton).not.toBeInTheDocument();
expect(container.querySelectorAll('.discussion-post')).toHaveLength(4);
});
test('should display dropdown menu button for bulk delete user posts for privileged users', async () => {
await setUpPrivilages(axiosMock, store, true, true);
await renderComponent();
expect(within(container).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
});
test('should NOT display dropdown menu button for bulk delete user posts for other users', async () => {
await setUpPrivilages(axiosMock, store, true, false);
await renderComponent();
expect(within(container).queryByRole('button', { name: /actions menu/i })).not.toBeInTheDocument();
});
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))
.reply(202, { thread_count: 2, comment_count: 3 });
await renderComponent();
const actionsButton = await screen.findByRole('button', { name: /actions menu/i });
await act(async () => {
fireEvent.click(actionsButton);
});
const deleteCourseItem = await screen.findByTestId('delete-course-posts');
await act(async () => {
fireEvent.click(deleteCourseItem);
});
await waitFor(() => {
const dialog = screen.getByText('Are you sure you want to delete this user\'s discussion contributions?');
expect(dialog).toBeInTheDocument();
expect(screen.getByText('You are about to delete 5 discussion contributions by this user in this course. This includes all discussion threads, responses, and comments authored by them.')).toBeInTheDocument();
expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
});
});
test('should complete delete course posts flow and redirect', async () => {
await setUpPrivilages(axiosMock, store, true, true);
axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', false))
.reply(202, { thread_count: 2, comment_count: 3 });
axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', true))
.reply(202, { thread_count: 0, comment_count: 0 });
await renderComponent();
const actionsButton = await screen.findByRole('button', { name: /actions menu/i });
await act(async () => {
fireEvent.click(actionsButton);
});
const deleteCourseItem = await screen.findByTestId('delete-course-posts');
await act(async () => {
fireEvent.click(deleteCourseItem);
});
await waitFor(() => {
expect(screen.getByText('Are you sure you want to delete this user\'s discussion contributions?')).toBeInTheDocument();
});
const confirmButton = await screen.findByText('Delete');
await act(async () => {
fireEvent.click(confirmButton);
});
await waitFor(() => {
expect(lastLocation.pathname.endsWith('/learners')).toBeTruthy();
expect(screen.queryByText('Are you sure you want to delete this user\'s discussion contributions?')).not.toBeInTheDocument();
});
});
test('should close confirmation dialog when cancel is clicked', async () => {
await setUpPrivilages(axiosMock, store, true, true);
axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', false))
.reply(202, { thread_count: 2, comment_count: 3 });
await renderComponent();
const actionsButton = await screen.findByRole('button', { name: /actions menu/i });
await act(async () => {
fireEvent.click(actionsButton);
});
const deleteCourseItem = await screen.findByTestId('delete-course-posts');
await act(async () => {
fireEvent.click(deleteCourseItem);
});
await waitFor(() => {
expect(screen.getByText('Are you sure you want to delete this user\'s discussion contributions?')).toBeInTheDocument();
});
const cancelButton = await screen.findByText('Cancel');
await act(async () => {
fireEvent.click(cancelButton);
});
await waitFor(() => {
expect(screen.queryByText('Are you sure you want to delete this user\'s discussion contributions?')).not.toBeInTheDocument();
});
});
test('should display confirmation dialog for org posts deletion', async () => {
await setUpPrivilages(axiosMock, store, true, true);
axiosMock.onPost(deletePostsApiUrl(courseId, username, 'org', false))
.reply(202, { thread_count: 5, comment_count: 10 });
await renderComponent();
const actionsButton = await screen.findByRole('button', { name: /actions menu/i });
await act(async () => {
fireEvent.click(actionsButton);
});
const deleteOrgItem = await screen.findByTestId('delete-org-posts');
await act(async () => {
fireEvent.click(deleteOrgItem);
});
await waitFor(() => {
expect(screen.getByText('Are you sure you want to delete this user\'s discussion contributions?')).toBeInTheDocument();
expect(screen.getByText('You are about to delete 15 discussion contributions by this user across the organization. This includes all discussion threads, responses, and comments authored by them.')).toBeInTheDocument();
expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument();
});
});
});

View File

@@ -11,6 +11,7 @@ export const getCoursesApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussio
export const getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/v1/accounts`;
export const learnerPostsApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/learner/`;
export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/activity_stats/`;
export const 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}`;
/**
* Fetches all the learners in the given course.
@@ -82,3 +83,23 @@ export async function getUserPosts(courseId, {
.get(learnerPostsApiUrl(courseId), { params });
return data;
}
/**
* Deletes posts by a specific user in a course or organization
* @param {string} courseId Course ID of the course
* @param {string} username Username of the user whose posts are to be deleted
* @param {string} courseOrOrg Can be 'course' or 'org' to specify deletion scope
* @param {boolean} execute If true, deletes posts; if false, returns count of threads and comments
* @returns API Response object in the format
* {
* thread_count: number,
* comment_count: number
* }
*/
export async function deleteUserPostsApi(courseId, username, courseOrOrg, execute) {
const { data } = await getAuthenticatedHttpClient().post(
deletePostsApiUrl(courseId, username, courseOrOrg, execute),
null,
);
return data;
}

View File

@@ -4,7 +4,7 @@ import { Factory } from 'rosie';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
import { setupDeleteUserPostsMockResponse, setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
import './__factories__';
@@ -80,4 +80,25 @@ describe('Learner api test cases', () => {
expect(threads.status).toEqual('denied');
});
it.each([
{ courseOrOrg: 'course', execute: false, response: { comment_count: 3, thread_count: 2 } },
{ courseOrOrg: 'course', execute: true, response: { comment_count: 0, thread_count: 0 } },
{ courseOrOrg: 'org', execute: false, response: { comment_count: 3, thread_count: 2 } },
{ courseOrOrg: 'org', execute: true, response: { comment_count: 0, thread_count: 0 } },
])(
'Successfully fetches user post stats and bulk deletes user posts based on execute',
async ({ courseOrOrg, execute, response }) => {
const learners = await setupDeleteUserPostsMockResponse({ courseOrOrg, execute, response });
expect(learners.status).toEqual('successful');
expect(Object.values(learners.bulkDeleteStats)).toEqual(Object.values(response));
},
);
it('Failed to bulk delete user posts', async () => {
const learners = await setupPostsMockResponse({ statusCode: 400 });
expect(learners.status).toEqual('failed');
});
});

View File

@@ -0,0 +1,4 @@
export const BulkDeleteType = {
COURSE: 'course',
ORG: 'org',
};

View File

@@ -16,3 +16,5 @@ export const selectLearnerNextPage = () => state => state.learners.nextPage;
export const selectLearnerAvatar = author => state => (
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
);
export const selectBulkDeleteStats = () => state => state.learners.bulkDeleteStats;

View File

@@ -25,6 +25,10 @@ const learnersSlice = createSlice({
cohort: '',
},
usernameSearch: null,
bulkDeleteStats: {
commentCount: 0,
threadCount: 0,
},
},
reducers: {
fetchLearnersSuccess: (state, { payload }) => (
@@ -84,6 +88,25 @@ const learnersSlice = createSlice({
postFilter: payload,
}
),
deleteUserPostsRequest: (state) => (
{
...state,
status: RequestStatus.IN_PROGRESS,
}
),
deleteUserPostsSuccess: (state, { payload }) => (
{
...state,
status: RequestStatus.SUCCESSFUL,
bulkDeleteStats: payload,
}
),
deleteUserPostsFailed: (state) => (
{
...state,
status: RequestStatus.FAILED,
}
),
},
});
@@ -95,6 +118,9 @@ export const {
setSortedBy,
setUsernameSearch,
setPostFilter,
deleteUserPostsRequest,
deleteUserPostsSuccess,
deleteUserPostsFailed,
} = learnersSlice.actions;
export const learnersReducer = learnersSlice.reducer;

View File

@@ -12,8 +12,16 @@ import {
} from '../../posts/data/slices';
import { normaliseThreads } from '../../posts/data/thunks';
import { getHttpErrorStatus } from '../../utils';
import { getLearners, getUserPosts, getUserProfiles } from './api';
import {
deleteUserPostsApi,
getLearners,
getUserPosts,
getUserProfiles,
} from './api';
import {
deleteUserPostsFailed,
deleteUserPostsRequest,
deleteUserPostsSuccess,
fetchLearnersDenied,
fetchLearnersFailed,
fetchLearnersRequest,
@@ -121,3 +129,19 @@ export function fetchUserPosts(courseId, {
}
};
}
export const deleteUserPosts = (
courseId,
username,
courseOrOrg,
execute,
) => async (dispatch) => {
try {
dispatch(deleteUserPostsRequest({ courseId, username }));
const response = await deleteUserPostsApi(courseId, username, courseOrOrg, execute);
dispatch(deleteUserPostsSuccess(camelCaseObject(response)));
} catch (error) {
dispatch(deleteUserPostsFailed());
logError(error);
}
};

View File

@@ -62,6 +62,45 @@ const messages = defineMessages({
defaultMessage: 'Posts',
description: 'Tooltip text for all posts icon',
},
deleteCoursePosts: {
id: 'discussions.learner.actions.deleteCoursePosts',
defaultMessage: 'Delete user posts within this course',
description: 'Action to delete user posts within a specific course',
},
deleteOrgPosts: {
id: 'discussions.learner.actions.deleteOrgPosts',
defaultMessage: 'Delete user posts within this organization',
description: 'Action to delete user posts within the organization',
},
deletePostsTitle: {
id: 'discussions.learner.deletePosts.title',
defaultMessage: 'Are you sure you want to delete this user\'s discussion contributions?',
description: 'Title for delete course posts confirmation dialog',
},
deletePostsDescription: {
id: 'discussions.learner.deletePosts.description',
defaultMessage: `{bulkType, select,
course {You are about to delete {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user in this course. This includes all discussion threads, responses, and comments authored by them.}
org {You are about to delete {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user across the organization. This includes all discussion threads, responses, and comments authored by them.}
other {You are about to delete {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user. This includes all discussion threads, responses, and comments authored by them.}
}`,
description: 'Description for delete posts confirmation dialog',
},
deletePostsConfirm: {
id: 'discussions.learner.deletePosts.confirm',
defaultMessage: 'Delete',
description: 'Confirm button text for delete posts',
},
deletePostConfirmPending: {
id: 'discussions.learner.deletePosts.confirm.pending',
defaultMessage: 'Deleting',
description: 'Pending state of confirm button text for delete posts',
},
deletePostsBoldDescription: {
id: 'discussions.learner.deletePosts.boldDescription',
defaultMessage: 'This action cannot be undone.',
description: 'Bold disclaimer description for delete confirmation dialog',
},
});
export default messages;

View File

@@ -7,8 +7,13 @@ import { initializeStore } from '../../store';
import executeThunk from '../../test-utils';
import { getDiscussionsConfigUrl } from '../data/api';
import fetchCourseConfig from '../data/thunks';
import { getUserProfileApiUrl, learnerPostsApiUrl, learnersApiUrl } from './data/api';
import { fetchLearners, fetchUserPosts } from './data/thunks';
import {
deletePostsApiUrl,
getUserProfileApiUrl,
learnerPostsApiUrl,
learnersApiUrl,
} from './data/api';
import { deleteUserPosts, fetchLearners, fetchUserPosts } from './data/thunks';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -54,9 +59,26 @@ export async function setupPostsMockResponse({
return store.getState().threads;
}
export async function setUpPrivilages(axiosMock, store, hasModerationPrivileges) {
export async function setupDeleteUserPostsMockResponse({
username = 'abc123',
courseOrOrg,
statusCode = 202,
execute,
response,
} = {}) {
const store = initializeStore();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onPost(deletePostsApiUrl(courseId, username, courseOrOrg, execute)).reply(statusCode, response);
await executeThunk(deleteUserPosts(courseId, username, courseOrOrg, execute), store.dispatch, store.getState);
return store.getState().learners;
}
export async function setUpPrivilages(axiosMock, store, hasModerationPrivileges, hasBulkDeletePrivileges) {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
hasModerationPrivileges,
hasBulkDeletePrivileges,
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react';
import { Delete } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ContentActions } 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: 'delete-org-posts',
action: ContentActions.DELETE_ORG_POSTS,
icon: Delete,
label: messages.deleteOrgPosts,
},
];
export function useLearnerActions(userHasBulkDeletePrivileges = false) {
const intl = useIntl();
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;
}

View File

@@ -553,7 +553,7 @@ code {
.actions-dropdown-item {
padding: 12px 16px;
height: 48px !important;
width: 195px !important
min-width: 195px !important
}
.font-xl {