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:
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,7 +11,6 @@ const configSlice = createSlice({
|
||||
userRoles: [],
|
||||
groupAtSubsection: false,
|
||||
hasModerationPrivileges: false,
|
||||
hasBulkDeletePrivileges: false,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin: false,
|
||||
isCourseStaff: false,
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export const BulkDeleteType = {
|
||||
COURSE: 'course',
|
||||
ORG: 'org',
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -553,7 +553,7 @@ code {
|
||||
.actions-dropdown-item {
|
||||
padding: 12px 16px;
|
||||
height: 48px !important;
|
||||
min-width: 195px !important
|
||||
width: 195px !important
|
||||
}
|
||||
|
||||
.font-xl {
|
||||
|
||||
Reference in New Issue
Block a user