feat: handle unsaved changes in text & problem editors (#1444)

The text & problem xblock editors will display a confirmation box before
cancelling only if user has changed something else it will directly go
back.
This commit is contained in:
Navin Karkera
2024-11-04 23:11:00 +05:30
committed by GitHub
parent 949e4ac94c
commit df8a65dc4e
23 changed files with 353 additions and 76 deletions

22
package-lock.json generated
View File

@@ -64,8 +64,8 @@
"react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.23.1",
"react-router-dom": "6.23.1",
"react-router": "6.27.0",
"react-router-dom": "6.27.0",
"react-select": "5.8.0",
"react-textarea-autosize": "^8.5.3",
"react-transition-group": "4.4.5",
@@ -4275,7 +4275,9 @@
}
},
"node_modules/@remix-run/router": {
"version": "1.16.1",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz",
"integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -17514,10 +17516,12 @@
}
},
"node_modules/react-router": {
"version": "6.23.1",
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz",
"integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.16.1"
"@remix-run/router": "1.20.0"
},
"engines": {
"node": ">=14.0.0"
@@ -17527,11 +17531,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.23.1",
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz",
"integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.16.1",
"react-router": "6.23.1"
"@remix-run/router": "1.20.0",
"react-router": "6.27.0"
},
"engines": {
"node": ">=14.0.0"

View File

@@ -93,8 +93,8 @@
"react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.23.1",
"react-router-dom": "6.23.1",
"react-router": "6.27.0",
"react-router-dom": "6.27.0",
"react-select": "5.8.0",
"react-textarea-autosize": "^8.5.3",
"react-transition-group": "4.4.5",

View File

@@ -7,7 +7,7 @@ import {
} from '@openedx/paragon';
import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
@@ -19,6 +19,7 @@ import { copyToClipboard } from '../../generic/data/thunks';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import XBlockMessages from './xblock-messages/XBlockMessages';
import messages from './messages';
import { createCorrectInternalRoute } from '../../utils';
const CourseXBlock = ({
id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo,
@@ -28,7 +29,6 @@ const CourseXBlock = ({
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const dispatch = useDispatch();
const navigate = useNavigate();
const canEdit = useSelector(getCanEdit);
const courseId = useSelector(getCourseId);
const intl = useIntl();
@@ -58,7 +58,11 @@ const CourseXBlock = ({
case COMPONENT_TYPES.html:
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
navigate(`/course/${courseId}/editor/${type}/${id}`);
// Not using useNavigate from react router to use browser navigation
// which allows us to block back button if unsaved changes in editor are present.
window.location.assign(
createCorrectInternalRoute(`/course/${courseId}/editor/${type}/${id}`),
);
break;
default:
}

View File

@@ -29,7 +29,6 @@ const blockId = '567890';
const handleDeleteMock = jest.fn();
const handleDuplicateMock = jest.fn();
const handleConfigureSubmitMock = jest.fn();
const mockedUsedNavigate = jest.fn();
const {
name,
block_id: id,
@@ -42,11 +41,6 @@ const unitXBlockActionsMock = {
handleDuplicate: handleDuplicateMock,
};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedUsedNavigate,
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
@@ -78,6 +72,16 @@ useSelector.mockImplementation((selector) => {
});
describe('<CourseXBlock />', () => {
const locationTemp = window.location;
beforeAll(() => {
delete window.location;
window.location = {
assign: jest.fn(),
};
});
afterAll(() => {
window.location = locationTemp;
});
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
@@ -168,8 +172,8 @@ describe('<CourseXBlock />', () => {
expect(editButton).toBeInTheDocument();
userEvent.click(editButton);
expect(mockedUsedNavigate).toHaveBeenCalled();
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/html/${id}`);
expect(window.location.assign).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/html/${id}`);
});
it('navigates to editor page on edit Video xblock', () => {
@@ -182,8 +186,8 @@ describe('<CourseXBlock />', () => {
expect(editButton).toBeInTheDocument();
userEvent.click(editButton);
expect(mockedUsedNavigate).toHaveBeenCalled();
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/video/${id}`);
expect(window.location.assign).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/video/${id}`);
});
it('navigates to editor page on edit Problem xblock', () => {
@@ -196,8 +200,8 @@ describe('<CourseXBlock />', () => {
expect(editButton).toBeInTheDocument();
userEvent.click(editButton);
expect(mockedUsedNavigate).toHaveBeenCalled();
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`);
expect(window.location.assign).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`);
expect(handleDeleteMock).toHaveBeenCalledWith(id);
});
});

View File

@@ -29,6 +29,12 @@ jest.spyOn(editorCmsApi, 'fetchByUnitId').mockImplementation(async () => ({
},
}));
const isDirtyMock = jest.fn();
jest.mock('../TextEditor/hooks', () => ({
...jest.requireActual('../TextEditor/hooks'),
isDirty: () => isDirtyMock,
}));
const defaultPropsHtml = {
blockId: 'block-v1:Org+TS100+24+type@html+block@123456html',
blockType: 'html',
@@ -45,15 +51,27 @@ const fieldsHtml = {
};
describe('EditorContainer', () => {
let mockEvent: Event;
beforeEach(() => {
initializeMocks();
mockEvent = new Event('beforeunload');
jest.spyOn(window, 'addEventListener');
jest.spyOn(window, 'removeEventListener');
jest.spyOn(mockEvent, 'preventDefault');
Object.defineProperty(mockEvent, 'returnValue', { writable: true });
});
test('it displays a confirmation dialog when closing the editor modal', async () => {
afterEach(() => {
jest.restoreAllMocks();
});
test('it displays a confirmation dialog when closing the editor modal if data is changed', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));
isDirtyMock.mockReturnValue(true);
render(<EditorPage {...defaultPropsHtml} />);
// Then the editor should open
@@ -68,12 +86,48 @@ describe('EditorContainer', () => {
fireEvent.click(closeButton);
// Now we should see the confirmation message:
expect(await screen.findByText(confirmMessage)).toBeInTheDocument();
expect(defaultPropsHtml.onClose).not.toHaveBeenCalled();
// Should close modal if cancelled
const cancelBtn = await screen.findByRole('button', { name: 'Cancel' });
fireEvent.click(cancelBtn);
expect(defaultPropsHtml.onClose).not.toHaveBeenCalled();
// open modal again
fireEvent.click(closeButton);
// And can confirm the cancelation:
const confirmButton = await screen.findByRole('button', { name: 'OK' });
fireEvent.click(confirmButton);
expect(defaultPropsHtml.onClose).toHaveBeenCalled();
window.dispatchEvent(mockEvent);
// should not be blocked by beforeunload event as the page was unloaded using close/cancel option
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
test('it does not display any confirmation dialog when closing the editor modal if data is not changed', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));
isDirtyMock.mockReturnValue(false);
render(<EditorPage {...defaultPropsHtml} />);
// Then the editor should open
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
// Assert the "are you sure?" message isn't visible yet
const confirmMessage = /Are you sure you want to exit the editor/;
expect(screen.queryByText(confirmMessage)).not.toBeInTheDocument();
// Find and click the close button
const closeButton = await screen.findByRole('button', { name: 'Exit the editor' });
fireEvent.click(closeButton);
// Even now we should not see the confirmation message as data is not dirty, i.e. not changed:
expect(screen.queryByText(confirmMessage)).not.toBeInTheDocument();
// And onClose is directly called
expect(defaultPropsHtml.onClose).toHaveBeenCalled();
});
test('it disables the save button until the fields have been loaded', async () => {
@@ -94,4 +148,21 @@ describe('EditorContainer', () => {
// Now the save button should be active:
await waitFor(() => expect(saveButton).not.toBeDisabled());
});
test('beforeunload event is triggered on page unload if data is changed', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));
isDirtyMock.mockReturnValue(true);
render(<EditorPage {...defaultPropsHtml} />);
// Then the editor should open
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
// on beforeunload event block user
window.dispatchEvent(mockEvent);
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.returnValue).toBe(true);
});
});

View File

@@ -20,6 +20,7 @@ import TitleHeader from './components/TitleHeader';
import * as hooks from './hooks';
import messages from './messages';
import './index.scss';
import usePromptIfDirty from '../../../generic/promptIfDirty/usePromptIfDirty';
interface WrapperProps {
children: React.ReactNode;
@@ -61,32 +62,57 @@ export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => {
interface Props extends EditorComponent {
children: React.ReactNode;
getContent: Function;
isDirty: () => boolean;
validateEntry?: Function | null;
}
const EditorContainer: React.FC<Props> = ({
children,
getContent,
isDirty,
onClose = null,
validateEntry = null,
returnFunction = null,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
// Required to mark data as not dirty on save
const [saved, setSaved] = React.useState(false);
const isInitialized = hooks.isInitialized();
const { isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal } = hooks.cancelConfirmModalToggle();
const handleCancel = hooks.handleCancel({ onClose, returnFunction });
const disableSave = !isInitialized;
const saveFailed = hooks.saveFailed();
const clearSaveFailed = hooks.clearSaveError({ dispatch });
const onSave = hooks.handleSaveClicked({
const handleSave = hooks.handleSaveClicked({
dispatch,
getContent,
validateEntry,
returnFunction,
});
const onSave = () => {
setSaved(true);
handleSave();
};
// Stops user from navigating away if they have unsaved changes.
usePromptIfDirty(() => {
// Do not block if cancel modal is used or data is saved.
if (isCancelConfirmOpen || saved) {
return false;
}
return isDirty();
});
const confirmCancelIfDirty = () => {
if (isDirty()) {
openCancelConfirmModal();
} else {
handleCancel();
}
};
return (
<EditorModalWrapper onClose={openCancelConfirmModal}>
<EditorModalWrapper onClose={confirmCancelIfDirty}>
{saveFailed && (
<Toast show onClose={clearSaveFailed}>
<FormattedMessage {...messages.contentSaveFailed} />
@@ -108,7 +134,9 @@ const EditorContainer: React.FC<Props> = ({
</Button>
)}
isOpen={isCancelConfirmOpen}
close={closeCancelConfirmModal}
close={() => {
closeCancelConfirmModal();
}}
title={intl.formatMessage(messages.cancelConfirmTitle)}
>
<FormattedMessage {...messages.cancelConfirmDescription} />
@@ -121,7 +149,7 @@ const EditorContainer: React.FC<Props> = ({
<IconButton
src={Close}
iconAs={Icon}
onClick={openCancelConfirmModal}
onClick={confirmCancelIfDirty}
alt={intl.formatMessage(messages.exitButtonAlt)}
/>
</div>
@@ -135,7 +163,7 @@ const EditorContainer: React.FC<Props> = ({
<Button
aria-label={intl.formatMessage(messages.cancelButtonAriaLabel)}
variant="tertiary"
onClick={openCancelConfirmModal}
onClick={confirmCancelIfDirty}
>
<FormattedMessage {...messages.cancelButtonLabel} />
</Button>

View File

@@ -3,6 +3,7 @@
exports[`EditorProblemView component renders raw editor 1`] = `
<EditorContainer
getContent={[Function]}
isDirty={[Function]}
returnFunction={null}
>
<AlertModal
@@ -72,6 +73,7 @@ exports[`EditorProblemView component renders raw editor 1`] = `
exports[`EditorProblemView component renders simple view 1`] = `
<EditorContainer
getContent={[Function]}
isDirty={[Function]}
returnFunction={null}
>
<AlertModal

View File

@@ -20,6 +20,19 @@ export const saveWarningModalToggle = () => {
};
};
/** Checks if any tinymce editor in window is dirty */
export const checkIfEditorsDirty = () => {
const EditorsArray = window.tinymce.editors;
return Object.entries(EditorsArray).some(([id, editor]) => {
if (Number.isNaN(parseInt(id, 10))) {
if (!editor.isNotDirty) {
return true;
}
}
return false;
});
};
export const fetchEditorContent = ({ format }) => {
const editorObject = { hints: [] };
const EditorsArray = window.tinymce.editors;

View File

@@ -362,3 +362,43 @@ describe('EditProblemView hooks parseState', () => {
});
});
});
describe('checkIfEditorsDirty', () => {
let windowSpy;
beforeEach(() => {
windowSpy = jest.spyOn(window, 'window', 'get');
});
afterEach(() => {
windowSpy.mockRestore();
});
describe('state hook', () => {
test('should return false if none of editors are dirty', () => {
windowSpy.mockImplementation(() => ({
tinymce: {
editors: {
some_id: { isNotDirty: true },
some_id2: { isNotDirty: true },
some_id3: { isNotDirty: true },
some_id4: { isNotDirty: true },
some_id5: { isNotDirty: true },
},
},
}));
expect(hooks.checkIfEditorsDirty()).toEqual(false);
});
test('should return true if any editor is dirty', () => {
windowSpy.mockImplementation(() => ({
tinymce: {
editors: {
some_id: { isNotDirty: true },
some_id2: { isNotDirty: true },
some_id3: { isNotDirty: false },
some_id4: { isNotDirty: true },
some_id5: { isNotDirty: false },
},
},
}));
expect(hooks.checkIfEditorsDirty()).toEqual(true);
});
});
});

View File

@@ -17,7 +17,9 @@ import { selectors } from '../../../../data/redux';
import RawEditor from '../../../../sharedComponents/RawEditor';
import { ProblemTypeKeys } from '../../../../data/constants/problem';
import { parseState, saveWarningModalToggle, getContent } from './hooks';
import {
checkIfEditorsDirty, parseState, saveWarningModalToggle, getContent,
} from './hooks';
import './index.scss';
import messages from './messages';
@@ -32,6 +34,7 @@ const EditProblemView = ({
lmsEndpointUrl,
returnUrl,
analytics,
isDirty,
// injected
intl,
}) => {
@@ -40,6 +43,14 @@ const EditProblemView = ({
const isAdvancedProblemType = problemType === ProblemTypeKeys.ADVANCED;
const { isSaveWarningModalOpen, openSaveWarningModal, closeSaveWarningModal } = saveWarningModalToggle();
const checkIfDirty = () => {
if (isAdvancedProblemType && editorRef && editorRef?.current) {
/* istanbul ignore next */
return editorRef.current.observer?.lastChange !== 0;
}
return isDirty || checkIfEditorsDirty();
};
return (
<EditorContainer
getContent={() => getContent({
@@ -49,6 +60,7 @@ const EditProblemView = ({
editorRef,
lmsEndpointUrl,
})}
isDirty={checkIfDirty}
returnFunction={returnFunction}
>
<AlertModal
@@ -117,6 +129,7 @@ const EditProblemView = ({
EditProblemView.defaultProps = {
lmsEndpointUrl: null,
returnFunction: null,
isDirty: false,
};
EditProblemView.propTypes = {
@@ -127,6 +140,7 @@ EditProblemView.propTypes = {
analytics: PropTypes.shape({}).isRequired,
lmsEndpointUrl: PropTypes.string,
returnUrl: PropTypes.string.isRequired,
isDirty: PropTypes.bool,
// injected
intl: intlShape.isRequired,
};
@@ -137,6 +151,7 @@ export const mapStateToProps = (state) => ({
returnUrl: selectors.app.returnUrl(state),
problemType: selectors.problem.problemType(state),
problemState: selectors.problem.completeState(state),
isDirty: selectors.problem.isDirty(state),
});
export const EditProblemViewInternal = EditProblemView; // For testing only

View File

@@ -14,6 +14,18 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
},
}
}
isDirty={
{
"isDirty": {
"editorRef": {
"current": {
"value": "something",
},
},
"showRawEditor": false,
},
}
}
onClose={[MockFunction props.onClose]}
returnFunction={null}
>
@@ -67,6 +79,18 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = `
},
}
}
isDirty={
{
"isDirty": {
"editorRef": {
"current": {
"value": "something",
},
},
"showRawEditor": true,
},
}
}
onClose={[MockFunction props.onClose]}
returnFunction={null}
>
@@ -114,6 +138,18 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
},
}
}
isDirty={
{
"isDirty": {
"editorRef": {
"current": {
"value": "something",
},
},
"showRawEditor": false,
},
}
}
onClose={[MockFunction props.onClose]}
returnFunction={null}
>
@@ -153,6 +189,18 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
},
}
}
isDirty={
{
"isDirty": {
"editorRef": {
"current": {
"value": "something",
},
},
"showRawEditor": false,
},
}
}
onClose={[MockFunction props.onClose]}
returnFunction={null}
>
@@ -206,6 +254,18 @@ exports[`TextEditor snapshots renders static images with relative paths 1`] = `
},
}
}
isDirty={
{
"isDirty": {
"editorRef": {
"current": {
"value": "something",
},
},
"showRawEditor": false,
},
}
}
onClose={[MockFunction props.onClose]}
returnFunction={null}
>

View File

@@ -9,3 +9,14 @@ export const getContent = ({ editorRef, showRawEditor }) => () => {
: editorRef.current?.getContent());
return setAssetToStaticUrl({ editorValue: content });
};
export const isDirty = ({ editorRef, showRawEditor }) => () => {
/* istanbul ignore next */
if (!editorRef?.current) {
return false;
}
const dirty = (showRawEditor && editorRef && editorRef.current
? editorRef.current.observer?.lastChange !== 0
: !editorRef.current.isNotDirty);
return dirty;
};

View File

@@ -61,5 +61,26 @@ describe('TextEditor hooks', () => {
expect(getContent).toEqual(rawContent);
});
});
describe('isDirty', () => {
test('checks isNotDirty flag when showRawEditor is false', () => {
const editorRef = {
current: {
isNotDirty: false,
},
};
const isDirty = module.isDirty({ editorRef, showRawEditor: false })();
expect(isDirty).toEqual(true);
});
test('checks observer.lastChange flag when showRawEditor is true', () => {
const editorRef = {
current: {
observer: { lastChange: 123 },
},
};
const isDirty = module.isDirty({ editorRef, showRawEditor: true })();
expect(isDirty).toEqual(true);
});
});
});
});

View File

@@ -80,6 +80,7 @@ const TextEditor = ({
return (
<EditorContainer
getContent={hooks.getContent({ editorRef, showRawEditor })}
isDirty={hooks.isDirty({ editorRef, showRawEditor })}
onClose={onClose}
returnFunction={returnFunction}
>

View File

@@ -22,6 +22,7 @@ jest.mock('../EditorContainer', () => 'EditorContainer');
jest.mock('./hooks', () => ({
getContent: jest.fn(args => ({ getContent: args })),
isDirty: jest.fn(args => ({ isDirty: args })),
nullMethod: jest.fn().mockName('hooks.nullMethod'),
}));

View File

@@ -5,6 +5,7 @@ exports[`VideoEditor snapshots renders as expected with default behavior 1`] = `
value="hooks.errorsHook.error"
>
<EditorContainer
isDirty={[Function]}
onClose={[MockFunction props.onClose]}
validateEntry={[MockFunction validateEntry]}
>

View File

@@ -31,6 +31,7 @@ const VideoEditor: React.FC<EditorComponent> = ({
<ErrorContext.Provider value={error}>
<EditorContainer
getContent={fetchVideoContent()}
isDirty={/* istanbul ignore next */ () => true}
onClose={onClose}
returnFunction={returnFunction}
validateEntry={validateEntry}

View File

@@ -16,6 +16,7 @@ const initialState = {
generalFeedback: '',
additionalAttributes: {},
defaultSettings: {},
isDirty: false,
settings: {
randomization: null,
scoring: {
@@ -52,6 +53,7 @@ const problem = createSlice({
updateQuestion: (state, { payload }) => ({
...state,
question: payload,
isDirty: true,
}),
updateAnswer: (state, { payload }) => {
const { id, hasSingleAnswer, ...answer } = payload;
@@ -77,6 +79,7 @@ const problem = createSlice({
...state,
correctAnswerCount,
answers,
isDirty: true,
};
},
deleteAnswer: (state, { payload }) => {
@@ -86,6 +89,7 @@ const problem = createSlice({
return {
...state,
correctAnswerCount: state.problemType === ProblemTypeKeys.NUMERIC ? 1 : 0,
isDirty: true,
answers: [{
id: 'A',
title: '',
@@ -140,6 +144,7 @@ const problem = createSlice({
answers,
correctAnswerCount: correct ? state.correctAnswerCount - 1 : state.correctAnswerCount,
groupFeedbackList,
isDirty: true,
};
},
addAnswer: (state) => {
@@ -167,6 +172,7 @@ const problem = createSlice({
return {
...state,
correctAnswerCount,
isDirty: true,
answers,
};
},
@@ -185,6 +191,7 @@ const problem = createSlice({
...state,
correctAnswerCount,
answers: [newOption],
isDirty: true,
};
},
@@ -194,6 +201,7 @@ const problem = createSlice({
...state.settings,
...payload,
},
isDirty: true,
}),
load: (state, { payload: { settings: { scoring, showAnswer, ...settings }, ...payload } }) => ({
...state,

View File

@@ -19,6 +19,7 @@ describe('problem reducer', () => {
it(`load ${target} from payload`, () => {
expect(reducer(testingState, actions[action](testValue))).toEqual({
...testingState,
isDirty: true,
[target]: testValue,
});
});
@@ -62,6 +63,7 @@ describe('problem reducer', () => {
expect(reducer(testingState, actions.addAnswer(answer))).toEqual({
...testingState,
answers: [answer],
isDirty: true,
});
});
});
@@ -79,6 +81,7 @@ describe('problem reducer', () => {
const payload = { hints: ['soMehInt'] };
expect(reducer(testingState, actions.updateSettings(payload))).toEqual({
...testingState,
isDirty: true,
settings: {
...testingState.settings,
...payload,
@@ -99,6 +102,7 @@ describe('problem reducer', () => {
expect(reducer({ ...testingState, problemType: 'choiceresponse' }, actions.addAnswer())).toEqual({
...testingState,
problemType: 'choiceresponse',
isDirty: true,
answers: [answer],
});
});
@@ -111,6 +115,7 @@ describe('problem reducer', () => {
expect(reducer(numericTestState, actions.addAnswer())).toEqual({
...numericTestState,
correctAnswerCount: 1,
isDirty: true,
answers: [{
...answer,
correct: true,
@@ -131,6 +136,7 @@ describe('problem reducer', () => {
expect(reducer({ ...testingState, problemType: ProblemTypeKeys.NUMERIC }, actions.addAnswerRange())).toEqual({
...testingState,
correctAnswerCount: 1,
isDirty: true,
problemType: ProblemTypeKeys.NUMERIC,
answers: [answerRange],
});
@@ -151,6 +157,7 @@ describe('problem reducer', () => {
)).toEqual({
...testingState,
correctAnswerCount: 1,
isDirty: true,
answers: [{ id: 'A', correct: true }],
});
});
@@ -183,6 +190,7 @@ describe('problem reducer', () => {
actions.deleteAnswer(payload),
)).toEqual({
...testingState,
isDirty: true,
correctAnswerCount: 0,
answers: [{
id: 'A',
@@ -220,6 +228,7 @@ describe('problem reducer', () => {
)).toEqual({
...testingState,
correctAnswerCount: 1,
isDirty: true,
answers: [{
id: 'A',
correct: true,
@@ -259,6 +268,7 @@ describe('problem reducer', () => {
)).toEqual({
...testingState,
problemType: ProblemTypeKeys.SINGLESELECT,
isDirty: true,
correctAnswerCount: 1,
answers: [{
id: 'A',
@@ -300,6 +310,7 @@ describe('problem reducer', () => {
)).toEqual({
...testingState,
correctAnswerCount: 1,
isDirty: true,
answers: [{
id: 'A',
correct: true,
@@ -380,6 +391,7 @@ describe('problem reducer', () => {
)).toEqual({
...testingState,
correctAnswerCount: 1,
isDirty: true,
answers: [{
id: 'A',
correct: true,
@@ -429,6 +441,7 @@ describe('problem reducer', () => {
...testingState,
problemType: ProblemTypeKeys.NUMERIC,
correctAnswerCount: 1,
isDirty: true,
answers: [{
id: 'A',
title: '',

View File

@@ -17,6 +17,7 @@ export const simpleSelectors = {
question: mkSimpleSelector(problemData => problemData.question),
defaultSettings: mkSimpleSelector(problemData => problemData.defaultSettings),
completeState: mkSimpleSelector(problemData => problemData),
isDirty: mkSimpleSelector(problemData => problemData.isDirty),
};
export default {

View File

@@ -1,21 +1,15 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';
import PromptIfDirty from './PromptIfDirty';
import { renderHook } from '@testing-library/react-hooks';
import usePromptIfDirty from './usePromptIfDirty';
describe('PromptIfDirty', () => {
let container = null;
describe('usePromptIfDirty', () => {
let mockEvent = null;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
mockEvent = new Event('beforeunload');
jest.spyOn(window, 'addEventListener');
jest.spyOn(window, 'removeEventListener');
jest.spyOn(mockEvent, 'preventDefault');
Object.defineProperty(mockEvent, 'returnValue', { writable: true });
mockEvent.returnValue = '';
});
afterEach(() => {
@@ -23,49 +17,32 @@ describe('PromptIfDirty', () => {
window.removeEventListener.mockRestore();
mockEvent.preventDefault.mockRestore();
mockEvent = null;
unmountComponentAtNode(container);
container.remove();
container = null;
});
it('should add event listener on mount', () => {
act(() => {
render(<PromptIfDirty dirty />, container);
});
renderHook(() => usePromptIfDirty(() => true));
expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
});
it('should remove event listener on unmount', () => {
act(() => {
render(<PromptIfDirty dirty />, container);
});
act(() => {
unmountComponentAtNode(container);
});
const { unmount } = renderHook(() => usePromptIfDirty(() => true));
unmount();
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
});
it('should call preventDefault and set returnValue when dirty is true', () => {
act(() => {
render(<PromptIfDirty dirty />, container);
});
act(() => {
window.dispatchEvent(mockEvent);
});
renderHook(() => usePromptIfDirty(() => true));
window.dispatchEvent(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.returnValue).toBe('');
expect(mockEvent.returnValue).toBe(true);
});
it('should not call preventDefault when dirty is false', () => {
act(() => {
render(<PromptIfDirty dirty={false} />, container);
});
act(() => {
window.dispatchEvent(mockEvent);
});
renderHook(() => usePromptIfDirty(() => false));
window.dispatchEvent(mockEvent);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});

View File

@@ -1,12 +1,13 @@
import { useEffect } from 'react';
import PropTypes from 'prop-types';
const PromptIfDirty = ({ dirty }) => {
const usePromptIfDirty = (checkIfDirty : () => boolean) => {
useEffect(() => {
// eslint-disable-next-line consistent-return
const handleBeforeUnload = (event) => {
if (dirty) {
if (checkIfDirty()) {
event.preventDefault();
// Included for legacy support, e.g. Chrome/Edge < 119
event.returnValue = true; // eslint-disable-line no-param-reassign
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
@@ -14,11 +15,9 @@ const PromptIfDirty = ({ dirty }) => {
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [dirty]);
}, [checkIfDirty]);
return null;
};
PromptIfDirty.propTypes = {
dirty: PropTypes.bool.isRequired,
};
export default PromptIfDirty;
export default usePromptIfDirty;

View File

@@ -18,7 +18,7 @@ import {
} from '@openedx/paragon';
import FormikControl from '../../generic/FormikControl';
import PromptIfDirty from '../../generic/promptIfDirty/PromptIfDirty';
import PromptIfDirty from '../../generic/prompt-if-dirty/PromptIfDirty';
import ModalDropzone from '../../generic/modal-dropzone/ModalDropzone';
import { useModel } from '../../generic/model-store';
import { UPLOAD_FILE_MAX_SIZE } from '../../constants';