Skip to content

Commit

Permalink
feat: add experimental feature for direct XBlock rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
Agrendalath committed Feb 1, 2024
1 parent e50b1e8 commit 826daf1
Show file tree
Hide file tree
Showing 10 changed files with 129 additions and 40 deletions.
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 UnitSuspense from './UnitSuspense';
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 @@ const Unit = ({
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);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error:', error);
}
})();
}, [id]);
}

return (
<div className="unit">
<h1 className="mb-0 h3">{unit.title}</h1>
Expand All @@ -45,15 +64,21 @@ const Unit = ({
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} />
))}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';

import wrapBlockHtmlForIFrame from './wrap';

ensureConfig(['LMS_BASE_URL', 'SECURE_ORIGIN_XBLOCK_BOOTSTRAP_HTML_URL'], 'library block component');
import { getBlockHandlerUrl, renderXBlockView } from '../../../data/api';

/**
* React component that displays an XBlock in a sandboxed IFrame.
Expand All @@ -15,14 +14,15 @@ ensureConfig(['LMS_BASE_URL', 'SECURE_ORIGIN_XBLOCK_BOOTSTRAP_HTML_URL'], 'libra
* 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 LibraryBlock extends React.Component {
class XBlock extends React.Component {
constructor(props) {
super(props);
this.iframeRef = React.createRef();
this.state = {
html: null,
iFrameHeight: 400,
iframeKey: 0,
view: null,
};
}

Expand All @@ -35,12 +35,15 @@ class LibraryBlock extends React.Component {
// with the surrounding UI.
window.addEventListener('message', this.receivedWindowMessage);

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

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

componentDidUpdate(prevProps) {
if (prevProps.view !== this.props.view) {
componentDidUpdate(prevProps, prevState) {
if (prevState.view !== this.state.view) {
this.processView();
}
}
Expand All @@ -49,6 +52,19 @@ class LibraryBlock extends React.Component {
window.removeEventListener('message', this.receivedWindowMessage);
}

/**
* 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 });
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error:', error);
}
};

/**
* Handle any messages we receive from the XBlock Runtime code in the IFrame.
* See wrap.ts to see the code that sends these messages.
Expand All @@ -67,7 +83,7 @@ class LibraryBlock extends React.Component {
if (method === 'bootstrap') {
sendReply({ initialHtml: this.state.html });
} else if (method === 'get_handler_url') {
const handlerUrl = await this.props.getHandlerUrl(args.usageId);
const handlerUrl = await getBlockHandlerUrl(args.usageId, 'handler_name');
sendReply({ handlerUrl });
} else if (method === 'update_frame_height') {
this.setState({ iFrameHeight: args.height });
Expand All @@ -83,11 +99,17 @@ class LibraryBlock extends React.Component {
};

processView() {
const { view } = this.props;
if (view.value) {
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;
const contentString = JSON.stringify(this.state.view.content || '');
const updatedContentString = contentString.replace(regexp, `$1${getConfig().LMS_BASE_URL}/$2/`);
const content = JSON.parse(updatedContentString);

const html = wrapBlockHtmlForIFrame(
view.value.content,
view.value.resources,
content,
this.state.view.resources || [],
getConfig().LMS_BASE_URL,
);

Expand Down Expand Up @@ -121,7 +143,7 @@ class LibraryBlock extends React.Component {
key={this.state.iframeKey}
ref={this.iframeRef}
title="block"
src={getConfig().SECURE_ORIGIN_XBLOCK_BOOTSTRAP_HTML_URL}
src="/xblock-bootstrap.html"
data-testid="block-preview"
style={{
position: 'absolute',
Expand Down Expand Up @@ -153,19 +175,13 @@ class LibraryBlock extends React.Component {
}
}

LibraryBlock.propTypes = {
getHandlerUrl: PropTypes.func.isRequired,
XBlock.propTypes = {
onBlockNotification: PropTypes.func,
view: PropTypes.shape({
value: PropTypes.shape({
content: PropTypes.string.isRequired,
resources: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
}).isRequired,
}).isRequired,
usageId: PropTypes.string.isRequired,
};

LibraryBlock.defaultProps = {
XBlock.defaultProps = {
onBlockNotification: null,
};

export default LibraryBlock;
export default XBlock;
2 changes: 1 addition & 1 deletion src/courseware/course/sequence/XBlock/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/* eslint-disable-next-line import/prefer-default-export */
export { default as LibraryBlock } from './LibraryBlock';
export { default as XBlock } from './XBlock';
21 changes: 15 additions & 6 deletions src/courseware/course/sequence/XBlock/wrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ function blockFrameJS() {
// so we first have to generate a secure handler URL for it:
postMessageToParent({ method: 'get_handler_url', usageId }, (handlerData) => {
element[HANDLER_URL] = handlerData.handlerUrl;

// HACK: Replace the old handler URL with the v2 XBlock API.
element.innerHTML = element.innerHTML.replace(/data-url="[^"]*"/, `data-url="${handlerData.handlerUrl.replace('handler_name/', 'xmodule_handler')}"`);

// Now proceed with initializing the block's JavaScript:
const InitFunction = (window)[initFunctionName];
// Does the XBlock HTML contain arguments to pass to the InitFunction?
Expand Down Expand Up @@ -220,13 +224,13 @@ export default function wrapBlockHtmlForIFrame(html, resources, lmsBaseUrl) {
/* Extract CSS resources. */
const cssUrls = urlResources.filter((r) => r.mimetype === 'text/css').map((r) => r.data);
const sheets = textResources.filter((r) => r.mimetype === 'text/css').map((r) => r.data);
let cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${url}">`).join('\n');
let cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${lmsBaseUrl}${url}">`).join('\n');
cssTags += sheets.map((sheet) => `<style>${sheet}</style>`).join('\n');

/* Extract JS resources. */
const jsUrls = urlResources.filter((r) => r.mimetype === 'application/javascript').map((r) => r.data);
const scripts = textResources.filter((r) => r.mimetype === 'application/javascript').map((r) => r.data);
let jsTags = jsUrls.map((url) => `<script src="${url}"></script>`).join('\n');
let jsTags = jsUrls.map((url) => `<script src="${lmsBaseUrl}${url}"></script>`).join('\n');
jsTags += scripts.map((script) => `<script>${script}</script>`).join('\n');

// Most older XModules/XBlocks have a ton of undeclared dependencies on various JavaScript in the global scope.
Expand All @@ -237,10 +241,10 @@ export default function wrapBlockHtmlForIFrame(html, resources, lmsBaseUrl) {
// Otherwise, if the XBlock uses 'student_view', 'author_view', or 'studio_view', include known required globals:
let legacyIncludes = '';
if (
html.indexOf('xblock-v1-student_view') !== -1
|| html.indexOf('xblock-v1-public_view') !== -1
|| html.indexOf('xblock-v1-studio_view') !== -1
|| html.indexOf('xblock-v1-author_view') !== -1
html.indexOf('xblock-student_view') !== -1
|| html.indexOf('xblock-public_view') !== -1
|| html.indexOf('xblock-studio_view') !== -1
|| html.indexOf('xblock-author_view') !== -1
) {
legacyIncludes += `
<!-- gettext & XBlock JS i18n code -->
Expand Down Expand Up @@ -276,6 +280,7 @@ export default function wrapBlockHtmlForIFrame(html, resources, lmsBaseUrl) {
draggabilly: 'js/vendor/draggabilly',
hls: 'common/js/vendor/hls',
moment: 'common/js/vendor/moment-with-locales',
'moment-timezone': 'common/js/vendor/moment-timezone-with-data',
HtmlUtils: 'edx-ui-toolkit/js/utils/html-utils',
},
});
Expand All @@ -288,6 +293,10 @@ export default function wrapBlockHtmlForIFrame(html, resources, lmsBaseUrl) {
<!-- edX HTML Utils requires GlobalLoader -->
<script type="text/javascript" src="${lmsBaseUrl}/static/edx-ui-toolkit/js/utils/global-loader.js"></script>
<script>
// Required by moment-timezone, which is used by the LibraryContentBlock.
RequireJS.require(['moment']);
// The video XBlock has an undeclared dependency on edX HTML Utils
RequireJS.require(['HtmlUtils'], function (HtmlUtils) {
window.edx.HtmlUtils = HtmlUtils;
Expand Down
24 changes: 24 additions & 0 deletions src/courseware/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,30 @@ export async function getSequenceMetadata(sequenceId) {

const getSequenceHandlerUrl = (courseId, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler`;

export async function getBlockMetadataWithChildren(usageKey) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/xblock/v2/xblocks/${usageKey}/?include=children`, {});

return camelCaseObject(data);
}

export async function getBlockHandlerUrl(usageKey, handlerName) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/xblock/v2/xblocks/${usageKey}/handler_url/${handlerName}/`, {});

return data.handler_url;
}

export const renderXBlockView = async (usageKey, viewName) => {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/xblock/v2/xblocks/${usageKey}/view/${viewName}/`, {});

return {
content: data.content,
resources: data.resources,
};
};

export async function getBlockCompletion(courseId, sequenceId, usageKey) {
const { data } = await getAuthenticatedHttpClient().post(
`${getSequenceHandlerUrl(courseId, sequenceId)}/get_completion`,
Expand Down
3 changes: 2 additions & 1 deletion src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ initialize({
PROCTORED_EXAM_FAQ_URL: process.env.PROCTORED_EXAM_FAQ_URL || null,
PROCTORED_EXAM_RULES_URL: process.env.PROCTORED_EXAM_RULES_URL || null,
CHAT_RESPONSE_URL: process.env.CHAT_RESPONSE_URL || null,
PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL || null,
RENDER_XBLOCKS_DEFAULT: process.env.RENDER_XBLOCKS_DEFAULT || null,
RENDER_XBLOCKS_EXPERIMENTAL: process.env.RENDER_XBLOCKS_EXPERIMENTAL || null,
}, 'LearnerAppConfig');
},
},
Expand Down

0 comments on commit 826daf1

Please sign in to comment.