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

This reverts commit 909d133acc.

See https://github.com/openedx/edx-platform/issues/37402 for details

We leave the original PR's changes to Confirmation.jsx in place
because they are OK and are now tangled in with recent changes
and more difficult to revert.
This commit is contained in:
Kyle McCormick
2025-09-29 18:29:43 -04:00
committed by GitHub
parent df53c7cff8
commit 4d51cf8855
17 changed files with 23 additions and 674 deletions

View File

@@ -58,8 +58,6 @@ 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

@@ -11,8 +11,6 @@ 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,7 +11,6 @@ const configSlice = createSlice({
userRoles: [],
groupAtSubsection: false,
hasModerationPrivileges: false,
hasBulkDeletePrivileges: false,
isGroupTa: false,
isCourseAdmin: false,
isCourseStaff: false,

View File

@@ -1,107 +0,0 @@
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

@@ -1,153 +0,0 @@
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, useState,
useCallback, useContext, useEffect, useMemo,
} from 'react';
import {
Button, Icon, IconButton, Spinner, useToggle,
Button, Icon, IconButton, Spinner,
} from '@openedx/paragon';
import { ArrowBack } from '@openedx/paragon/icons';
import capitalize from 'lodash/capitalize';
@@ -13,18 +13,11 @@ 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 {
selectUserHasBulkDeletePrivileges,
selectUserHasModerationPrivileges,
selectUserIsStaff,
} from '../data/selectors';
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import usePostList from '../posts/data/hooks';
import {
selectAllThreadsIds,
@@ -35,11 +28,8 @@ import { clearPostsPages } from '../posts/data/slices';
import NoResults from '../posts/NoResults';
import { PostLink } from '../posts/post';
import { discussionsPath } from '../utils';
import { BulkDeleteType } from './data/constants';
import { learnersLoadingStatus, selectBulkDeleteStats } from './data/selectors';
import { deleteUserPosts, fetchUserPosts } from './data/thunks';
import { fetchUserPosts } from './data/thunks';
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
import LearnerActionsDropdown from './LearnerActionsDropdown';
import messages from './messages';
const LearnerPostsView = () => {
@@ -48,20 +38,14 @@ 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 = {
@@ -75,23 +59,6 @@ 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
@@ -110,31 +77,19 @@ const LearnerPostsView = () => {
return (
<div className="discussion-posts d-flex flex-column">
<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">
<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">
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
</div>
{userHasBulkDeletePrivileges ? (
<div className="col-2">
<LearnerActionsDropdown
id={username}
actionHandlers={actionHandlers}
userHasBulkDeletePrivileges={userHasBulkDeletePrivileges}
dropDownIconSize
/>
</div>
)
: (<div style={{ padding: '18px' }} />)}
<div style={{ padding: '18px' }} />
</div>
<div className="bg-light-400 border border-light-300" />
<LearnerPostFilterBar />
@@ -154,22 +109,6 @@ const LearnerPostsView = () => {
)
)}
</div>
<Confirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deletePostsTitle)}
description={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, within,
fireEvent, render, screen, waitFor,
} 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 { deletePostsApiUrl, learnerPostsApiUrl } from './data/api';
import { learnerPostsApiUrl } from './data/api';
import { fetchUserPosts } from './data/thunks';
import LearnerPostsView from './LearnerPostsView';
import { setUpPrivilages } from './test-utils';
@@ -220,128 +220,4 @@ 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,7 +11,6 @@ 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.
@@ -83,23 +82,3 @@ 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 { setupDeleteUserPostsMockResponse, setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
import { setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
import './__factories__';
@@ -80,25 +80,4 @@ 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

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

View File

@@ -16,5 +16,3 @@ 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,10 +25,6 @@ const learnersSlice = createSlice({
cohort: '',
},
usernameSearch: null,
bulkDeleteStats: {
commentCount: 0,
threadCount: 0,
},
},
reducers: {
fetchLearnersSuccess: (state, { payload }) => (
@@ -88,25 +84,6 @@ 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,
}
),
},
});
@@ -118,9 +95,6 @@ export const {
setSortedBy,
setUsernameSearch,
setPostFilter,
deleteUserPostsRequest,
deleteUserPostsSuccess,
deleteUserPostsFailed,
} = learnersSlice.actions;
export const learnersReducer = learnersSlice.reducer;

View File

@@ -12,16 +12,8 @@ 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,
@@ -129,19 +121,3 @@ 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,45 +62,6 @@ 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,13 +7,8 @@ import { initializeStore } from '../../store';
import executeThunk from '../../test-utils';
import { getDiscussionsConfigUrl } from '../data/api';
import fetchCourseConfig from '../data/thunks';
import {
deletePostsApiUrl,
getUserProfileApiUrl,
learnerPostsApiUrl,
learnersApiUrl,
} from './data/api';
import { deleteUserPosts, fetchLearners, fetchUserPosts } from './data/thunks';
import { getUserProfileApiUrl, learnerPostsApiUrl, learnersApiUrl } from './data/api';
import { fetchLearners, fetchUserPosts } from './data/thunks';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -59,26 +54,9 @@ export async function setupPostsMockResponse({
return store.getState().threads;
}
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) {
export async function setUpPrivilages(axiosMock, store, hasModerationPrivileges) {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
hasModerationPrivileges,
hasBulkDeletePrivileges,
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);

View File

@@ -1,42 +0,0 @@
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;
min-width: 195px !important
width: 195px !important
}
.font-xl {