Skip to content
Draft
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
3 changes: 3 additions & 0 deletions src/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export const ContentActions = {
VOTE: 'voted',
DELETE_COURSE_POSTS: 'delete-course-posts',
DELETE_ORG_POSTS: 'delete-org-posts',
MUTE_USER: 'mute_user',
UNMUTE_USER: 'unmute_user',
MUTE_AND_REPORT: 'mute_and_report',
};

/**
Expand Down
30 changes: 27 additions & 3 deletions src/discussions/common/ActionsDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
import {
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
} from '@openedx/paragon';
import { MoreHoriz } from '@openedx/paragon/icons';
import { ChevronRight, MoreHoriz } from '@openedx/paragon/icons';
import { useSelector } from 'react-redux';

import { useIntl } from '@edx/frontend-platform/i18n';
Expand Down Expand Up @@ -69,6 +69,24 @@ const ActionsDropdown = ({
className="bg-white shadow d-flex flex-column mt-1"
data-testid="actions-dropdown-modal-popup"
>
<style>
{`
.mute-icon-solid {
fill: currentColor !important;
stroke: currentColor !important;
stroke-width: 0 !important;
}
.mute-icon-solid circle {
fill: currentColor !important;
stroke: currentColor !important;
stroke-width: 2px !important;
}
.mute-icon-solid path {
fill: currentColor !important;
stroke: none !important;
}
`}
</style>
{actions.map(action => (
<React.Fragment key={action.id}>
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
Expand All @@ -85,11 +103,17 @@ const ActionsDropdown = ({
>
<Icon
src={action.icon}
className="icon-size-24"
className={`icon-size-24 ${(action.id === 'mute' || action.id === 'unmute') ? 'mute-icon-solid' : ''}`}
/>
<span className="font-weight-normal ml-2">
<span className="font-weight-normal ml-2 flex-grow-1">
{intl.formatMessage(action.label)}
</span>
{action.hasChevron && (
<Icon
src={ChevronRight}
className="icon-size-24 ml-auto"
/>
)}
</Dropdown.Item>
</React.Fragment>
))}
Expand Down
98 changes: 98 additions & 0 deletions src/discussions/common/MuteConfirmModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';

import {
ActionRow,
Button,
ModalDialog,
} from '@openedx/paragon';

/**
* Configurable confirmation modal for mute/unmute actions.
* Consolidates the functionality of 4 separate confirmation modals:
* - MutePersonalConfirmModal
* - MuteCourseWideConfirmModal
* - UnmutePersonalConfirmModal
* - UnmuteCourseWideConfirmModal
*/
const MuteConfirmModal = ({
isOpen,
onClose,
onConfirm,
username,
type = 'mute-personal',
}) => {
const handleConfirm = () => {
onConfirm();
onClose();
};

// Configuration for different modal types
const modalConfig = {
'mute-personal': {
title: 'Mute this user?',
description: `Are you sure you want to mute ${username}? Their discussion activity will be hidden from your view, but still visible to other learners and staff.`,
buttonText: 'Mute',
buttonVariant: 'danger',
},
'mute-coursewide': {
title: 'Mute this user?',
description: `Are you sure you want to mute ${username} course-wide? Their discussion activity will be hidden from all learners and staff.`,
buttonText: 'Mute',
buttonVariant: 'danger',
},
'unmute-personal': {
title: 'Unmute this user?',
description: `Are you sure you want to unmute ${username}? Their discussion activity will become visible to you again.`,
buttonText: 'Unmute',
buttonVariant: 'primary',
},
'unmute-coursewide': {
title: 'Unmute this user course-wide?',
description: `Are you sure you want to unmute ${username} course-wide? Their discussion activity will become visible to all learners and staff.`,
buttonText: 'Unmute',
buttonVariant: 'primary',
},
};

const config = modalConfig[type] || modalConfig['mute-personal'];

return (
<ModalDialog
title={config.title}
isOpen={isOpen}
onClose={onClose}
hasCloseButton={false}
zIndex={5000}
>
<ModalDialog.Header>
<ModalDialog.Title>
{config.title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<p>{config.description}</p>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
Cancel
</ModalDialog.CloseButton>
<Button variant={config.buttonVariant} onClick={handleConfirm}>
{config.buttonText}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};

MuteConfirmModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
username: PropTypes.string.isRequired,
type: PropTypes.oneOf(['mute-personal', 'mute-coursewide', 'unmute-personal', 'unmute-coursewide']).isRequired,
};

export default MuteConfirmModal;
112 changes: 112 additions & 0 deletions src/discussions/common/MuteConfirmModal.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React from 'react';

import { fireEvent, render, screen } from '@testing-library/react';

import { IntlProvider } from '@edx/frontend-platform/i18n';

import MuteConfirmModal from './MuteConfirmModal';

const renderWithIntl = (component) => render(
<IntlProvider locale="en">
{component}
</IntlProvider>,
);

const mockProps = {
isOpen: true,
onClose: jest.fn(),
onConfirm: jest.fn(),
username: 'testuser',
};

describe('MuteConfirmModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Mute Personal Modal', () => {
it('renders correctly for personal mute', () => {
renderWithIntl(
<MuteConfirmModal {...mockProps} type="mute-personal" />,
);

expect(screen.getByText('Mute this user?')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to mute testuser\? Their discussion activity will be hidden from your view/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});

it('calls onConfirm and onClose when mute is clicked', () => {
renderWithIntl(
<MuteConfirmModal {...mockProps} type="mute-personal" />,
);

fireEvent.click(screen.getByRole('button', { name: 'Mute' }));
expect(mockProps.onConfirm).toHaveBeenCalledTimes(1);
expect(mockProps.onClose).toHaveBeenCalledTimes(1);
});
});

describe('Mute Course-wide Modal', () => {
it('renders correctly for course-wide mute', () => {
renderWithIntl(
<MuteConfirmModal {...mockProps} type="mute-coursewide" />,
);

expect(screen.getByText('Mute this user?')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to mute testuser course-wide\? Their discussion activity will be hidden from all learners/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument();
});
});

describe('Unmute Personal Modal', () => {
it('renders correctly for personal unmute', () => {
renderWithIntl(
<MuteConfirmModal {...mockProps} type="unmute-personal" />,
);

expect(screen.getByText('Unmute this user?')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to unmute testuser\? Their discussion activity will become visible to you again/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Unmute' })).toBeInTheDocument();
});
});

describe('Unmute Course-wide Modal', () => {
it('renders correctly for course-wide unmute', () => {
renderWithIntl(
<MuteConfirmModal {...mockProps} type="unmute-coursewide" />,
);

expect(screen.getByText('Unmute this user course-wide?')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to unmute testuser course-wide\? Their discussion activity will become visible to all learners/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Unmute' })).toBeInTheDocument();
});
});

it('calls onClose when cancel is clicked', () => {
renderWithIntl(
<MuteConfirmModal {...mockProps} type="mute-personal" />,
);

fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(mockProps.onClose).toHaveBeenCalledTimes(1);
expect(mockProps.onConfirm).not.toHaveBeenCalled();
});

it('does not render when closed', () => {
renderWithIntl(
<MuteConfirmModal {...mockProps} isOpen={false} type="mute-personal" />,
);

expect(screen.queryByText('Mute this user?')).not.toBeInTheDocument();
});

it('defaults to mute-personal config when invalid type provided', () => {
renderWithIntl(
<MuteConfirmModal {...mockProps} type="invalid-type" />,
);

expect(screen.getByText('Mute this user?')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to mute testuser\? Their discussion activity will be hidden from your view/)).toBeInTheDocument();
});
});
Loading
Loading