Skip to content

Commit 231c15a

Browse files
authored
Merge pull request #808 from Alam-2U/ealam/LP-85
fix: resolve InContext Sidebar post menu dropdown clipping that triggered scroll
1 parent 4d51cf8 commit 231c15a

File tree

3 files changed

+194
-30
lines changed

3 files changed

+194
-30
lines changed

src/discussions/common/ActionsDropdown.jsx

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ const ActionsDropdown = ({
3232
const isPostingEnabled = useSelector(selectIsPostingEnabled);
3333
const actions = useActions(contentType, id);
3434

35+
// Check if we're in in-context sidebar mode
36+
const isInContextSidebar = useMemo(() => (
37+
typeof window !== 'undefined' && window.location.search.includes('inContextSidebar')
38+
), []);
39+
3540
const handleActions = useCallback((action) => {
3641
const actionFunction = actionHandlers[action];
3742
if (actionFunction) {
@@ -59,6 +64,38 @@ const ActionsDropdown = ({
5964
setTarget(null);
6065
}, [close]);
6166

67+
const dropdownContent = (
68+
<div
69+
className="bg-white shadow d-flex flex-column mt-1"
70+
data-testid="actions-dropdown-modal-popup"
71+
>
72+
{actions.map(action => (
73+
<React.Fragment key={action.id}>
74+
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
75+
<Dropdown.Item
76+
as={Button}
77+
variant="tertiary"
78+
size="inline"
79+
onClick={() => {
80+
close();
81+
handleActions(action.action);
82+
}}
83+
className="d-flex justify-content-start actions-dropdown-item"
84+
data-testId={action.id}
85+
>
86+
<Icon
87+
src={action.icon}
88+
className="icon-size-24"
89+
/>
90+
<span className="font-weight-normal ml-2">
91+
{intl.formatMessage(action.label)}
92+
</span>
93+
</Dropdown.Item>
94+
</React.Fragment>
95+
))}
96+
</div>
97+
);
98+
6299
return (
63100
<>
64101
<IconButton
@@ -71,42 +108,14 @@ const ActionsDropdown = ({
71108
ref={buttonRef}
72109
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''}
73110
/>
74-
<div className="actions-dropdown">
111+
<div className={`actions-dropdown ${isInContextSidebar ? 'in-context-sidebar' : ''}`}>
75112
<ModalPopup
76113
onClose={onCloseModal}
77114
positionRef={target}
78115
isOpen={isOpen}
79116
placement="bottom-end"
80117
>
81-
<div
82-
className="bg-white shadow d-flex flex-column mt-1"
83-
data-testid="actions-dropdown-modal-popup"
84-
>
85-
{actions.map(action => (
86-
<React.Fragment key={action.id}>
87-
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
88-
<Dropdown.Item
89-
as={Button}
90-
variant="tertiary"
91-
size="inline"
92-
onClick={() => {
93-
close();
94-
handleActions(action.action);
95-
}}
96-
className="d-flex justify-content-start actions-dropdown-item"
97-
data-testId={action.id}
98-
>
99-
<Icon
100-
src={action.icon}
101-
className="icon-size-24"
102-
/>
103-
<span className="font-weight-normal ml-2">
104-
{intl.formatMessage(action.label)}
105-
</span>
106-
</Dropdown.Item>
107-
</React.Fragment>
108-
))}
109-
</div>
118+
{dropdownContent}
110119
</ModalPopup>
111120
</div>
112121
</>

src/discussions/common/ActionsDropdown.test.jsx

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Factory } from 'rosie';
88

99
import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/frontend-platform';
1010
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
11+
import { logError } from '@edx/frontend-platform/logging';
1112
import { AppProvider } from '@edx/frontend-platform/react';
1213

1314
import { ContentActions } from '../../data/constants';
@@ -27,6 +28,11 @@ import ActionsDropdown from './ActionsDropdown';
2728
import '../post-comments/data/__factories__';
2829
import '../posts/data/__factories__';
2930

31+
jest.mock('@edx/frontend-platform/logging', () => ({
32+
...jest.requireActual('@edx/frontend-platform/logging'),
33+
logError: jest.fn(),
34+
}));
35+
3036
let store;
3137
let axiosMock;
3238
const commentsApiUrl = getCommentsApiUrl();
@@ -303,4 +309,148 @@ describe('ActionsDropdown', () => {
303309
});
304310
});
305311
});
312+
313+
it('applies in-context-sidebar class when inContextSidebar is in URL', async () => {
314+
const originalLocation = window.location;
315+
delete window.location;
316+
window.location = { ...originalLocation, search: '?inContextSidebar=true' };
317+
318+
const discussionObject = buildTestContent().discussion;
319+
await mockThreadAndComment(discussionObject);
320+
321+
renderComponent({ ...camelCaseObject(discussionObject) });
322+
323+
const openButton = await findOpenActionsDropdownButton();
324+
await act(async () => {
325+
fireEvent.click(openButton);
326+
});
327+
328+
const dropdown = screen.getByTestId('actions-dropdown-modal-popup').closest('.actions-dropdown');
329+
expect(dropdown).toHaveClass('in-context-sidebar');
330+
331+
window.location = originalLocation;
332+
});
333+
334+
it('does not apply in-context-sidebar class when inContextSidebar is not in URL', async () => {
335+
const originalLocation = window.location;
336+
delete window.location;
337+
window.location = { ...originalLocation, search: '' };
338+
339+
const discussionObject = buildTestContent().discussion;
340+
await mockThreadAndComment(discussionObject);
341+
342+
renderComponent({ ...camelCaseObject(discussionObject) });
343+
344+
const openButton = await findOpenActionsDropdownButton();
345+
await act(async () => {
346+
fireEvent.click(openButton);
347+
});
348+
349+
const dropdown = screen.getByTestId('actions-dropdown-modal-popup').closest('.actions-dropdown');
350+
expect(dropdown).not.toHaveClass('in-context-sidebar');
351+
352+
window.location = originalLocation;
353+
});
354+
355+
it('handles SSR environment when window is undefined', () => {
356+
const testSSRLogic = () => {
357+
if (typeof window !== 'undefined') {
358+
return window.location.search.includes('inContextSidebar');
359+
}
360+
return false;
361+
};
362+
363+
const originalWindow = global.window;
364+
const originalProcess = global.process;
365+
366+
try {
367+
delete global.window;
368+
369+
const result = testSSRLogic();
370+
expect(result).toBe(false);
371+
372+
global.window = originalWindow;
373+
const resultWithWindow = testSSRLogic();
374+
expect(resultWithWindow).toBe(false);
375+
} finally {
376+
global.window = originalWindow;
377+
global.process = originalProcess;
378+
}
379+
});
380+
381+
it('calls logError for unknown action', async () => {
382+
const discussionObject = buildTestContent().discussion;
383+
await mockThreadAndComment(discussionObject);
384+
385+
logError.mockClear();
386+
387+
renderComponent({
388+
...discussionObject,
389+
actionHandlers: {
390+
[ContentActions.EDIT_CONTENT]: jest.fn(),
391+
},
392+
});
393+
394+
const openButton = await findOpenActionsDropdownButton();
395+
await act(async () => {
396+
fireEvent.click(openButton);
397+
});
398+
399+
const copyLinkButton = await screen.findByText('Copy link');
400+
await act(async () => {
401+
fireEvent.click(copyLinkButton);
402+
});
403+
404+
expect(logError).toHaveBeenCalledWith('Unknown or unimplemented action copy_link');
405+
});
406+
407+
describe('posting restrictions', () => {
408+
it('removes edit action when posting is disabled', async () => {
409+
const discussionObject = buildTestContent({
410+
editable_fields: ['raw_body'],
411+
}).discussion;
412+
413+
await mockThreadAndComment(discussionObject);
414+
415+
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`)
416+
.reply(200, { isPostingEnabled: false });
417+
418+
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
419+
420+
renderComponent({ ...discussionObject });
421+
422+
const openButton = await findOpenActionsDropdownButton();
423+
await act(async () => {
424+
fireEvent.click(openButton);
425+
});
426+
427+
await waitFor(() => {
428+
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
429+
});
430+
});
431+
432+
it('keeps edit action when posting is enabled', async () => {
433+
const discussionObject = buildTestContent({
434+
editable_fields: ['raw_body'],
435+
}).discussion;
436+
437+
await mockThreadAndComment(discussionObject);
438+
439+
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`)
440+
.reply(200, { isPostingEnabled: true });
441+
442+
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
443+
444+
renderComponent({ ...discussionObject });
445+
446+
const openButton = await findOpenActionsDropdownButton();
447+
await act(async () => {
448+
fireEvent.click(openButton);
449+
});
450+
451+
await waitFor(() => {
452+
expect(screen.queryByText('Edit')).toBeInTheDocument();
453+
});
454+
});
455+
});
306456
});

src/index.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,11 @@ header {
366366
z-index: 1;
367367
}
368368

369+
.actions-dropdown.in-context-sidebar {
370+
position: fixed !important;
371+
z-index: 10 !important;
372+
}
373+
369374
.discussion-topic-group:last-of-type .divider {
370375
display: none;
371376
}

0 commit comments

Comments
 (0)