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 ( +
+