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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user