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_TOPIC: 'topic_id',
|
||||||
CHANGE_TYPE: 'type',
|
CHANGE_TYPE: 'type',
|
||||||
VOTE: 'voted',
|
VOTE: 'voted',
|
||||||
|
DELETE_COURSE_POSTS: 'delete-course-posts',
|
||||||
|
DELETE_ORG_POSTS: 'delete-org-posts',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
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';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
@@ -11,34 +15,56 @@ const Confirmation = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
boldDescription,
|
||||||
onClose,
|
onClose,
|
||||||
confirmAction,
|
confirmAction,
|
||||||
closeButtonVariant,
|
closeButtonVariant,
|
||||||
confirmButtonVariant,
|
confirmButtonVariant,
|
||||||
confirmButtonText,
|
confirmButtonText,
|
||||||
|
isDataLoading,
|
||||||
|
isConfirmButtonPending,
|
||||||
|
pendingConfirmButtonText,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
||||||
<ModalDialog.Header>
|
{isDataLoading && !isConfirmButtonPending ? (
|
||||||
<ModalDialog.Title>
|
<ModalDialog.Body>
|
||||||
{title}
|
<div className="d-flex justify-content-center p-4">
|
||||||
</ModalDialog.Title>
|
<Spinner animation="border" variant="primary" size="lg" />
|
||||||
</ModalDialog.Header>
|
</div>
|
||||||
<ModalDialog.Body>
|
</ModalDialog.Body>
|
||||||
{description}
|
) : (
|
||||||
</ModalDialog.Body>
|
<>
|
||||||
<ModalDialog.Footer>
|
<ModalDialog.Header>
|
||||||
<ActionRow>
|
<ModalDialog.Title>
|
||||||
<ModalDialog.CloseButton variant={closeButtonVariant}>
|
{title}
|
||||||
{intl.formatMessage(messages.confirmationCancel)}
|
</ModalDialog.Title>
|
||||||
</ModalDialog.CloseButton>
|
</ModalDialog.Header>
|
||||||
<Button variant={confirmButtonVariant} onClick={confirmAction}>
|
<ModalDialog.Body>
|
||||||
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
|
{description}
|
||||||
</Button>
|
{boldDescription && <><br /><p className="font-weight-bold pt-2">{boldDescription}</p></>}
|
||||||
</ActionRow>
|
</ModalDialog.Body>
|
||||||
</ModalDialog.Footer>
|
<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>
|
</ModalDialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -49,15 +75,23 @@ Confirmation.propTypes = {
|
|||||||
confirmAction: PropTypes.func.isRequired,
|
confirmAction: PropTypes.func.isRequired,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
description: PropTypes.string.isRequired,
|
description: PropTypes.string.isRequired,
|
||||||
|
boldDescription: PropTypes.string,
|
||||||
closeButtonVariant: PropTypes.string,
|
closeButtonVariant: PropTypes.string,
|
||||||
confirmButtonVariant: PropTypes.string,
|
confirmButtonVariant: PropTypes.string,
|
||||||
confirmButtonText: PropTypes.string,
|
confirmButtonText: PropTypes.string,
|
||||||
|
isDataLoading: PropTypes.bool,
|
||||||
|
isConfirmButtonPending: PropTypes.bool,
|
||||||
|
pendingConfirmButtonText: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
Confirmation.defaultProps = {
|
Confirmation.defaultProps = {
|
||||||
closeButtonVariant: 'default',
|
closeButtonVariant: 'default',
|
||||||
confirmButtonVariant: 'primary',
|
confirmButtonVariant: 'primary',
|
||||||
confirmButtonText: '',
|
confirmButtonText: '',
|
||||||
|
boldDescription: '',
|
||||||
|
isDataLoading: false,
|
||||||
|
isConfirmButtonPending: false,
|
||||||
|
pendingConfirmButtonText: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(Confirmation);
|
export default React.memo(Confirmation);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const selectAnonymousPostingConfig = state => ({
|
|||||||
|
|
||||||
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges;
|
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges;
|
||||||
|
|
||||||
|
export const selectUserHasBulkDeletePrivileges = state => state.config.hasBulkDeletePrivileges;
|
||||||
|
|
||||||
export const selectUserIsStaff = state => state.config.isUserAdmin;
|
export const selectUserIsStaff = state => state.config.isUserAdmin;
|
||||||
|
|
||||||
export const selectUserIsGroupTa = state => state.config.isGroupTa;
|
export const selectUserIsGroupTa = state => state.config.isGroupTa;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const configSlice = createSlice({
|
|||||||
userRoles: [],
|
userRoles: [],
|
||||||
groupAtSubsection: false,
|
groupAtSubsection: false,
|
||||||
hasModerationPrivileges: false,
|
hasModerationPrivileges: false,
|
||||||
|
hasBulkDeletePrivileges: false,
|
||||||
isGroupTa: false,
|
isGroupTa: false,
|
||||||
isCourseAdmin: false,
|
isCourseAdmin: false,
|
||||||
isCourseStaff: 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, {
|
import React, {
|
||||||
useCallback, useContext, useEffect, useMemo,
|
useCallback, useContext, useEffect, useMemo, useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button, Icon, IconButton, Spinner,
|
Button, Icon, IconButton, Spinner, useToggle,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { ArrowBack } from '@openedx/paragon/icons';
|
import { ArrowBack } from '@openedx/paragon/icons';
|
||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
@@ -13,11 +13,18 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ContentActions,
|
||||||
RequestStatus,
|
RequestStatus,
|
||||||
Routes,
|
Routes,
|
||||||
} from '../../data/constants';
|
} from '../../data/constants';
|
||||||
|
import useDispatchWithState from '../../data/hooks';
|
||||||
|
import { Confirmation } from '../common';
|
||||||
import DiscussionContext from '../common/context';
|
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 usePostList from '../posts/data/hooks';
|
||||||
import {
|
import {
|
||||||
selectAllThreadsIds,
|
selectAllThreadsIds,
|
||||||
@@ -28,8 +35,11 @@ import { clearPostsPages } from '../posts/data/slices';
|
|||||||
import NoResults from '../posts/NoResults';
|
import NoResults from '../posts/NoResults';
|
||||||
import { PostLink } from '../posts/post';
|
import { PostLink } from '../posts/post';
|
||||||
import { discussionsPath } from '../utils';
|
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 LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
|
||||||
|
import LearnerActionsDropdown from './LearnerActionsDropdown';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const LearnerPostsView = () => {
|
const LearnerPostsView = () => {
|
||||||
@@ -38,14 +48,20 @@ const LearnerPostsView = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const [bulkDeleting, dispatchDelete] = useDispatchWithState();
|
||||||
const postsIds = useSelector(selectAllThreadsIds);
|
const postsIds = useSelector(selectAllThreadsIds);
|
||||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||||
|
const learnerLoadingStatus = useSelector(learnersLoadingStatus());
|
||||||
const postFilter = useSelector(state => state.learners.postFilter);
|
const postFilter = useSelector(state => state.learners.postFilter);
|
||||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
||||||
const nextPage = useSelector(selectThreadNextPage());
|
const nextPage = useSelector(selectThreadNextPage());
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsStaff = useSelector(selectUserIsStaff);
|
const userIsStaff = useSelector(selectUserIsStaff);
|
||||||
|
const userHasBulkDeletePrivileges = useSelector(selectUserHasBulkDeletePrivileges);
|
||||||
|
const bulkDeleteStats = useSelector(selectBulkDeleteStats());
|
||||||
const sortedPostsIds = usePostList(postsIds);
|
const sortedPostsIds = usePostList(postsIds);
|
||||||
|
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||||
|
const [isDeletingCourseOrOrg, setIsDeletingCourseOrOrg] = useState(BulkDeleteType.COURSE);
|
||||||
|
|
||||||
const loadMorePosts = useCallback((pageNum = undefined) => {
|
const loadMorePosts = useCallback((pageNum = undefined) => {
|
||||||
const params = {
|
const params = {
|
||||||
@@ -59,6 +75,23 @@ const LearnerPostsView = () => {
|
|||||||
dispatch(fetchUserPosts(courseId, params));
|
dispatch(fetchUserPosts(courseId, params));
|
||||||
}, [courseId, postFilter, username, userHasModerationPrivileges, userIsStaff]);
|
}, [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(() => (
|
const postInstances = useMemo(() => (
|
||||||
sortedPostsIds?.map((postId, idx) => (
|
sortedPostsIds?.map((postId, idx) => (
|
||||||
<PostLink
|
<PostLink
|
||||||
@@ -77,19 +110,31 @@ const LearnerPostsView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="discussion-posts d-flex flex-column">
|
<div className="discussion-posts d-flex flex-column">
|
||||||
<div className="d-flex align-items-center justify-content-between px-2.5">
|
<div className="row d-flex align-items-center justify-content-between px-2.5">
|
||||||
<IconButton
|
<div className="col-1">
|
||||||
src={ArrowBack}
|
<IconButton
|
||||||
iconAs={Icon}
|
src={ArrowBack}
|
||||||
style={{ padding: '18px' }}
|
iconAs={Icon}
|
||||||
size="inline"
|
style={{ padding: '18px' }}
|
||||||
onClick={() => navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) })}
|
size="inline"
|
||||||
alt={intl.formatMessage(messages.back)}
|
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>
|
||||||
|
<div className=" col-auto text-primary-500 font-style font-weight-bold py-2.5">
|
||||||
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
|
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
|
||||||
</div>
|
</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>
|
||||||
<div className="bg-light-400 border border-light-300" />
|
<div className="bg-light-400 border border-light-300" />
|
||||||
<LearnerPostFilterBar />
|
<LearnerPostFilterBar />
|
||||||
@@ -109,6 +154,23 @@ const LearnerPostsView = () => {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fireEvent, render, screen, waitFor,
|
fireEvent, render, screen, waitFor, within,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
@@ -20,7 +20,7 @@ import executeThunk from '../../test-utils';
|
|||||||
import { getCohortsApiUrl } from '../cohorts/data/api';
|
import { getCohortsApiUrl } from '../cohorts/data/api';
|
||||||
import fetchCourseCohorts from '../cohorts/data/thunks';
|
import fetchCourseCohorts from '../cohorts/data/thunks';
|
||||||
import DiscussionContext from '../common/context';
|
import DiscussionContext from '../common/context';
|
||||||
import { learnerPostsApiUrl } from './data/api';
|
import { deletePostsApiUrl, learnerPostsApiUrl } from './data/api';
|
||||||
import { fetchUserPosts } from './data/thunks';
|
import { fetchUserPosts } from './data/thunks';
|
||||||
import LearnerPostsView from './LearnerPostsView';
|
import LearnerPostsView from './LearnerPostsView';
|
||||||
import { setUpPrivilages } from './test-utils';
|
import { setUpPrivilages } from './test-utils';
|
||||||
@@ -220,4 +220,128 @@ describe('Learner Posts View', () => {
|
|||||||
expect(loadMoreButton).not.toBeInTheDocument();
|
expect(loadMoreButton).not.toBeInTheDocument();
|
||||||
expect(container.querySelectorAll('.discussion-post')).toHaveLength(4);
|
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 getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/v1/accounts`;
|
||||||
export const learnerPostsApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/learner/`;
|
export const learnerPostsApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/learner/`;
|
||||||
export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/activity_stats/`;
|
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.
|
* Fetches all the learners in the given course.
|
||||||
@@ -82,3 +83,23 @@ export async function getUserPosts(courseId, {
|
|||||||
.get(learnerPostsApiUrl(courseId), { params });
|
.get(learnerPostsApiUrl(courseId), { params });
|
||||||
return data;
|
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 { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||||
|
|
||||||
import { setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
|
import { setupDeleteUserPostsMockResponse, setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
|
||||||
|
|
||||||
import './__factories__';
|
import './__factories__';
|
||||||
|
|
||||||
@@ -80,4 +80,25 @@ describe('Learner api test cases', () => {
|
|||||||
|
|
||||||
expect(threads.status).toEqual('denied');
|
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 => (
|
export const selectLearnerAvatar = author => state => (
|
||||||
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
|
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const selectBulkDeleteStats = () => state => state.learners.bulkDeleteStats;
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const learnersSlice = createSlice({
|
|||||||
cohort: '',
|
cohort: '',
|
||||||
},
|
},
|
||||||
usernameSearch: null,
|
usernameSearch: null,
|
||||||
|
bulkDeleteStats: {
|
||||||
|
commentCount: 0,
|
||||||
|
threadCount: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
fetchLearnersSuccess: (state, { payload }) => (
|
fetchLearnersSuccess: (state, { payload }) => (
|
||||||
@@ -84,6 +88,25 @@ const learnersSlice = createSlice({
|
|||||||
postFilter: payload,
|
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,
|
setSortedBy,
|
||||||
setUsernameSearch,
|
setUsernameSearch,
|
||||||
setPostFilter,
|
setPostFilter,
|
||||||
|
deleteUserPostsRequest,
|
||||||
|
deleteUserPostsSuccess,
|
||||||
|
deleteUserPostsFailed,
|
||||||
} = learnersSlice.actions;
|
} = learnersSlice.actions;
|
||||||
|
|
||||||
export const learnersReducer = learnersSlice.reducer;
|
export const learnersReducer = learnersSlice.reducer;
|
||||||
|
|||||||
@@ -12,8 +12,16 @@ import {
|
|||||||
} from '../../posts/data/slices';
|
} from '../../posts/data/slices';
|
||||||
import { normaliseThreads } from '../../posts/data/thunks';
|
import { normaliseThreads } from '../../posts/data/thunks';
|
||||||
import { getHttpErrorStatus } from '../../utils';
|
import { getHttpErrorStatus } from '../../utils';
|
||||||
import { getLearners, getUserPosts, getUserProfiles } from './api';
|
|
||||||
import {
|
import {
|
||||||
|
deleteUserPostsApi,
|
||||||
|
getLearners,
|
||||||
|
getUserPosts,
|
||||||
|
getUserProfiles,
|
||||||
|
} from './api';
|
||||||
|
import {
|
||||||
|
deleteUserPostsFailed,
|
||||||
|
deleteUserPostsRequest,
|
||||||
|
deleteUserPostsSuccess,
|
||||||
fetchLearnersDenied,
|
fetchLearnersDenied,
|
||||||
fetchLearnersFailed,
|
fetchLearnersFailed,
|
||||||
fetchLearnersRequest,
|
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',
|
defaultMessage: 'Posts',
|
||||||
description: 'Tooltip text for all posts icon',
|
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;
|
export default messages;
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ import { initializeStore } from '../../store';
|
|||||||
import executeThunk from '../../test-utils';
|
import executeThunk from '../../test-utils';
|
||||||
import { getDiscussionsConfigUrl } from '../data/api';
|
import { getDiscussionsConfigUrl } from '../data/api';
|
||||||
import fetchCourseConfig from '../data/thunks';
|
import fetchCourseConfig from '../data/thunks';
|
||||||
import { getUserProfileApiUrl, learnerPostsApiUrl, learnersApiUrl } from './data/api';
|
import {
|
||||||
import { fetchLearners, fetchUserPosts } from './data/thunks';
|
deletePostsApiUrl,
|
||||||
|
getUserProfileApiUrl,
|
||||||
|
learnerPostsApiUrl,
|
||||||
|
learnersApiUrl,
|
||||||
|
} from './data/api';
|
||||||
|
import { deleteUserPosts, fetchLearners, fetchUserPosts } from './data/thunks';
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
|
||||||
@@ -54,9 +59,26 @@ export async function setupPostsMockResponse({
|
|||||||
return store.getState().threads;
|
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, {
|
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||||
hasModerationPrivileges,
|
hasModerationPrivileges,
|
||||||
|
hasBulkDeletePrivileges,
|
||||||
});
|
});
|
||||||
|
|
||||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
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 {
|
.actions-dropdown-item {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
height: 48px !important;
|
height: 48px !important;
|
||||||
width: 195px !important
|
min-width: 195px !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-xl {
|
.font-xl {
|
||||||
|
|||||||
Reference in New Issue
Block a user