feat: preview components (xblocks) on library authoring pages (#1242)

This commit is contained in:
Rômulo Penido
2024-09-14 14:03:49 -03:00
committed by GitHub
parent a37a1b1ef8
commit 121ced42ec
10 changed files with 197 additions and 2 deletions

View File

@@ -9,3 +9,7 @@
.mw-300px {
max-width: 300px;
}
.right-0 {
right: 0;
}

View File

@@ -0,0 +1,93 @@
import { useEffect, useRef, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
interface LibraryBlockProps {
onBlockNotification?: (event: { eventType: string; [key: string]: any }) => void;
usageKey: string;
}
/**
* 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 = ({ onBlockNotification, usageKey }: LibraryBlockProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [iFrameHeight, setIFrameHeight] = useState(600);
const lmsBaseUrl = getConfig().LMS_BASE_URL;
const intl = useIntl();
/**
* Handle any messages we receive from the XBlock Runtime code in the IFrame.
* See wrap.ts to see the code that sends these messages.
*/
/* istanbul ignore next */
const receivedWindowMessage = async (event) => {
if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) {
return; // This is some other random message.
}
const { method, replyKey, ...args } = event.data;
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,
});
}
}
};
/**
* Prepare to receive messages from the IFrame.
*/
useEffect(() => {
// Messages are the only way that the code in the IFrame can communicate
// with the surrounding UI.
window.addEventListener('message', receivedWindowMessage);
return () => {
window.removeEventListener('message', receivedWindowMessage);
};
}, []);
return (
<div style={{
height: `${iFrameHeight}px`,
boxSizing: 'content-box',
position: 'relative',
overflow: 'hidden',
minHeight: '200px',
}}
>
<iframe
ref={iframeRef}
title={intl.formatMessage(messages.iframeTitle)}
src={`${lmsBaseUrl}/xblocks/v2/${usageKey}/embed/student_view/`}
data-testid="block-preview"
style={{
width: '100%',
height: '100%',
minHeight: '200px',
border: '0 none',
}}
// 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"
/>
</div>
);
};
export default LibraryBlock;

View File

@@ -0,0 +1,2 @@
/* eslint-disable-next-line import/prefer-default-export */
export { default as LibraryBlock } from './LibraryBlock';

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
iframeTitle: {
id: 'course-authoring.library-authoring.library-block.iframe-title',
defaultMessage: 'Preview',
description: 'The title for the LibraryBlock iframe',
},
});
export default messages;

View File

@@ -11,13 +11,14 @@ import { Link } from 'react-router-dom';
import { getEditUrl } from '../components/utils';
import { ComponentMenu } from '../components';
import { ComponentDeveloperInfo } from './ComponentDeveloperInfo';
import ComponentPreview from './ComponentPreview';
import messages from './messages';
interface ComponentInfoProps {
usageKey: string;
}
const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
const ComponentInfo = ({ usageKey }: ComponentInfoProps) => {
const intl = useIntl();
const editUrl = getEditUrl(usageKey);
@@ -42,7 +43,7 @@ const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
defaultActiveKey="preview"
>
<Tab eventKey="preview" title={intl.formatMessage(messages.previewTabTitle)}>
Preview tab placeholder
<ComponentPreview usageKey={usageKey} />
</Tab>
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
Manage tab placeholder

View File

@@ -0,0 +1,3 @@
.component-preview-modal {
min-width: map-get($grid-breakpoints, "md");
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, StandardModal, useToggle } from '@openedx/paragon';
import { OpenInFull } from '@openedx/paragon/icons';
import { LibraryBlock } from '../LibraryBlock';
import messages from './messages';
// This is a simple overlay to prevent interaction with the preview
const PreviewOverlay = () => (
<div className="position-absolute w-100 h-100 zindex-9" />
);
interface ModalComponentPreviewProps {
isOpen: boolean;
close: () => void;
usageKey: string;
}
const ModalComponentPreview = ({ isOpen, close, usageKey }: ModalComponentPreviewProps) => {
const intl = useIntl();
return (
<StandardModal
title={intl.formatMessage(messages.previewModalTitle)}
isOpen={isOpen}
onClose={close}
className="component-preview-modal"
>
<LibraryBlock usageKey={usageKey} />
</StandardModal>
);
};
interface ComponentPreviewProps {
usageKey: string;
}
const ComponentPreview = ({ usageKey }: ComponentPreviewProps) => {
const intl = useIntl();
const [isModalOpen, openModal, closeModal] = useToggle();
return (
<>
<div className="position-relative m-2">
<PreviewOverlay />
<Button
size="sm"
variant="light"
iconBefore={OpenInFull}
onClick={openModal}
className="position-absolute right-0 zindex-10 m-1"
>
{intl.formatMessage(messages.previewExpandButtonTitle)}
</Button>
<LibraryBlock usageKey={usageKey} />
</div>
<ModalComponentPreview isOpen={isModalOpen} close={closeModal} usageKey={usageKey} />
</>
);
};
export default ComponentPreview;

View File

@@ -41,6 +41,16 @@ const messages = defineMessages({
defaultMessage: 'Details',
description: 'Title for details tab',
},
previewExpandButtonTitle: {
id: 'course-authoring.library-authoring.component.preview.expand.title',
defaultMessage: 'Expand',
description: 'Title for expand preview button',
},
previewModalTitle: {
id: 'course-authoring.library-authoring.component.preview.modal.title',
defaultMessage: 'Component Preview',
description: 'Title for preview modal',
},
});
export default messages;

View File

@@ -7,23 +7,29 @@ const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
* Get the URL for the content library API.
*/
export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`;
/**
* Get the URL for getting block types of a library (what types can be created).
*/
export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/block_types/`;
/**
* Get the URL for create content in library.
*/
export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`;
export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`;
/**
* Get the URL for commit/revert changes in library.
*/
export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/`;
/**
* Get the URL for paste clipboard content into library.
*/
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
/**
* Get the URL for the xblock fields/metadata API.
*/

View File

@@ -1,3 +1,4 @@
@import "library-authoring/component-info/ComponentPreview";
@import "library-authoring/components/ComponentCard";
@import "library-authoring/library-info/LibraryPublishStatus";
@import "library-authoring/LibraryAuthoringPage";