diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index 3b559b39..35912508 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -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 = ( +
+ {actions.map(action => ( + + {(action.action === ContentActions.DELETE) && } + { + close(); + handleActions(action.action); + }} + className="d-flex justify-content-start actions-dropdown-item" + data-testId={action.id} + > + + + {intl.formatMessage(action.label)} + + + + ))} +
+ ); + return ( <> -
+
-
- {actions.map(action => ( - - {(action.action === ContentActions.DELETE) && } - { - close(); - handleActions(action.action); - }} - className="d-flex justify-content-start actions-dropdown-item" - data-testId={action.id} - > - - - {intl.formatMessage(action.label)} - - - - ))} -
+ {dropdownContent}
diff --git a/src/discussions/common/ActionsDropdown.test.jsx b/src/discussions/common/ActionsDropdown.test.jsx index 6da4385f..a852a4ab 100644 --- a/src/discussions/common/ActionsDropdown.test.jsx +++ b/src/discussions/common/ActionsDropdown.test.jsx @@ -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(); + }); + }); + }); }); diff --git a/src/index.scss b/src/index.scss index 2981b535..a51610e3 100755 --- a/src/index.scss +++ b/src/index.scss @@ -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; }