Skip to content
2 changes: 2 additions & 0 deletions src/course-unit/CourseUnit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const CourseUnit = ({ courseId }) => {
handleCloseXBlockMovedAlert,
handleNavigateToTargetUnit,
addComponentTemplateData,
resetXBlockPublishState,
} = useCourseUnit({ courseId, blockId });
const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType);

Expand Down Expand Up @@ -213,6 +214,7 @@ const CourseUnit = ({ courseId }) => {
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
resetXBlockPublishState={resetXBlockPublishState}
/>
{!readOnly && (
<AddComponent
Expand Down
55 changes: 55 additions & 0 deletions src/course-unit/CourseUnit.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2304,4 +2304,59 @@ describe('<CourseUnit />', () => {
// 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(<RootWrapper />);
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();
});
});
});
5 changes: 5 additions & 0 deletions src/course-unit/data/slice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
},
});

Expand All @@ -108,6 +112,7 @@ export const {
updateCourseOutlineInfo,
updateCourseOutlineInfoLoadingStatus,
updateMovedXBlockParams,
setXBlockPublishState,
} = slice.actions;

export const {
Expand Down
31 changes: 31 additions & 0 deletions src/course-unit/data/slice.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
24 changes: 6 additions & 18 deletions src/course-unit/hooks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from './data/selectors';
import {
changeEditTitleFormOpen,
setXBlockPublishState,
updateMovedXBlockParams,
updateQueryPendingStatus,
} from './data/slice';
Expand Down Expand Up @@ -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}`);
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -266,6 +253,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handleNavigateToTargetUnit,
addComponentTemplateData,
setAddComponentTemplateData,
resetXBlockPublishState,
};
};

Expand Down
75 changes: 74 additions & 1 deletion src/course-unit/hooks.test.jsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
</QueryClientProvider>
);

describe('useLayoutGrid', () => {
it('returns fullWidth layout when isUnitLibraryType is true', () => {
const { result } = renderHook(() => useLayoutGrid('someCategory', true));
Expand Down Expand Up @@ -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');
});
});
});
14 changes: 11 additions & 3 deletions src/course-unit/xblock-container-iframe/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ import VideoSelectorPage from '../../editors/VideoSelectorPage';
import EditorPage from '../../editors/EditorPage';

const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
courseId,
blockId,
unitXBlockActions,
courseVerticalChildren,
handleConfigureSubmit,
isUnitVerticalType,
resetXBlockPublishState,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
Expand Down Expand Up @@ -73,8 +79,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
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);
Expand Down
1 change: 1 addition & 0 deletions src/course-unit/xblock-container-iframe/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface XBlockContainerIframeProps {
};
courseVerticalChildren: Array<XBlockTypes>;
handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void;
resetXBlockPublishState: () => void;
}

export type UserPartitionInfoTypes = {
Expand Down
10 changes: 0 additions & 10 deletions src/editors/data/redux/thunkActions/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
}));
Expand Down
6 changes: 1 addition & 5 deletions src/editors/data/redux/thunkActions/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down