diff --git a/src/constants.js b/src/constants.js index bf4696d734..411d3f2486 100644 --- a/src/constants.js +++ b/src/constants.js @@ -78,5 +78,5 @@ export const REGEX_RULES = { }; export const IFRAME_FEATURE_POLICY = ( - 'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *' + 'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *' ); diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index 5c147be8f4..b19c7ba378 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -61,7 +61,6 @@ export const messageTypes = { duplicateXBlock: 'duplicateXBlock', refreshXBlockPositions: 'refreshPositions', newXBlockEditor: 'newXBlockEditor', - currentXBlockId: 'currentXBlockId', toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown', }; @@ -75,3 +74,9 @@ export const COMPONENT_TYPES = { video: 'video', dragAndDrop: 'drag-and-drop-v2', }; + +export const COMPONENT_TYPES_WITH_NEW_EDITOR = { + html: 'html', + problem: 'problem', + video: 'video', +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/index.ts b/src/course-unit/xblock-container-iframe/hooks/index.ts new file mode 100644 index 0000000000..c49993dc1e --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/index.ts @@ -0,0 +1,5 @@ +export { useIframeMessages } from './useIframeMessages'; +export { useIframeContent } from './useIframeContent'; +export { useMessageHandlers } from './useMessageHandlers'; +export { useIFrameBehavior } from './useIFrameBehavior'; +export { useLoadBearingHook } from './useLoadBearingHook'; diff --git a/src/course-unit/xblock-container-iframe/tests/hooks.test.tsx b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx similarity index 97% rename from src/course-unit/xblock-container-iframe/tests/hooks.test.tsx rename to src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx index 13b5467622..8883efb04d 100644 --- a/src/course-unit/xblock-container-iframe/tests/hooks.test.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx @@ -3,8 +3,8 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useKeyedState } from '@edx/react-unit-test-utils'; import { logError } from '@edx/frontend-platform/logging'; -import { stateKeys, messageTypes } from '../../constants'; -import { useIFrameBehavior, useLoadBearingHook } from '../hooks'; +import { stateKeys, messageTypes } from '../../../constants'; +import { useLoadBearingHook, useIFrameBehavior } from '..'; jest.mock('@edx/react-unit-test-utils', () => ({ useKeyedState: jest.fn(), diff --git a/src/course-unit/xblock-container-iframe/hooks/types.ts b/src/course-unit/xblock-container-iframe/hooks/types.ts new file mode 100644 index 0000000000..3974656c49 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/types.ts @@ -0,0 +1,25 @@ +export type UseMessageHandlersTypes = { + courseId: string; + navigate: (path: string) => void; + dispatch: (action: any) => void; + setIframeOffset: (height: number) => void; + handleDeleteXBlock: (usageId: string) => void; + handleRefetchXBlocks: () => void; + handleDuplicateXBlock: (blockType: string, usageId: string) => void; + handleManageXBlockAccess: (usageId: string) => void; +}; + +export type MessageHandlersTypes = Record void>; + +export interface UseIFrameBehaviorTypes { + id: string; + iframeUrl: string; + onLoaded?: boolean; +} + +export interface UseIFrameBehaviorReturnTypes { + iframeHeight: number; + handleIFrameLoad: () => void; + showError: boolean; + hasLoaded: boolean; +} diff --git a/src/course-unit/xblock-container-iframe/hooks.tsx b/src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx similarity index 58% rename from src/course-unit/xblock-container-iframe/hooks.tsx rename to src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx index 1a81e7852a..832ac94cd3 100644 --- a/src/course-unit/xblock-container-iframe/hooks.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx @@ -1,57 +1,12 @@ -import { - useState, useLayoutEffect, useCallback, useEffect, -} from 'react'; +import { useCallback, useEffect } from 'react'; import { logError } from '@edx/frontend-platform/logging'; // eslint-disable-next-line import/no-extraneous-dependencies import { useKeyedState } from '@edx/react-unit-test-utils'; -import { useEventListener } from '../../generic/hooks'; -import { stateKeys, messageTypes } from '../constants'; - -interface UseIFrameBehaviorParams { - id: string; - iframeUrl: string; - onLoaded?: boolean; -} - -interface UseIFrameBehaviorReturn { - iframeHeight: number; - handleIFrameLoad: () => void; - showError: boolean; - hasLoaded: boolean; -} - -/** - * We discovered an error in Firefox where - upon iframe load - React would cease to call any - * useEffect hooks until the user interacts with the page again. This is particularly confusing - * when navigating between sequences, as the UI partially updates leaving the user in a nebulous - * state. - * - * We were able to solve this error by using a layout effect to update some component state, which - * executes synchronously on render. Somehow this forces React to continue it's lifecycle - * immediately, rather than waiting for user interaction. This layout effect could be anywhere in - * the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's - * a joke) one here so it wouldn't be accidentally removed elsewhere. - * - * If we remove this hook when one of these happens: - * 1. React figures out that there's an issue here and fixes a bug. - * 2. We cease to use an iframe for unit rendering. - * 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug. - * 4. We stop supporting Firefox. - * 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to - * Firefox/React for review, and they kindly help us figure out what in the world is happening - * so we can fix it. - * - * This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If - * we change whether or not the Unit component is re-mounted when the unit ID changes, this may - * become important, as this hook will otherwise only evaluate the useLayoutEffect once. - */ -export const useLoadBearingHook = (id: string): void => { - const setValue = useState(0)[1]; - useLayoutEffect(() => { - setValue(currentValue => currentValue + 1); - }, [id]); -}; +import { useEventListener } from '../../../generic/hooks'; +import { stateKeys, messageTypes } from '../../constants'; +import { useLoadBearingHook } from './useLoadBearingHook'; +import { UseIFrameBehaviorTypes, UseIFrameBehaviorReturnTypes } from './types'; /** * Custom hook to manage iframe behavior. @@ -70,7 +25,7 @@ export const useIFrameBehavior = ({ id, iframeUrl, onLoaded = true, -}: UseIFrameBehaviorParams): UseIFrameBehaviorReturn => { +}: UseIFrameBehaviorTypes): UseIFrameBehaviorReturnTypes => { // Do not remove this hook. See function description. useLoadBearingHook(id); diff --git a/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx b/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx new file mode 100644 index 0000000000..abbc98b212 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx @@ -0,0 +1,33 @@ +import { useEffect, useCallback, RefObject } from 'react'; + +import { messageTypes } from '../../constants'; + +/** + * Hook for managing iframe content and providing utilities to interact with the iframe. + * + * @param {React.RefObject} iframeRef - A React ref for the iframe element. + * @param {(ref: React.RefObject) => void} setIframeRef - + * A function to associate the iframeRef with the parent context. + * @param {(type: string, payload: any) => void} sendMessageToIframe - A function to send messages to the iframe. + * + * @returns {Object} - An object containing utility functions. + * @returns {() => void} return.refreshIframeContent - + * A function to refresh the iframe content by sending a specific message. + */ +export const useIframeContent = ( + iframeRef: RefObject, + setIframeRef: (ref: RefObject) => void, + sendMessageToIframe: (type: string, payload: any) => void, +): { refreshIframeContent: () => void } => { + useEffect(() => { + setIframeRef(iframeRef); + }, [setIframeRef, iframeRef]); + + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + const refreshIframeContent = useCallback(() => { + setTimeout(() => sendMessageToIframe(messageTypes.refreshXBlock, null), 1000); + }, [sendMessageToIframe]); + + return { refreshIframeContent }; +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx b/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx new file mode 100644 index 0000000000..6f192615da --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +/** + * Hook for managing and handling messages received by the iframe. + * + * @param {Record void>} messageHandlers - + * A mapping of message types to their corresponding handler functions. + */ +export const useIframeMessages = (messageHandlers: Record void>) => { + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const { type, payload } = event.data || {}; + if (type in messageHandlers) { + messageHandlers[type](payload); + } + }; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [messageHandlers]); +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx b/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx new file mode 100644 index 0000000000..73a38b0223 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx @@ -0,0 +1,33 @@ +import { useLayoutEffect, useState } from 'react'; + +/** + * We discovered an error in Firefox where - upon iframe load - React would cease to call any + * useEffect hooks until the user interacts with the page again. This is particularly confusing + * when navigating between sequences, as the UI partially updates leaving the user in a nebulous + * state. + * + * We were able to solve this error by using a layout effect to update some component state, which + * executes synchronously on render. Somehow this forces React to continue it's lifecycle + * immediately, rather than waiting for user interaction. This layout effect could be anywhere in + * the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's + * a joke) one here so it wouldn't be accidentally removed elsewhere. + * + * If we remove this hook when one of these happens: + * 1. React figures out that there's an issue here and fixes a bug. + * 2. We cease to use an iframe for unit rendering. + * 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug. + * 4. We stop supporting Firefox. + * 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to + * Firefox/React for review, and they kindly help us figure out what in the world is happening + * so we can fix it. + * + * This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If + * we change whether or not the Unit component is re-mounted when the unit ID changes, this may + * become important, as this hook will otherwise only evaluate the useLayoutEffect once. + */ +export const useLoadBearingHook = (id: string): void => { + const setValue = useState(0)[1]; + useLayoutEffect(() => { + setValue(currentValue => currentValue + 1); + }, [id]); +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx new file mode 100644 index 0000000000..974f7bf0c6 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; + +import { copyToClipboard } from '../../../generic/data/thunks'; +import { messageTypes } from '../../constants'; +import { MessageHandlersTypes, UseMessageHandlersTypes } from './types'; + +/** + * Hook for creating message handlers used to handle iframe messages. + * + * @param params - The parameters required to create message handlers. + * @returns {MessageHandlersTypes} - An object mapping message types to their handler functions. + */ +export const useMessageHandlers = ({ + courseId, + navigate, + dispatch, + setIframeOffset, + handleDeleteXBlock, + handleRefetchXBlocks, + handleDuplicateXBlock, + handleManageXBlockAccess, +}: UseMessageHandlersTypes): MessageHandlersTypes => useMemo(() => ({ + [messageTypes.copyXBlock]: ({ usageId }) => dispatch(copyToClipboard(usageId)), + [messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId), + [messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`), + [messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId), + [messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId), + [messageTypes.refreshXBlockPositions]: handleRefetchXBlocks, + [messageTypes.toggleCourseXBlockDropdown]: ({ + courseXBlockDropdownHeight, + }: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight), +}), [ + courseId, + handleDeleteXBlock, + handleRefetchXBlocks, + handleDuplicateXBlock, + handleManageXBlockAccess, +]); diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index b26ae8e0ed..3b2dca75bb 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -2,25 +2,28 @@ 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 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 { IFRAME_FEATURE_POLICY } from '../../constants'; +import { COMPONENT_TYPES_WITH_NEW_EDITOR } from '../constants'; import { fetchCourseUnitQuery } from '../data/thunk'; import { useIframe } from '../context/hooks'; -import { useIFrameBehavior } from './hooks'; +import { + useMessageHandlers, + useIframeContent, + useIframeMessages, + useIFrameBehavior, +} from './hooks'; +import { formatAccessManagedXBlockData, getIframeUrl } from './utils'; import messages from './messages'; import { XBlockContainerIframeProps, - XBlockDataTypes, - MessagePayloadTypes, + AccessManagedXBlockDataTypes, } from './types'; const XBlockContainerIframe: FC = ({ @@ -33,133 +36,100 @@ const XBlockContainerIframe: FC = ({ const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); - const { setIframeRef, sendMessageToIframe } = useIframe(); - const [currentXBlockId, setCurrentXBlockId] = useState(null); - const [currentXBlockData, setCurrentXBlockData] = useState({}); - const [courseXBlockIframeOffset, setCourseXBlockIframeOffset] = useState(0); + const [accessManagedXBlockData, setAccessManagedXBlockData] = useState({}); + const [iframeOffset, setIframeOffset] = useState(0); + const [deleteXBlockId, setDeleteXBlockId] = useState(null); + const [configureXBlockId, setConfigureXBlockId] = useState(null); - const iframeUrl = useMemo(() => `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`, [blockId]); + const iframeUrl = useMemo(() => getIframeUrl(blockId), [blockId]); + + const { setIframeRef, sendMessageToIframe } = useIframe(); + const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl }); + const { refreshIframeContent } = useIframeContent(iframeRef, setIframeRef, sendMessageToIframe); 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); + (blockType: string, usageId: string) => { + unitXBlockActions.handleDuplicate(usageId); + if (COMPONENT_TYPES_WITH_NEW_EDITOR[blockType]) { + navigate(`/course/${courseId}/editor/${blockType}/${usageId}`); + } + refreshIframeContent(); }, - [unitXBlockActions, courseId, navigate, handleRefreshIframe], + [unitXBlockActions, courseId, navigate, refreshIframeContent], ); - 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 void> = { - [messageTypes.deleteXBlock]: openDeleteModal, - [messageTypes.manageXBlockAccess]: () => openConfigureModal(), - [messageTypes.copyXBlock]: () => dispatch(copyToClipboard(currentXBlockId)), - [messageTypes.duplicateXBlock]: () => handleDuplicateXBlock(currentXBlockData), - [messageTypes.refreshXBlockPositions]: 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 || {}; - - if (type && messageHandlers[type]) { - messageHandlers[type](payload); - } - }; - - window.addEventListener('message', handleMessage); + const handleDeleteXBlock = (usageId: string) => { + setDeleteXBlockId(usageId); + openDeleteModal(); + }; - return () => { - window.removeEventListener('message', handleMessage); - }; - }, [dispatch, blockId, courseVerticalChildren, currentXBlockId, currentXBlockData]); + const handleManageXBlockAccess = (usageId: string) => { + openConfigureModal(); + setConfigureXBlockId(usageId); + const foundXBlock = courseVerticalChildren?.find(xblock => xblock.blockId === usageId); + if (foundXBlock) { + setAccessManagedXBlockData(formatAccessManagedXBlockData(foundXBlock, usageId)); + } + }; - const { iframeHeight } = useIFrameBehavior({ - id: blockId, - iframeUrl, - }); + const handleRefetchXBlocks = useCallback(() => { + setTimeout(() => dispatch(fetchCourseUnitQuery(blockId)), 1000); + }, [dispatch, blockId]); - const handleDeleteItemSubmit = () => { - if (currentXBlockId) { - unitXBlockActions.handleDelete(currentXBlockId); + const onDeleteSubmit = () => { + if (deleteXBlockId) { + unitXBlockActions.handleDelete(deleteXBlockId); closeDeleteModal(); - handleRefreshIframe(); + refreshIframeContent(); } }; - const onConfigureSubmit = (...args: any[]) => { - if (currentXBlockId) { - handleConfigureSubmit(currentXBlockId, ...args, closeConfigureModal); - handleRefreshIframe(); + const onManageXBlockAccessSubmit = (...args: any[]) => { + if (configureXBlockId) { + handleConfigureSubmit(configureXBlockId, ...args, closeConfigureModal); + setAccessManagedXBlockData({}); + refreshIframeContent(); } }; + const messageHandlers = useMessageHandlers({ + courseId, + navigate, + dispatch, + setIframeOffset, + handleDeleteXBlock, + handleRefetchXBlocks, + handleDuplicateXBlock, + handleManageXBlockAccess, + }); + + useIframeMessages(messageHandlers); + return ( <> - {currentXBlockId && ( + {Object.keys(accessManagedXBlockData).length ? ( { + closeConfigureModal(); + setAccessManagedXBlockData({}); + }} + onConfigureSubmit={onManageXBlockAccessSubmit} + currentItemData={accessManagedXBlockData as AccessManagedXBlockDataTypes} isSelfPaced={false} /> - )} + ) : null}