feat: preview components (xblocks) on library authoring pages (#1242)
This commit is contained in:
@@ -9,3 +9,7 @@
|
||||
.mw-300px {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.right-0 {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
93
src/library-authoring/LibraryBlock/LibraryBlock.tsx
Normal file
93
src/library-authoring/LibraryBlock/LibraryBlock.tsx
Normal 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;
|
||||
2
src/library-authoring/LibraryBlock/index.ts
Normal file
2
src/library-authoring/LibraryBlock/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable-next-line import/prefer-default-export */
|
||||
export { default as LibraryBlock } from './LibraryBlock';
|
||||
11
src/library-authoring/LibraryBlock/messages.ts
Normal file
11
src/library-authoring/LibraryBlock/messages.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.component-preview-modal {
|
||||
min-width: map-get($grid-breakpoints, "md");
|
||||
}
|
||||
64
src/library-authoring/component-info/ComponentPreview.tsx
Normal file
64
src/library-authoring/component-info/ComponentPreview.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user