Skip to content

Commit

Permalink
feat: preview components (xblocks) on library authoring pages (#1242)
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido authored Sep 14, 2024
1 parent a37a1b1 commit 121ced4
Show file tree
Hide file tree
Showing 10 changed files with 197 additions and 2 deletions.
4 changes: 4 additions & 0 deletions src/assets/scss/_utilities.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@
.mw-300px {
max-width: 300px;
}

.right-0 {
right: 0;
}
93 changes: 93 additions & 0 deletions src/library-authoring/LibraryBlock/LibraryBlock.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions src/library-authoring/LibraryBlock/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* eslint-disable-next-line import/prefer-default-export */
export { default as LibraryBlock } from './LibraryBlock';
11 changes: 11 additions & 0 deletions src/library-authoring/LibraryBlock/messages.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 3 additions & 2 deletions src/library-authoring/component-info/ComponentInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/library-authoring/component-info/ComponentPreview.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.component-preview-modal {
min-width: map-get($grid-breakpoints, "md");
}
64 changes: 64 additions & 0 deletions src/library-authoring/component-info/ComponentPreview.tsx
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions src/library-authoring/component-info/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions src/library-authoring/index.scss
Original file line number Diff line number Diff line change
@@ -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";

0 comments on commit 121ced4

Please sign in to comment.