Skip to content

Commit 05cedbd

Browse files
committed
feat: implement discussion mute/unmute feature with user and staff-level controls
1 parent 15d8988 commit 05cedbd

19 files changed

Lines changed: 233 additions & 94 deletions

src/discussions/common/ActionsDropdown.jsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ const ActionsDropdown = ({
4747
}, [actionHandlers]);
4848

4949
// Find and remove edit action if in Posting is disabled.
50-
useMemo(() => {
50+
const filteredActions = useMemo(() => {
5151
if (!isPostingEnabled) {
52-
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
52+
return actions.filter(action => action.id !== 'edit');
5353
}
54+
return actions;
5455
}, [actions, isPostingEnabled]);
5556

5657
const onClickButton = useCallback((event) => {
@@ -69,7 +70,7 @@ const ActionsDropdown = ({
6970
className="bg-white shadow d-flex flex-column mt-1"
7071
data-testid="actions-dropdown-modal-popup"
7172
>
72-
{actions.map(action => (
73+
{filteredActions.map(action => (
7374
<React.Fragment key={action.id}>
7475
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
7576
<Dropdown.Item

src/discussions/common/ActionsDropdown.test.jsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ jest.mock('@edx/frontend-platform/logging', () => ({
3333
logError: jest.fn(),
3434
}));
3535

36+
beforeAll(() => {
37+
jest.spyOn(console, 'warn').mockImplementation((msg) => {
38+
if (msg?.includes?.('Missing locale')) {
39+
return undefined;
40+
}
41+
return undefined;
42+
});
43+
});
44+
45+
afterAll(() => {
46+
jest.restoreAllMocks();
47+
});
48+
3649
let store;
3750
let axiosMock;
3851
const commentsApiUrl = getCommentsApiUrl();
@@ -272,7 +285,27 @@ describe('ActionsDropdown', () => {
272285
}) => {
273286
describe(`for ${testFor}`, () => {
274287
it(`can "${label}" when allowed`, async () => {
288+
const updatedCommentOrPost = action === 'mute_user' || action === 'unmute_user'
289+
? { ...commentOrPost, authorLabel: null }
290+
: commentOrPost;
291+
292+
// For unmute tests, add the post author to muted users
293+
if (action === 'unmute_user') {
294+
store.dispatch({
295+
type: 'learner/fetchMutedUsersSuccess',
296+
payload: {
297+
mutedUsers: [
298+
{
299+
username: updatedCommentOrPost.author,
300+
scope: 'personal',
301+
},
302+
],
303+
},
304+
});
305+
}
306+
275307
await mockThreadAndComment(commentOrPost);
308+
276309
const mockHandler = jest.fn();
277310
renderComponent({ ...commentOrPost, actionHandlers: { [action]: mockHandler } });
278311

src/discussions/common/MuteModalManager.jsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,7 @@ const MuteModalManager = ({
148148

149149
// Staff option selection handler
150150
const handleStaffOptionSelect = useCallback((option) => {
151-
// Close staff options modal first
152-
onCloseStaffMuteModal();
151+
// Note: StaffMuteOptionsModal handles closing itself when option is selected
153152

154153
// Map option to confirmation modal type
155154
const modalTypeMap = {
@@ -164,7 +163,7 @@ const MuteModalManager = ({
164163
setConfirmModalType(modalType);
165164
openConfirmModal();
166165
}
167-
}, [onCloseStaffMuteModal, openConfirmModal]);
166+
}, [openConfirmModal]);
168167

169168
// Staff confirmation handler
170169
const handleStaffConfirm = useCallback(async () => {
@@ -200,7 +199,7 @@ const MuteModalManager = ({
200199
<ModalDialog
201200
title={intl.formatMessage(messages.learnerMuteTitle)}
202201
isOpen={showLearnerMuteModal}
203-
onClose={onCloseLearnerMuteModal}
202+
onClose={() => {}}
204203
hasCloseButton={false}
205204
zIndex={5000}
206205
>
@@ -248,7 +247,7 @@ const MuteModalManager = ({
248247
<ModalDialog
249248
title={intl.formatMessage(messages.unmuteTitle)}
250249
isOpen={showUnmuteModal}
251-
onClose={onCloseUnmuteModal}
250+
onClose={() => {}}
252251
hasCloseButton={false}
253252
zIndex={5000}
254253
>

src/discussions/common/MuteModalManager.test.jsx

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,44 @@
11
import React from 'react';
22

3-
import { fireEvent, render, screen } from '@testing-library/react';
3+
import {
4+
fireEvent, render, screen, waitFor,
5+
} from '@testing-library/react';
46
import { Provider } from 'react-redux';
5-
import { createStore } from 'redux';
7+
import { applyMiddleware, createStore } from 'redux';
8+
import thunk from 'redux-thunk';
69

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

912
import MuteModalManager from './MuteModalManager';
1013

11-
// Mock the thunks
12-
const mockMuteUserThunk = jest.fn(() => ({ type: 'MUTE_USER' }));
13-
const mockUnmuteUserThunk = jest.fn(() => ({ type: 'UNMUTE_USER' }));
14-
const mockMuteAndReportUserThunk = jest.fn(() => ({ type: 'MUTE_AND_REPORT_USER' }));
14+
const mockMuteUserThunk = jest.fn();
15+
const mockUnmuteUserThunk = jest.fn();
16+
const mockMuteAndReportUserThunk = jest.fn();
17+
const mockFetchMutedUsersThunk = jest.fn();
1518

19+
// Mock functions should return Redux action creators that return Promises for async behavior
1620
jest.mock('../data/thunks', () => ({
17-
muteUserThunk: mockMuteUserThunk,
18-
unmuteUserThunk: mockUnmuteUserThunk,
19-
muteAndReportUserThunk: mockMuteAndReportUserThunk,
21+
muteUserThunk: (...args) => () => {
22+
mockMuteUserThunk(...args);
23+
return Promise.resolve({ type: 'MOCK_MUTE_USER', payload: args });
24+
},
25+
unmuteUserThunk: (...args) => () => {
26+
mockUnmuteUserThunk(...args);
27+
return Promise.resolve({ type: 'MOCK_UNMUTE_USER', payload: args });
28+
},
29+
muteAndReportUserThunk: (...args) => () => {
30+
mockMuteAndReportUserThunk(...args);
31+
return Promise.resolve({ type: 'MOCK_MUTE_AND_REPORT_USER', payload: args });
32+
},
33+
fetchMutedUsersThunk: (...args) => () => {
34+
mockFetchMutedUsersThunk(...args);
35+
return Promise.resolve({ type: 'MOCK_FETCH_MUTED_USERS', payload: args });
36+
},
37+
}));
38+
39+
// Mock posts thunks to prevent logError issues
40+
jest.mock('../posts/data/thunks', () => ({
41+
fetchThreads: () => () => Promise.resolve({ type: 'MOCK_FETCH_THREADS' }),
2042
}));
2143

2244
const mockStore = createStore(() => ({
@@ -26,36 +48,43 @@ const mockStore = createStore(() => ({
2648
userIsStaff: true,
2749
userHasModerationPrivileges: true,
2850
},
29-
}));
51+
}), applyMiddleware(thunk));
3052

3153
const mockMessages = {
3254
learnerMuteTitle: {
3355
id: 'test.learnerMuteTitle',
3456
defaultMessage: 'Mute this user?',
57+
description: 'Title for learner mute modal',
3558
},
3659
learnerMuteDescription: {
3760
id: 'test.learnerMuteDescription',
3861
defaultMessage: 'Are you sure you want to mute {username}?',
62+
description: 'Description for learner mute modal',
3963
},
4064
learnerMuteButton: {
4165
id: 'test.learnerMuteButton',
4266
defaultMessage: 'Mute',
67+
description: 'Button text for mute action',
4368
},
4469
learnerMuteAndReportButton: {
4570
id: 'test.learnerMuteAndReportButton',
4671
defaultMessage: 'Mute and report',
72+
description: 'Button text for mute and report action',
4773
},
4874
unmuteTitle: {
4975
id: 'test.unmuteTitle',
5076
defaultMessage: 'Unmute this user?',
77+
description: 'Title for unmute modal',
5178
},
5279
unmuteDescription: {
5380
id: 'test.unmuteDescription',
5481
defaultMessage: 'Are you sure you want to unmute {username}?',
82+
description: 'Description for unmute modal',
5583
},
5684
unmuteButton: {
5785
id: 'test.unmuteButton',
5886
defaultMessage: 'Unmute',
87+
description: 'Button text for unmute action',
5988
},
6089
};
6190

@@ -112,7 +141,7 @@ describe('MuteModalManager', () => {
112141
expect(mockProps.onCloseLearnerMuteModal).toHaveBeenCalledTimes(1);
113142
});
114143

115-
it('handles learner mute action correctly', () => {
144+
it('handles learner mute action correctly', async () => {
116145
renderWithProvider(
117146
<MuteModalManager
118147
{...mockProps}
@@ -122,10 +151,13 @@ describe('MuteModalManager', () => {
122151

123152
fireEvent.click(screen.getByRole('button', { name: 'Mute' }));
124153
expect(mockMuteUserThunk).toHaveBeenCalledWith('testuser', false);
125-
expect(mockProps.onCloseLearnerMuteModal).toHaveBeenCalledTimes(1);
154+
155+
await waitFor(() => {
156+
expect(mockProps.onCloseLearnerMuteModal).toHaveBeenCalledTimes(1);
157+
});
126158
});
127159

128-
it('handles learner mute and report action correctly', () => {
160+
it('handles learner mute and report action correctly', async () => {
129161
renderWithProvider(
130162
<MuteModalManager
131163
{...mockProps}
@@ -135,7 +167,10 @@ describe('MuteModalManager', () => {
135167

136168
fireEvent.click(screen.getByRole('button', { name: 'Mute and report' }));
137169
expect(mockMuteAndReportUserThunk).toHaveBeenCalledWith('testuser', 'test-content-id');
138-
expect(mockProps.onCloseLearnerMuteModal).toHaveBeenCalledTimes(1);
170+
171+
await waitFor(() => {
172+
expect(mockProps.onCloseLearnerMuteModal).toHaveBeenCalledTimes(1);
173+
});
139174
});
140175
});
141176

@@ -200,7 +235,7 @@ describe('MuteModalManager', () => {
200235
expect(mockProps.onCloseUnmuteModal).toHaveBeenCalledTimes(1);
201236
});
202237

203-
it('handles unmute action correctly', () => {
238+
it('handles unmute action correctly', async () => {
204239
renderWithProvider(
205240
<MuteModalManager
206241
{...mockProps}
@@ -210,7 +245,10 @@ describe('MuteModalManager', () => {
210245

211246
fireEvent.click(screen.getByRole('button', { name: 'Unmute' }));
212247
expect(mockUnmuteUserThunk).toHaveBeenCalledWith('testuser', false);
213-
expect(mockProps.onCloseUnmuteModal).toHaveBeenCalledTimes(1);
248+
249+
await waitFor(() => {
250+
expect(mockProps.onCloseUnmuteModal).toHaveBeenCalledTimes(1);
251+
});
214252
});
215253
});
216254

src/discussions/common/StaffMuteOptionsModal.test.jsx

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22

33
import { configureStore } from '@reduxjs/toolkit';
44
import { fireEvent, render, screen } from '@testing-library/react';
5+
import { IntlProvider } from 'react-intl';
56
import { Provider } from 'react-redux';
67

78
import StaffMuteOptionsModal from './StaffMuteOptionsModal';
@@ -31,9 +32,11 @@ describe('StaffMuteOptionsModal', () => {
3132
it('renders with all four options', () => {
3233
const store = createMockStore();
3334
render(
34-
<Provider store={store}>
35-
<StaffMuteOptionsModal {...defaultProps} />
36-
</Provider>,
35+
<IntlProvider locale="en">
36+
<Provider store={store}>
37+
<StaffMuteOptionsModal {...defaultProps} />
38+
</Provider>
39+
</IntlProvider>,
3740
);
3841

3942
expect(screen.getByText('Mute user (for me)')).toBeInTheDocument();
@@ -45,9 +48,11 @@ describe('StaffMuteOptionsModal', () => {
4548
it('disables unmute options when user is not muted', () => {
4649
const store = createMockStore([], []); // No muted users
4750
render(
48-
<Provider store={store}>
49-
<StaffMuteOptionsModal {...defaultProps} />
50-
</Provider>,
51+
<IntlProvider locale="en">
52+
<Provider store={store}>
53+
<StaffMuteOptionsModal {...defaultProps} />
54+
</Provider>
55+
</IntlProvider>,
5156
);
5257

5358
const unmutePersonalBtn = screen.getByText('Unmute user (for me)');
@@ -60,9 +65,11 @@ describe('StaffMuteOptionsModal', () => {
6065
it('disables mute options when user is already muted', () => {
6166
const store = createMockStore(['testuser'], []); // User is personally muted
6267
render(
63-
<Provider store={store}>
64-
<StaffMuteOptionsModal {...defaultProps} />
65-
</Provider>,
68+
<IntlProvider locale="en">
69+
<Provider store={store}>
70+
<StaffMuteOptionsModal {...defaultProps} />
71+
</Provider>
72+
</IntlProvider>,
6673
);
6774

6875
const mutePersonalBtn = screen.getByText('Mute user (for me)');
@@ -74,12 +81,14 @@ describe('StaffMuteOptionsModal', () => {
7481
const mockOnSelectOption = jest.fn();
7582

7683
render(
77-
<Provider store={store}>
78-
<StaffMuteOptionsModal
79-
{...defaultProps}
80-
onSelectOption={mockOnSelectOption}
81-
/>
82-
</Provider>,
84+
<IntlProvider locale="en">
85+
<Provider store={store}>
86+
<StaffMuteOptionsModal
87+
{...defaultProps}
88+
onSelectOption={mockOnSelectOption}
89+
/>
90+
</Provider>
91+
</IntlProvider>,
8392
);
8493

8594
const mutePersonalBtn = screen.getByText('Mute user (for me)');
@@ -91,9 +100,11 @@ describe('StaffMuteOptionsModal', () => {
91100
it('does not render when isOpen is false', () => {
92101
const store = createMockStore();
93102
render(
94-
<Provider store={store}>
95-
<StaffMuteOptionsModal {...defaultProps} isOpen={false} />
96-
</Provider>,
103+
<IntlProvider locale="en">
104+
<Provider store={store}>
105+
<StaffMuteOptionsModal {...defaultProps} isOpen={false} />
106+
</Provider>
107+
</IntlProvider>,
97108
);
98109

99110
expect(screen.queryByText('Mute user (for me)')).not.toBeInTheDocument();
@@ -104,12 +115,14 @@ describe('StaffMuteOptionsModal', () => {
104115
const mockOnClose = jest.fn();
105116

106117
render(
107-
<Provider store={store}>
108-
<StaffMuteOptionsModal
109-
{...defaultProps}
110-
onClose={mockOnClose}
111-
/>
112-
</Provider>,
118+
<IntlProvider locale="en">
119+
<Provider store={store}>
120+
<StaffMuteOptionsModal
121+
{...defaultProps}
122+
onClose={mockOnClose}
123+
/>
124+
</Provider>
125+
</IntlProvider>,
113126
);
114127

115128
fireEvent.keyDown(document, { key: 'Escape' });

src/discussions/in-context-topics/TopicsView.test.jsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,15 @@ describe('InContext Topics View', () => {
177177

178178
coursewareTopics.forEach(async (topic, index) => {
179179
await waitFor(async () => {
180-
const stats = await sectionGroups[index].querySelectorAll('.icon-size:not([data-testid="subsection-group"].icon-size)');
180+
// Find all icon-size elements in the section group
181+
const allIconElements = sectionGroups[index].querySelectorAll('.icon-size');
182+
// Find icon-size elements that are inside subsection groups
183+
const subsectionIconElements = sectionGroups[index].querySelectorAll('[data-testid="subsection-group"] .icon-size');
184+
// The stats should be only those icons that are NOT inside subsection groups
185+
const stats = Array.from(allIconElements).filter(icon => !Array.from(subsectionIconElements).includes(icon));
181186
const subsectionGroups = await within(sectionGroups[index]).getAllByTestId('subsection-group');
182187

183-
expect(within(sectionGroups[index]).queryByText(topic.displayName)).toBeInTheDocument();
188+
await within(sectionGroups[index]).findByText(topic.displayName);
184189
expect(stats).toHaveLength(0);
185190
expect(subsectionGroups).toHaveLength(2);
186191
});

0 commit comments

Comments
 (0)