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 experimental feature for direct XBlock rendering [FC-0035] #1281

Closed
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
RENDER_XBLOCKS_DEFAULT=true
RENDER_XBLOCKS_EXPERIMENTAL=false
2 changes: 2 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''
RENDER_XBLOCKS_DEFAULT=true
RENDER_XBLOCKS_EXPERIMENTAL=false
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,5 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
RENDER_XBLOCKS_DEFAULT=true
RENDER_XBLOCKS_EXPERIMENTAL=false
8 changes: 8 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ TWITTER_URL

Example: https://twitter.com/edXOnline

RENDER_XBLOCKS_EXPERIMENTAL
Enables the experimental rendering of XBlocks directly in the MFE. This
feature is not yet ready for production use. The default value is ``false``.
Note: if you enable this feature (by setting this variable to ``true``), you
can disable the default rendering of XBlocks by setting
``RENDER_XBLOCKS_DEFAULT`` to ``false``.
Optional.

Getting Help
===========

Expand Down
45 changes: 35 additions & 10 deletions src/courseware/course/sequence/Unit/index.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { useEffect, useState } from 'react';

import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';

import { getConfig } from '@edx/frontend-platform';
import { useModel } from '../../../../generic/model-store';

import BookmarkButton from '../../bookmark/BookmarkButton';
Expand All @@ -13,6 +14,8 @@
import { modelKeys, views } from './constants';
import { useExamAccess, useShouldDisplayHonorCode } from './hooks';
import { getIFrameUrl } from './urls';
import { getBlockMetadataWithChildren } from '../../../data/api';
import { XBlock } from '../XBlock';

const Unit = ({
courseId,
Expand All @@ -35,6 +38,22 @@
examAccess,
});

const [unitChildren, setUnitChildren] = useState(null);

if (getConfig().RENDER_XBLOCKS_EXPERIMENTAL === true || getConfig().RENDER_XBLOCKS_EXPERIMENTAL === 'true') {
useEffect(() => {
(async () => {
try {
const response = await getBlockMetadataWithChildren(id);
setUnitChildren(response.children);

Check warning on line 48 in src/courseware/course/sequence/Unit/index.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/Unit/index.jsx#L44-L48

Added lines #L44 - L48 were not covered by tests
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error:', error);

Check warning on line 51 in src/courseware/course/sequence/Unit/index.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/Unit/index.jsx#L51

Added line #L51 was not covered by tests
}
})();
}, [id]);
}

return (
<div className="unit">
<h1 className="mb-0 h3">{unit.title}</h1>
Expand All @@ -45,15 +64,21 @@
isProcessing={isProcessing}
/>
<UnitSuspense {...{ courseId, id }} />
<ContentIFrame
elementId="unit-iframe"
id={id}
iframeUrl={iframeUrl}
loadingMessage={formatMessage(messages.loadingSequence)}
onLoaded={onLoaded}
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
title={unit.title}
/>
{getConfig().RENDER_XBLOCKS_DEFAULT !== false && getConfig().RENDER_XBLOCKS_DEFAULT !== 'false' && (
<ContentIFrame
elementId="unit-iframe"
id={id}
iframeUrl={iframeUrl}
loadingMessage={formatMessage(messages.loadingSequence)}
onLoaded={onLoaded}
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
title={unit.title}
/>
)}
{(getConfig().RENDER_XBLOCKS_EXPERIMENTAL === true || getConfig().RENDER_XBLOCKS_EXPERIMENTAL === 'true')
&& unitChildren && unitChildren.map((child) => (
<XBlock key={child} usageId={child} />

Check warning on line 80 in src/courseware/course/sequence/Unit/index.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/Unit/index.jsx#L79-L80

Added lines #L79 - L80 were not covered by tests
))}
</div>
);
};
Expand Down
187 changes: 187 additions & 0 deletions src/courseware/course/sequence/XBlock/XBlock.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';

import wrapBlockHtmlForIFrame from './wrap';
import { getBlockHandlerUrl, renderXBlockView } from '../../../data/api';

/**
* React component that displays an XBlock in a sandboxed IFrame.
*
* The IFrame is resized responsively so that it fits the content height.
*
* We use an IFrame so that the XBlock code, including user-authored HTML,
* 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.
*/
class XBlock extends React.Component {
constructor(props) {
super(props);
this.iframeRef = React.createRef();
this.state = {

Check warning on line 21 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L18-L21

Added lines #L18 - L21 were not covered by tests
html: null,
iFrameHeight: 400,
iframeKey: 0,
view: null,
};
}

/**
* Load the XBlock data from the LMS and then inject it into our IFrame.
*/
componentDidMount() {

Check warning on line 32 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L32

Added line #L32 was not covered by tests
// Prepare to receive messages from the IFrame.
// Messages are the only way that the code in the IFrame can communicate
// with the surrounding UI.
window.addEventListener('message', this.receivedWindowMessage);

Check warning on line 36 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L36

Added line #L36 was not covered by tests

// Fetch the XBlock HTML from the LMS:
this.fetchBlockHtml();

Check warning on line 39 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L39

Added line #L39 was not covered by tests

// Process the XBlock view:
this.processView();

Check warning on line 42 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L42

Added line #L42 was not covered by tests
}

componentDidUpdate(prevProps, prevState) {

Check warning on line 45 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L45

Added line #L45 was not covered by tests
if (prevState.view !== this.state.view) {
this.processView();

Check warning on line 47 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L47

Added line #L47 was not covered by tests
}
}

componentWillUnmount() {
window.removeEventListener('message', this.receivedWindowMessage);

Check warning on line 52 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L51-L52

Added lines #L51 - L52 were not covered by tests
}

/**
* Fetch the XBlock HTML and resources from the LMS.
*/
fetchBlockHtml = async () => {
try {
const response = await renderXBlockView(this.props.usageId, 'student_view');
this.setState({ view: response });

Check warning on line 61 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L58-L61

Added lines #L58 - L61 were not covered by tests
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error:', error);

Check warning on line 64 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L64

Added line #L64 was not covered by tests
}
};

/**
* Handle any messages we receive from the XBlock Runtime code in the IFrame.
* See wrap.ts to see the code that sends these messages.
*/
receivedWindowMessage = async (event) => {

Check warning on line 72 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L72

Added line #L72 was not covered by tests
if (this.iframeRef.current === null || event.source !== this.iframeRef.current.contentWindow) {
return; // This is some other random message.

Check warning on line 74 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L74

Added line #L74 was not covered by tests
}

const { method, replyKey, ...args } = event.data;
const frame = this.iframeRef.current.contentWindow;
const sendReply = async (data) => {
frame.postMessage({ ...data, replyKey }, '*');

Check warning on line 80 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L77-L80

Added lines #L77 - L80 were not covered by tests
};

if (method === 'bootstrap') {
sendReply({ initialHtml: this.state.html });

Check warning on line 84 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L84

Added line #L84 was not covered by tests
} else if (method === 'get_handler_url') {
const handlerUrl = await getBlockHandlerUrl(args.usageId, 'handler_name');
sendReply({ handlerUrl });

Check warning on line 87 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L86-L87

Added lines #L86 - L87 were not covered by tests
} else if (method === 'update_frame_height') {
this.setState({ iFrameHeight: args.height });

Check warning on line 89 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L89

Added line #L89 was not covered by tests
} else if (method?.indexOf('xblock:') === 0) {
// This is a notification from the XBlock's frontend via 'runtime.notify(event, args)'
if (this.props.onBlockNotification) {
this.props.onBlockNotification({

Check warning on line 93 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L93

Added line #L93 was not covered by tests
eventType: method.substr(7), // Remove the 'xblock:' prefix that we added in wrap.ts
...args,
});
}
}
};

processView() {

Check warning on line 101 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L101

Added line #L101 was not covered by tests
if (this.state.view) {
// HACK: Replace relative URLs starting with /static/, /assets/, or /xblock/ with absolute ones.
// This regexp captures the quote character (', ", or their encoded equivalents) followed by the relative path.
const regexp = /(["']|&#34;|&#39;)\/(static|assets|xblock)\//g;

Check warning on line 105 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L105

Added line #L105 was not covered by tests
const contentString = JSON.stringify(this.state.view.content || '');
const updatedContentString = contentString.replace(regexp, `$1${getConfig().LMS_BASE_URL}/$2/`);
const content = JSON.parse(updatedContentString);

Check warning on line 108 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L107-L108

Added lines #L107 - L108 were not covered by tests

const html = wrapBlockHtmlForIFrame(

Check warning on line 110 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L110

Added line #L110 was not covered by tests
content,
this.state.view.resources || [],
getConfig().LMS_BASE_URL,
);

// Load the XBlock HTML into the IFrame:
// iframe will only re-render in react when its property changes (key here)
this.setState(prevState => ({

Check warning on line 118 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L118

Added line #L118 was not covered by tests
html,
iframeKey: prevState.iframeKey + 1,
}));
}
}

render() {

Check warning on line 125 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L125

Added line #L125 was not covered by tests
/* Only draw the iframe if the HTML has already been set. This is because xblock-bootstrap.html will only request
* HTML once, upon being rendered. */
if (this.state.html === null) {
return null;

Check warning on line 129 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L129

Added line #L129 was not covered by tests
}

return (

Check warning on line 132 in src/courseware/course/sequence/XBlock/XBlock.jsx

View check run for this annotation

Codecov / codecov/patch

src/courseware/course/sequence/XBlock/XBlock.jsx#L132

Added line #L132 was not covered by tests
<div style={{
height: `${this.state.iFrameHeight}px`,
boxSizing: 'content-box',
position: 'relative',
overflow: 'hidden',
minHeight: '200px',
margin: '24px',
}}
>
<iframe
key={this.state.iframeKey}
ref={this.iframeRef}
title="block"
src="/xblock-bootstrap.html"
data-testid="block-preview"
style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
minHeight: '200px',
border: '0 none',
backgroundColor: 'white',
}}
// allowing 'autoplay' is required to allow the video XBlock to control the YouTube iframe it has.
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
sandbox={[
'allow-forms',
'allow-modals',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-presentation',
'allow-same-origin', // This is only secure IF the IFrame source
// is served from a completely different domain name
// e.g. labxchange-xblocks.net vs www.labxchange.org
'allow-scripts',
'allow-top-navigation-by-user-activation',
].join(' ')}
/>
</div>
);
}
}

XBlock.propTypes = {
onBlockNotification: PropTypes.func,
usageId: PropTypes.string.isRequired,
};

XBlock.defaultProps = {
onBlockNotification: null,
};

export default XBlock;
2 changes: 2 additions & 0 deletions src/courseware/course/sequence/XBlock/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* eslint-disable-next-line import/prefer-default-export */
export { default as XBlock } from './XBlock';
Loading
Loading