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();