Skip to content

Commit

Permalink
feat: Add AdvancedEditors with an iframe [FC-0076] (#1568)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ChrisChV authored Jan 16, 2025
1 parent 619ab9a commit fd6a6dd
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 20 deletions.
90 changes: 90 additions & 0 deletions src/editors/AdvancedEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 55 additions & 0 deletions src/editors/AdvancedEditor.tsx
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 12 additions & 5 deletions src/editors/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down
9 changes: 5 additions & 4 deletions src/editors/EditorPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: '' } }
));
Expand All @@ -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();
});
});
11 changes: 5 additions & 6 deletions src/editors/messages.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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;
12 changes: 10 additions & 2 deletions src/library-authoring/LibraryBlock/LibraryBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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`,
Expand All @@ -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%',
Expand Down
4 changes: 1 addition & 3 deletions src/library-authoring/components/ComponentEditorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>> = () => {
Expand Down

0 comments on commit fd6a6dd

Please sign in to comment.