From 2a03de9e5746c59e11f001c04f8821c5644b41a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 29 Aug 2024 21:43:02 -0300 Subject: [PATCH 01/15] feat: add component preview to library authoring --- .../LibraryBlock/LibraryBlock.tsx | 144 +++++++ src/library-authoring/LibraryBlock/index.ts | 2 + src/library-authoring/LibraryBlock/wrap.js | 391 ++++++++++++++++++ .../LibraryBlock/xblock-bootstrap.html | 66 +++ .../component-info/ComponentInfo.tsx | 5 +- .../component-info/ComponentPreview.tsx | 25 ++ src/library-authoring/data/api.ts | 44 ++ src/library-authoring/data/apiHooks.ts | 39 +- 8 files changed, 712 insertions(+), 4 deletions(-) create mode 100644 src/library-authoring/LibraryBlock/LibraryBlock.tsx create mode 100644 src/library-authoring/LibraryBlock/index.ts create mode 100644 src/library-authoring/LibraryBlock/wrap.js create mode 100644 src/library-authoring/LibraryBlock/xblock-bootstrap.html create mode 100644 src/library-authoring/component-info/ComponentPreview.tsx diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx new file mode 100644 index 0000000000..017422deee --- /dev/null +++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx @@ -0,0 +1,144 @@ +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; +import { ensureConfig, getConfig } from '@edx/frontend-platform'; + +import wrapBlockHtmlForIFrame from './wrap'; + +// FixMe: We need this? +ensureConfig(['LMS_BASE_URL', 'SECURE_ORIGIN_XBLOCK_BOOTSTRAP_HTML_URL'], 'library block component'); + +/** + * 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. + */ +const LibraryBlock = ({ getHandlerUrl, onBlockNotification, view }) => { + const iframeRef = useRef(null); + const [html, setHtml] = useState(null); + const [iFrameHeight, setIFrameHeight] = useState(400); + const [iFrameKey, setIFrameKey] = useState(0); + + /** + * Handle any messages we receive from the XBlock Runtime code in the IFrame. + * See wrap.ts to see the code that sends these messages. + */ + const receivedWindowMessage = useCallback(async (event) => { + if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) { + return; // This is some other random message. + } + + const { method, replyKey, ...args } = event.data; + const frame = iframeRef.current.contentWindow; + const sendReply = async (data) => { + frame?.postMessage({ ...data, replyKey }, '*'); + }; + + if (method === 'bootstrap') { + sendReply({ initialHtml: html }); + } else if (method === 'get_handler_url') { + const handlerUrl = await getHandlerUrl(args.usageId); + sendReply({ handlerUrl }); + } else if (method === 'update_frame_height') { + setIFrameHeight(args.height); + } else if (method?.indexOf('xblock:') === 0) { + // This is a notification from the XBlock's frontend via 'runtime.notify(event, args)' + if (onBlockNotification) { + onBlockNotification({ + eventType: method.substr(7), // Remove the 'xblock:' prefix that we added in wrap.ts + ...args, + }); + } + } + }, [html]); + + const processView = useCallback(() => { + if (!view) { + return; + } + + const newHtml = wrapBlockHtmlForIFrame( + view.content, + 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) + setHtml(newHtml); + setIFrameKey(prevValue => prevValue + 1); + }, [view]); + + /** + * Load the XBlock data from the LMS and then inject it into our IFrame. + */ + useEffect(() => { + // 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', receivedWindowMessage); + + processView(); + + return () => { + window.removeEventListener('message', receivedWindowMessage); + }; + }, [view, html]); + + /* 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 (!html) { + return null; + } + + return ( +
+