diff --git a/src/data/constants.js b/src/data/constants.js index 50b2078c..269212d8 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -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', }; /** diff --git a/src/discussions/common/Confirmation.jsx b/src/discussions/common/Confirmation.jsx index a0cfc55b..4798e5cd 100644 --- a/src/discussions/common/Confirmation.jsx +++ b/src/discussions/common/Confirmation.jsx @@ -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 ( - - - {title} - - - - {description} - - - - - {intl.formatMessage(messages.confirmationCancel)} - - - - + {isDataLoading && !isConfirmButtonPending ? ( + +
+ +
+
+ ) : ( + <> + + + {title} + + + + {description} + {boldDescription && <>

{boldDescription}

} +
+ + + + {intl.formatMessage(messages.confirmationCancel)} + + + + + + )}
); }; @@ -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); diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js index b82201e0..7cbe2ec8 100644 --- a/src/discussions/data/selectors.js +++ b/src/discussions/data/selectors.js @@ -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; diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js index f895c3f2..ccbe6bc6 100644 --- a/src/discussions/data/slices.js +++ b/src/discussions/data/slices.js @@ -11,6 +11,7 @@ const configSlice = createSlice({ userRoles: [], groupAtSubsection: false, hasModerationPrivileges: false, + hasBulkDeletePrivileges: false, isGroupTa: false, isCourseAdmin: false, isCourseStaff: false, diff --git a/src/discussions/learners/LearnerActionsDropdown.jsx b/src/discussions/learners/LearnerActionsDropdown.jsx new file mode 100644 index 00000000..9571ceef --- /dev/null +++ b/src/discussions/learners/LearnerActionsDropdown.jsx @@ -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 ( + <> + +
+ +
+ {actions.map(action => ( + + { + close(); + handleActions(action.action); + }} + className="d-flex justify-content-start actions-dropdown-item" + data-testId={action.id} + > + + + {action.label.defaultMessage} + + + + ))} +
+
+
+ + ); +}; + +LearnerActionsDropdown.propTypes = { + actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired, + dropDownIconSize: PropTypes.bool, + userHasBulkDeletePrivileges: PropTypes.bool, +}; + +LearnerActionsDropdown.defaultProps = { + dropDownIconSize: false, + userHasBulkDeletePrivileges: false, +}; + +export default LearnerActionsDropdown; diff --git a/src/discussions/learners/LearnerActionsDropdown.test.jsx b/src/discussions/learners/LearnerActionsDropdown.test.jsx new file mode 100644 index 00000000..65b7ab0f --- /dev/null +++ b/src/discussions/learners/LearnerActionsDropdown.test.jsx @@ -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( + + + + + , + ); +}; + +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(); + }); +}); diff --git a/src/discussions/learners/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx index 9122b457..092e812c 100644 --- a/src/discussions/learners/LearnerPostsView.jsx +++ b/src/discussions/learners/LearnerPostsView.jsx @@ -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) => ( { return (
-
- navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) })} - alt={intl.formatMessage(messages.back)} - /> -
+
+
+ navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) })} + alt={intl.formatMessage(messages.back)} + /> +
+
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
-
+ {userHasBulkDeletePrivileges ? ( +
+ +
+ ) + : (
)}
@@ -109,6 +154,23 @@ const LearnerPostsView = () => { ) )}
+ handleDeletePosts(isDeletingCourseOrOrg)} + confirmButtonText={intl.formatMessage(messages.deletePostsConfirm)} + confirmButtonVariant="danger" + isDataLoading={!(learnerLoadingStatus === RequestStatus.SUCCESSFUL)} + isConfirmButtonPending={bulkDeleting} + pendingConfirmButtonText={intl.formatMessage(messages.deletePostConfirmPending)} + />
); }; diff --git a/src/discussions/learners/LearnerPostsView.test.jsx b/src/discussions/learners/LearnerPostsView.test.jsx index 5977d0f5..dcca0656 100644 --- a/src/discussions/learners/LearnerPostsView.test.jsx +++ b/src/discussions/learners/LearnerPostsView.test.jsx @@ -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(); + }); + }); }); diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js index 53a4d9c8..05121079 100644 --- a/src/discussions/learners/data/api.js +++ b/src/discussions/learners/data/api.js @@ -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; +} diff --git a/src/discussions/learners/data/api.test.jsx b/src/discussions/learners/data/api.test.jsx index e03e883f..444debaf 100644 --- a/src/discussions/learners/data/api.test.jsx +++ b/src/discussions/learners/data/api.test.jsx @@ -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'); + }); }); diff --git a/src/discussions/learners/data/constants.js b/src/discussions/learners/data/constants.js new file mode 100644 index 00000000..c957e256 --- /dev/null +++ b/src/discussions/learners/data/constants.js @@ -0,0 +1,4 @@ +export const BulkDeleteType = { + COURSE: 'course', + ORG: 'org', +}; diff --git a/src/discussions/learners/data/selectors.js b/src/discussions/learners/data/selectors.js index bf3ea98e..10763445 100644 --- a/src/discussions/learners/data/selectors.js +++ b/src/discussions/learners/data/selectors.js @@ -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; diff --git a/src/discussions/learners/data/slices.js b/src/discussions/learners/data/slices.js index cc94aaee..534fe850 100644 --- a/src/discussions/learners/data/slices.js +++ b/src/discussions/learners/data/slices.js @@ -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; diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js index 93603c9c..afc554b6 100644 --- a/src/discussions/learners/data/thunks.js +++ b/src/discussions/learners/data/thunks.js @@ -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); + } +}; diff --git a/src/discussions/learners/messages.js b/src/discussions/learners/messages.js index 816730af..38403e56 100644 --- a/src/discussions/learners/messages.js +++ b/src/discussions/learners/messages.js @@ -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; diff --git a/src/discussions/learners/test-utils.js b/src/discussions/learners/test-utils.js index a14ba17e..253c4585 100644 --- a/src/discussions/learners/test-utils.js +++ b/src/discussions/learners/test-utils.js @@ -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); diff --git a/src/discussions/learners/utils.js b/src/discussions/learners/utils.js new file mode 100644 index 00000000..7f4cf4e7 --- /dev/null +++ b/src/discussions/learners/utils.js @@ -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; +} diff --git a/src/index.scss b/src/index.scss index a882aafd..6ee3813c 100755 --- a/src/index.scss +++ b/src/index.scss @@ -553,7 +553,7 @@ code { .actions-dropdown-item { padding: 12px 16px; height: 48px !important; - width: 195px !important + min-width: 195px !important } .font-xl {