diff --git a/.env b/.env
index 32adc06e74..ff93b4a571 100644
--- a/.env
+++ b/.env
@@ -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
diff --git a/.env.development b/.env.development
index aa70982169..8998fd67cb 100644
--- a/.env.development
+++ b/.env.development
@@ -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
diff --git a/.env.test b/.env.test
index 34745fe206..a31bb49b96 100644
--- a/.env.test
+++ b/.env.test
@@ -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
diff --git a/README.rst b/README.rst
index df3cf427f9..80d2c371bd 100644
--- a/README.rst
+++ b/README.rst
@@ -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
===========
diff --git a/codecov.yml b/codecov.yml
index c41479bad7..8b5fb40d5f 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -8,3 +8,7 @@ coverage:
default:
target: auto
threshold: 0%
+ignore:
+ # This is an experimental approach, which should be extracted into a separate repository, so that it can
+ # be reused between frontend-app-learning, frontend-app-course-authoring and frontend-app-library-authoring.
+ - src/courseware/course/sequence/XBlock/*
diff --git a/src/courseware/course/sequence/Unit/hooks/index.js b/src/courseware/course/sequence/Unit/hooks/index.js
index 8c2b08b733..e8a131ae8b 100644
--- a/src/courseware/course/sequence/Unit/hooks/index.js
+++ b/src/courseware/course/sequence/Unit/hooks/index.js
@@ -1,5 +1,6 @@
export { default as useExamAccess } from './useExamAccess';
export { default as useIFrameBehavior } from './useIFrameBehavior';
export { default as useLoadBearingHook } from './useLoadBearingHook';
+export { default as useLoadUnitChildren } from './useLoadUnitChildren';
export { default as useModalIFrameData } from './useModalIFrameData';
export { default as useShouldDisplayHonorCode } from './useShouldDisplayHonorCode';
diff --git a/src/courseware/course/sequence/Unit/hooks/useLoadUnitChildren.js b/src/courseware/course/sequence/Unit/hooks/useLoadUnitChildren.js
new file mode 100644
index 0000000000..dacdbc2043
--- /dev/null
+++ b/src/courseware/course/sequence/Unit/hooks/useLoadUnitChildren.js
@@ -0,0 +1,30 @@
+import React from 'react';
+
+import { getConfig } from '@edx/frontend-platform';
+import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
+import { logError } from '@edx/frontend-platform/logging';
+import { getBlockMetadataWithChildren } from '../../../../data/api';
+
+export const stateKeys = StrictDict({
+ unitChildren: 'unitChildren',
+});
+const useLoadUnitChildren = (usageId) => {
+ const [unitChildren, setUnitChildren] = useKeyedState(stateKeys.unitChildren, []);
+
+ if (getConfig().RENDER_XBLOCKS_EXPERIMENTAL === true || getConfig().RENDER_XBLOCKS_EXPERIMENTAL === 'true') {
+ React.useEffect(() => {
+ (async () => {
+ try {
+ const response = await getBlockMetadataWithChildren(usageId);
+ setUnitChildren(response.children);
+ } catch (error) {
+ logError(error);
+ }
+ })();
+ }, [usageId]);
+ }
+
+ return unitChildren;
+};
+
+export default useLoadUnitChildren;
diff --git a/src/courseware/course/sequence/Unit/hooks/useLoadUnitChildren.test.js b/src/courseware/course/sequence/Unit/hooks/useLoadUnitChildren.test.js
new file mode 100644
index 0000000000..e90117894c
--- /dev/null
+++ b/src/courseware/course/sequence/Unit/hooks/useLoadUnitChildren.test.js
@@ -0,0 +1,90 @@
+import { getConfig } from '@edx/frontend-platform';
+import { mockUseKeyedState } from '@edx/react-unit-test-utils';
+import React from 'react';
+import { isEqual } from 'lodash';
+import { logError } from '@edx/frontend-platform/logging';
+import { waitFor } from '@testing-library/dom';
+import { getBlockMetadataWithChildren } from '../../../../data/api';
+import useLoadUnitChildren, { stateKeys } from './useLoadUnitChildren';
+
+const getEffect = (prereqs) => {
+ const { calls } = React.useEffect.mock;
+ const match = calls.filter(call => isEqual(call[1], prereqs));
+ return match.length ? match[0][0] : null;
+};
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useEffect: jest.fn(),
+}));
+jest.mock('@edx/frontend-platform/logging', () => ({
+ logError: jest.fn(),
+}));
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(),
+}));
+getConfig.mockReturnValue({ RENDER_XBLOCKS_EXPERIMENTAL: false });
+
+jest.mock('../../../../data/api', () => ({
+ getBlockMetadataWithChildren: jest.fn(),
+}));
+const mockChildren = ['child1', 'child2'];
+getBlockMetadataWithChildren.mockResolvedValue({ children: mockChildren });
+
+const state = mockUseKeyedState(stateKeys);
+
+describe('useLoadUnitChildren hook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ state.mock();
+ });
+ describe('behavior', () => {
+ const usageId = 'testUsageId';
+
+ it('initializes children with an empty array', () => {
+ useLoadUnitChildren(usageId);
+ state.expectInitializedWith(stateKeys.unitChildren, []);
+ });
+
+ it('does not fetch children when RENDER_XBLOCKS_EXPERIMENTAL is false (default)', () => {
+ getBlockMetadataWithChildren.mockResolvedValue({ children: mockChildren });
+
+ useLoadUnitChildren(usageId);
+ state.expectInitializedWith(stateKeys.unitChildren, []);
+
+ const useEffectCb = getEffect([usageId], React);
+ expect(useEffectCb).toBeNull();
+
+ expect(getBlockMetadataWithChildren).not.toHaveBeenCalled();
+ expect(state.setState[stateKeys.unitChildren]).not.toHaveBeenCalled();
+ });
+
+ it('fetches children when RENDER_XBLOCKS_EXPERIMENTAL is true', async () => {
+ getConfig.mockReturnValueOnce({ RENDER_XBLOCKS_EXPERIMENTAL: true });
+ getBlockMetadataWithChildren.mockResolvedValue({ children: mockChildren });
+
+ useLoadUnitChildren(usageId);
+ state.expectInitializedWith(stateKeys.unitChildren, []);
+
+ getEffect([usageId], React)();
+
+ await waitFor(() => expect(getBlockMetadataWithChildren).toHaveBeenCalled());
+ state.expectSetStateCalledWith(stateKeys.unitChildren, mockChildren);
+ });
+
+ it('logs an error when fetching children fails', async () => {
+ const testError = 'test-error';
+ getConfig.mockReturnValueOnce({ RENDER_XBLOCKS_EXPERIMENTAL: true });
+ getBlockMetadataWithChildren.mockRejectedValue(testError);
+
+ useLoadUnitChildren(usageId);
+ state.expectInitializedWith(stateKeys.unitChildren, []);
+
+ getEffect([usageId], React)();
+
+ await waitFor(() => expect(getBlockMetadataWithChildren).toHaveBeenCalled());
+ expect(state.setState[stateKeys.unitChildren]).not.toHaveBeenCalled();
+ expect(logError).toHaveBeenCalledWith(testError);
+ });
+ });
+});
diff --git a/src/courseware/course/sequence/Unit/index.jsx b/src/courseware/course/sequence/Unit/index.jsx
index 232f911d42..a23468dcf9 100644
--- a/src/courseware/course/sequence/Unit/index.jsx
+++ b/src/courseware/course/sequence/Unit/index.jsx
@@ -4,6 +4,7 @@ import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
+import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { useModel } from '../../../../generic/model-store';
import BookmarkButton from '../../bookmark/BookmarkButton';
@@ -11,8 +12,11 @@ import messages from '../messages';
import ContentIFrame from './ContentIFrame';
import UnitSuspense from './UnitSuspense';
import { modelKeys, views } from './constants';
-import { useExamAccess, useShouldDisplayHonorCode } from './hooks';
+import { useExamAccess, useShouldDisplayHonorCode, useLoadUnitChildren } from './hooks';
import { getIFrameUrl } from './urls';
+import { XBlock } from '../XBlock';
+
+ensureConfig(['RENDER_XBLOCKS_EXPERIMENTAL', 'RENDER_XBLOCKS_DEFAULT']);
const Unit = ({
courseId,
@@ -27,6 +31,7 @@ const Unit = ({
const unit = useModel(modelKeys.units, id);
const isProcessing = unit.bookmarkedUpdateState === 'loading';
const view = authenticatedUser ? views.student : views.public;
+ const unitChildren = useLoadUnitChildren(id);
const iframeUrl = getIFrameUrl({
id,
@@ -45,15 +50,21 @@ const Unit = ({
isProcessing={isProcessing}
/>
-
+ {getConfig().RENDER_XBLOCKS_DEFAULT !== false && getConfig().RENDER_XBLOCKS_DEFAULT !== 'false' && (
+
+ )}
+ {(getConfig().RENDER_XBLOCKS_EXPERIMENTAL === true || getConfig().RENDER_XBLOCKS_EXPERIMENTAL === 'true')
+ && unitChildren && unitChildren.map((child) => (
+
+ ))}
);
};
diff --git a/src/courseware/course/sequence/Unit/index.test.jsx b/src/courseware/course/sequence/Unit/index.test.jsx
index 1754ca7da7..70cdd212e0 100644
--- a/src/courseware/course/sequence/Unit/index.test.jsx
+++ b/src/courseware/course/sequence/Unit/index.test.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';
+import { getConfig } from '@edx/frontend-platform';
import { useModel } from '../../../../generic/model-store';
import BookmarkButton from '../../bookmark/BookmarkButton';
@@ -28,6 +29,7 @@ jest.mock('./ContentIFrame', () => 'ContentIFrame');
jest.mock('./UnitSuspense', () => 'UnitSuspense');
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
+jest.mock('../XBlock/XBlock', () => 'XBlock');
jest.mock('../../../../generic/model-store', () => ({
useModel: jest.fn(),
@@ -41,6 +43,7 @@ jest.mock('react', () => ({
jest.mock('./hooks', () => ({
useExamAccess: jest.fn(),
useShouldDisplayHonorCode: jest.fn(),
+ useLoadUnitChildren: jest.fn(),
}));
jest.mock('./urls', () => ({
@@ -64,6 +67,9 @@ const examAccess = {
hooks.useExamAccess.mockReturnValue(examAccess);
hooks.useShouldDisplayHonorCode.mockReturnValue(false);
+const unitChildren = [];
+hooks.useLoadUnitChildren.mockReturnValue(unitChildren);
+
const unit = {
id: 'unit-id',
title: 'unit-title',
@@ -72,6 +78,12 @@ const unit = {
};
useModel.mockReturnValue(unit);
+jest.mock('@edx/frontend-platform', () => ({
+ ...jest.requireActual('@edx/frontend-platform'),
+ getConfig: jest.fn(),
+}));
+getConfig.mockReturnValue({ RENDER_XBLOCKS_EXPERIMENTAL: false, RENDER_XBLOCKS_DEFAULT: true });
+
let el;
describe('Unit component', () => {
beforeEach(() => {
@@ -84,6 +96,7 @@ describe('Unit component', () => {
courseId: props.courseId,
id: props.id,
});
+ expect(hooks.useLoadUnitChildren).toHaveBeenCalledWith(props.id);
});
});
describe('output', () => {
@@ -187,5 +200,50 @@ describe('Unit component', () => {
});
});
});
+ describe('Experimental XBlock Rendering', () => {
+ const defaultProps = {
+ courseId: 'course-id',
+ format: 'format',
+ onLoaded: jest.fn(),
+ id: 'unit-id',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const configurations = [
+ { experimental: true, default: true, description: 'both true' },
+ { experimental: true, default: false, description: 'experimental true, default false' },
+ { experimental: false, default: true, description: 'experimental false, default true' },
+ { experimental: false, default: false, description: 'both false' },
+ ];
+
+ configurations.forEach(({ experimental, default: defaultConfig, description }) => {
+ it(`renders with RENDER_XBLOCKS_EXPERIMENTAL=${experimental} and RENDER_XBLOCKS_DEFAULT=${defaultConfig} (${description})`, async () => {
+ getConfig.mockReturnValue({
+ RENDER_XBLOCKS_EXPERIMENTAL: experimental,
+ RENDER_XBLOCKS_DEFAULT: defaultConfig,
+ });
+
+ if (experimental) {
+ hooks.useLoadUnitChildren.mockReturnValueOnce(['child1', 'child2']);
+ }
+
+ component = shallow();
+
+ if (experimental) {
+ expect(component.instance.findByType('XBlock').length).toEqual(2);
+ } else {
+ expect(component.instance.findByType('XBlock').length).toEqual(0);
+ }
+ if (defaultConfig) {
+ expect(component.instance.findByType('ContentIFrame').length).toEqual(1);
+ } else {
+ expect(component.instance.findByType('ContentIFrame').length).toEqual(0);
+ }
+ });
+ });
+ });
});
});
diff --git a/src/courseware/course/sequence/XBlock/XBlock.jsx b/src/courseware/course/sequence/XBlock/XBlock.jsx
new file mode 100644
index 0000000000..c5ba8cbada
--- /dev/null
+++ b/src/courseware/course/sequence/XBlock/XBlock.jsx
@@ -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 = {
+ html: null,
+ iFrameHeight: 400,
+ iframeKey: 0,
+ view: null,
+ };
+ }
+
+ /**
+ * Load the XBlock data from the LMS and then inject it into our IFrame.
+ */
+ componentDidMount() {
+ // 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);
+
+ // Fetch the XBlock HTML from the LMS:
+ this.fetchBlockHtml();
+
+ // Process the XBlock view:
+ this.processView();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.view !== this.state.view) {
+ this.processView();
+ }
+ }
+
+ componentWillUnmount() {
+ 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.
+ */
+ receivedWindowMessage = async (event) => {
+ if (this.iframeRef.current === null || event.source !== this.iframeRef.current.contentWindow) {
+ return; // This is some other random message.
+ }
+
+ const { method, replyKey, ...args } = event.data;
+ const frame = this.iframeRef.current.contentWindow;
+ const sendReply = async (data) => {
+ frame.postMessage({ ...data, replyKey }, '*');
+ };
+
+ if (method === 'bootstrap') {
+ sendReply({ initialHtml: this.state.html });
+ } else if (method === 'get_handler_url') {
+ const handlerUrl = await getBlockHandlerUrl(args.usageId, 'handler_name');
+ sendReply({ handlerUrl });
+ } else if (method === 'update_frame_height') {
+ this.setState({ iFrameHeight: args.height });
+ } 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({
+ eventType: method.substr(7), // Remove the 'xblock:' prefix that we added in wrap.ts
+ ...args,
+ });
+ }
+ }
+ };
+
+ processView() {
+ 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 = /(["']|"|')\/(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(
+ 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 => ({
+ html,
+ iframeKey: prevState.iframeKey + 1,
+ }));
+ }
+ }
+
+ render() {
+ /* 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;
+ }
+
+ return (
+
+
+
+ );
+ }
+}
+
+XBlock.propTypes = {
+ onBlockNotification: PropTypes.func,
+ usageId: PropTypes.string.isRequired,
+};
+
+XBlock.defaultProps = {
+ onBlockNotification: null,
+};
+
+export default XBlock;
diff --git a/src/courseware/course/sequence/XBlock/index.js b/src/courseware/course/sequence/XBlock/index.js
new file mode 100644
index 0000000000..cbe0e97eeb
--- /dev/null
+++ b/src/courseware/course/sequence/XBlock/index.js
@@ -0,0 +1,2 @@
+/* eslint-disable-next-line import/prefer-default-export */
+export { default as XBlock } from './XBlock';
diff --git a/src/courseware/course/sequence/XBlock/wrap.js b/src/courseware/course/sequence/XBlock/wrap.js
new file mode 100644
index 0000000000..a63d831a4a
--- /dev/null
+++ b/src/courseware/course/sequence/XBlock/wrap.js
@@ -0,0 +1,399 @@
+/* eslint-disable no-param-reassign */
+/**
+ * Code to wrap an XBlock so that we can embed it in an IFrame
+ */
+
+/**
+ * The JavaScript code which runs inside our IFrame and is responsible
+ * for communicating with the parent window.
+ *
+ * This cannot use any imported functions because it runs in the IFrame,
+ * not in our app webpack bundle.
+ */
+function blockFrameJS() {
+ const CHILDREN_KEY = '_jsrt_xb_children'; // JavaScript RunTime XBlock children
+ const USAGE_ID_KEY = '_jsrt_xb_usage_id';
+ const HANDLER_URL = '_jsrt_xb_handler_url';
+
+ const uniqueKeyPrefix = `k${+Date.now()}-${Math.floor(Math.random() * 1e10)}-`;
+ let messageCount = 0;
+ /**
+ * A helper method for sending messages to the parent window of this IFrame
+ * and getting a reply, even when the IFrame is securely sandboxed.
+ * @param messageData The message to send. Must be an object, as we add a key/value pair to it.
+ * @param callback The callback to call when the parent window replies
+ */
+ function postMessageToParent(messageData, callback) {
+ messageCount += 1;
+ const messageReplyKey = uniqueKeyPrefix + messageCount;
+ messageData.replyKey = messageReplyKey;
+ if (callback !== undefined) {
+ const handleResponse = (event) => {
+ if (event.source === window.parent && event.data.replyKey === messageReplyKey) {
+ callback(event.data);
+ window.removeEventListener('message', handleResponse);
+ }
+ };
+ window.addEventListener('message', handleResponse);
+ }
+ window.parent.postMessage(messageData, '*');
+ }
+
+ /**
+ * The JavaScript runtime for any XBlock in the IFrame
+ */
+ const runtime = {
+ /**
+ * An obscure and little-used API that retrieves a particular
+ * XBlock child using its 'data-name' attribute
+ * @param block The root DIV element of the XBlock calling this method
+ * @param childName The value of the 'data-name' attribute of the root
+ * DIV element of the XBlock child in question.
+ */
+ childMap: (block, childName) => runtime.children(block).find((child) => child.element.getAttribute('data-name') === childName),
+ children: (block) => block[CHILDREN_KEY],
+ /**
+ * Get the URL for the specified handler. This method must be synchronous, so
+ * cannot make HTTP requests.
+ */
+ handlerUrl: (block, handlerName, suffix, query) => {
+ let url = block[HANDLER_URL].replace('handler_name', handlerName);
+ if (suffix) {
+ url += `${suffix}/`;
+ }
+ if (query) {
+ url += `?${query}`;
+ }
+ return url;
+ },
+ /**
+ * Pass an arbitrary message from the XBlock to the parent application.
+ * This is mostly used by the studio_view to inform the user of save events.
+ * Standard events are as follows:
+ *
+ * save: {state: 'start'|'end', message: string}
+ * -> Displays a "Saving..." style message + animation to the user until called
+ * again with {state: 'end'}. Then closes the modal holding the studio_view.
+ *
+ * error: {title: string, message: string}
+ * -> Displays an error message to the user
+ *
+ * cancel: {}
+ * -> Close the modal holding the studio_view
+ */
+ notify: (eventType, params) => {
+ params.method = `xblock:${eventType}`;
+ postMessageToParent(params);
+ },
+ };
+
+ /**
+ * Initialize an XBlock. This function should only be called by initializeXBlockAndChildren
+ * because it assumes that function has already run.
+ */
+ function initializeXBlock(element, callback) {
+ const usageId = element[USAGE_ID_KEY];
+ // Check if the XBlock has an initialization function:
+ const initFunctionName = element.getAttribute('data-init');
+ if (initFunctionName !== null) {
+ // Since this block has an init function, it may need to call handlers,
+ // 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?
+ let data = {};
+ [].forEach.call(element.children, (childNode) => {
+ // The newer/pure/Blockstore runtime uses 'xblock_json_init_args'
+ // while the LMS runtime uses 'xblock-json-init-args'.
+ if (
+ childNode.matches('script.xblock_json_init_args')
+ || childNode.matches('script.xblock-json-init-args')
+ ) {
+ data = JSON.parse(childNode.textContent);
+ }
+ });
+ // An unfortunate inconsistency is that the old Studio runtime used
+ // to pass 'element' as a jQuery-wrapped DOM element, whereas the LMS
+ // runtime used to pass 'element' as the pure DOM node. In order not to
+ // break backwards compatibility, we would need to maintain that.
+ // However, this is currently disabled as it causes issues (need to
+ // modify the runtime methods like handlerUrl too), and we decided not
+ // to maintain support for legacy studio_view in this runtime.
+ // const isStudioView = element.className.indexOf('studio_view') !== -1;
+ // const passElement = isStudioView && (window as any).$ ? (window as any).$(element) : element;
+
+ const blockJS = new InitFunction(runtime, element, data) || {};
+ blockJS.element = element;
+ callback(blockJS);
+ });
+ } else {
+ const blockJS = { element };
+ callback(blockJS);
+ }
+ }
+
+ // Recursively initialize the JavaScript code of each XBlock:
+ function initializeXBlockAndChildren(element, callback) {
+ // The newer/pure/Blockstore runtime uses the 'data-usage' attribute, while the LMS uses 'data-usage-id'
+ const usageId = element.getAttribute('data-usage') || element.getAttribute('data-usage-id');
+ if (usageId !== null) {
+ element[USAGE_ID_KEY] = usageId;
+ } else {
+ throw new Error('XBlock is missing a usage ID attribute on its root HTML node.');
+ }
+
+ const version = element.getAttribute('data-runtime-version');
+ if (version != null && version !== '1') {
+ throw new Error('Unsupported XBlock runtime version requirement.');
+ }
+
+ // Recursively initialize any children first:
+ // We need to find all div.xblock-v1 children, unless they're grandchilden
+ // So we build a list of all div.xblock-v1 descendants that aren't descendants
+ // of an already-found descendant:
+ const childNodesFound = [];
+ [].forEach.call(element.querySelectorAll('.xblock, .xblock-v1'), (childNode) => {
+ if (!childNodesFound.find((el) => el.contains(childNode))) {
+ childNodesFound.push(childNode);
+ }
+ });
+
+ // This code is awkward because we can't use promises (IE11 etc.)
+ let childrenInitialized = -1;
+ function initNextChild() {
+ childrenInitialized += 1;
+ if (childrenInitialized < childNodesFound.length) {
+ const childNode = childNodesFound[childrenInitialized];
+ initializeXBlockAndChildren(childNode, initNextChild);
+ } else {
+ // All children are initialized:
+ initializeXBlock(element, callback);
+ }
+ }
+ initNextChild();
+ }
+
+ // Find the root XBlock node.
+ // The newer/pure/Blockstore runtime uses '.xblock-v1' while the LMS runtime uses '.xblock'.
+ const rootNode = document.querySelector('.xblock, .xblock-v1'); // will always return the first matching element
+ initializeXBlockAndChildren(rootNode, () => {
+ // When done, tell the parent window the size of this block:
+ postMessageToParent({
+ height: document.body.scrollHeight,
+ method: 'update_frame_height',
+ });
+ postMessageToParent({ method: 'init_done' });
+ });
+
+ let lastHeight = -1;
+ function checkFrameHeight() {
+ const newHeight = document.documentElement.scrollHeight;
+ if (newHeight !== lastHeight) {
+ postMessageToParent({ method: 'update_frame_height', height: newHeight });
+ lastHeight = newHeight;
+ }
+ }
+ // Check the size whenever the DOM changes:
+ new MutationObserver(checkFrameHeight).observe(document.body, { attributes: true, childList: true, subtree: true });
+ // And whenever the IFrame is resized
+ window.addEventListener('resize', checkFrameHeight);
+}
+
+/**
+ * Given an XBlock's fragment data (HTML plus CSS and JS URLs), return the
+ * inner HTML that should go into an IFrame in order to display that XBlock
+ * and interact with the surrounding LabXchange UI and with the LMS.
+ * @param html The XBlock's HTML (Fragment.content)
+ * @param jsUrls A list of any JavaScript URLs the XBlock may require
+ * @param cssUrls A list of any CSS URLs the XBlock may require
+ * @param lmsBaseUrl The absolute URL of the LMS, e.g. http://localhost:18000
+ * Only required for legacy XBlocks that don't declare their
+ * JS and CSS dependencies properly.
+ */
+export default function wrapBlockHtmlForIFrame(html, resources, lmsBaseUrl) {
+ /* Separate resources by kind. */
+ const urlResources = resources.filter((r) => r.kind === 'url');
+ const textResources = resources.filter((r) => r.kind === 'text');
+
+ /* 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) => ``).join('\n');
+ cssTags += sheets.map((sheet) => ``).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) => ``).join('\n');
+ jsTags += scripts.map((script) => ``).join('\n');
+
+ // Most older XModules/XBlocks have a ton of undeclared dependencies on various JavaScript in the global scope.
+ // ALL XBlocks should be re-written to fully provide their own JS dependencies.
+ // We use 'learn_view' and 'edit_view' to declare a new, global-free, iframe JS environment for those new XBlocks
+ // that want full control over their JavaScript environment.
+ //
+ // Otherwise, if the XBlock uses 'student_view', 'author_view', or 'studio_view', include known required globals:
+ let legacyIncludes = '';
+ if (
+ 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 += `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ const result = `
+
+
+
+
+
+
+ ${legacyIncludes}
+ ${cssTags}
+
+
+
+ ${html}
+ ${jsTags}
+
+
+
+ `;
+
+ return result;
+}
diff --git a/src/courseware/course/sequence/XBlock/xblock-bootstrap.html b/src/courseware/course/sequence/XBlock/xblock-bootstrap.html
new file mode 100644
index 0000000000..35ee6cfbf6
--- /dev/null
+++ b/src/courseware/course/sequence/XBlock/xblock-bootstrap.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/courseware/data/api.js b/src/courseware/data/api.js
index f3a82a5e2b..5762a4b6e8 100644
--- a/src/courseware/data/api.js
+++ b/src/courseware/data/api.js
@@ -183,6 +183,33 @@ export async function getSequenceMetadata(sequenceId) {
const getSequenceHandlerUrl = (courseId, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler`;
+/* istanbul ignore next */
+export async function getBlockMetadataWithChildren(usageKey) {
+ const { data } = await getAuthenticatedHttpClient()
+ .get(`${getConfig().LMS_BASE_URL}/api/xblock/v2/xblocks/${usageKey}/?include=children`, {});
+
+ return camelCaseObject(data);
+}
+
+/* istanbul ignore next */
+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;
+}
+
+/* istanbul ignore next */
+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`,
diff --git a/src/index.jsx b/src/index.jsx
index 7f2af36f58..6297fe1af4 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -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');
},
},
diff --git a/src/setupTest.js b/src/setupTest.js
index 371664a781..c2ca53c2c7 100755
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -65,6 +65,8 @@ export function initializeMockApp() {
administrator: false,
},
SUPPORT_URL_ID_VERIFICATION: 'http://example.com',
+ RENDER_XBLOCKS_EXPERIMENTAL: process.env.RENDER_XBLOCKS_EXPERIMENTAL || false,
+ RENDER_XBLOCKS_DEFAULT: process.env.RENDER_XBLOCKS_DEFAULT || true,
});
const loggingService = configureLogging(MockLoggingService, {
diff --git a/webpack.dev.config.js b/webpack.dev.config.js
new file mode 100644
index 0000000000..b8ce29b686
--- /dev/null
+++ b/webpack.dev.config.js
@@ -0,0 +1,26 @@
+const path = require('path');
+const { createConfig } = require('@edx/frontend-build');
+const CopyPlugin = require('copy-webpack-plugin');
+
+const config = createConfig('webpack-dev', {
+ resolve: {
+ fallback: {
+ fs: false,
+ constants: false,
+ },
+ },
+});
+
+/**
+ * Allow serving xblock-bootstrap.html from the MFE itself.
+ */
+config.plugins.push(
+ new CopyPlugin({
+ patterns: [{
+ context: path.resolve(__dirname, 'src/courseware/course/sequence/XBlock'),
+ from: 'xblock-bootstrap.html',
+ }],
+ }),
+);
+
+module.exports = config;
diff --git a/webpack.prod.config.js b/webpack.prod.config.js
index 6510da424d..127db45aac 100644
--- a/webpack.prod.config.js
+++ b/webpack.prod.config.js
@@ -15,4 +15,16 @@ config.plugins.push(
}),
);
+/**
+ * Allow serving xblock-bootstrap.html from the MFE itself.
+ */
+config.plugins.push(
+ new CopyPlugin({
+ patterns: [{
+ context: path.resolve(__dirname, 'src/courseware/course/sequence/XBlock'),
+ from: 'xblock-bootstrap.html',
+ }],
+ }),
+);
+
module.exports = config;