Merge pull request #808 from Alam-2U/ealam/LP-85

fix: resolve InContext Sidebar post menu dropdown clipping that triggered scroll
This commit is contained in:
Ehtesham Alam
2025-10-06 20:00:21 +05:30
committed by GitHub
parent 4d51cf8855
commit 231c15aa6d
3 changed files with 194 additions and 30 deletions

View File

@@ -32,6 +32,11 @@ const ActionsDropdown = ({
const isPostingEnabled = useSelector(selectIsPostingEnabled);
const actions = useActions(contentType, id);
// Check if we're in in-context sidebar mode
const isInContextSidebar = useMemo(() => (
typeof window !== 'undefined' && window.location.search.includes('inContextSidebar')
), []);
const handleActions = useCallback((action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
@@ -59,6 +64,38 @@ const ActionsDropdown = ({
setTarget(null);
}, [close]);
const dropdownContent = (
<div
className="bg-white shadow d-flex flex-column mt-1"
data-testid="actions-dropdown-modal-popup"
>
{actions.map(action => (
<React.Fragment key={action.id}>
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
<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}
>
<Icon
src={action.icon}
className="icon-size-24"
/>
<span className="font-weight-normal ml-2">
{intl.formatMessage(action.label)}
</span>
</Dropdown.Item>
</React.Fragment>
))}
</div>
);
return (
<>
<IconButton
@@ -71,42 +108,14 @@ const ActionsDropdown = ({
ref={buttonRef}
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''}
/>
<div className="actions-dropdown">
<div className={`actions-dropdown ${isInContextSidebar ? 'in-context-sidebar' : ''}`}>
<ModalPopup
onClose={onCloseModal}
positionRef={target}
isOpen={isOpen}
placement="bottom-end"
>
<div
className="bg-white shadow d-flex flex-column mt-1"
data-testid="actions-dropdown-modal-popup"
>
{actions.map(action => (
<React.Fragment key={action.id}>
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
<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}
>
<Icon
src={action.icon}
className="icon-size-24"
/>
<span className="font-weight-normal ml-2">
{intl.formatMessage(action.label)}
</span>
</Dropdown.Item>
</React.Fragment>
))}
</div>
{dropdownContent}
</ModalPopup>
</div>
</>

View File

@@ -8,6 +8,7 @@ import { Factory } from 'rosie';
import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { AppProvider } from '@edx/frontend-platform/react';
import { ContentActions } from '../../data/constants';
@@ -27,6 +28,11 @@ import ActionsDropdown from './ActionsDropdown';
import '../post-comments/data/__factories__';
import '../posts/data/__factories__';
jest.mock('@edx/frontend-platform/logging', () => ({
...jest.requireActual('@edx/frontend-platform/logging'),
logError: jest.fn(),
}));
let store;
let axiosMock;
const commentsApiUrl = getCommentsApiUrl();
@@ -303,4 +309,148 @@ describe('ActionsDropdown', () => {
});
});
});
it('applies in-context-sidebar class when inContextSidebar is in URL', async () => {
const originalLocation = window.location;
delete window.location;
window.location = { ...originalLocation, search: '?inContextSidebar=true' };
const discussionObject = buildTestContent().discussion;
await mockThreadAndComment(discussionObject);
renderComponent({ ...camelCaseObject(discussionObject) });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
const dropdown = screen.getByTestId('actions-dropdown-modal-popup').closest('.actions-dropdown');
expect(dropdown).toHaveClass('in-context-sidebar');
window.location = originalLocation;
});
it('does not apply in-context-sidebar class when inContextSidebar is not in URL', async () => {
const originalLocation = window.location;
delete window.location;
window.location = { ...originalLocation, search: '' };
const discussionObject = buildTestContent().discussion;
await mockThreadAndComment(discussionObject);
renderComponent({ ...camelCaseObject(discussionObject) });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
const dropdown = screen.getByTestId('actions-dropdown-modal-popup').closest('.actions-dropdown');
expect(dropdown).not.toHaveClass('in-context-sidebar');
window.location = originalLocation;
});
it('handles SSR environment when window is undefined', () => {
const testSSRLogic = () => {
if (typeof window !== 'undefined') {
return window.location.search.includes('inContextSidebar');
}
return false;
};
const originalWindow = global.window;
const originalProcess = global.process;
try {
delete global.window;
const result = testSSRLogic();
expect(result).toBe(false);
global.window = originalWindow;
const resultWithWindow = testSSRLogic();
expect(resultWithWindow).toBe(false);
} finally {
global.window = originalWindow;
global.process = originalProcess;
}
});
it('calls logError for unknown action', async () => {
const discussionObject = buildTestContent().discussion;
await mockThreadAndComment(discussionObject);
logError.mockClear();
renderComponent({
...discussionObject,
actionHandlers: {
[ContentActions.EDIT_CONTENT]: jest.fn(),
},
});
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
const copyLinkButton = await screen.findByText('Copy link');
await act(async () => {
fireEvent.click(copyLinkButton);
});
expect(logError).toHaveBeenCalledWith('Unknown or unimplemented action copy_link');
});
describe('posting restrictions', () => {
it('removes edit action when posting is disabled', async () => {
const discussionObject = buildTestContent({
editable_fields: ['raw_body'],
}).discussion;
await mockThreadAndComment(discussionObject);
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`)
.reply(200, { isPostingEnabled: false });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
renderComponent({ ...discussionObject });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => {
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
});
});
it('keeps edit action when posting is enabled', async () => {
const discussionObject = buildTestContent({
editable_fields: ['raw_body'],
}).discussion;
await mockThreadAndComment(discussionObject);
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`)
.reply(200, { isPostingEnabled: true });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
renderComponent({ ...discussionObject });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => {
expect(screen.queryByText('Edit')).toBeInTheDocument();
});
});
});
});

View File

@@ -366,6 +366,11 @@ header {
z-index: 1;
}
.actions-dropdown.in-context-sidebar {
position: fixed !important;
z-index: 10 !important;
}
.discussion-topic-group:last-of-type .divider {
display: none;
}