-
-
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 {