diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index dd64c64022..b60c4be580 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -17,7 +17,7 @@ import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; import CourseTeam from './course-team/CourseTeam'; import { CourseUpdates } from './course-updates'; -import { CourseUnit, IframeProvider } from './course-unit'; +import { CourseUnit } from './course-unit'; import { Certificates } from './certificates'; import CourseExportPage from './export-page/CourseExportPage'; import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage'; @@ -26,6 +26,7 @@ import { DECODED_ROUTES } from './constants'; import CourseChecklist from './course-checklist'; import GroupConfigurations from './group-configurations'; import { CourseLibraries } from './course-libraries'; +import { IframeProvider } from './generic/hooks/context/iFrameContext'; /** * As of this writing, these routes are mounted at a path prefixed with the following: diff --git a/src/constants.js b/src/constants.js index 849247d395..b1c84e921f 100644 --- a/src/constants.js +++ b/src/constants.js @@ -92,3 +92,17 @@ export const REGEX_RULES = { export const IFRAME_FEATURE_POLICY = ( 'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *' ); + +export const iframeStateKeys = { + iframeHeight: 'iframeHeight', + hasLoaded: 'hasLoaded', + showError: 'showError', + windowTopOffset: 'windowTopOffset', +}; + +export const iframeMessageTypes = { + modal: 'plugin.modal', + resize: 'plugin.resize', + videoFullScreen: 'plugin.videoFullScreen', + xblockEvent: 'xblock-event', +}; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 43db176b24..d92d5d4b81 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -59,7 +59,7 @@ import configureModalMessages from '../generic/configure-modal/messages'; import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; import addComponentMessages from './add-component/messages'; import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; -import { IframeProvider } from './context/iFrameContext'; +import { IframeProvider } from '../generic/hooks/context/iFrameContext'; import moveModalMessages from './move-modal/messages'; import xblockContainerIframeMessages from './xblock-container-iframe/messages'; import headerNavigationsMessages from './header-navigations/messages'; diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index be9481bad7..12dbd665d1 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -14,7 +14,7 @@ import AddComponentButton from './add-component-btn'; import messages from './messages'; import { ComponentPicker } from '../../library-authoring/component-picker'; import { messageTypes } from '../constants'; -import { useIframe } from '../context/hooks'; +import { useIframe } from '../../generic/hooks/context/hooks'; import { useEventListener } from '../../generic/hooks'; const AddComponent = ({ diff --git a/src/course-unit/add-component/AddComponent.test.jsx b/src/course-unit/add-component/AddComponent.test.jsx index bda1ee1ea4..d3c31ec7be 100644 --- a/src/course-unit/add-component/AddComponent.test.jsx +++ b/src/course-unit/add-component/AddComponent.test.jsx @@ -18,7 +18,7 @@ import { courseSectionVerticalMock } from '../__mocks__'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import AddComponent from './AddComponent'; import messages from './messages'; -import { IframeProvider } from '../context/iFrameContext'; +import { IframeProvider } from '../../generic/hooks/context/iFrameContext'; import { messageTypes } from '../constants'; let store; @@ -52,7 +52,7 @@ jest.mock('../../library-authoring/component-picker', () => ({ })); const mockSendMessageToIframe = jest.fn(); -jest.mock('../context/hooks', () => ({ +jest.mock('../../generic/hooks/context/hooks', () => ({ useIframe: () => ({ sendMessageToIframe: mockSendMessageToIframe, }), diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index da6c742616..dcea2f603b 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -39,17 +39,7 @@ export const getXBlockSupportMessages = (intl) => ({ }, }); -export const stateKeys = { - iframeHeight: 'iframeHeight', - hasLoaded: 'hasLoaded', - showError: 'showError', - windowTopOffset: 'windowTopOffset', -}; - export const messageTypes = { - modal: 'plugin.modal', - resize: 'plugin.resize', - videoFullScreen: 'plugin.videoFullScreen', refreshXBlock: 'refreshXBlock', showMoveXBlockModal: 'showMoveXBlockModal', completeXBlockMoving: 'completeXBlockMoving', diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 638fe0d30b..fc8fe092eb 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -9,7 +9,7 @@ import { camelCaseObject } from '@edx/frontend-platform/utils'; import { RequestStatus } from '../data/constants'; import { useClipboard } from '../generic/clipboard'; import { useEventListener } from '../generic/hooks'; -import { COURSE_BLOCK_NAMES } from '../constants'; +import { COURSE_BLOCK_NAMES, iframeMessageTypes } from '../constants'; import { messageTypes, PUBLISH_TYPES } from './constants'; import { createNewCourseXBlock, @@ -41,7 +41,7 @@ import { updateMovedXBlockParams, updateQueryPendingStatus, } from './data/slice'; -import { useIframe } from './context/hooks'; +import { useIframe } from '../generic/hooks/context/hooks'; export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); @@ -313,7 +313,7 @@ export const useScrollToLastPosition = (storageKey = 'createXBlockLastYPosition' }, [storageKey]); const handleMessage = useCallback((event) => { - if (event.data?.type === messageTypes.resize) { + if (event.data?.type === iframeMessageTypes.resize) { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } diff --git a/src/course-unit/hooks.test.jsx b/src/course-unit/hooks.test.jsx index 8cf756ce3c..cec7ab5e5f 100644 --- a/src/course-unit/hooks.test.jsx +++ b/src/course-unit/hooks.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { act, renderHook } from '@testing-library/react'; import { useScrollToLastPosition, useLayoutGrid } from './hooks'; -import { messageTypes } from './constants'; +import { iframeMessageTypes } from '../constants'; jest.useFakeTimers(); @@ -108,7 +108,7 @@ describe('useScrollToLastPosition', () => { const { unmount } = renderHook(() => useScrollToLastPosition(storageKey)); act(() => { - window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } })); + window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } })); jest.advanceTimersByTime(1000); }); @@ -136,8 +136,8 @@ describe('useScrollToLastPosition', () => { renderHook(() => useScrollToLastPosition(storageKey)); act(() => { - window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } })); - window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } })); + window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } })); + window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } })); }); expect(clearTimeoutSpy).toHaveBeenCalled(); @@ -150,9 +150,9 @@ describe('useScrollToLastPosition', () => { renderHook(() => useScrollToLastPosition(storageKey)); act(() => { - window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } })); + window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } })); jest.advanceTimersByTime(500); - window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } })); + window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } })); }); expect(window.scrollTo).not.toHaveBeenCalled(); @@ -164,7 +164,7 @@ describe('useScrollToLastPosition', () => { renderHook(() => useScrollToLastPosition(storageKey)); act(() => { - window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } })); + window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } })); jest.advanceTimersByTime(1000); }); diff --git a/src/course-unit/index.js b/src/course-unit/index.js index e4ace54b03..f31a91ce92 100644 --- a/src/course-unit/index.js +++ b/src/course-unit/index.js @@ -1,2 +1 @@ export { default as CourseUnit } from './CourseUnit'; -export { IframeProvider } from './context/iFrameContext'; diff --git a/src/course-unit/move-modal/hooks.tsx b/src/course-unit/move-modal/hooks.tsx index d21014e3bb..be06cf1b3f 100644 --- a/src/course-unit/move-modal/hooks.tsx +++ b/src/course-unit/move-modal/hooks.tsx @@ -11,7 +11,7 @@ import { RequestStatus } from '../../data/constants'; import { useEventListener } from '../../generic/hooks'; import { getCourseOutlineInfo, getCourseOutlineInfoLoadingStatus } from '../data/selectors'; import { getCourseOutlineInfoQuery, patchUnitItemQuery } from '../data/thunk'; -import { useIframe } from '../context/hooks'; +import { useIframe } from '../../generic/hooks/context/hooks'; import { messageTypes } from '../constants'; import { CATEGORIES, MOVE_DIRECTIONS } from './constants'; import { diff --git a/src/course-unit/move-modal/moveModal.test.tsx b/src/course-unit/move-modal/moveModal.test.tsx index 6080a8c42e..cc83995ee6 100644 --- a/src/course-unit/move-modal/moveModal.test.tsx +++ b/src/course-unit/move-modal/moveModal.test.tsx @@ -11,7 +11,7 @@ import { getCourseOutlineInfoUrl } from '../data/api'; import { courseOutlineInfoMock } from '../__mocks__'; import { executeThunk } from '../../utils'; import { getCourseOutlineInfoQuery } from '../data/thunk'; -import { IframeProvider } from '../context/iFrameContext'; +import { IframeProvider } from '../../generic/hooks/context/iFrameContext'; import { IXBlock } from './interfaces'; import MoveModal from './index'; import messages from './messages'; diff --git a/src/course-unit/preview-changes/index.test.tsx b/src/course-unit/preview-changes/index.test.tsx index ccdb13c7f9..a67fe6a7d2 100644 --- a/src/course-unit/preview-changes/index.test.tsx +++ b/src/course-unit/preview-changes/index.test.tsx @@ -10,7 +10,6 @@ import { import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.'; import { messageTypes } from '../constants'; -import { IframeProvider } from '../context/iFrameContext'; import { libraryBlockChangesUrl } from '../data/api'; import { ToastActionData } from '../../generic/toast-context'; import { getLibraryBlockMetadataUrl } from '../../library-authoring/data/api'; @@ -25,15 +24,15 @@ const defaultEventData: LibraryChangesMessageData = { }; const mockSendMessageToIframe = jest.fn(); -jest.mock('../context/hooks', () => ({ +jest.mock('../../generic/hooks/context/hooks', () => ({ useIframe: () => ({ + iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } }, + setIframeRef: () => {}, sendMessageToIframe: mockSendMessageToIframe, }), })); const render = (eventData?: LibraryChangesMessageData) => { - baseRender(, { - extraWrapper: ({ children }) => { children }, - }); + baseRender(); const message = { data: { type: messageTypes.showXBlockLibraryChangesPreview, diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index 606910ad02..c26550eb12 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -8,7 +8,7 @@ import { useEventListener } from '../../generic/hooks'; import { messageTypes } from '../constants'; import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget'; import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks'; -import { useIframe } from '../context/hooks'; +import { useIframe } from '../../generic/hooks/context/hooks'; import DeleteModal from '../../generic/delete-modal/DeleteModal'; import messages from './messages'; import { ToastContext } from '../../generic/toast-context'; diff --git a/src/course-unit/sidebar/PublishControls.jsx b/src/course-unit/sidebar/PublishControls.jsx index d5076aa838..2a6ea83995 100644 --- a/src/course-unit/sidebar/PublishControls.jsx +++ b/src/course-unit/sidebar/PublishControls.jsx @@ -4,7 +4,7 @@ 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 { useIframe } from '../../generic/hooks/context/hooks'; import { editCourseUnitVisibilityAndData } from '../data/thunk'; import { SidebarBody, SidebarFooter, SidebarHeader } from './components'; import { PUBLISH_TYPES, messageTypes } from '../constants'; diff --git a/src/course-unit/xblock-container-iframe/hooks/index.ts b/src/course-unit/xblock-container-iframe/hooks/index.ts index c49993dc1e..2dda3523dc 100644 --- a/src/course-unit/xblock-container-iframe/hooks/index.ts +++ b/src/course-unit/xblock-container-iframe/hooks/index.ts @@ -1,5 +1 @@ -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/hooks/tests/hooks.test.tsx b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx index b91a7fec1b..124226b799 100644 --- a/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx @@ -1,16 +1,14 @@ import React from 'react'; import { act, renderHook } from '@testing-library/react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; -import { useKeyedState } from '@edx/react-unit-test-utils'; import { initializeMockApp } from '@edx/frontend-platform'; -import { logError } from '@edx/frontend-platform/logging'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { Provider } from 'react-redux'; -import { stateKeys, messageTypes } from '../../../constants'; +import { messageTypes } from '../../../constants'; import { mockBroadcastChannel } from '../../../../generic/data/api.mock'; import initializeStore from '../../../../store'; -import { useLoadBearingHook, useIFrameBehavior, useMessageHandlers } from '..'; +import { useMessageHandlers } from '..'; jest.useFakeTimers(); @@ -24,171 +22,6 @@ jest.mock('@edx/frontend-platform/logging', () => ({ mockBroadcastChannel(); -describe('useIFrameBehavior', () => { - const id = 'test-id'; - const iframeUrl = 'http://example.com'; - const setIframeHeight = jest.fn(); - const setHasLoaded = jest.fn(); - const setShowError = jest.fn(); - const setWindowTopOffset = jest.fn(); - - beforeEach(() => { - (useKeyedState as jest.Mock).mockImplementation((key, initialValue) => { - switch (key) { - case stateKeys.iframeHeight: - return [0, setIframeHeight]; - case stateKeys.hasLoaded: - return [false, setHasLoaded]; - case stateKeys.showError: - return [false, setShowError]; - case stateKeys.windowTopOffset: - return [null, setWindowTopOffset]; - default: - return [initialValue, jest.fn()]; - } - }); - - window.scrollTo = jest.fn((x: number | ScrollToOptions, y?: number): void => { - const scrollY = typeof x === 'number' ? y : (x as ScrollToOptions).top || 0; - Object.defineProperty(window, 'scrollY', { value: scrollY, writable: true }); - }) as typeof window.scrollTo; - }); - - it('initializes state correctly', () => { - const { result } = renderHook(() => useIFrameBehavior({ id, iframeUrl })); - - expect(result.current.iframeHeight).toBe(0); - expect(result.current.showError).toBe(false); - expect(result.current.hasLoaded).toBe(false); - }); - - it('scrolls to previous position on video fullscreen exit', () => { - const mockWindowTopOffset = 100; - - (useKeyedState as jest.Mock).mockImplementation((key) => { - if (key === stateKeys.windowTopOffset) { - return [mockWindowTopOffset, setWindowTopOffset]; - } - return [null, jest.fn()]; - }); - - renderHook(() => useIFrameBehavior({ id, iframeUrl })); - - const message = { - data: { - type: messageTypes.videoFullScreen, - payload: { open: false }, - }, - }; - - act(() => { - window.dispatchEvent(new MessageEvent('message', message)); - }); - - expect(window.scrollTo).toHaveBeenCalledWith(0, mockWindowTopOffset); - }); - - it('handles resize message correctly', () => { - renderHook(() => useIFrameBehavior({ id, iframeUrl })); - - const message = { - data: { - type: messageTypes.resize, - payload: { height: 500 }, - }, - }; - - act(() => { - window.dispatchEvent(new MessageEvent('message', message)); - }); - - expect(setIframeHeight).toHaveBeenCalledWith(500); - expect(setHasLoaded).toHaveBeenCalledWith(true); - }); - - it('handles videoFullScreen message correctly', () => { - renderHook(() => useIFrameBehavior({ id, iframeUrl })); - - const message = { - data: { - type: messageTypes.videoFullScreen, - payload: { open: true }, - }, - }; - - act(() => { - window.dispatchEvent(new MessageEvent('message', message)); - }); - - expect(setWindowTopOffset).toHaveBeenCalledWith(window.scrollY); - }); - - it('handles offset message correctly', () => { - document.body.innerHTML = '
'; - renderHook(() => useIFrameBehavior({ id, iframeUrl })); - - const message = { - data: { offset: 100 }, - }; - - act(() => { - window.dispatchEvent(new MessageEvent('message', message)); - }); - - expect(window.scrollY).toBe(100 + (document.getElementById('unit-iframe') as HTMLElement).offsetTop); - }); - - it('handles iframe load error correctly', () => { - const { result } = renderHook(() => useIFrameBehavior({ id, iframeUrl })); - - act(() => { - result.current.handleIFrameLoad(); - }); - - expect(setShowError).toHaveBeenCalledWith(true); - expect(logError).toHaveBeenCalledWith('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', { - iframeUrl, - }); - }); - - it('resets state when iframeUrl changes', () => { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { rerender } = renderHook(({ id, iframeUrl }) => useIFrameBehavior({ id, iframeUrl }), { - initialProps: { id, iframeUrl }, - }); - - rerender({ id, iframeUrl: 'http://new-url.com' }); - - expect(setIframeHeight).toHaveBeenCalledWith(0); - expect(setHasLoaded).toHaveBeenCalledWith(false); - }); -}); - -describe('useLoadBearingHook', () => { - const setValue = jest.fn(); - - beforeEach(() => { - jest.spyOn(React, 'useState').mockReturnValue([0, setValue]); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('updates state when id changes', () => { - const { rerender } = renderHook(({ id }) => useLoadBearingHook(id), { - initialProps: { id: 'initial-id' }, - }); - - setValue.mockClear(); - - rerender({ id: 'new-id' }); - - expect(setValue).toHaveBeenCalledWith(expect.any(Function)); - expect(setValue.mock.calls); - }); -}); - describe('useMessageHandlers', () => { let handlers; let result; diff --git a/src/course-unit/xblock-container-iframe/hooks/types.ts b/src/course-unit/xblock-container-iframe/hooks/types.ts index b43b8502cf..4775673c1c 100644 --- a/src/course-unit/xblock-container-iframe/hooks/types.ts +++ b/src/course-unit/xblock-container-iframe/hooks/types.ts @@ -18,16 +18,3 @@ export type UseMessageHandlersTypes = { }; 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/useIFrameBehavior.tsx b/src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx deleted file mode 100644 index 832ac94cd3..0000000000 --- a/src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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'; -import { useLoadBearingHook } from './useLoadBearingHook'; -import { UseIFrameBehaviorTypes, UseIFrameBehaviorReturnTypes } from './types'; - -/** - * Custom hook to manage iframe behavior. - * - * @param {Object} params - The parameters for the hook. - * @param {string} params.id - The unique identifier for the iframe. - * @param {string} params.iframeUrl - The URL of the iframe. - * @param {boolean} [params.onLoaded=true] - Flag to indicate if the iframe has loaded. - * @returns {Object} The state and handlers for the iframe. - * @returns {number} return.iframeHeight - The height of the iframe. - * @returns {Function} return.handleIFrameLoad - The handler for iframe load event. - * @returns {boolean} return.showError - Flag to indicate if there was an error loading the iframe. - * @returns {boolean} return.hasLoaded - Flag to indicate if the iframe has loaded. - */ -export const useIFrameBehavior = ({ - id, - iframeUrl, - onLoaded = true, -}: UseIFrameBehaviorTypes): UseIFrameBehaviorReturnTypes => { - // Do not remove this hook. See function description. - useLoadBearingHook(id); - - const [iframeHeight, setIframeHeight] = useKeyedState(stateKeys.iframeHeight, 0); - const [hasLoaded, setHasLoaded] = useKeyedState(stateKeys.hasLoaded, false); - const [showError, setShowError] = useKeyedState(stateKeys.showError, false); - const [windowTopOffset, setWindowTopOffset] = useKeyedState(stateKeys.windowTopOffset, null); - - const receiveMessage = useCallback(({ data }: MessageEvent) => { - const { payload, type } = data; - - if (type === messageTypes.resize) { - setIframeHeight(payload.height); - - if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { - setHasLoaded(true); - } - } else if (type === messageTypes.videoFullScreen) { - // We observe exit from the video xblock fullscreen mode - // and scroll to the previously saved scroll position - if (!payload.open && windowTopOffset !== null) { - window.scrollTo(0, Number(windowTopOffset)); - } - - // We listen for this message from LMS to know when we need to - // save or reset scroll position on toggle video xblock fullscreen mode - setWindowTopOffset(payload.open ? window.scrollY : null); - } else if (data.offset) { - // We listen for this message from LMS to know when the page needs to - // be scrolled to another location on the page. - window.scrollTo(0, data.offset + document.getElementById('unit-iframe')!.offsetTop); - } - }, [ - id, - onLoaded, - hasLoaded, - setHasLoaded, - iframeHeight, - setIframeHeight, - windowTopOffset, - setWindowTopOffset, - ]); - - useEventListener('message', receiveMessage); - - const handleIFrameLoad = () => { - if (!hasLoaded) { - setShowError(true); - logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', { - iframeUrl, - }); - } - }; - - useEffect(() => { - setIframeHeight(0); - setHasLoaded(false); - }, [iframeUrl]); - - return { - iframeHeight, - handleIFrameLoad, - showError, - hasLoaded, - }; -}; diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 984359d8bf..9e95ee8829 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -1,5 +1,5 @@ import { - useRef, FC, useEffect, useState, useMemo, useCallback, + FC, useEffect, useState, useMemo, useCallback, } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useToggle, Sheet } from '@openedx/paragon'; @@ -16,7 +16,7 @@ import ModalIframe from '../../generic/modal-iframe'; import { IFRAME_FEATURE_POLICY } from '../../constants'; import ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer'; import supportedEditors from '../../editors/supportedEditors'; -import { useIframe } from '../context/hooks'; +import { useIframe } from '../../generic/hooks/context/hooks'; import { fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, @@ -25,9 +25,6 @@ import { import { messageTypes } from '../constants'; import { useMessageHandlers, - useIframeContent, - useIframeMessages, - useIFrameBehavior, } from './hooks'; import { XBlockContainerIframeProps, @@ -35,12 +32,14 @@ import { } from './types'; import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils'; import messages from './messages'; +import { useIframeBehavior } from '../../generic/hooks/useIframeBehavior'; +import { useIframeContent } from '../../generic/hooks/useIframeContent'; +import { useIframeMessages } from '../../generic/hooks/useIframeMessages'; const XBlockContainerIframe: FC = ({ courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType, }) => { const intl = useIntl(); - const iframeRef = useRef(null); const dispatch = useDispatch(); const navigate = useNavigate(); @@ -56,8 +55,8 @@ const XBlockContainerIframe: FC = ({ const iframeUrl = useMemo(() => getIframeUrl(blockId), [blockId]); const legacyEditModalUrl = useMemo(() => getLegacyEditModalUrl(configureXBlockId), [configureXBlockId]); - const { setIframeRef, sendMessageToIframe } = useIframe(); - const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl }); + const { iframeRef, setIframeRef, sendMessageToIframe } = useIframe(); + const { iframeHeight } = useIframeBehavior({ id: blockId, iframeUrl, iframeRef }); useIframeContent(iframeRef, setIframeRef); diff --git a/src/custom-pages/CustomPages.jsx b/src/custom-pages/CustomPages.jsx index 28669b856d..ad69e670f2 100644 --- a/src/custom-pages/CustomPages.jsx +++ b/src/custom-pages/CustomPages.jsx @@ -186,17 +186,18 @@ const CustomPages = ({ marginBottom: '16px', boxShadow: '0px 1px 5px #ADADAD', }} - > - - + actions={( + + )} + /> ))} { return ( <> - + + + diff --git a/src/editors/sharedComponents/DraggableList/SortableItem.jsx b/src/editors/sharedComponents/DraggableList/SortableItem.jsx index 938c2546f8..01816624af 100644 --- a/src/editors/sharedComponents/DraggableList/SortableItem.jsx +++ b/src/editors/sharedComponents/DraggableList/SortableItem.jsx @@ -3,14 +3,20 @@ import PropTypes from 'prop-types'; import { intlShape, injectIntl } from '@edx/frontend-platform/i18n'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Icon, IconButtonWithTooltip, Row } from '@openedx/paragon'; +import { + ActionRow, Card, Icon, IconButtonWithTooltip, +} from '@openedx/paragon'; import { DragIndicator } from '@openedx/paragon/icons'; import messages from './messages'; const SortableItem = ({ id, componentStyle, + actions, + actionStyle, children, + isClickable, + onClick, // injected intl, }) => { @@ -20,42 +26,65 @@ const SortableItem = ({ setNodeRef, transform, transition, - } = useSortable({ id }); + setActivatorNodeRef, + isDragging, + } = useSortable({ + id, + animateLayoutChanges: () => false, + }); const style = { - transform: CSS.Transform.toString(transform), + transform: CSS.Translate.toString(transform), + zIndex: isDragging ? 200 : undefined, transition, ...componentStyle, }; return ( - - {children} - - + + + {actions} + + + {children} + + ); }; SortableItem.defaultProps = { componentStyle: null, + actions: null, + actionStyle: null, + isClickable: false, + onClick: null, }; SortableItem.propTypes = { id: PropTypes.string.isRequired, children: PropTypes.node.isRequired, + actions: PropTypes.node, + actionStyle: PropTypes.shape({}), componentStyle: PropTypes.shape({}), + isClickable: PropTypes.bool, + onClick: PropTypes.func, // injected intl: intlShape.isRequired, }; diff --git a/src/course-unit/context/hooks.test.tsx b/src/generic/hooks/context/hooks.test.tsx similarity index 100% rename from src/course-unit/context/hooks.test.tsx rename to src/generic/hooks/context/hooks.test.tsx diff --git a/src/course-unit/context/hooks.tsx b/src/generic/hooks/context/hooks.tsx similarity index 100% rename from src/course-unit/context/hooks.tsx rename to src/generic/hooks/context/hooks.tsx diff --git a/src/course-unit/context/iFrameContext.tsx b/src/generic/hooks/context/iFrameContext.tsx similarity index 95% rename from src/course-unit/context/iFrameContext.tsx rename to src/generic/hooks/context/iFrameContext.tsx index 0afc0ae92b..57448ee484 100644 --- a/src/course-unit/context/iFrameContext.tsx +++ b/src/generic/hooks/context/iFrameContext.tsx @@ -4,6 +4,7 @@ import React, { import { logError } from '@edx/frontend-platform/logging'; export interface IframeContextType { + iframeRef: MutableRefObject; setIframeRef: (ref: MutableRefObject) => void; sendMessageToIframe: (messageType: string, payload: unknown, consumerWindow?: Window | null) => void; } @@ -31,6 +32,7 @@ export const IframeProvider: React.FC<{ children: ReactNode }> = ({ children }) }, [iframeRef]); const value = useMemo(() => ({ + iframeRef, setIframeRef, sendMessageToIframe, }), [setIframeRef, sendMessageToIframe]); diff --git a/src/generic/hooks/tests/hooks.test.tsx b/src/generic/hooks/tests/hooks.test.tsx new file mode 100644 index 0000000000..da284b900e --- /dev/null +++ b/src/generic/hooks/tests/hooks.test.tsx @@ -0,0 +1,213 @@ +import React from 'react'; +import { getConfig } from '@edx/frontend-platform'; +import { act, renderHook } from '@testing-library/react'; +import { useKeyedState } from '@edx/react-unit-test-utils'; +import { logError } from '@edx/frontend-platform/logging'; +import { mockBroadcastChannel } from '../../data/api.mock'; +import { iframeMessageTypes, iframeStateKeys } from '../../../constants'; +import { useIframeBehavior } from '../useIframeBehavior'; +import { useLoadBearingHook } from '../useLoadBearingHook'; + +jest.useFakeTimers(); + +jest.mock('@edx/react-unit-test-utils', () => ({ + useKeyedState: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +mockBroadcastChannel(); + +describe('useIframeBehavior', () => { + const id = 'test-id'; + const iframeUrl = 'http://example.com'; + const setIframeHeight = jest.fn(); + const setHasLoaded = jest.fn(); + const setShowError = jest.fn(); + const setWindowTopOffset = jest.fn(); + const iframeRef = { current: { contentWindow: null } as HTMLIFrameElement }; + + beforeEach(() => { + (useKeyedState as jest.Mock).mockImplementation((key, initialValue) => { + switch (key) { + case iframeStateKeys.iframeHeight: + return [0, setIframeHeight]; + case iframeStateKeys.hasLoaded: + return [false, setHasLoaded]; + case iframeStateKeys.showError: + return [false, setShowError]; + case iframeStateKeys.windowTopOffset: + return [null, setWindowTopOffset]; + default: + return [initialValue, jest.fn()]; + } + }); + + window.scrollTo = jest.fn((x: number | ScrollToOptions, y?: number): void => { + const scrollY = typeof x === 'number' ? y : (x as ScrollToOptions).top || 0; + Object.defineProperty(window, 'scrollY', { value: scrollY, writable: true }); + }) as typeof window.scrollTo; + }); + + it('initializes state correctly', () => { + const { result } = renderHook(() => useIframeBehavior({ id, iframeUrl, iframeRef })); + + expect(result.current.iframeHeight).toBe(0); + expect(result.current.showError).toBe(false); + expect(result.current.hasLoaded).toBe(false); + }); + + it('scrolls to previous position on video fullscreen exit', () => { + const mockWindowTopOffset = 100; + + (useKeyedState as jest.Mock).mockImplementation((key) => { + if (key === iframeStateKeys.windowTopOffset) { + return [mockWindowTopOffset, setWindowTopOffset]; + } + return [null, jest.fn()]; + }); + + renderHook(() => useIframeBehavior({ id, iframeUrl, iframeRef })); + + const message = { + data: { + type: iframeMessageTypes.videoFullScreen, + payload: { open: false }, + }, + }; + + act(() => { + window.dispatchEvent(new MessageEvent('message', message)); + }); + + expect(window.scrollTo).toHaveBeenCalledWith(0, mockWindowTopOffset); + }); + + it('handles resize message correctly', () => { + renderHook(() => useIframeBehavior({ id, iframeUrl, iframeRef })); + + const message = { + data: { + type: iframeMessageTypes.resize, + payload: { height: 500 }, + }, + }; + + act(() => { + window.dispatchEvent(new MessageEvent('message', message)); + }); + + expect(setIframeHeight).toHaveBeenCalledWith(500); + expect(setHasLoaded).toHaveBeenCalledWith(true); + }); + + it('handles xblock-event message correctly', () => { + const onBlockNotification = jest.fn(); + renderHook(() => useIframeBehavior({ + id, iframeUrl, iframeRef, onBlockNotification, + })); + + const messageEvent = new MessageEvent('message', { + data: { + type: 'xblock-event', + method: 'xblock:cancel', + someArgs: 'value', + }, + origin: getConfig().STUDIO_BASE_URL, + }); + + act(() => { + window.dispatchEvent(messageEvent); + }); + + expect(onBlockNotification).toHaveBeenCalledWith({ + eventType: 'cancel', + someArgs: 'value', + type: 'xblock-event', + }); + }); + + it('handles videoFullScreen message correctly', () => { + renderHook(() => useIframeBehavior({ id, iframeUrl, iframeRef })); + + const message = { + data: { + type: iframeMessageTypes.videoFullScreen, + payload: { open: true }, + }, + }; + + act(() => { + window.dispatchEvent(new MessageEvent('message', message)); + }); + + expect(setWindowTopOffset).toHaveBeenCalledWith(window.scrollY); + }); + + it('handles offset message correctly', () => { + document.body.innerHTML = '
'; + renderHook(() => useIframeBehavior({ id, iframeUrl, iframeRef })); + + const message = { + data: { offset: 100 }, + }; + + act(() => { + window.dispatchEvent(new MessageEvent('message', message)); + }); + + expect(window.scrollY).toBe(100 + (document.getElementById('unit-iframe') as HTMLElement).offsetTop); + }); + + it('handles iframe load error correctly', () => { + const { result } = renderHook(() => useIframeBehavior({ id, iframeUrl, iframeRef })); + + act(() => { + result.current.handleIFrameLoad(); + }); + + expect(setShowError).toHaveBeenCalledWith(true); + expect(logError).toHaveBeenCalledWith('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', { + iframeUrl, + }); + }); + + it('resets state when iframeUrl changes', () => { + // eslint-disable-next-line @typescript-eslint/no-shadow + const { rerender } = renderHook(({ id, iframeUrl }) => useIframeBehavior({ id, iframeUrl, iframeRef }), { + initialProps: { id, iframeUrl }, + }); + + rerender({ id, iframeUrl: 'http://new-url.com' }); + + expect(setIframeHeight).toHaveBeenCalledWith(0); + expect(setHasLoaded).toHaveBeenCalledWith(false); + }); +}); + +describe('useLoadBearingHook', () => { + const setValue = jest.fn(); + + beforeEach(() => { + jest.spyOn(React, 'useState').mockReturnValue([0, setValue]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('updates state when id changes', () => { + const { rerender } = renderHook(({ id }) => useLoadBearingHook(id), { + initialProps: { id: 'initial-id' }, + }); + + setValue.mockClear(); + + rerender({ id: 'new-id' }); + + expect(setValue).toHaveBeenCalledWith(expect.any(Function)); + expect(setValue.mock.calls); + }); +}); diff --git a/src/generic/hooks/useIframeBehavior.tsx b/src/generic/hooks/useIframeBehavior.tsx new file mode 100644 index 0000000000..2c327a23dd --- /dev/null +++ b/src/generic/hooks/useIframeBehavior.tsx @@ -0,0 +1,115 @@ +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 { useLoadBearingHook } from './useLoadBearingHook'; +import { iframeStateKeys, iframeMessageTypes } from '../../constants'; +import { UseIFrameBehaviorReturnTypes, UseIFrameBehaviorTypes } from '../types'; +import { useEventListener } from './useEventListener'; + +/** + * Custom hook to manage iframe behavior. + * + * @param {Object} params - The parameters for the hook. + * @param {string} params.id - The unique identifier for the iframe. + * @param {string} params.iframeUrl - The URL of the iframe. + * @param {boolean} [params.onLoaded=true] - Flag to indicate if the iframe has loaded. + * @returns {Object} The state and handlers for the iframe. + * @returns {number} return.iframeHeight - The height of the iframe. + * @returns {Function} return.handleIFrameLoad - The handler for iframe load event. + * @returns {boolean} return.showError - Flag to indicate if there was an error loading the iframe. + * @returns {boolean} return.hasLoaded - Flag to indicate if the iframe has loaded. + */ +export const useIframeBehavior = ({ + id, + iframeUrl, + onLoaded = true, + iframeRef, + onBlockNotification, +}: UseIFrameBehaviorTypes): UseIFrameBehaviorReturnTypes => { + // Do not remove this hook. See function description. + useLoadBearingHook(id); + + const [iframeHeight, setIframeHeight] = useKeyedState(iframeStateKeys.iframeHeight, 0); + const [hasLoaded, setHasLoaded] = useKeyedState(iframeStateKeys.hasLoaded, false); + const [showError, setShowError] = useKeyedState(iframeStateKeys.showError, false); + const [windowTopOffset, setWindowTopOffset] = useKeyedState(iframeStateKeys.windowTopOffset, null); + + const receiveMessage = useCallback((event: MessageEvent) => { + if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) { + return; // This is some other random message. + } + const { data } = event; + const { payload, type } = data; + const { method, replyKey, ...args } = data; + + switch (type) { + case iframeMessageTypes.resize: + setIframeHeight(payload.height); + if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { + setHasLoaded(true); + } + break; + case iframeMessageTypes.videoFullScreen: + // We observe exit from the video xblock fullscreen mode + // and scroll to the previously saved scroll position + if (!payload.open && windowTopOffset !== null) { + window.scrollTo(0, Number(windowTopOffset)); + } + + // We listen for this message from LMS to know when we need to + // save or reset scroll position on toggle video xblock fullscreen mode + setWindowTopOffset(payload.open ? window.scrollY : null); + break; + case iframeMessageTypes.xblockEvent: + if (method?.indexOf('xblock:') === 0) { + // This is a notification from the XBlock's frontend via 'runtime.notify(event, args)' + onBlockNotification?.({ + eventType: method.substr(7), // Remove the 'xblock:' prefix that we added in wrap.ts + ...args, + }); + } + break; + default: + if (data.offset) { + // We listen for this message from LMS to know when the page needs to + // be scrolled to another location on the page. + window.scrollTo(0, data.offset + document.getElementById('unit-iframe')!.offsetTop); + } + break; + } + }, [ + id, + onLoaded, + hasLoaded, + setHasLoaded, + iframeHeight, + setIframeHeight, + windowTopOffset, + setWindowTopOffset, + ]); + + useEventListener('message', receiveMessage); + + const handleIFrameLoad = () => { + if (!hasLoaded) { + setShowError(true); + logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', { + iframeUrl, + }); + } + }; + + useEffect(() => { + setIframeHeight(0); + setHasLoaded(false); + }, [iframeUrl]); + + return { + iframeHeight, + handleIFrameLoad, + showError, + hasLoaded, + }; +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx b/src/generic/hooks/useIframeContent.tsx similarity index 100% rename from src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx rename to src/generic/hooks/useIframeContent.tsx diff --git a/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx b/src/generic/hooks/useIframeMessages.tsx similarity index 100% rename from src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx rename to src/generic/hooks/useIframeMessages.tsx diff --git a/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx b/src/generic/hooks/useLoadBearingHook.tsx similarity index 100% rename from src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx rename to src/generic/hooks/useLoadBearingHook.tsx diff --git a/src/generic/types.ts b/src/generic/types.ts new file mode 100644 index 0000000000..430192cd24 --- /dev/null +++ b/src/generic/types.ts @@ -0,0 +1,16 @@ +import { MutableRefObject } from 'react'; + +export interface UseIFrameBehaviorTypes { + id: string; + iframeUrl: string; + onLoaded?: boolean; + iframeRef: MutableRefObject; + onBlockNotification?: (event: { eventType: string; [key: string]: any }) => void; +} + +export interface UseIFrameBehaviorReturnTypes { + iframeHeight: number; + handleIFrameLoad: () => void; + showError: boolean; + hasLoaded: boolean; +} diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx index b23386c4d5..ef151c219f 100644 --- a/src/library-authoring/LibraryBlock/LibraryBlock.tsx +++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx @@ -1,8 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; import messages from './messages'; +import { IFRAME_FEATURE_POLICY } from '../../constants'; +import { useIframeBehavior } from '../../generic/hooks/useIframeBehavior'; +import { useIframe } from '../../generic/hooks/context/hooks'; +import { useIframeContent } from '../../generic/hooks/useIframeContent'; export type VersionSpec = 'published' | 'draft' | number; @@ -11,6 +14,7 @@ interface LibraryBlockProps { usageKey: string; version?: VersionSpec; view?: string; + scrolling?: string; } /** * React component that displays an XBlock in a sandboxed IFrame. @@ -26,84 +30,42 @@ export const LibraryBlock = ({ usageKey, version, view, + scrolling = 'no', }: LibraryBlockProps) => { - const iframeRef = useRef(null); + const { iframeRef, setIframeRef } = useIframe(); const xblockView = view ?? 'student_view'; - const defaultiFrameHeight = xblockView === 'studio_view' ? 80 : 50; - const [iFrameHeight, setIFrameHeight] = useState(defaultiFrameHeight); const studioBaseUrl = getConfig().STUDIO_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); - if (window.self !== window.top) { - // This component is loaded inside an iframe. - setIFrameHeight(86); - } - - return () => { - window.removeEventListener('message', receivedWindowMessage); - }; - }, []); - const queryStr = version ? `?version=${version}` : ''; + const iframeUrl = `${studioBaseUrl}/xblocks/v2/${usageKey}/embed/${xblockView}/${queryStr}`; + const { iframeHeight } = useIframeBehavior({ + id: usageKey, + iframeUrl, + iframeRef, + onBlockNotification, + }); - return ( -
-