Skip to content

Commit

Permalink
feat: listen to xblock interaction events
Browse files Browse the repository at this point in the history
  • Loading branch information
PKulkoRaccoonGang committed Nov 22, 2024
1 parent bc8d59b commit b34f1f5
Show file tree
Hide file tree
Showing 14 changed files with 606 additions and 159 deletions.
4 changes: 4 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,7 @@ export const REGEX_RULES = {
specialCharsRule: /^[a-zA-Z0-9_\-.'*~\s]+$/,
noSpaceRule: /^\S*$/,
};

export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *'
);
2 changes: 2 additions & 0 deletions src/course-unit/CourseUnit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,11 @@ const CourseUnit = ({ courseId }) => {
/>
)}
<XBlockContainerIframe
courseId={courseId}
blockId={blockId}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
/>
<AddComponent
blockId={blockId}
Expand Down
334 changes: 333 additions & 1 deletion src/course-unit/CourseUnit.test.jsx

Large diffs are not rendered by default.

21 changes: 18 additions & 3 deletions src/course-unit/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,23 @@ export const messageTypes = {
videoFullScreen: 'plugin.videoFullScreen',
refreshXBlock: 'refreshXBlock',
showMoveXBlockModal: 'showMoveXBlockModal',
copyXBlock: 'copyXBlock',
manageXBlockAccess: 'manageXBlockAccess',
deleteXBlock: 'deleteXBlock',
duplicateXBlock: 'duplicateXBlock',
refreshPositions: 'refreshPositions',
newXBlockEditor: 'newXBlockEditor',
currentXBlockId: 'currentXBlockId',
toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown',
};

export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *'
);
export const COMPONENT_TYPES = {
advanced: 'advanced',
discussion: 'discussion',
library: 'library',
html: 'html',
openassessment: 'openassessment',
problem: 'problem',
video: 'video',
dragAndDrop: 'drag-and-drop-v2',
};
18 changes: 0 additions & 18 deletions src/course-unit/data/slice.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,22 +93,6 @@ const slice = createSlice({
updateCourseVerticalChildrenLoadingStatus: (state, { payload }) => {
state.loadingStatus.courseVerticalChildrenLoadingStatus = payload.status;
},
deleteXBlock: (state, { payload }) => {
state.courseVerticalChildren.children = state.courseVerticalChildren.children.filter(
(component) => component.id !== payload,
);
},
duplicateXBlock: (state, { payload }) => {
state.courseVerticalChildren = {
...payload.newCourseVerticalChildren,
children: payload.newCourseVerticalChildren.children.map((component) => {
if (component.blockId === payload.newId) {
component.shouldScroll = true;
}
return component;
}),
};
},
fetchStaticFileNoticesSuccess: (state, { payload }) => {
state.staticFileNotices = payload;
},
Expand Down Expand Up @@ -139,8 +123,6 @@ export const {
updateLoadingCourseXblockStatus,
updateCourseVerticalChildren,
updateCourseVerticalChildrenLoadingStatus,
deleteXBlock,
duplicateXBlock,
fetchStaticFileNoticesSuccess,
updateCourseOutlineInfo,
updateCourseOutlineInfoLoadingStatus,
Expand Down
10 changes: 1 addition & 9 deletions src/course-unit/data/thunk.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ import {
updateCourseVerticalChildren,
updateCourseVerticalChildrenLoadingStatus,
updateQueryPendingStatus,
deleteXBlock,
duplicateXBlock,
fetchStaticFileNoticesSuccess,
updateCourseOutlineInfo,
updateCourseOutlineInfoLoadingStatus,
Expand Down Expand Up @@ -229,7 +227,6 @@ export function deleteUnitItemQuery(itemId, xblockId) {

try {
await deleteUnitItem(xblockId);
dispatch(deleteXBlock(xblockId));
const { userClipboard } = await getCourseSectionVerticalData(itemId);
dispatch(updateClipboardData(userClipboard));
const courseUnit = await getCourseUnitData(itemId);
Expand All @@ -249,12 +246,7 @@ export function duplicateUnitItemQuery(itemId, xblockId) {
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));

try {
const { locator } = await duplicateUnitItem(itemId, xblockId);
const newCourseVerticalChildren = await getCourseVerticalChildren(itemId);
dispatch(duplicateXBlock({
newId: locator,
newCourseVerticalChildren,
}));
await duplicateUnitItem(itemId, xblockId);
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(hideProcessingNotification());
Expand Down
8 changes: 8 additions & 0 deletions src/course-unit/header-title/HeaderTitle.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import { getCourseUnitData } from '../data/selectors';
import { updateQueryPendingStatus } from '../data/slice';
import { messageTypes } from '../constants';
import { useIframe } from '../context/hooks';
import messages from './messages';

const HeaderTitle = ({
Expand All @@ -26,9 +28,15 @@ const HeaderTitle = ({
const currentItemData = useSelector(getCourseUnitData);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo;
const { sendMessageToIframe } = useIframe();

const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
// TODO: this artificial delay is a temporary solution
// to ensure the iframe content is properly refreshed.
setTimeout(() => {
sendMessageToIframe(messageTypes.refreshXBlock, null);
}, 1000);
};

const getVisibilityMessage = () => {
Expand Down
9 changes: 8 additions & 1 deletion src/course-unit/sidebar/PublishControls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { useToggle } from '@openedx/paragon';
import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import useCourseUnitData from './hooks';
import { useIframe } from '../context/hooks';
import { editCourseUnitVisibilityAndData } from '../data/thunk';
import { SidebarBody, SidebarFooter, SidebarHeader } from './components';
import { PUBLISH_TYPES } from '../constants';
import { PUBLISH_TYPES, messageTypes } from '../constants';
import { getCourseUnitData } from '../data/selectors';
import messages from './messages';
import ModalNotification from '../../generic/modal-notification';
Expand All @@ -20,6 +21,7 @@ const PublishControls = ({ blockId }) => {
visibleToStaffOnly,
} = useCourseUnitData(useSelector(getCourseUnitData));
const intl = useIntl();
const { sendMessageToIframe } = useIframe();

const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false);
const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false);
Expand All @@ -34,6 +36,11 @@ const PublishControls = ({ blockId }) => {
const handleCourseUnitDiscardChanges = () => {
closeDiscardModal();
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges));
// TODO: this artificial delay is a temporary solution
// to ensure the iframe content is properly refreshed.
setTimeout(() => {
sendMessageToIframe(messageTypes.refreshXBlock, null);
}, 1000);
};

const handleCourseUnitPublish = () => {
Expand Down
196 changes: 162 additions & 34 deletions src/course-unit/xblock-container-iframe/index.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,185 @@
import { useRef, useEffect, FC } from 'react';
import PropTypes from 'prop-types';
import {
useRef, FC, useEffect, useState, useMemo, useCallback,
} from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { useToggle } from '@openedx/paragon';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';

import { IFRAME_FEATURE_POLICY } from '../constants';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import { copyToClipboard } from '../../generic/data/thunks';
import { COURSE_BLOCK_NAMES, IFRAME_FEATURE_POLICY } from '../../constants';
import { messageTypes, COMPONENT_TYPES } from '../constants';
import { fetchCourseUnitQuery } from '../data/thunk';
import { useIframe } from '../context/hooks';
import { useIFrameBehavior } from './hooks';
import messages from './messages';

/**
* This offset is necessary to fully display the dropdown actions of the XBlock
* in case the XBlock does not have content inside.
*/
const IFRAME_BOTTOM_OFFSET = 220;
import {
XBlockContainerIframeProps,
XBlockDataTypes,
MessagePayloadTypes,
} from './types';

interface XBlockContainerIframeProps {
blockId: string;
}

const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({ blockId }) => {
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit,
}) => {
const intl = useIntl();
const iframeRef = useRef<HTMLIFrameElement>(null);
const { setIframeRef } = useIframe();
const dispatch = useDispatch();
const navigate = useNavigate();

const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const { setIframeRef, sendMessageToIframe } = useIframe();
const [currentXBlockId, setCurrentXBlockId] = useState<string | null>(null);
const [currentXBlockData, setCurrentXBlockData] = useState<any>({});
const [courseXBlockIframeOffset, setCourseXBlockIframeOffset] = useState<number>(0);

const iframeUrl = useMemo(() => `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`, [blockId]);

useEffect(() => {
setIframeRef(iframeRef);
}, [setIframeRef]);

useEffect(() => {
if (currentXBlockId) {
const foundXBlockInfo = courseVerticalChildren?.find(xblock => xblock.blockId === currentXBlockId);
if (foundXBlockInfo) {
const { name, userPartitionInfo, blockType } = foundXBlockInfo;

setCurrentXBlockData({
category: COURSE_BLOCK_NAMES.component.id,
displayName: name,
userPartitionInfo,
showCorrectness: 'always',
blockType,
id: currentXBlockId,
});
}
}
}, [isConfigureModalOpen, currentXBlockId, courseVerticalChildren]);

const handleRefreshIframe = useCallback(() => {
// TODO: this artificial delay is a temporary solution
// to ensure the iframe content is properly refreshed.
setTimeout(() => {
sendMessageToIframe(messageTypes.refreshXBlock, null);
}, 1000);
}, [sendMessageToIframe]);

const handleDuplicateXBlock = useCallback(
(xblockData: XBlockDataTypes) => {
const duplicateAndNavigate = (blockType: string) => {
unitXBlockActions.handleDuplicate(xblockData.id);
if ([COMPONENT_TYPES.html, COMPONENT_TYPES.problem, COMPONENT_TYPES.video].includes(blockType)) {
navigate(`/course/${courseId}/editor/${blockType}/${xblockData.id}`);
}
handleRefreshIframe();
};

duplicateAndNavigate(xblockData.blockType);
},
[unitXBlockActions, courseId, navigate, handleRefreshIframe],
);

const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`;
const handleRefetchXBlocks = useCallback(() => {
// TODO: this artificial delay is a temporary solution
// to ensure the iframe content is properly refreshed.
setTimeout(() => {
dispatch(fetchCourseUnitQuery(blockId));
}, 1000);
}, [dispatch, blockId]);

useEffect(() => {
const messageHandlers: Record<string, (payload: MessagePayloadTypes) => void> = {
[messageTypes.deleteXBlock]: openDeleteModal,
[messageTypes.manageXBlockAccess]: () => openConfigureModal(),
[messageTypes.copyXBlock]: () => dispatch(copyToClipboard(currentXBlockId)),
[messageTypes.duplicateXBlock]: () => handleDuplicateXBlock(currentXBlockData),
[messageTypes.refreshPositions]: handleRefetchXBlocks,
[messageTypes.newXBlockEditor]: ({ url }) => navigate(`/course/${courseId}/editor${url}`),
[messageTypes.currentXBlockId]: ({ id }) => setCurrentXBlockId(id),
[messageTypes.toggleCourseXBlockDropdown]: ({
courseXBlockDropdownHeight,
}) => setCourseXBlockIframeOffset(courseXBlockDropdownHeight),
};

const handleMessage = (event: MessageEvent) => {
const { type, payload } = event.data || {};

// console.log('event.data =============>', event.data);

if (type && messageHandlers[type]) {
messageHandlers[type](payload);
}
};

window.addEventListener('message', handleMessage);

return () => {
window.removeEventListener('message', handleMessage);
};
}, [dispatch, blockId, courseVerticalChildren, currentXBlockId, currentXBlockData]);

const { iframeHeight } = useIFrameBehavior({
id: blockId,
iframeUrl,
});

useEffect(() => {
setIframeRef(iframeRef);
}, [setIframeRef]);
const handleDeleteItemSubmit = () => {
if (currentXBlockId) {
unitXBlockActions.handleDelete(currentXBlockId);
closeDeleteModal();
handleRefreshIframe();
}
};

const onConfigureSubmit = (...args: any[]) => {
if (currentXBlockId) {
handleConfigureSubmit(currentXBlockId, ...args, closeConfigureModal);
handleRefreshIframe();
}
};

return (
<iframe
ref={iframeRef}
title={intl.formatMessage(messages.xblockIframeTitle)}
src={iframeUrl}
frameBorder="0"
allow={IFRAME_FEATURE_POLICY}
allowFullScreen
loading="lazy"
style={{ width: '100%', height: iframeHeight + IFRAME_BOTTOM_OFFSET }}
scrolling="no"
referrerPolicy="origin"
/>
<>
<DeleteModal
category="component"
isOpen={isDeleteModalOpen}
close={closeDeleteModal}
onDeleteSubmit={handleDeleteItemSubmit}
/>
{currentXBlockId && (
<ConfigureModal
isXBlockComponent
isOpen={isConfigureModalOpen}
onClose={closeConfigureModal}
onConfigureSubmit={onConfigureSubmit}
currentItemData={currentXBlockData}
isSelfPaced={false}
/>
)}
<iframe
ref={iframeRef}
title={intl.formatMessage(messages.xblockIframeTitle)}
src={iframeUrl}
frameBorder="0"
allow={IFRAME_FEATURE_POLICY}
allowFullScreen
loading="lazy"
style={{
width: '100%',
height: iframeHeight + courseXBlockIframeOffset,
}}
scrolling="no"
referrerPolicy="origin"
aria-label={intl.formatMessage(messages.xblockIframeLabel, { xblockCount: courseVerticalChildren.length })}
/>
</>
);
};

XBlockContainerIframe.propTypes = {
blockId: PropTypes.string.isRequired,
};

export default XBlockContainerIframe;
4 changes: 4 additions & 0 deletions src/course-unit/xblock-container-iframe/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const messages = defineMessages({
defaultMessage: 'Course unit iframe',
description: 'Title for the xblock iframe',
},
xblockIframeLabel: {
id: 'course-authoring.course-unit.xblock.iframe.label',
defaultMessage: '{xblockCount} xBlocks inside the frame',
},
});

export default messages;
Loading

0 comments on commit b34f1f5

Please sign in to comment.