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