Skip to content
Open
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
69 changes: 39 additions & 30 deletions src/discussions/common/ActionsDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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>
</>
Expand Down
150 changes: 150 additions & 0 deletions src/discussions/common/ActionsDropdown.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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();
});
});
});
});
5 changes: 5 additions & 0 deletions src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,11 @@ header {
z-index: 1;
}

.actions-dropdown.in-context-sidebar {
position: fixed !important;
z-index: 10 !important;
}
Comment on lines +369 to +372
Copy link

Copilot AI Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using !important declarations should be avoided as they make CSS harder to maintain and override. Consider using more specific selectors or restructuring the CSS hierarchy to achieve the desired specificity without !important.

Copilot uses AI. Check for mistakes.


.discussion-topic-group:last-of-type .divider {
display: none;
}
Expand Down