feat: Add AdvancedEditors with an iframe [FC-0076] (#1568)
Creates the AdvancedEditor to support editors like Drag and Drop, openresponse, poll, survey, and other advanced editors. - AdvancedEditor created to call studio_view of the block - Update LibraryBlock to support any view (and use studio_view in AdvancedEditor) Intercept xblock-event message to close the Advanced editor on cancel or save
This commit is contained in:
90
src/editors/AdvancedEditor.test.tsx
Normal file
90
src/editors/AdvancedEditor.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import {
|
||||
render,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '../testUtils';
|
||||
import AdvancedEditor from './AdvancedEditor';
|
||||
|
||||
jest.mock('./containers/EditorContainer', () => ({
|
||||
EditorModalWrapper: jest.fn(() => (<div>Advanced Editor Iframe</div>)),
|
||||
}));
|
||||
const onCloseMock = jest.fn();
|
||||
|
||||
describe('AdvancedEditor', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('should call onClose when receiving "cancel-clicked" message', () => {
|
||||
render(<AdvancedEditor usageKey="test" onClose={onCloseMock} />);
|
||||
|
||||
const messageEvent = new MessageEvent('message', {
|
||||
data: {
|
||||
type: 'xblock-event',
|
||||
eventName: 'cancel',
|
||||
},
|
||||
origin: getConfig().STUDIO_BASE_URL,
|
||||
});
|
||||
|
||||
window.dispatchEvent(messageEvent);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onClose when receiving "save-clicked" message', () => {
|
||||
render(<AdvancedEditor usageKey="test" onClose={onCloseMock} />);
|
||||
|
||||
const messageEvent = new MessageEvent('message', {
|
||||
data: {
|
||||
type: 'xblock-event',
|
||||
eventName: 'save',
|
||||
data: {
|
||||
state: 'end',
|
||||
},
|
||||
},
|
||||
origin: getConfig().STUDIO_BASE_URL,
|
||||
});
|
||||
|
||||
window.dispatchEvent(messageEvent);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call showToast when receiving "error" message', async () => {
|
||||
const { mockShowToast } = initializeMocks();
|
||||
|
||||
render(<AdvancedEditor usageKey="test" onClose={onCloseMock} />);
|
||||
|
||||
const messageEvent = new MessageEvent('message', {
|
||||
data: {
|
||||
type: 'xblock-event',
|
||||
eventName: 'error',
|
||||
},
|
||||
origin: getConfig().STUDIO_BASE_URL,
|
||||
});
|
||||
|
||||
window.dispatchEvent(messageEvent);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call onClose if the message is from an invalid origin', () => {
|
||||
render(<AdvancedEditor usageKey="test" onClose={onCloseMock} />);
|
||||
|
||||
const messageEvent = new MessageEvent('message', {
|
||||
data: {
|
||||
type: 'xblock-event',
|
||||
eventName: 'cancel',
|
||||
},
|
||||
origin: 'https://invalid-origin.com',
|
||||
});
|
||||
|
||||
window.dispatchEvent(messageEvent);
|
||||
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
55
src/editors/AdvancedEditor.tsx
Normal file
55
src/editors/AdvancedEditor.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { LibraryBlock } from '../library-authoring/LibraryBlock';
|
||||
import { EditorModalWrapper } from './containers/EditorContainer';
|
||||
import { ToastContext } from '../generic/toast-context';
|
||||
import messages from './messages';
|
||||
|
||||
interface AdvancedEditorProps {
|
||||
usageKey: string,
|
||||
onClose: Function | null,
|
||||
}
|
||||
|
||||
const AdvancedEditor = ({ usageKey, onClose }: AdvancedEditorProps) => {
|
||||
const intl = useIntl();
|
||||
const { showToast } = React.useContext(ToastContext);
|
||||
|
||||
useEffect(() => {
|
||||
const handleIframeMessage = (event) => {
|
||||
if (event.origin !== getConfig().STUDIO_BASE_URL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === 'xblock-event') {
|
||||
const { eventName, data } = event.data;
|
||||
|
||||
if (onClose && (eventName === 'cancel'
|
||||
|| (eventName === 'save' && data.state === 'end'))
|
||||
) {
|
||||
onClose();
|
||||
} else if (eventName === 'error') {
|
||||
showToast(intl.formatMessage(messages.advancedEditorGenericError));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleIframeMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleIframeMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EditorModalWrapper onClose={onClose as () => void}>
|
||||
<LibraryBlock
|
||||
usageKey={usageKey}
|
||||
view="studio_view"
|
||||
/>
|
||||
</EditorModalWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedEditor;
|
||||
@@ -2,14 +2,13 @@
|
||||
// <EditorPage> as its parent, so they are tested together in EditorPage.test.tsx
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
import supportedEditors from './supportedEditors';
|
||||
import type { EditorComponent } from './EditorComponent';
|
||||
import { useEditorContext } from './EditorContext';
|
||||
import AdvancedEditor from './AdvancedEditor';
|
||||
|
||||
export interface Props extends EditorComponent {
|
||||
blockType: string;
|
||||
@@ -43,9 +42,17 @@ const Editor: React.FC<Props> = ({
|
||||
const { fullScreen } = useEditorContext();
|
||||
|
||||
const EditorComponent = supportedEditors[blockType];
|
||||
const innerEditor = (EditorComponent !== undefined)
|
||||
? <EditorComponent {...{ onClose, returnFunction }} />
|
||||
: <FormattedMessage {...messages.couldNotFindEditor} />;
|
||||
|
||||
if (EditorComponent === undefined && blockId) {
|
||||
return (
|
||||
<AdvancedEditor
|
||||
usageKey={blockId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const innerEditor = <EditorComponent {...{ onClose, returnFunction }} />;
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,9 @@ jest.spyOn(editorCmsApi, 'fetchByUnitId').mockImplementation(async () => ({
|
||||
}],
|
||||
},
|
||||
}));
|
||||
jest.mock('../library-authoring/LibraryBlock', () => ({
|
||||
LibraryBlock: jest.fn(() => (<div>Advanced Editor Iframe</div>)),
|
||||
}));
|
||||
|
||||
const defaultPropsHtml = {
|
||||
blockId: 'block-v1:Org+TS100+24+type@html+block@123456html',
|
||||
@@ -79,9 +82,7 @@ describe('EditorPage', () => {
|
||||
expect(modalElement.classList).not.toContain('pgn__modal-xl');
|
||||
});
|
||||
|
||||
test('it shows an error message if there is no corresponding editor', async () => {
|
||||
// We can edit 'html', 'problem', and 'video' blocks.
|
||||
// But if we try to edit some other type, say 'fake', we should get an error:
|
||||
test('it shows the Advanced Editor if there is no corresponding editor', async () => {
|
||||
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( // eslint-disable-next-line
|
||||
{ status: 200, data: { display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '' } }
|
||||
));
|
||||
@@ -93,6 +94,6 @@ describe('EditorPage', () => {
|
||||
};
|
||||
render(<EditorPage {...defaultPropsFake} />);
|
||||
|
||||
expect(await screen.findByText('Error: Could Not find Editor')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Advanced Editor Iframe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
||||
couldNotFindEditor: {
|
||||
id: 'authoring.editorpage.selecteditor.error',
|
||||
defaultMessage: 'Error: Could Not find Editor',
|
||||
description: 'Error Message Dispayed When An unsopported Editor is desired in V2',
|
||||
},
|
||||
dropVideoFileHere: {
|
||||
defaultMessage: 'Drag and drop video here or click to upload',
|
||||
id: 'VideoUploadEditor.dropVideoFileHere',
|
||||
@@ -37,6 +31,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'View in Library',
|
||||
description: 'Link text for opening library block in another tab.',
|
||||
},
|
||||
advancedEditorGenericError: {
|
||||
id: 'authoring.advancedEditor.error.generic',
|
||||
defaultMessage: 'An unexpected error occurred in the editor',
|
||||
description: 'Generic error message shown when an error occurs in the Advanced Editor.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -10,6 +10,7 @@ interface LibraryBlockProps {
|
||||
onBlockNotification?: (event: { eventType: string; [key: string]: any }) => void;
|
||||
usageKey: string;
|
||||
version?: VersionSpec;
|
||||
view?: string;
|
||||
}
|
||||
/**
|
||||
* React component that displays an XBlock in a sandboxed IFrame.
|
||||
@@ -20,7 +21,12 @@ interface LibraryBlockProps {
|
||||
* cannot access things like the user's cookies, nor can it make GET/POST
|
||||
* requests as the user. However, it is allowed to call any XBlock handlers.
|
||||
*/
|
||||
export const LibraryBlock = ({ onBlockNotification, usageKey, version }: LibraryBlockProps) => {
|
||||
export const LibraryBlock = ({
|
||||
onBlockNotification,
|
||||
usageKey,
|
||||
version,
|
||||
view,
|
||||
}: LibraryBlockProps) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [iFrameHeight, setIFrameHeight] = useState(50);
|
||||
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
@@ -71,6 +77,8 @@ export const LibraryBlock = ({ onBlockNotification, usageKey, version }: Library
|
||||
|
||||
const queryStr = version ? `?version=${version}` : '';
|
||||
|
||||
const xblockView = view ?? 'student_view';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: `${iFrameHeight}vh`,
|
||||
@@ -83,7 +91,7 @@ export const LibraryBlock = ({ onBlockNotification, usageKey, version }: Library
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={intl.formatMessage(messages.iframeTitle)}
|
||||
src={`${studioBaseUrl}/xblocks/v2/${usageKey}/embed/student_view/${queryStr}`}
|
||||
src={`${studioBaseUrl}/xblocks/v2/${usageKey}/embed/${xblockView}/${queryStr}`}
|
||||
data-testid="block-preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
|
||||
@@ -15,9 +15,7 @@ export function canEditComponent(usageKey: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Which XBlock/component types are supported by the 'editors' built in to this repo?
|
||||
const mfeEditorTypes = ['html', 'problem', 'video'];
|
||||
return mfeEditorTypes.includes(blockType);
|
||||
return getConfig().LIBRARY_SUPPORTED_BLOCKS.includes(blockType);
|
||||
}
|
||||
|
||||
export const ComponentEditorModal: React.FC<Record<never, never>> = () => {
|
||||
|
||||
Reference in New Issue
Block a user