Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add AdvancedEditors with an iframe [FC-0076] #1568

Merged
merged 8 commits into from
Jan 16, 2025
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
Loading