Skip to content

Commit 909d133

Browse files
authored
feat: added bulk delete user posts feature for privileged users (#788)
* feat: added bulk delete user posts feature for privileged users
1 parent 3cda02b commit 909d133

18 files changed

+728
-42
lines changed

src/data/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export const ContentActions = {
5858
CHANGE_TOPIC: 'topic_id',
5959
CHANGE_TYPE: 'type',
6060
VOTE: 'voted',
61+
DELETE_COURSE_POSTS: 'delete-course-posts',
62+
DELETE_ORG_POSTS: 'delete-org-posts',
6163
};
6264

6365
/**
Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import React from 'react';
22
import PropTypes from 'prop-types';
33

4-
import { ActionRow, Button, ModalDialog } from '@openedx/paragon';
4+
import {
5+
ActionRow,
6+
ModalDialog,
7+
Spinner, StatefulButton,
8+
} from '@openedx/paragon';
59

610
import { useIntl } from '@edx/frontend-platform/i18n';
711

@@ -11,34 +15,56 @@ const Confirmation = ({
1115
isOpen,
1216
title,
1317
description,
18+
boldDescription,
1419
onClose,
1520
confirmAction,
1621
closeButtonVariant,
1722
confirmButtonVariant,
1823
confirmButtonText,
24+
isDataLoading,
25+
isConfirmButtonPending,
26+
pendingConfirmButtonText,
1927
}) => {
2028
const intl = useIntl();
2129

2230
return (
2331
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
24-
<ModalDialog.Header>
25-
<ModalDialog.Title>
26-
{title}
27-
</ModalDialog.Title>
28-
</ModalDialog.Header>
29-
<ModalDialog.Body>
30-
{description}
31-
</ModalDialog.Body>
32-
<ModalDialog.Footer>
33-
<ActionRow>
34-
<ModalDialog.CloseButton variant={closeButtonVariant}>
35-
{intl.formatMessage(messages.confirmationCancel)}
36-
</ModalDialog.CloseButton>
37-
<Button variant={confirmButtonVariant} onClick={confirmAction}>
38-
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
39-
</Button>
40-
</ActionRow>
41-
</ModalDialog.Footer>
32+
{isDataLoading && !isConfirmButtonPending ? (
33+
<ModalDialog.Body>
34+
<div className="d-flex justify-content-center p-4">
35+
<Spinner animation="border" variant="primary" size="lg" />
36+
</div>
37+
</ModalDialog.Body>
38+
) : (
39+
<>
40+
<ModalDialog.Header>
41+
<ModalDialog.Title>
42+
{title}
43+
</ModalDialog.Title>
44+
</ModalDialog.Header>
45+
<ModalDialog.Body>
46+
{description}
47+
{boldDescription && <><br /><p className="font-weight-bold pt-2">{boldDescription}</p></>}
48+
</ModalDialog.Body>
49+
<ModalDialog.Footer>
50+
<ActionRow>
51+
<ModalDialog.CloseButton variant={closeButtonVariant}>
52+
{intl.formatMessage(messages.confirmationCancel)}
53+
</ModalDialog.CloseButton>
54+
<StatefulButton
55+
labels={{
56+
default: confirmButtonText || intl.formatMessage(messages.confirmationConfirm),
57+
pending: pendingConfirmButtonText || confirmButtonText
58+
|| intl.formatMessage(messages.confirmationConfirm),
59+
}}
60+
state={isConfirmButtonPending ? 'pending' : confirmButtonVariant}
61+
variant={confirmButtonVariant}
62+
onClick={confirmAction}
63+
/>
64+
</ActionRow>
65+
</ModalDialog.Footer>
66+
</>
67+
)}
4268
</ModalDialog>
4369
);
4470
};
@@ -49,15 +75,23 @@ Confirmation.propTypes = {
4975
confirmAction: PropTypes.func.isRequired,
5076
title: PropTypes.string.isRequired,
5177
description: PropTypes.string.isRequired,
78+
boldDescription: PropTypes.string,
5279
closeButtonVariant: PropTypes.string,
5380
confirmButtonVariant: PropTypes.string,
5481
confirmButtonText: PropTypes.string,
82+
isDataLoading: PropTypes.bool,
83+
isConfirmButtonPending: PropTypes.bool,
84+
pendingConfirmButtonText: PropTypes.string,
5585
};
5686

5787
Confirmation.defaultProps = {
5888
closeButtonVariant: 'default',
5989
confirmButtonVariant: 'primary',
6090
confirmButtonText: '',
91+
boldDescription: '',
92+
isDataLoading: false,
93+
isConfirmButtonPending: false,
94+
pendingConfirmButtonText: '',
6195
};
6296

6397
export default React.memo(Confirmation);

src/discussions/data/selectors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const selectAnonymousPostingConfig = state => ({
1111

1212
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges;
1313

14+
export const selectUserHasBulkDeletePrivileges = state => state.config.hasBulkDeletePrivileges;
15+
1416
export const selectUserIsStaff = state => state.config.isUserAdmin;
1517

1618
export const selectUserIsGroupTa = state => state.config.isGroupTa;

src/discussions/data/slices.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const configSlice = createSlice({
1111
userRoles: [],
1212
groupAtSubsection: false,
1313
hasModerationPrivileges: false,
14+
hasBulkDeletePrivileges: false,
1415
isGroupTa: false,
1516
isCourseAdmin: false,
1617
isCourseStaff: false,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import React, {
2+
useCallback, useRef, useState,
3+
} from 'react';
4+
import PropTypes from 'prop-types';
5+
6+
import {
7+
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
8+
} from '@openedx/paragon';
9+
import { MoreHoriz } from '@openedx/paragon/icons';
10+
11+
import { useIntl } from '@edx/frontend-platform/i18n';
12+
13+
import { useLearnerActions } from './utils';
14+
15+
const LearnerActionsDropdown = ({
16+
actionHandlers,
17+
dropDownIconSize,
18+
userHasBulkDeletePrivileges,
19+
}) => {
20+
const buttonRef = useRef();
21+
const intl = useIntl();
22+
const [isOpen, open, close] = useToggle(false);
23+
const [target, setTarget] = useState(null);
24+
const actions = useLearnerActions(userHasBulkDeletePrivileges);
25+
26+
const handleActions = useCallback((action) => {
27+
const actionFunction = actionHandlers[action];
28+
if (actionFunction) {
29+
actionFunction();
30+
}
31+
}, [actionHandlers]);
32+
33+
const onClickButton = useCallback((event) => {
34+
event.preventDefault();
35+
setTarget(buttonRef.current);
36+
open();
37+
}, [open]);
38+
39+
const onCloseModal = useCallback(() => {
40+
close();
41+
setTarget(null);
42+
}, [close]);
43+
44+
return (
45+
<>
46+
<IconButton
47+
onClick={onClickButton}
48+
alt={intl.formatMessage({ id: 'discussions.learner.actions.alt', defaultMessage: 'Actions menu' })}
49+
src={MoreHoriz}
50+
iconAs={Icon}
51+
size="sm"
52+
ref={buttonRef}
53+
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''}
54+
/>
55+
<div className="actions-dropdown">
56+
<ModalPopup
57+
onClose={onCloseModal}
58+
positionRef={target}
59+
isOpen={isOpen}
60+
placement="bottom-start"
61+
>
62+
<div
63+
className="bg-white shadow d-flex flex-column mt-1"
64+
data-testid="learner-actions-dropdown-modal-popup"
65+
>
66+
{actions.map(action => (
67+
<React.Fragment key={action.id}>
68+
<Dropdown.Item
69+
as={Button}
70+
variant="tertiary"
71+
size="inline"
72+
onClick={() => {
73+
close();
74+
handleActions(action.action);
75+
}}
76+
className="d-flex justify-content-start actions-dropdown-item"
77+
data-testId={action.id}
78+
>
79+
<Icon
80+
src={action.icon}
81+
className="icon-size-24"
82+
/>
83+
<span className="font-weight-normal ml-2">
84+
{action.label.defaultMessage}
85+
</span>
86+
</Dropdown.Item>
87+
</React.Fragment>
88+
))}
89+
</div>
90+
</ModalPopup>
91+
</div>
92+
</>
93+
);
94+
};
95+
96+
LearnerActionsDropdown.propTypes = {
97+
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
98+
dropDownIconSize: PropTypes.bool,
99+
userHasBulkDeletePrivileges: PropTypes.bool,
100+
};
101+
102+
LearnerActionsDropdown.defaultProps = {
103+
dropDownIconSize: false,
104+
userHasBulkDeletePrivileges: false,
105+
};
106+
107+
export default LearnerActionsDropdown;
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {
2+
fireEvent, render, screen, waitFor,
3+
} from '@testing-library/react';
4+
import MockAdapter from 'axios-mock-adapter';
5+
import { act } from 'react-dom/test-utils';
6+
import { IntlProvider } from 'react-intl';
7+
import { Factory } from 'rosie';
8+
9+
import { initializeMockApp } from '@edx/frontend-platform';
10+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
11+
import { AppProvider } from '@edx/frontend-platform/react';
12+
13+
import { ContentActions } from '../../data/constants';
14+
import { initializeStore } from '../../store';
15+
import executeThunk from '../../test-utils';
16+
import { getCourseConfigApiUrl } from '../data/api';
17+
import fetchCourseConfig from '../data/thunks';
18+
import LearnerActionsDropdown from './LearnerActionsDropdown';
19+
20+
let store;
21+
let axiosMock;
22+
const courseId = 'course-v1:edX+TestX+Test_Course';
23+
const username = 'abc123';
24+
25+
const renderComponent = ({
26+
contentType = 'LEARNER',
27+
userHasBulkDeletePrivileges = false,
28+
actionHandlers = {},
29+
} = {}) => {
30+
render(
31+
<IntlProvider locale="en">
32+
<AppProvider store={store}>
33+
<LearnerActionsDropdown
34+
contentType={contentType}
35+
userHasBulkDeletePrivileges={userHasBulkDeletePrivileges}
36+
actionHandlers={actionHandlers}
37+
/>
38+
</AppProvider>
39+
</IntlProvider>,
40+
);
41+
};
42+
43+
const findOpenActionsDropdownButton = async () => (
44+
screen.findByRole('button', { name: 'Actions menu' })
45+
);
46+
47+
describe('LearnerActionsDropdown', () => {
48+
beforeEach(async () => {
49+
initializeMockApp({
50+
authenticatedUser: {
51+
userId: 3,
52+
username,
53+
administrator: false,
54+
roles: [],
55+
},
56+
});
57+
store = initializeStore();
58+
Factory.resetAll();
59+
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
60+
61+
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`)
62+
.reply(200, { isPostingEnabled: true });
63+
64+
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
65+
});
66+
67+
it('can open dropdown if enabled', async () => {
68+
renderComponent({ userHasBulkDeletePrivileges: true });
69+
70+
const openButton = await findOpenActionsDropdownButton();
71+
await act(async () => {
72+
fireEvent.click(openButton);
73+
});
74+
75+
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument());
76+
});
77+
78+
it('shows delete action for privileged users', async () => {
79+
const mockHandler = jest.fn();
80+
renderComponent({
81+
userHasBulkDeletePrivileges: true,
82+
actionHandlers: { deleteCoursePosts: mockHandler, deleteOrgPosts: mockHandler },
83+
});
84+
85+
const openButton = await findOpenActionsDropdownButton();
86+
await act(async () => {
87+
fireEvent.click(openButton);
88+
});
89+
90+
await waitFor(() => {
91+
const deleteCourseItem = screen.queryByTestId('delete-course-posts');
92+
const deleteOrgItem = screen.queryByTestId('delete-org-posts');
93+
expect(deleteCourseItem).toBeInTheDocument();
94+
expect(deleteOrgItem).toBeInTheDocument();
95+
});
96+
});
97+
98+
it('triggers deleteCoursePosts handler when delete-course-posts is clicked', async () => {
99+
const mockDeleteCourseHandler = jest.fn();
100+
const mockDeleteOrgHandler = jest.fn();
101+
renderComponent({
102+
userHasBulkDeletePrivileges: true,
103+
actionHandlers: {
104+
[ContentActions.DELETE_COURSE_POSTS]: mockDeleteCourseHandler,
105+
[ContentActions.DELETE_ORG_POSTS]: mockDeleteOrgHandler,
106+
},
107+
});
108+
109+
const openButton = await findOpenActionsDropdownButton();
110+
await act(async () => {
111+
fireEvent.click(openButton);
112+
});
113+
114+
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument());
115+
116+
const deleteCourseItem = await screen.findByTestId('delete-course-posts');
117+
await act(async () => {
118+
fireEvent.click(deleteCourseItem);
119+
});
120+
121+
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).not.toBeInTheDocument());
122+
expect(mockDeleteCourseHandler).toHaveBeenCalled();
123+
expect(mockDeleteOrgHandler).not.toHaveBeenCalled();
124+
});
125+
126+
it('triggers deleteOrgPosts handler when delete-org-posts is clicked', async () => {
127+
const mockDeleteCourseHandler = jest.fn();
128+
const mockDeleteOrgHandler = jest.fn();
129+
renderComponent({
130+
userHasBulkDeletePrivileges: true,
131+
actionHandlers: {
132+
[ContentActions.DELETE_COURSE_POSTS]: mockDeleteCourseHandler,
133+
[ContentActions.DELETE_ORG_POSTS]: mockDeleteOrgHandler,
134+
},
135+
});
136+
137+
const openButton = await findOpenActionsDropdownButton();
138+
await act(async () => {
139+
fireEvent.click(openButton);
140+
});
141+
142+
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument());
143+
144+
const deleteOrgItem = await screen.findByTestId('delete-org-posts');
145+
await act(async () => {
146+
fireEvent.click(deleteOrgItem);
147+
});
148+
149+
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).not.toBeInTheDocument());
150+
expect(mockDeleteOrgHandler).toHaveBeenCalled();
151+
expect(mockDeleteCourseHandler).not.toHaveBeenCalled();
152+
});
153+
});

0 commit comments

Comments
 (0)