Skip to content

Commit 121ced4

Browse files
authored
feat: preview components (xblocks) on library authoring pages (#1242)
1 parent a37a1b1 commit 121ced4

File tree

10 files changed

+197
-2
lines changed

10 files changed

+197
-2
lines changed

src/assets/scss/_utilities.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@
99
.mw-300px {
1010
max-width: 300px;
1111
}
12+
13+
.right-0 {
14+
right: 0;
15+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import { getConfig } from '@edx/frontend-platform';
4+
5+
import messages from './messages';
6+
7+
interface LibraryBlockProps {
8+
onBlockNotification?: (event: { eventType: string; [key: string]: any }) => void;
9+
usageKey: string;
10+
}
11+
/**
12+
* React component that displays an XBlock in a sandboxed IFrame.
13+
*
14+
* The IFrame is resized responsively so that it fits the content height.
15+
*
16+
* We use an IFrame so that the XBlock code, including user-authored HTML,
17+
* cannot access things like the user's cookies, nor can it make GET/POST
18+
* requests as the user. However, it is allowed to call any XBlock handlers.
19+
*/
20+
const LibraryBlock = ({ onBlockNotification, usageKey }: LibraryBlockProps) => {
21+
const iframeRef = useRef<HTMLIFrameElement>(null);
22+
const [iFrameHeight, setIFrameHeight] = useState(600);
23+
const lmsBaseUrl = getConfig().LMS_BASE_URL;
24+
25+
const intl = useIntl();
26+
27+
/**
28+
* Handle any messages we receive from the XBlock Runtime code in the IFrame.
29+
* See wrap.ts to see the code that sends these messages.
30+
*/
31+
/* istanbul ignore next */
32+
const receivedWindowMessage = async (event) => {
33+
if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) {
34+
return; // This is some other random message.
35+
}
36+
37+
const { method, replyKey, ...args } = event.data;
38+
39+
if (method === 'update_frame_height') {
40+
setIFrameHeight(args.height);
41+
} else if (method?.indexOf('xblock:') === 0) {
42+
// This is a notification from the XBlock's frontend via 'runtime.notify(event, args)'
43+
if (onBlockNotification) {
44+
onBlockNotification({
45+
eventType: method.substr(7), // Remove the 'xblock:' prefix that we added in wrap.ts
46+
...args,
47+
});
48+
}
49+
}
50+
};
51+
52+
/**
53+
* Prepare to receive messages from the IFrame.
54+
*/
55+
useEffect(() => {
56+
// Messages are the only way that the code in the IFrame can communicate
57+
// with the surrounding UI.
58+
window.addEventListener('message', receivedWindowMessage);
59+
60+
return () => {
61+
window.removeEventListener('message', receivedWindowMessage);
62+
};
63+
}, []);
64+
65+
return (
66+
<div style={{
67+
height: `${iFrameHeight}px`,
68+
boxSizing: 'content-box',
69+
position: 'relative',
70+
overflow: 'hidden',
71+
minHeight: '200px',
72+
}}
73+
>
74+
<iframe
75+
ref={iframeRef}
76+
title={intl.formatMessage(messages.iframeTitle)}
77+
src={`${lmsBaseUrl}/xblocks/v2/${usageKey}/embed/student_view/`}
78+
data-testid="block-preview"
79+
style={{
80+
width: '100%',
81+
height: '100%',
82+
minHeight: '200px',
83+
border: '0 none',
84+
}}
85+
// allowing 'autoplay' is required to allow the video XBlock to control the YouTube iframe it has.
86+
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
87+
88+
/>
89+
</div>
90+
);
91+
};
92+
93+
export default LibraryBlock;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/* eslint-disable-next-line import/prefer-default-export */
2+
export { default as LibraryBlock } from './LibraryBlock';
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
iframeTitle: {
5+
id: 'course-authoring.library-authoring.library-block.iframe-title',
6+
defaultMessage: 'Preview',
7+
description: 'The title for the LibraryBlock iframe',
8+
},
9+
});
10+
11+
export default messages;

src/library-authoring/component-info/ComponentInfo.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ import { Link } from 'react-router-dom';
1111
import { getEditUrl } from '../components/utils';
1212
import { ComponentMenu } from '../components';
1313
import { ComponentDeveloperInfo } from './ComponentDeveloperInfo';
14+
import ComponentPreview from './ComponentPreview';
1415
import messages from './messages';
1516

1617
interface ComponentInfoProps {
1718
usageKey: string;
1819
}
1920

20-
const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
21+
const ComponentInfo = ({ usageKey }: ComponentInfoProps) => {
2122
const intl = useIntl();
2223
const editUrl = getEditUrl(usageKey);
2324

@@ -42,7 +43,7 @@ const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
4243
defaultActiveKey="preview"
4344
>
4445
<Tab eventKey="preview" title={intl.formatMessage(messages.previewTabTitle)}>
45-
Preview tab placeholder
46+
<ComponentPreview usageKey={usageKey} />
4647
</Tab>
4748
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
4849
Manage tab placeholder
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.component-preview-modal {
2+
min-width: map-get($grid-breakpoints, "md");
3+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import { Button, StandardModal, useToggle } from '@openedx/paragon';
4+
import { OpenInFull } from '@openedx/paragon/icons';
5+
6+
import { LibraryBlock } from '../LibraryBlock';
7+
import messages from './messages';
8+
9+
// This is a simple overlay to prevent interaction with the preview
10+
const PreviewOverlay = () => (
11+
<div className="position-absolute w-100 h-100 zindex-9" />
12+
);
13+
14+
interface ModalComponentPreviewProps {
15+
isOpen: boolean;
16+
close: () => void;
17+
usageKey: string;
18+
}
19+
20+
const ModalComponentPreview = ({ isOpen, close, usageKey }: ModalComponentPreviewProps) => {
21+
const intl = useIntl();
22+
23+
return (
24+
<StandardModal
25+
title={intl.formatMessage(messages.previewModalTitle)}
26+
isOpen={isOpen}
27+
onClose={close}
28+
className="component-preview-modal"
29+
>
30+
<LibraryBlock usageKey={usageKey} />
31+
</StandardModal>
32+
);
33+
};
34+
35+
interface ComponentPreviewProps {
36+
usageKey: string;
37+
}
38+
39+
const ComponentPreview = ({ usageKey }: ComponentPreviewProps) => {
40+
const intl = useIntl();
41+
42+
const [isModalOpen, openModal, closeModal] = useToggle();
43+
44+
return (
45+
<>
46+
<div className="position-relative m-2">
47+
<PreviewOverlay />
48+
<Button
49+
size="sm"
50+
variant="light"
51+
iconBefore={OpenInFull}
52+
onClick={openModal}
53+
className="position-absolute right-0 zindex-10 m-1"
54+
>
55+
{intl.formatMessage(messages.previewExpandButtonTitle)}
56+
</Button>
57+
<LibraryBlock usageKey={usageKey} />
58+
</div>
59+
<ModalComponentPreview isOpen={isModalOpen} close={closeModal} usageKey={usageKey} />
60+
</>
61+
);
62+
};
63+
64+
export default ComponentPreview;

src/library-authoring/component-info/messages.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ const messages = defineMessages({
4141
defaultMessage: 'Details',
4242
description: 'Title for details tab',
4343
},
44+
previewExpandButtonTitle: {
45+
id: 'course-authoring.library-authoring.component.preview.expand.title',
46+
defaultMessage: 'Expand',
47+
description: 'Title for expand preview button',
48+
},
49+
previewModalTitle: {
50+
id: 'course-authoring.library-authoring.component.preview.modal.title',
51+
defaultMessage: 'Component Preview',
52+
description: 'Title for preview modal',
53+
},
4454
});
4555

4656
export default messages;

src/library-authoring/data/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,29 @@ const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
77
* Get the URL for the content library API.
88
*/
99
export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`;
10+
1011
/**
1112
* Get the URL for getting block types of a library (what types can be created).
1213
*/
1314
export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/block_types/`;
15+
1416
/**
1517
* Get the URL for create content in library.
1618
*/
1719
export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`;
20+
1821
export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`;
22+
1923
/**
2024
* Get the URL for commit/revert changes in library.
2125
*/
2226
export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/`;
27+
2328
/**
2429
* Get the URL for paste clipboard content into library.
2530
*/
2631
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
32+
2733
/**
2834
* Get the URL for the xblock fields/metadata API.
2935
*/

src/library-authoring/index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
@import "library-authoring/component-info/ComponentPreview";
12
@import "library-authoring/components/ComponentCard";
23
@import "library-authoring/library-info/LibraryPublishStatus";
34
@import "library-authoring/LibraryAuthoringPage";

0 commit comments

Comments
 (0)