diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 10a9ad25b0..4ae15aa390 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -71,6 +71,7 @@ const CourseUnit = ({ courseId }) => { handleCloseXBlockMovedAlert, handleNavigateToTargetUnit, addComponentTemplateData, + resetXBlockPublishState, } = useCourseUnit({ courseId, blockId }); const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType); @@ -213,6 +214,7 @@ const CourseUnit = ({ courseId }) => { unitXBlockActions={unitXBlockActions} courseVerticalChildren={courseVerticalChildren.children} handleConfigureSubmit={handleConfigureSubmit} + resetXBlockPublishState={resetXBlockPublishState} /> {!readOnly && ( ', () => { // Does not render the "Add Components" section expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument(); }); + + it('resets XBlock publish state after discarding changes', async () => { + render(); + let courseUnitSidebar; + let discardChangesBtn; + + await waitFor(() => { + courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); + // Ensure we are in the draft/unpublished state + expect(within(courseUnitSidebar) + .getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + discardChangesBtn = within(courseUnitSidebar) + .queryByRole('button', { name: sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage }); + expect(discardChangesBtn).toBeInTheDocument(); + userEvent.click(discardChangesBtn); + }); + + // Confirm discard in modal + const modalNotification = await screen.findByRole('dialog'); + const actionBtn = within(modalNotification) + .getByRole('button', { name: sidebarMessages.modalDiscardUnitChangesActionButtonText.defaultMessage }); + userEvent.click(actionBtn); + + // Mock API responses for discard + axiosMock + .onPost(getXBlockBaseApiUrl(blockId), { + publish: PUBLISH_TYPES.discardChanges, + }) + .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + published: true, + has_changes: false, + }, + }); + + await executeThunk(editCourseUnitVisibilityAndData( + blockId, + PUBLISH_TYPES.discardChanges, + true, + ), store.dispatch); + + // Now the sidebar should reflect the published state (no draft/unpublished changes) + await waitFor(() => { + expect(within(courseUnitSidebar) + .getByText(sidebarMessages.sidebarTitlePublishedNotYetReleased.defaultMessage)).toBeInTheDocument(); + expect( + within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage }), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 3d87139087..2527ba0634 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -88,6 +88,10 @@ const slice = createSlice({ updateMovedXBlockParams: (state, { payload }) => { state.movedXBlockParams = { ...state.movedXBlockParams, ...payload }; }, + setXBlockPublishState: (state, { payload }) => { + state.courseSectionVertical.xblockInfo.published = payload; + state.courseSectionVertical.xblockInfo.hasChanges = !payload; + }, }, }); @@ -108,6 +112,7 @@ export const { updateCourseOutlineInfo, updateCourseOutlineInfoLoadingStatus, updateMovedXBlockParams, + setXBlockPublishState, } = slice.actions; export const { diff --git a/src/course-unit/data/slice.test.js b/src/course-unit/data/slice.test.js new file mode 100644 index 0000000000..ea05ef33bb --- /dev/null +++ b/src/course-unit/data/slice.test.js @@ -0,0 +1,31 @@ +import { reducer, setXBlockPublishState } from './slice'; + +describe('setXBlockPublishState reducer', () => { + it('sets published and hasChanges correctly when payload is true', () => { + const prevState = { + courseSectionVertical: { + xblockInfo: { + published: false, + hasChanges: true, + }, + }, + }; + const nextState = reducer(prevState, setXBlockPublishState(true)); + expect(nextState.courseSectionVertical.xblockInfo.published).toBe(true); + expect(nextState.courseSectionVertical.xblockInfo.hasChanges).toBe(false); + }); + + it('sets published and hasChanges correctly when payload is false', () => { + const prevState = { + courseSectionVertical: { + xblockInfo: { + published: true, + hasChanges: false, + }, + }, + }; + const nextState = reducer(prevState, setXBlockPublishState(false)); + expect(nextState.courseSectionVertical.xblockInfo.published).toBe(false); + expect(nextState.courseSectionVertical.xblockInfo.hasChanges).toBe(true); + }); +}); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index b1e6cea334..7f36450f30 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -37,6 +37,7 @@ import { } from './data/selectors'; import { changeEditTitleFormOpen, + setXBlockPublishState, updateMovedXBlockParams, updateQueryPendingStatus, } from './data/slice'; @@ -163,6 +164,10 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(updateMovedXBlockParams({ isSuccess: false })); }; + const resetXBlockPublishState = () => { + dispatch(setXBlockPublishState(false)); + }; + const handleNavigateToTargetUnit = () => { navigate(`/course/${courseId}/container/${movedXBlockParams.targetParentLocator}`); }; @@ -213,24 +218,6 @@ export const useCourseUnit = ({ courseId, blockId }) => { } }, [isMoveModalOpen]); - useEffect(() => { - const handlePageRefreshUsingStorage = (event) => { - // ignoring tests for if block, because it triggers when someone - // edits the component using editor which has a separate store - /* istanbul ignore next */ - if (event.key === 'courseRefreshTriggerOnComponentEditSave') { - dispatch(fetchCourseSectionVerticalData(blockId, sequenceId)); - dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType)); - localStorage.removeItem(event.key); - } - }; - - window.addEventListener('storage', handlePageRefreshUsingStorage); - return () => { - window.removeEventListener('storage', handlePageRefreshUsingStorage); - }; - }, [blockId, sequenceId, isSplitTestType]); - return { sequenceId, courseUnit, @@ -266,6 +253,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleNavigateToTargetUnit, addComponentTemplateData, setAddComponentTemplateData, + resetXBlockPublishState, }; }; diff --git a/src/course-unit/hooks.test.jsx b/src/course-unit/hooks.test.jsx index cec7ab5e5f..0f71533d23 100644 --- a/src/course-unit/hooks.test.jsx +++ b/src/course-unit/hooks.test.jsx @@ -1,10 +1,41 @@ import React from 'react'; import { act, renderHook } from '@testing-library/react'; -import { useScrollToLastPosition, useLayoutGrid } from './hooks'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useScrollToLastPosition, useLayoutGrid, useCourseUnit } from './hooks'; import { iframeMessageTypes } from '../constants'; +import { setXBlockPublishState } from './data/slice'; + +const queryClient = new QueryClient(); jest.useFakeTimers(); +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + useNavigate: jest.fn(), + useSearchParams: jest.fn(), +})); + +jest.mock('../generic/hooks/context/hooks', () => ({ + useIframe: jest.fn().mockReturnValue({ + sendMessageToIframe: jest.fn(), + }), +})); + +const wrapper = ({ children }) => ( + + + {children} + + +); + describe('useLayoutGrid', () => { it('returns fullWidth layout when isUnitLibraryType is true', () => { const { result } = renderHook(() => useLayoutGrid('someCategory', true)); @@ -179,3 +210,45 @@ describe('useScrollToLastPosition', () => { expect(setStateSpy).not.toHaveBeenCalledWith(false); }); }); + +describe('useCourseUnit', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + useDispatch.mockReturnValue(mockDispatch); + useNavigate.mockReturnValue(jest.fn()); + useSearchParams.mockReturnValue([new URLSearchParams(), jest.fn()]); + + useSelector.mockImplementation(() => ({})); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('resetXBlockPublishState', () => { + it('dispatches setXBlockPublishState action with false', () => { + const { result } = renderHook( + () => useCourseUnit({ courseId: 'test-course', blockId: 'test-block' }), + { wrapper }, + ); + + act(() => { + result.current.resetXBlockPublishState(); + }); + + const filteredCalls = mockDispatch.mock.calls.filter( + ([action]) => action.type === setXBlockPublishState.type, + ); + expect(filteredCalls).toHaveLength(1); + expect(filteredCalls[0][0]).toEqual(setXBlockPublishState(false)); + }); + + it('is included in the hook return value', () => { + const { result } = renderHook( + () => useCourseUnit({ courseId: 'test-course', blockId: 'test-block' }), + { wrapper }, + ); + + expect(typeof result.current.resetXBlockPublishState).toBe('function'); + }); + }); +}); diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index d706de3526..425439ea33 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -39,7 +39,13 @@ import VideoSelectorPage from '../../editors/VideoSelectorPage'; import EditorPage from '../../editors/EditorPage'; const XBlockContainerIframe: FC = ({ - courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType, + courseId, + blockId, + unitXBlockActions, + courseVerticalChildren, + handleConfigureSubmit, + isUnitVerticalType, + resetXBlockPublishState, }) => { const intl = useIntl(); const dispatch = useDispatch(); @@ -73,8 +79,10 @@ const XBlockContainerIframe: FC = ({ const onXBlockSave = useCallback(/* istanbul ignore next */ () => { closeXBlockEditorModal(); closeVideoSelectorModal(); - sendMessageToIframe(messageTypes.refreshXBlock, null); - }, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]); + sendMessageToIframe(messageTypes.completeXBlockEditing, { locator: newBlockId }); + // This ensures the publish button is able + resetXBlockPublishState(); + }, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe, newBlockId]); const handleEditXBlock = useCallback((type: string, id: string) => { setBlockType(type); diff --git a/src/course-unit/xblock-container-iframe/types.ts b/src/course-unit/xblock-container-iframe/types.ts index 084577d163..de46ca8a6d 100644 --- a/src/course-unit/xblock-container-iframe/types.ts +++ b/src/course-unit/xblock-container-iframe/types.ts @@ -48,6 +48,7 @@ export interface XBlockContainerIframeProps { }; courseVerticalChildren: Array; handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void; + resetXBlockPublishState: () => void; } export type UserPartitionInfoTypes = { diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index 4c5079a5a9..8da9f24b71 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -125,16 +125,6 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => { content, onSuccess: (response) => { dispatch(actions.app.setSaveResponse(response)); - const parsedData = JSON.parse(response.config.data); - if (parsedData?.has_changes) { - const storageKey = 'courseRefreshTriggerOnComponentEditSave'; - localStorage.setItem(storageKey, Date.now()); - - window.dispatchEvent(new StorageEvent('storage', { - key: storageKey, - newValue: Date.now().toString(), - })); - } returnToUnit(response.data); }, })); diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index 3f8dc10c9e..35debc7f3c 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -352,11 +352,7 @@ describe('app thunkActions', () => { }); it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => { dispatch.mockClear(); - const mockParsedData = { has_changes: true }; - const response = { - config: { data: JSON.stringify(mockParsedData) }, - data: {}, - }; + const response = 'testRESPONSE'; calls[1][0].saveBlock.onSuccess(response); expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response)); expect(returnToUnit).toHaveBeenCalled();