Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

/**
Expand Down
2 changes: 2 additions & 0 deletions src/discussions/data/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/discussions/data/slices.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const configSlice = createSlice({
userRoles: [],
groupAtSubsection: false,
hasModerationPrivileges: false,
hasBulkDeletePrivileges: false,
isGroupTa: false,
isCourseAdmin: false,
isCourseStaff: false,
Expand Down
107 changes: 107 additions & 0 deletions src/discussions/learners/LearnerActionsDropdown.jsx
Original file line number Diff line number Diff line change
@@ -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}

Copilot AI Oct 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'data-testId' to 'data-testid' (lowercase 'i' in 'id').

Suggested change
data-testId={action.id}
data-testid={action.id}

Copilot uses AI. Check for mistakes.
>
<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 changes: 153 additions & 0 deletions src/discussions/learners/LearnerActionsDropdown.test.jsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading