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 (
-
-
-
+ return (
+
);
};
diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx
index 8c48bae7a6..21961d7b2e 100644
--- a/src/library-authoring/LibraryLayout.tsx
+++ b/src/library-authoring/LibraryLayout.tsx
@@ -16,6 +16,7 @@ import { CreateUnitModal } from './create-unit';
import LibraryCollectionPage from './collections/LibraryCollectionPage';
import { ComponentPicker } from './component-picker';
import { ComponentEditorModal } from './components/ComponentEditorModal';
+import { LibraryUnitPage } from './units';
const LibraryLayout = () => {
const { libraryId } = useParams();
@@ -26,14 +27,18 @@ const LibraryLayout = () => {
}
// The top-level route is `${BASE_ROUTE}/*`, so match will always be non-null.
- const match = useMatch(`${BASE_ROUTE}${ROUTES.COLLECTION}`) as PathMatch<'libraryId' | 'collectionId'> | null;
- const collectionId = match?.params.collectionId;
+ const matchCollection = useMatch(`${BASE_ROUTE}${ROUTES.COLLECTION}`) as PathMatch<'libraryId' | 'collectionId'> | null;
+ const collectionId = matchCollection?.params.collectionId;
+
+ // The top-level route is `${BASE_ROUTE}/*`, so match will always be non-null.
+ const matchUnit = useMatch(`${BASE_ROUTE}${ROUTES.UNIT}`) as PathMatch<'libraryId' | 'unitId'> | null;
+ const unitId = matchUnit?.params.unitId;
const context = useCallback((childPage) => (
{
>
- ), [collectionId]);
+ ), [collectionId, unitId]);
return (
@@ -71,6 +76,10 @@ const LibraryLayout = () => {
path={ROUTES.COLLECTION}
element={context()}
/>
+ )}
+ />
);
};
diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx
index 306a64ab5b..90dee503c1 100644
--- a/src/library-authoring/collections/LibraryCollectionPage.tsx
+++ b/src/library-authoring/collections/LibraryCollectionPage.tsx
@@ -139,7 +139,7 @@ const LibraryCollectionPage = () => {
return ;
}
- const breadcumbs = !componentPickerMode ? (
+ const breadcrumbs = !componentPickerMode ? (
{
>
}
- breadcrumbs={breadcumbs}
+ breadcrumbs={breadcrumbs}
headerActions={}
hideBorder
/>
diff --git a/src/library-authoring/common/context/LibraryContext.tsx b/src/library-authoring/common/context/LibraryContext.tsx
index 9b467e19a7..f00ea43388 100644
--- a/src/library-authoring/common/context/LibraryContext.tsx
+++ b/src/library-authoring/common/context/LibraryContext.tsx
@@ -132,10 +132,10 @@ export const LibraryProvider = ({
libraryData,
collectionId,
setCollectionId,
- componentId,
- setComponentId,
unitId,
setUnitId,
+ componentId,
+ setComponentId,
readOnly,
isLoadingLibraryData,
showOnlyPublished,
@@ -157,10 +157,10 @@ export const LibraryProvider = ({
libraryData,
collectionId,
setCollectionId,
- componentId,
- setComponentId,
unitId,
setUnitId,
+ componentId,
+ setComponentId,
readOnly,
isLoadingLibraryData,
showOnlyPublished,
diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx
index d82106e83f..83e545e8eb 100644
--- a/src/library-authoring/common/context/SidebarContext.tsx
+++ b/src/library-authoring/common/context/SidebarContext.tsx
@@ -51,6 +51,12 @@ const toSidebarInfoTab = (tab: string): SidebarInfoTab | undefined => (
? tab : undefined
);
+export interface DefaultTabs {
+ component: ComponentInfoTab;
+ unit: UnitInfoTab;
+ collection: CollectionInfoTab;
+}
+
export interface SidebarComponentInfo {
type: SidebarBodyComponentId;
id: string;
@@ -76,6 +82,10 @@ export type SidebarContextData = {
resetSidebarAction: () => void;
sidebarTab: SidebarInfoTab;
setSidebarTab: (tab: SidebarInfoTab) => void;
+ defaultTab: DefaultTabs;
+ setDefaultTab: (tabs: DefaultTabs) => void;
+ hiddenTabs: Array;
+ setHiddenTabs: (tabs: ComponentInfoTab[]) => void;
};
/**
@@ -103,8 +113,15 @@ export const SidebarProvider = ({
initialSidebarComponentInfo,
);
+ const [defaultTab, setDefaultTab] = useState({
+ component: COMPONENT_INFO_TABS.Preview,
+ unit: UNIT_INFO_TABS.Preview,
+ collection: COLLECTION_INFO_TABS.Manage,
+ });
+ const [hiddenTabs, setHiddenTabs] = useState>([]);
+
const [sidebarTab, setSidebarTab] = useStateWithUrlSearchParam(
- COMPONENT_INFO_TABS.Preview,
+ defaultTab.component,
'st',
(value: string) => toSidebarInfoTab(value),
(value: SidebarInfoTab) => value.toString(),
@@ -178,6 +195,10 @@ export const SidebarProvider = ({
resetSidebarAction,
sidebarTab,
setSidebarTab,
+ defaultTab,
+ setDefaultTab,
+ hiddenTabs,
+ setHiddenTabs,
};
return contextValue;
@@ -195,6 +216,10 @@ export const SidebarProvider = ({
resetSidebarAction,
sidebarTab,
setSidebarTab,
+ defaultTab,
+ setDefaultTab,
+ hiddenTabs,
+ setHiddenTabs,
]);
return (
@@ -222,6 +247,14 @@ export function useSidebarContext(): SidebarContextData {
sidebarTab: COMPONENT_INFO_TABS.Preview,
setSidebarTab: () => {},
sidebarComponentInfo: undefined,
+ defaultTab: {
+ component: COMPONENT_INFO_TABS.Preview,
+ unit: UNIT_INFO_TABS.Preview,
+ collection: COLLECTION_INFO_TABS.Manage,
+ },
+ setDefaultTab: () => {},
+ hiddenTabs: [],
+ setHiddenTabs: () => {},
};
}
return ctx;
diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx
index 4a6e253033..6ee3fca5b5 100644
--- a/src/library-authoring/component-comparison/CompareChangesWidget.tsx
+++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx
@@ -1,5 +1,6 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Tab, Tabs } from '@openedx/paragon';
+import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
import { LibraryBlock, type VersionSpec } from '../LibraryBlock';
@@ -24,15 +25,19 @@ const CompareChangesWidget = ({ usageKey, oldVersion = 'published', newVersion =
return (
-
+
-
+
+
+
-
+
+
+
diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx
index 686ca14e54..1b542c6956 100644
--- a/src/library-authoring/component-info/ComponentInfo.tsx
+++ b/src/library-authoring/component-info/ComponentInfo.tsx
@@ -108,6 +108,8 @@ const ComponentInfo = () => {
setSidebarTab,
sidebarComponentInfo,
sidebarAction,
+ defaultTab,
+ hiddenTabs,
} = useSidebarContext();
const [
isPublishConfirmationOpen,
@@ -120,7 +122,7 @@ const ComponentInfo = () => {
const tab: ComponentInfoTab = (
isComponentInfoTab(sidebarTab)
? sidebarTab
- : COMPONENT_INFO_TABS.Preview
+ : defaultTab.component
);
useEffect(() => {
@@ -154,6 +156,19 @@ const ComponentInfo = () => {
});
}, [publishComponent, showToast, intl]);
+ // TODO: refactor sidebar Tabs to handle rendering and disabledTabs in one place.
+ const renderTab = React.useCallback((infoTab: ComponentInfoTab, component: React.ReactNode, title: string) => {
+ if (hiddenTabs.includes(infoTab)) {
+ // For some reason, returning anything other than empty list breaks the tab style
+ return [];
+ }
+ return (
+
+ {component}
+
+ );
+ }, [hiddenTabs, defaultTab.component]);
+
return (
<>
@@ -181,19 +196,13 @@ const ComponentInfo = () => {
-
-
-
-
-
-
-
-
-
+ {renderTab(COMPONENT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))}
+ {renderTab(COMPONENT_INFO_TABS.Manage, , intl.formatMessage(messages.manageTabTitle))}
+ {renderTab(COMPONENT_INFO_TABS.Details, , intl.formatMessage(messages.detailsTabTitle))}
{
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
return (
- <>
+
- >
+
);
};
diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/components/ContainerCard.test.tsx
index a6359fafd6..a76601e1d0 100644
--- a/src/library-authoring/components/ContainerCard.test.tsx
+++ b/src/library-authoring/components/ContainerCard.test.tsx
@@ -166,9 +166,7 @@ describe('', () => {
};
render();
- await waitFor(() => {
- expect(screen.getAllByTitle('text block').length).toBe(5);
- });
+ expect((await screen.findAllByTitle(/text block */)).length).toBe(5);
expect(screen.queryByText('+0')).not.toBeInTheDocument();
});
@@ -179,9 +177,7 @@ describe('', () => {
};
render();
- await waitFor(() => {
- expect(screen.getAllByTitle('text block').length).toBe(4);
- });
+ expect((await screen.findAllByTitle(/text block */)).length).toBe(4);
expect(screen.queryByText('+2')).toBeInTheDocument();
});
});
diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx
index e3efa65530..b518b7077c 100644
--- a/src/library-authoring/components/ContainerCard.tsx
+++ b/src/library-authoring/components/ContainerCard.tsx
@@ -78,7 +78,8 @@ type ContainerCardPreviewProps = {
};
const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCardPreviewProps) => {
- const { data, isLoading, isError } = useContainerChildren(containerId);
+ const { libraryId } = useLibraryContext();
+ const { data, isLoading, isError } = useContainerChildren(libraryId, containerId);
if (isLoading || isError) {
return null;
}
@@ -131,7 +132,7 @@ type ContainerCardProps = {
const ContainerCard = ({ hit } : ContainerCardProps) => {
const { componentPickerMode } = useComponentPickerContext();
- const { showOnlyPublished } = useLibraryContext();
+ const { setUnitId, showOnlyPublished } = useLibraryContext();
const { openUnitInfoSidebar } = useSidebarContext();
const {
@@ -157,7 +158,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
const openContainer = useCallback(() => {
if (itemType === 'unit') {
openUnitInfoSidebar(unitId);
-
+ setUnitId(unitId);
navigateTo({ unitId });
}
}, [unitId, itemType, openUnitInfoSidebar, navigateTo]);
diff --git a/src/library-authoring/containers/ContainerInfoHeader.tsx b/src/library-authoring/containers/ContainerInfoHeader.tsx
index 3ac06045a2..cec30a8c45 100644
--- a/src/library-authoring/containers/ContainerInfoHeader.tsx
+++ b/src/library-authoring/containers/ContainerInfoHeader.tsx
@@ -18,7 +18,7 @@ const ContainerInfoHeader = () => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
- const { readOnly } = useLibraryContext();
+ const { libraryId, readOnly } = useLibraryContext();
const { sidebarComponentInfo } = useSidebarContext();
const containerId = sidebarComponentInfo?.id;
@@ -27,7 +27,7 @@ const ContainerInfoHeader = () => {
throw new Error('containerId is required');
}
- const { data: container } = useContainer(containerId);
+ const { data: container } = useContainer(libraryId, containerId);
const updateMutation = useUpdateContainer(containerId);
const { showToast } = useContext(ToastContext);
diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx
index 41ab09bccc..9ca05bb8ea 100644
--- a/src/library-authoring/containers/UnitInfo.tsx
+++ b/src/library-authoring/containers/UnitInfo.tsx
@@ -10,8 +10,10 @@ import {
useToggle,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
+import { useCallback } from 'react';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
+import { useLibraryContext } from '../common/context/LibraryContext';
import {
type UnitInfoTab,
UNIT_INFO_TABS,
@@ -19,6 +21,8 @@ import {
useSidebarContext,
} from '../common/context/SidebarContext';
import ContainerOrganize from './ContainerOrganize';
+import { useLibraryRoutes } from '../routes';
+import { LibraryUnitBlocks } from '../units/LibraryUnitBlocks';
import messages from './messages';
import componentMessages from '../components/messages';
import ContainerDeleter from '../components/ContainerDeleter';
@@ -65,23 +69,47 @@ const UnitMenu = ({ containerId, displayName }: ContainerMenuProps) => {
const UnitInfo = () => {
const intl = useIntl();
+ const { libraryId, setUnitId } = useLibraryContext();
const { componentPickerMode } = useComponentPickerContext();
- const { sidebarComponentInfo, sidebarTab, setSidebarTab } = useSidebarContext();
+ const {
+ defaultTab, hiddenTabs, sidebarComponentInfo, sidebarTab, setSidebarTab,
+ } = useSidebarContext();
+ const { insideUnit, navigateTo } = useLibraryRoutes();
const tab: UnitInfoTab = (
sidebarTab && isUnitInfoTab(sidebarTab)
- ) ? sidebarTab : UNIT_INFO_TABS.Preview;
+ ) ? sidebarTab : defaultTab.unit;
const unitId = sidebarComponentInfo?.id;
+ const { data: container } = useContainer(libraryId, unitId);
+
+ const handleOpenUnit = useCallback(() => {
+ if (componentPickerMode) {
+ setUnitId(unitId);
+ } else {
+ navigateTo({ unitId });
+ }
+ }, [componentPickerMode, navigateTo, unitId]);
+
+ const showOpenUnitButton = !insideUnit || componentPickerMode;
+
+ const renderTab = useCallback((infoTab: UnitInfoTab, component: React.ReactNode, title: string) => {
+ if (hiddenTabs.includes(infoTab)) {
+ // For some reason, returning anything other than empty list breaks the tab style
+ return [];
+ }
+ return (
+
+ {component}
+
+ );
+ }, [hiddenTabs, defaultTab.unit, unitId]);
+
// istanbul ignore if: this should never happen
if (!unitId) {
throw new Error('unitId is required');
}
- const showOpenUnitButton = !componentPickerMode;
-
- const { data: container } = useContainer(unitId);
-
if (!container) {
return null;
}
@@ -93,7 +121,7 @@ const UnitInfo = () => {
@@ -106,19 +134,13 @@ const UnitInfo = () => {
-
- Unit Preview
-
-
-
-
-
- Unit Settings
-
+ {renderTab(UNIT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))}
+ {renderTab(UNIT_INFO_TABS.Organize, , intl.formatMessage(messages.organizeTabTitle))}
+ {renderTab(UNIT_INFO_TABS.Settings, 'Unit Settings', intl.formatMessage(messages.settingsTabTitle))}
);
diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts
index 185169b904..54fb567a7b 100644
--- a/src/library-authoring/data/api.mocks.ts
+++ b/src/library-authoring/data/api.mocks.ts
@@ -271,6 +271,7 @@ export async function mockXBlockFields(usageKey: string): PromiseThis is a text component which uses HTML.',
metadata: { displayName: 'Introduction to Testing' },
} satisfies api.XBlockFields;
+// Mock of another "regular" HTML (Text) block:
+mockXBlockFields.usageKey0 = 'lb:org1:Demo_course:html:text-0';
+mockXBlockFields.dataHtml0 = {
+ displayName: 'text block 0',
+ data: 'This is a text component which uses HTML.
',
+ metadata: { displayName: 'text block 0' },
+} satisfies api.XBlockFields;
// Mock of a blank/new HTML (Text) block:
mockXBlockFields.usageKeyNewHtml = 'lb:Axim:TEST:html:123';
mockXBlockFields.dataNewHtml = {
@@ -464,7 +472,7 @@ mockGetCollectionMetadata.applyMock = () => {
*/
export async function mockGetContainerMetadata(containerId: string): Promise {
switch (containerId) {
- case mockGetCollectionMetadata.collectionIdError:
+ case mockGetContainerMetadata.containerIdError:
throw createAxiosError({
code: 404,
message: 'Not found.',
@@ -500,13 +508,16 @@ mockGetContainerMetadata.applyMock = () => {
};
/**
- * Mock for `getContainerChildren()`
+ * Mock for `getLibraryContainerChildren()`
*
* This mock returns a fixed response for the given container ID.
*/
export async function mockGetContainerChildren(containerId: string): Promise {
let numChildren: number;
switch (containerId) {
+ case mockGetContainerMetadata.containerId:
+ numChildren = 3;
+ break;
case mockGetContainerChildren.fiveChildren:
numChildren = 5;
break;
@@ -523,6 +534,7 @@ export async function mockGetContainerChildren(containerId: string): Promise {
- jest.spyOn(api, 'getContainerChildren').mockImplementation(mockGetContainerChildren);
+ jest.spyOn(api, 'getLibraryContainerChildren').mockImplementation(mockGetContainerChildren);
};
/**
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index c1491c5298..1447a00917 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -644,8 +644,7 @@ export async function restoreContainer(containerId: string) {
/**
* Fetch a library container's children's metadata.
*/
-export async function getContainerChildren(containerId: string): Promise {
- const client = getAuthenticatedHttpClient();
- const { data } = await client.get(getLibraryContainerChildrenApiUrl(containerId));
+export async function getLibraryContainerChildren(containerId: string): Promise {
+ const { data } = await getAuthenticatedHttpClient().get(getLibraryContainerChildrenApiUrl(containerId));
return camelCaseObject(data);
}
diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx
index 8208b9c386..540622112e 100644
--- a/src/library-authoring/data/apiHooks.test.tsx
+++ b/src/library-authoring/data/apiHooks.test.tsx
@@ -146,11 +146,12 @@ describe('library api hooks', () => {
});
it('should get container metadata', async () => {
+ const libraryId = 'lib:org:1';
const containerId = 'lct:lib:org:unit:unit1';
const url = getLibraryContainerApiUrl(containerId);
axiosMock.onGet(url).reply(200, { 'test-data': 'test-value' });
- const { result } = renderHook(() => useContainer(containerId), { wrapper });
+ const { result } = renderHook(() => useContainer(libraryId, containerId), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
@@ -183,6 +184,7 @@ describe('library api hooks', () => {
});
it('should get container children', async () => {
+ const libraryId = 'lib:org:1';
const containerId = 'lct:lib:org:unit:unit1';
const url = getLibraryContainerChildrenApiUrl(containerId);
@@ -218,7 +220,7 @@ describe('library api hooks', () => {
collections: ['col2'],
},
]);
- const { result } = renderHook(() => useContainerChildren(containerId), { wrapper });
+ const { result } = renderHook(() => useContainerChildren(libraryId, containerId), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index 71b1726af3..65a584da10 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -53,7 +53,7 @@ import {
deleteContainer,
type UpdateContainerDataRequest,
restoreContainer,
- getContainerChildren,
+ getLibraryContainerChildren,
} from './api';
import { VersionSpec } from '../LibraryBlock';
@@ -93,15 +93,21 @@ export const libraryAuthoringQueryKeys = {
libraryId,
collectionId,
],
- blockTypes: (libraryId?: string) => [
+ container: (libraryId?: string, containerId?: string) => [
...libraryAuthoringQueryKeys.all,
- 'blockTypes',
libraryId,
+ containerId,
],
- container: (libraryId?: string, containerId?: string) => [
+ containerChildren: (libraryId?: string, containerId?: string) => [
...libraryAuthoringQueryKeys.all,
libraryId,
containerId,
+ 'children',
+ ],
+ blockTypes: (libraryId?: string) => [
+ ...libraryAuthoringQueryKeys.all,
+ 'blockTypes',
+ libraryId,
],
};
@@ -599,10 +605,11 @@ export const useCreateLibraryContainer = (libraryId: string) => {
/**
* Get the metadata for a container in a library
*/
-export const useContainer = (containerId: string) => (
+export const useContainer = (libraryId?: string, containerId?: string) => (
useQuery({
- queryKey: containerQueryKeys.container(containerId),
- queryFn: containerId ? () => getContainerMetadata(containerId) : undefined,
+ enabled: !!libraryId && !!containerId,
+ queryKey: libraryAuthoringQueryKeys.container(libraryId, containerId),
+ queryFn: () => getContainerMetadata(containerId!),
})
);
@@ -618,7 +625,7 @@ export const useUpdateContainer = (containerId: string) => {
// NOTE: We invalidate the library query here because we need to update the library's
// container list.
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
- queryClient.invalidateQueries({ queryKey: containerQueryKeys.container(containerId) });
+ queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(libraryId, containerId) });
},
});
};
@@ -655,10 +662,10 @@ export const useRestoreContainer = (containerId: string) => {
/**
* Get the metadata and children for a container in a library
*/
-export const useContainerChildren = (containerId: string) => (
+export const useContainerChildren = (libraryId?: string, containerId?: string) => (
useQuery({
- enabled: !!containerId,
- queryKey: containerQueryKeys.children(containerId),
- queryFn: () => getContainerChildren(containerId!),
+ enabled: !!libraryId && !!containerId,
+ queryKey: libraryAuthoringQueryKeys.containerChildren(libraryId, containerId),
+ queryFn: () => getLibraryContainerChildren(containerId!),
})
);
diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss
index fcfb6732a7..9211dc2381 100644
--- a/src/library-authoring/index.scss
+++ b/src/library-authoring/index.scss
@@ -2,6 +2,7 @@
@import "./components/BaseCard";
@import "./generic";
@import "./LibraryAuthoringPage";
+@import "./units";
.library-cards-grid {
display: grid;
diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts
index da97e2d9a5..586c3df883 100644
--- a/src/library-authoring/routes.ts
+++ b/src/library-authoring/routes.ts
@@ -29,6 +29,9 @@ export const ROUTES = {
// LibraryCollectionPage route:
// * with a selected collectionId and/or an optionally selected componentId.
COLLECTION: '/collection/:collectionId/:componentId?',
+ // LibraryUnitPage route:
+ // * with a selected unitId and/or an optionally selected componentId.
+ UNIT: '/unit/:unitId/:componentId?',
};
export enum ContentType {
@@ -41,8 +44,8 @@ export enum ContentType {
export type NavigateToData = {
componentId?: string,
collectionId?: string,
- unitId?: string,
contentType?: ContentType,
+ unitId?: string,
};
export type LibraryRoutesData = {
@@ -50,6 +53,7 @@ export type LibraryRoutesData = {
insideCollections: PathMatch | null;
insideComponents: PathMatch | null;
insideUnits: PathMatch | null;
+ insideUnit: PathMatch | null;
// Navigate using the best route from the current location for the given parameters.
navigateTo: (dict?: NavigateToData) => void;
@@ -65,6 +69,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
const insideCollections = matchPath(BASE_ROUTE + ROUTES.COLLECTIONS, pathname);
const insideComponents = matchPath(BASE_ROUTE + ROUTES.COMPONENTS, pathname);
const insideUnits = matchPath(BASE_ROUTE + ROUTES.UNITS, pathname);
+ const insideUnit = matchPath(BASE_ROUTE + ROUTES.UNIT, pathname);
const navigateTo = useCallback(({
componentId,
@@ -90,7 +95,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
...(contentType === ContentType.collections && { collectionId: urlCollectionId || urlSelectedItemId }),
...(contentType === ContentType.units && { unitId: urlUnitId || urlSelectedItemId }),
};
- let route;
+ let route: string;
// Providing contentType overrides the current route so we can change tabs.
if (contentType === ContentType.components) {
@@ -119,21 +124,31 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
// optionally selecting a component.
route = ROUTES.COMPONENTS;
} else if (insideUnits) {
- // We're inside the Units tab, so stay there,
- // optionally selecting a unit.
- route = ROUTES.UNITS;
+ // We're inside the units tab,
+ route = (
+ (unitId && unitId === (urlUnitId || urlSelectedItemId))
+ // now open the previously-selected unit,
+ ? ROUTES.UNIT
+ // or stay there to list all units, or a selected unit.
+ : ROUTES.UNITS
+ );
+ } else if (insideUnit) {
+ // We're viewing a Unit, so stay there,
+ // and optionally select a component in that Unit.
+ route = ROUTES.UNIT;
} else if (componentId) {
// We're inside the All Content tab, so stay there,
// and select a component.
route = ROUTES.COMPONENT;
+ } else if (collectionId && collectionId === (urlCollectionId || urlSelectedItemId)) {
+ // now open the previously-selected collection
+ route = ROUTES.COLLECTION;
+ } else if (unitId && unitId === (urlUnitId || urlSelectedItemId)) {
+ // now open the previously-selected unit
+ route = ROUTES.UNIT;
} else {
- route = (
- (collectionId && collectionId === (urlCollectionId || urlSelectedItemId))
- // now open the previously-selected collection
- ? ROUTES.COLLECTION
- // or stay there to list all content, or optionally select a collection.
- : ROUTES.HOME
- );
+ // or stay there to list all content, or optionally select a collection.
+ route = ROUTES.HOME;
}
const newPath = generatePath(BASE_ROUTE + route, routeParams);
@@ -149,5 +164,6 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
insideCollections,
insideComponents,
insideUnits,
+ insideUnit,
};
};
diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx
new file mode 100644
index 0000000000..6bb1f06ace
--- /dev/null
+++ b/src/library-authoring/units/LibraryUnitBlocks.tsx
@@ -0,0 +1,138 @@
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow, Badge, Icon, Stack, useToggle,
+} from '@openedx/paragon';
+import { Description } from '@openedx/paragon/icons';
+import { useQueryClient } from '@tanstack/react-query';
+import classNames from 'classnames';
+import { useEffect, useState } from 'react';
+import { ContentTagsDrawerSheet } from '../../content-tags-drawer';
+import { blockTypes } from '../../editors/data/constants/app';
+import DraggableList, { SortableItem } from '../../editors/sharedComponents/DraggableList';
+
+import ErrorAlert from '../../generic/alert-error';
+import { getItemIcon } from '../../generic/block-type-utils';
+import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
+import Loading from '../../generic/Loading';
+import TagCount from '../../generic/tag-count';
+import { useLibraryContext } from '../common/context/LibraryContext';
+import ComponentMenu from '../components';
+import { LibraryBlockMetadata } from '../data/api';
+import { libraryAuthoringQueryKeys, useContainerChildren } from '../data/apiHooks';
+import { LibraryBlock } from '../LibraryBlock';
+import { useLibraryRoutes } from '../routes';
+import messages from './messages';
+
+export const LibraryUnitBlocks = () => {
+ const [orderedBlocks, setOrderedBlocks] = useState([]);
+ const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
+ const { navigateTo } = useLibraryRoutes();
+
+ const {
+ libraryId,
+ unitId,
+ showOnlyPublished,
+ componentId,
+ setComponentId,
+ } = useLibraryContext();
+
+ const queryClient = useQueryClient();
+ const {
+ data: blocks,
+ isLoading,
+ isError,
+ error,
+ } = useContainerChildren(libraryId, unitId);
+
+ useEffect(() => setOrderedBlocks(blocks || []), [blocks]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (isError) {
+ // istanbul ignore next
+ return ;
+ }
+
+ /* istanbul ignore next */
+ const handleReorder = () => (newOrder: LibraryBlockMetadata[]) => {
+ // eslint-disable-next-line no-console
+ console.log('LibraryUnitBlocks newOrder: ', newOrder);
+ // TODO: update order of components in unit
+ };
+
+ const onTagSidebarClose = () => {
+ queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(libraryId, unitId));
+ closeManageTagsDrawer();
+ };
+
+ const handleComponentSelection = (block: LibraryBlockMetadata) => {
+ setComponentId(block.id);
+ navigateTo({ componentId: block.id });
+ };
+
+ const renderedBlocks = orderedBlocks?.map((block) => (
+
+
+
+
+ {block.displayName}
+
+
+
+ {block.hasUnpublishedChanges && (
+
+
+
+
+
+
+ )}
+
+
+
+ >
+ )}
+ actionStyle={{
+ borderRadius: '8px 8px 0px 0px',
+ padding: '0.5rem 1rem',
+ background: '#FBFAF9',
+ borderBottom: 'solid 1px #E1DDDB',
+ }}
+ isClickable
+ onClick={() => handleComponentSelection(block)}
+ >
+
+
+
+
+
+ ));
+
+ return (
+
+
+ {renderedBlocks}
+
+
+
+ );
+};
diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx
new file mode 100644
index 0000000000..b2726fab04
--- /dev/null
+++ b/src/library-authoring/units/LibraryUnitPage.test.tsx
@@ -0,0 +1,115 @@
+import userEvent from '@testing-library/user-event';
+import {
+ initializeMocks,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '../../testUtils';
+import {
+ mockContentLibrary,
+ mockXBlockFields,
+ mockGetContainerMetadata,
+ mockGetContainerChildren,
+ mockLibraryBlockMetadata,
+} from '../data/api.mocks';
+import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
+import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
+import LibraryLayout from '../LibraryLayout';
+
+const path = '/library/:libraryId/*';
+const libraryTitle = mockContentLibrary.libraryData.title;
+
+mockClipboardEmpty.applyMock();
+mockGetContainerMetadata.applyMock();
+mockGetContainerChildren.applyMock();
+mockContentSearchConfig.applyMock();
+mockGetBlockTypes.applyMock();
+mockContentLibrary.applyMock();
+mockXBlockFields.applyMock();
+mockLibraryBlockMetadata.applyMock();
+mockBroadcastChannel();
+
+describe('', () => {
+ beforeEach(() => {
+ initializeMocks();
+ });
+
+ const renderLibraryUnitPage = (unitId?: string, libraryId?: string) => {
+ const libId = libraryId || mockContentLibrary.libraryId;
+ const uId = unitId || mockGetContainerMetadata.containerId;
+ render(, {
+ path,
+ routerProps: {
+ initialEntries: [`/library/${libId}/unit/${uId}`],
+ },
+ });
+ };
+
+ it('shows the spinner before the query is complete', async () => {
+ // This mock will never return data about the collection (it loads forever):
+ renderLibraryUnitPage(mockGetContainerMetadata.containerIdLoading);
+ const spinner = screen.getByRole('status');
+ expect(spinner.textContent).toEqual('Loading...');
+ });
+
+ it('shows an error component if no unit returned', async () => {
+ // This mock will simulate incorrect unit id
+ renderLibraryUnitPage(mockGetContainerMetadata.containerIdError);
+ const errorMessage = 'Not found';
+ expect(await screen.findByRole('alert')).toHaveTextContent(errorMessage);
+ });
+
+ it('shows unit data', async () => {
+ renderLibraryUnitPage();
+ expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
+ // Unit title
+ expect((await screen.findAllByText(mockGetContainerMetadata.containerData.displayName))[0]).toBeInTheDocument();
+ // unit info button
+ expect(await screen.findByRole('button', { name: 'Unit Info' })).toBeInTheDocument();
+ expect((await screen.findAllByRole('button', { name: 'Drag to reorder' })).length).toEqual(3);
+ // check all children components are rendered.
+ expect(await screen.findByText('text block 0')).toBeInTheDocument();
+ expect(await screen.findByText('text block 1')).toBeInTheDocument();
+ expect(await screen.findByText('text block 2')).toBeInTheDocument();
+ // 3 preview iframes
+ expect((await screen.findAllByTestId('block-preview')).length).toEqual(3);
+ });
+
+ it('should open and close the unit sidebar', async () => {
+ renderLibraryUnitPage();
+
+ // sidebar should be visible by default
+ const sidebar = await screen.findByTestId('library-sidebar');
+
+ const { findByText } = within(sidebar);
+
+ // The mock data for the sidebar has a title of "Test Unit"
+ expect(await findByText('Test Unit')).toBeInTheDocument();
+
+ // should close if open
+ userEvent.click(await screen.findByText('Unit Info'));
+ await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
+
+ // Open again
+ userEvent.click(await screen.findByText('Unit Info'));
+ expect(await screen.findByTestId('library-sidebar')).toBeInTheDocument();
+ });
+
+ it('should open and component sidebar on component selection', async () => {
+ renderLibraryUnitPage();
+
+ const component = await screen.findByText('text block 0');
+ userEvent.click(component);
+ const sidebar = await screen.findByTestId('library-sidebar');
+
+ const { findByRole, findByText } = within(sidebar);
+
+ // The mock data for the sidebar has a title of "text block 0"
+ expect(await findByText('text block 0')).toBeInTheDocument();
+
+ const closeButton = await findByRole('button', { name: /close/i });
+ userEvent.click(closeButton);
+ await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
+ });
+});
diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx
new file mode 100644
index 0000000000..2bd5e42a05
--- /dev/null
+++ b/src/library-authoring/units/LibraryUnitPage.tsx
@@ -0,0 +1,194 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Breadcrumb, Button, Container } from '@openedx/paragon';
+import { Add, InfoOutline } from '@openedx/paragon/icons';
+import { useCallback, useEffect } from 'react';
+import { Helmet } from 'react-helmet';
+import { Link } from 'react-router-dom';
+
+import ErrorAlert from '../../generic/alert-error';
+import Loading from '../../generic/Loading';
+import NotFoundAlert from '../../generic/NotFoundAlert';
+import SubHeader from '../../generic/sub-header/SubHeader';
+import Header from '../../header';
+import { useLibraryContext } from '../common/context/LibraryContext';
+import {
+ COLLECTION_INFO_TABS, COMPONENT_INFO_TABS, SidebarBodyComponentId, UNIT_INFO_TABS, useSidebarContext,
+} from '../common/context/SidebarContext';
+import { useContainer, useContentLibrary } from '../data/apiHooks';
+import { LibrarySidebar } from '../library-sidebar';
+import { SubHeaderTitle } from '../LibraryAuthoringPage';
+import { useLibraryRoutes } from '../routes';
+import { LibraryUnitBlocks } from './LibraryUnitBlocks';
+import messages from './messages';
+
+const HeaderActions = () => {
+ const intl = useIntl();
+
+ const { unitId, readOnly } = useLibraryContext();
+ const {
+ closeLibrarySidebar,
+ openUnitInfoSidebar,
+ sidebarComponentInfo,
+ } = useSidebarContext();
+ const { navigateTo } = useLibraryRoutes();
+
+ // istanbul ignore if: this should never happen
+ if (!unitId) {
+ throw new Error('it should not be possible to render HeaderActions without a unitId');
+ }
+
+ const infoSidebarIsOpen = sidebarComponentInfo?.type === SidebarBodyComponentId.UnitInfo
+ && sidebarComponentInfo?.id === unitId;
+
+ const handleOnClickInfoSidebar = useCallback(() => {
+ if (infoSidebarIsOpen) {
+ closeLibrarySidebar();
+ } else {
+ openUnitInfoSidebar(unitId);
+ }
+ navigateTo({ unitId });
+ }, [unitId, infoSidebarIsOpen]);
+
+ return (
+
+
+
+
+ );
+};
+
+export const LibraryUnitPage = () => {
+ const intl = useIntl();
+
+ const {
+ libraryId,
+ unitId,
+ collectionId,
+ componentId,
+ } = useLibraryContext();
+ const {
+ sidebarComponentInfo,
+ openInfoSidebar,
+ setDefaultTab,
+ setHiddenTabs,
+ } = useSidebarContext();
+
+ useEffect(() => {
+ setDefaultTab({
+ collection: COLLECTION_INFO_TABS.Details,
+ component: COMPONENT_INFO_TABS.Manage,
+ unit: UNIT_INFO_TABS.Organize,
+ });
+ setHiddenTabs([COMPONENT_INFO_TABS.Preview, UNIT_INFO_TABS.Preview]);
+ return () => {
+ setDefaultTab({
+ component: COMPONENT_INFO_TABS.Preview,
+ unit: UNIT_INFO_TABS.Preview,
+ collection: COLLECTION_INFO_TABS.Manage,
+ });
+ setHiddenTabs([]);
+ };
+ }, [setDefaultTab, setHiddenTabs]);
+
+ useEffect(() => {
+ openInfoSidebar(componentId, collectionId, unitId);
+ }, [componentId, unitId, collectionId]);
+
+ if (!unitId || !libraryId) {
+ // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
+ throw new Error('Rendered without unitId or libraryId URL parameter');
+ }
+
+ const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
+ const {
+ data: unitData,
+ isLoading,
+ isError,
+ error,
+ } = useContainer(libraryId, unitId);
+
+ // Only show loading if unit or library data is not fetched from index yet
+ if (isLibLoading || isLoading) {
+ return ;
+ }
+
+ if (!libraryData || !unitData) {
+ return ;
+ }
+
+ if (isError) {
+ // istanbul ignore next
+ return ;
+ }
+
+ const breadcrumbs = (
+ ` spacer.
+ {
+ label: '',
+ to: '',
+ },
+ ]}
+ linkAs={Link}
+ />
+ );
+
+ return (
+
+
+
{libraryData.title} | {process.env.SITE_NAME}
+
+
+
+ }
+ headerActions={}
+ breadcrumbs={breadcrumbs}
+ hideBorder
+ />
+
+
+
+
+
+
+ {!!sidebarComponentInfo?.type && (
+
+
+
+ )}
+
+ );
+};
diff --git a/src/library-authoring/units/index.scss b/src/library-authoring/units/index.scss
new file mode 100644
index 0000000000..91b5d78b1f
--- /dev/null
+++ b/src/library-authoring/units/index.scss
@@ -0,0 +1,32 @@
+.library-unit-page {
+ .pgn__card {
+ border-radius: 8px;
+ padding: 0;
+ margin-bottom: 1rem;
+ border: solid 1px $light-500;
+ }
+
+ .pgn__card.clickable {
+ box-shadow: none;
+ // this is required for clicks to be captured by card and iframe when it is not in focus
+ pointer-events: auto;
+
+ &:focus {
+ // this is required for clicks to be passed to underlying iframe component
+ pointer-events: none;
+ }
+ }
+
+ .pgn__action-row {
+ // this is required for clicks to be captured by card header
+ pointer-events: auto;
+ }
+
+ .pgn__card.clickable:hover {
+ box-shadow: 0 .125rem .25rem rgb(0 0 0 / .15), 0 .125rem .5rem rgb(0 0 0 / .15);
+ }
+
+ .sortable-item-children {
+ pointer-events: auto;
+ }
+}
diff --git a/src/library-authoring/units/index.tsx b/src/library-authoring/units/index.tsx
new file mode 100644
index 0000000000..663e85d61a
--- /dev/null
+++ b/src/library-authoring/units/index.tsx
@@ -0,0 +1 @@
+export { LibraryUnitPage } from './LibraryUnitPage';
diff --git a/src/library-authoring/units/messages.ts b/src/library-authoring/units/messages.ts
new file mode 100644
index 0000000000..2ce7891abf
--- /dev/null
+++ b/src/library-authoring/units/messages.ts
@@ -0,0 +1,26 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ infoButtonText: {
+ id: 'course-authoring.library-authoring.unit-header.buttons.info',
+ defaultMessage: 'Unit Info',
+ description: 'Button text to unit sidebar from unit page',
+ },
+ newContentButton: {
+ id: 'course-authoring.library-authoring.unit-header.buttons.new-content',
+ defaultMessage: 'Add Content',
+ description: 'Text of button to add new content to unit',
+ },
+ breadcrumbsAriaLabel: {
+ id: 'course-authoring.library-authoring.breadcrumbs.label.text',
+ defaultMessage: 'Navigation breadcrumbs',
+ description: 'Aria label for navigation breadcrumbs',
+ },
+ draftChipText: {
+ id: 'course-authoring.library-authoring.unit-component.draft-chip.text',
+ defaultMessage: 'Draft',
+ description: 'Chip in components in unit page that is shown when component has unpublished changes',
+ },
+});
+
+export default messages;