feat: library unit page skeleton [FC-0083] (#1779)
* View a unit page, which has its own URL * Components appear within a unit as full previews. Their top bar shows type icon and title on the left, and draft status (if any), tag count, overflow menu, and drag handle on the right. * Components have an overflow menu within a unit * Components can be selected within a unit * When components are selected, the standard component sidebar appears. The preview tab is hidden, since component previews are visible in the main content area. * Components within a unit full-page view have hover and selected states * Unit sidebar preview. * Frontend implementation Drag-n-drop components to reorder them in unit.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { default as CourseUnit } from './CourseUnit';
|
||||
export { IframeProvider } from './context/iFrameContext';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(<IframePreviewLibraryXBlockChanges />, {
|
||||
extraWrapper: ({ children }) => <IframeProvider>{ children }</IframeProvider>,
|
||||
});
|
||||
baseRender(<IframePreviewLibraryXBlockChanges />);
|
||||
const message = {
|
||||
data: {
|
||||
type: messageTypes.showXBlockLibraryChangesPreview,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
export { useIframeMessages } from './useIframeMessages';
|
||||
export { useIframeContent } from './useIframeContent';
|
||||
export { useMessageHandlers } from './useMessageHandlers';
|
||||
export { useIFrameBehavior } from './useIFrameBehavior';
|
||||
export { useLoadBearingHook } from './useLoadBearingHook';
|
||||
|
||||
@@ -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 = '<div id="unit-iframe" style="position: absolute; top: 50px;"></div>';
|
||||
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;
|
||||
|
||||
@@ -18,16 +18,3 @@ export type UseMessageHandlersTypes = {
|
||||
};
|
||||
|
||||
export type MessageHandlersTypes = Record<string, (payload: any) => void>;
|
||||
|
||||
export interface UseIFrameBehaviorTypes {
|
||||
id: string;
|
||||
iframeUrl: string;
|
||||
onLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface UseIFrameBehaviorReturnTypes {
|
||||
iframeHeight: number;
|
||||
handleIFrameLoad: () => void;
|
||||
showError: boolean;
|
||||
hasLoaded: boolean;
|
||||
}
|
||||
|
||||
@@ -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<number>(stateKeys.iframeHeight, 0);
|
||||
const [hasLoaded, setHasLoaded] = useKeyedState<boolean>(stateKeys.hasLoaded, false);
|
||||
const [showError, setShowError] = useKeyedState<boolean>(stateKeys.showError, false);
|
||||
const [windowTopOffset, setWindowTopOffset] = useKeyedState<number | null>(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,
|
||||
};
|
||||
};
|
||||
@@ -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<XBlockContainerIframeProps> = ({
|
||||
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -56,8 +55,8 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
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);
|
||||
|
||||
|
||||
@@ -186,17 +186,18 @@ const CustomPages = ({
|
||||
marginBottom: '16px',
|
||||
boxShadow: '0px 1px 5px #ADADAD',
|
||||
}}
|
||||
>
|
||||
<CustomPageCard
|
||||
{...{
|
||||
page,
|
||||
dispatch,
|
||||
deletePageStatus,
|
||||
courseId,
|
||||
setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
actions={(
|
||||
<CustomPageCard
|
||||
{...{
|
||||
page,
|
||||
dispatch,
|
||||
deletePageStatus,
|
||||
courseId,
|
||||
setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</DraggableList>
|
||||
<StatefulButton
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ToastContext } from '../generic/toast-context';
|
||||
|
||||
import messages from './messages';
|
||||
import CancelConfirmModal from './containers/EditorContainer/components/CancelConfirmModal';
|
||||
import { IframeProvider } from '../generic/hooks/context/iFrameContext';
|
||||
|
||||
interface AdvancedEditorProps {
|
||||
usageKey: string,
|
||||
@@ -49,10 +50,13 @@ const AdvancedEditor = ({ usageKey, onClose }: AdvancedEditorProps) => {
|
||||
return (
|
||||
<>
|
||||
<EditorModalWrapper onClose={openCancelConfirmModal}>
|
||||
<LibraryBlock
|
||||
usageKey={usageKey}
|
||||
view="studio_view"
|
||||
/>
|
||||
<IframeProvider>
|
||||
<LibraryBlock
|
||||
usageKey={usageKey}
|
||||
view="studio_view"
|
||||
scrolling="yes"
|
||||
/>
|
||||
</IframeProvider>
|
||||
</EditorModalWrapper>
|
||||
<CancelConfirmModal
|
||||
isOpen={isCancelConfirmOpen}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
|
||||
const DraggableList = ({
|
||||
itemList,
|
||||
@@ -48,6 +49,7 @@ const DraggableList = ({
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<Row
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="mx-0"
|
||||
>
|
||||
{children}
|
||||
<IconButtonWithTooltip
|
||||
key="drag-to-reorder-icon"
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(messages.tooltipContent)}
|
||||
src={DragIndicator}
|
||||
iconAs={Icon}
|
||||
variant="secondary"
|
||||
alt={intl.formatMessage(messages.tooltipContent)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
</Row>
|
||||
<Card
|
||||
style={style}
|
||||
className="mx-0"
|
||||
isClickable={isClickable}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ActionRow style={actionStyle}>
|
||||
{actions}
|
||||
<IconButtonWithTooltip
|
||||
key="drag-to-reorder-icon"
|
||||
ref={setActivatorNodeRef}
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(messages.tooltipContent)}
|
||||
src={DragIndicator}
|
||||
iconAs={Icon}
|
||||
variant="light"
|
||||
alt={intl.formatMessage(messages.tooltipContent)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
</ActionRow>
|
||||
{children}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, {
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
export interface IframeContextType {
|
||||
iframeRef: MutableRefObject<HTMLIFrameElement | null>;
|
||||
setIframeRef: (ref: MutableRefObject<HTMLIFrameElement | null>) => 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]);
|
||||
213
src/generic/hooks/tests/hooks.test.tsx
Normal file
213
src/generic/hooks/tests/hooks.test.tsx
Normal file
@@ -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 = '<div id="unit-iframe" style="position: absolute; top: 50px;"></div>';
|
||||
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);
|
||||
});
|
||||
});
|
||||
115
src/generic/hooks/useIframeBehavior.tsx
Normal file
115
src/generic/hooks/useIframeBehavior.tsx
Normal file
@@ -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<number>(iframeStateKeys.iframeHeight, 0);
|
||||
const [hasLoaded, setHasLoaded] = useKeyedState<boolean>(iframeStateKeys.hasLoaded, false);
|
||||
const [showError, setShowError] = useKeyedState<boolean>(iframeStateKeys.showError, false);
|
||||
const [windowTopOffset, setWindowTopOffset] = useKeyedState<number | null>(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,
|
||||
};
|
||||
};
|
||||
16
src/generic/types.ts
Normal file
16
src/generic/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
|
||||
export interface UseIFrameBehaviorTypes {
|
||||
id: string;
|
||||
iframeUrl: string;
|
||||
onLoaded?: boolean;
|
||||
iframeRef: MutableRefObject<HTMLIFrameElement | null>;
|
||||
onBlockNotification?: (event: { eventType: string; [key: string]: any }) => void;
|
||||
}
|
||||
|
||||
export interface UseIFrameBehaviorReturnTypes {
|
||||
iframeHeight: number;
|
||||
handleIFrameLoad: () => void;
|
||||
showError: boolean;
|
||||
hasLoaded: boolean;
|
||||
}
|
||||
@@ -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<HTMLIFrameElement>(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,
|
||||
});
|
||||
|
||||
useIframeContent(iframeRef, setIframeRef);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: `${iFrameHeight}vh`,
|
||||
boxSizing: 'content-box',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
minHeight: '200px',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={intl.formatMessage(messages.iframeTitle)}
|
||||
src={`${studioBaseUrl}/xblocks/v2/${usageKey}/embed/${xblockView}/${queryStr}`}
|
||||
data-testid="block-preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '200px',
|
||||
border: '0 none',
|
||||
}}
|
||||
// allowing 'autoplay' is required to allow the video XBlock to control the YouTube iframe it has.
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
|
||||
/>
|
||||
</div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={intl.formatMessage(messages.iframeTitle)}
|
||||
src={iframeUrl}
|
||||
data-testid="block-preview"
|
||||
name={`xblock-iframe-${usageKey}`}
|
||||
id={`xblock-iframe-${usageKey}`}
|
||||
frameBorder="0"
|
||||
loading="lazy"
|
||||
referrerPolicy="origin"
|
||||
style={{
|
||||
width: '100%', height: iframeHeight, pointerEvents: 'auto', minHeight: '700px',
|
||||
}}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
allowFullScreen
|
||||
scrolling={scrolling}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
<LibraryProvider
|
||||
/** We need to pass the collectionId as key to the LibraryProvider to force a re-render
|
||||
* when we navigate to a collection page. */
|
||||
key={collectionId}
|
||||
/** We need to pass the collectionId or unitId as key to the LibraryProvider to force a re-render
|
||||
* when we navigate to a collection or unit page. */
|
||||
key={collectionId || unitId}
|
||||
libraryId={libraryId}
|
||||
/** The component picker modal to use. We need to pass it as a reference instead of
|
||||
* directly importing it to avoid the import cycle:
|
||||
@@ -50,7 +55,7 @@ const LibraryLayout = () => {
|
||||
</>
|
||||
</SidebarProvider>
|
||||
</LibraryProvider>
|
||||
), [collectionId]);
|
||||
), [collectionId, unitId]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
@@ -71,6 +76,10 @@ const LibraryLayout = () => {
|
||||
path={ROUTES.COLLECTION}
|
||||
element={context(<LibraryCollectionPage />)}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.UNIT}
|
||||
element={context(<LibraryUnitPage />)}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -139,7 +139,7 @@ const LibraryCollectionPage = () => {
|
||||
return <ErrorAlert error={error} />;
|
||||
}
|
||||
|
||||
const breadcumbs = !componentPickerMode ? (
|
||||
const breadcrumbs = !componentPickerMode ? (
|
||||
<Breadcrumb
|
||||
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
|
||||
links={[
|
||||
@@ -204,7 +204,7 @@ const LibraryCollectionPage = () => {
|
||||
>
|
||||
<SubHeader
|
||||
title={<SubHeaderTitle title={collectionData.title} />}
|
||||
breadcrumbs={breadcumbs}
|
||||
breadcrumbs={breadcrumbs}
|
||||
headerActions={<HeaderActions />}
|
||||
hideBorder
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SidebarInfoTab>;
|
||||
setHiddenTabs: (tabs: ComponentInfoTab[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -103,8 +113,15 @@ export const SidebarProvider = ({
|
||||
initialSidebarComponentInfo,
|
||||
);
|
||||
|
||||
const [defaultTab, setDefaultTab] = useState<DefaultTabs>({
|
||||
component: COMPONENT_INFO_TABS.Preview,
|
||||
unit: UNIT_INFO_TABS.Preview,
|
||||
collection: COLLECTION_INFO_TABS.Manage,
|
||||
});
|
||||
const [hiddenTabs, setHiddenTabs] = useState<Array<SidebarInfoTab>>([]);
|
||||
|
||||
const [sidebarTab, setSidebarTab] = useStateWithUrlSearchParam<SidebarInfoTab>(
|
||||
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;
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Tabs variant="tabs" defaultActiveKey="new" id="preview-version-toggle">
|
||||
<Tabs variant="tabs" defaultActiveKey="new" id="preview-version-toggle" mountOnEnter>
|
||||
<Tab eventKey="old" title={intl.formatMessage(messages.oldVersionTitle)}>
|
||||
<div className="p-2 bg-white">
|
||||
<LibraryBlock usageKey={usageKey} version={oldVersion} />
|
||||
<IframeProvider>
|
||||
<LibraryBlock usageKey={usageKey} version={oldVersion} />
|
||||
</IframeProvider>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="new" title={intl.formatMessage(messages.newVersionTitle)}>
|
||||
<div className="p-2 bg-white">
|
||||
<LibraryBlock usageKey={usageKey} version={newVersion} />
|
||||
<IframeProvider>
|
||||
<LibraryBlock usageKey={usageKey} version={newVersion} />
|
||||
</IframeProvider>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -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 (
|
||||
<Tab eventKey={infoTab} title={title}>
|
||||
{component}
|
||||
</Tab>
|
||||
);
|
||||
}, [hiddenTabs, defaultTab.component]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack>
|
||||
@@ -181,19 +196,13 @@ const ComponentInfo = () => {
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
className="my-3 d-flex justify-content-around"
|
||||
defaultActiveKey={COMPONENT_INFO_TABS.Preview}
|
||||
defaultActiveKey={defaultTab.component}
|
||||
activeKey={tab}
|
||||
onSelect={setSidebarTab}
|
||||
>
|
||||
<Tab eventKey={COMPONENT_INFO_TABS.Preview} title={intl.formatMessage(messages.previewTabTitle)}>
|
||||
<ComponentPreview />
|
||||
</Tab>
|
||||
<Tab eventKey={COMPONENT_INFO_TABS.Manage} title={intl.formatMessage(messages.manageTabTitle)}>
|
||||
<ComponentManagement />
|
||||
</Tab>
|
||||
<Tab eventKey={COMPONENT_INFO_TABS.Details} title={intl.formatMessage(messages.detailsTabTitle)}>
|
||||
<ComponentDetails />
|
||||
</Tab>
|
||||
{renderTab(COMPONENT_INFO_TABS.Preview, <ComponentPreview />, intl.formatMessage(messages.previewTabTitle))}
|
||||
{renderTab(COMPONENT_INFO_TABS.Manage, <ComponentManagement />, intl.formatMessage(messages.manageTabTitle))}
|
||||
{renderTab(COMPONENT_INFO_TABS.Details, <ComponentDetails />, intl.formatMessage(messages.detailsTabTitle))}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
<PublishConfirmationModal
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { LibraryBlock } from '../LibraryBlock';
|
||||
import messages from './messages';
|
||||
import { useLibraryBlockMetadata } from '../data/apiHooks';
|
||||
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
|
||||
|
||||
interface ModalComponentPreviewProps {
|
||||
isOpen: boolean;
|
||||
@@ -51,7 +52,7 @@ const ComponentPreview = () => {
|
||||
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IframeProvider>
|
||||
<div className="position-relative m-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -76,7 +77,7 @@ const ComponentPreview = () => {
|
||||
}
|
||||
</div>
|
||||
<ModalComponentPreview isOpen={isModalOpen} close={closeModal} usageKey={usageKey} />
|
||||
</>
|
||||
</IframeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -166,9 +166,7 @@ describe('<ContainerCard />', () => {
|
||||
};
|
||||
render(<ContainerCard hit={containerWith5Children} />);
|
||||
|
||||
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('<ContainerCard />', () => {
|
||||
};
|
||||
render(<ContainerCard hit={containerWith6Children} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTitle('text block').length).toBe(4);
|
||||
});
|
||||
expect((await screen.findAllByTitle(/text block */)).length).toBe(4);
|
||||
expect(screen.queryByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<Tab eventKey={infoTab} title={title}>
|
||||
{component}
|
||||
</Tab>
|
||||
);
|
||||
}, [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 = () => {
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
className="m-1 text-nowrap flex-grow-1"
|
||||
disabled
|
||||
onClick={handleOpenUnit}
|
||||
>
|
||||
{intl.formatMessage(messages.openUnitButton)}
|
||||
</Button>
|
||||
@@ -106,19 +134,13 @@ const UnitInfo = () => {
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
className="my-3 d-flex justify-content-around"
|
||||
defaultActiveKey={UNIT_INFO_TABS.Preview}
|
||||
defaultActiveKey={defaultTab.unit}
|
||||
activeKey={tab}
|
||||
onSelect={setSidebarTab}
|
||||
>
|
||||
<Tab eventKey={UNIT_INFO_TABS.Preview} title={intl.formatMessage(messages.previewTabTitle)}>
|
||||
Unit Preview
|
||||
</Tab>
|
||||
<Tab eventKey={UNIT_INFO_TABS.Organize} title={intl.formatMessage(messages.organizeTabTitle)}>
|
||||
<ContainerOrganize />
|
||||
</Tab>
|
||||
<Tab eventKey={UNIT_INFO_TABS.Settings} title={intl.formatMessage(messages.settingsTabTitle)}>
|
||||
Unit Settings
|
||||
</Tab>
|
||||
{renderTab(UNIT_INFO_TABS.Preview, <LibraryUnitBlocks />, intl.formatMessage(messages.previewTabTitle))}
|
||||
{renderTab(UNIT_INFO_TABS.Organize, <ContainerOrganize />, intl.formatMessage(messages.organizeTabTitle))}
|
||||
{renderTab(UNIT_INFO_TABS.Settings, 'Unit Settings', intl.formatMessage(messages.settingsTabTitle))}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -271,6 +271,7 @@ export async function mockXBlockFields(usageKey: string): Promise<api.XBlockFiel
|
||||
case thisMock.usageKeyNewProblem: return thisMock.dataNewProblem;
|
||||
case thisMock.usageKeyNewVideo: return thisMock.dataNewVideo;
|
||||
case thisMock.usageKeyThirdParty: return thisMock.dataThirdParty;
|
||||
case thisMock.usageKey0: return thisMock.dataHtml0;
|
||||
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
|
||||
}
|
||||
}
|
||||
@@ -281,6 +282,13 @@ mockXBlockFields.dataHtml = {
|
||||
data: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
|
||||
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: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
|
||||
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<api.Container> {
|
||||
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<api.LibraryBlockMetadata[]> {
|
||||
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<api
|
||||
...child,
|
||||
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
|
||||
id: `lb:org1:Demo_course:html:text-${idx}`,
|
||||
displayName: `text block ${idx}`,
|
||||
}
|
||||
)),
|
||||
);
|
||||
@@ -546,7 +558,7 @@ mockGetContainerChildren.childTemplate = {
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockGetContainerChildren.applyMock = () => {
|
||||
jest.spyOn(api, 'getContainerChildren').mockImplementation(mockGetContainerChildren);
|
||||
jest.spyOn(api, 'getLibraryContainerChildren').mockImplementation(mockGetContainerChildren);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<LibraryBlockMetadata[]> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.get(getLibraryContainerChildrenApiUrl(containerId));
|
||||
export async function getLibraryContainerChildren(containerId: string): Promise<LibraryBlockMetadata[]> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getLibraryContainerChildrenApiUrl(containerId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
deleteContainer,
|
||||
type UpdateContainerDataRequest,
|
||||
restoreContainer,
|
||||
getContainerChildren,
|
||||
getLibraryContainerChildren,
|
||||
} from './api';
|
||||
import { VersionSpec } from '../LibraryBlock';
|
||||
|
||||
@@ -93,16 +93,22 @@ export const libraryAuthoringQueryKeys = {
|
||||
libraryId,
|
||||
collectionId,
|
||||
],
|
||||
blockTypes: (libraryId?: string) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
'blockTypes',
|
||||
libraryId,
|
||||
],
|
||||
container: (libraryId?: string, containerId?: string) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
libraryId,
|
||||
containerId,
|
||||
],
|
||||
containerChildren: (libraryId?: string, containerId?: string) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
libraryId,
|
||||
containerId,
|
||||
'children',
|
||||
],
|
||||
blockTypes: (libraryId?: string) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
'blockTypes',
|
||||
libraryId,
|
||||
],
|
||||
};
|
||||
|
||||
export const xblockQueryKeys = {
|
||||
@@ -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!),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@import "./components/BaseCard";
|
||||
@import "./generic";
|
||||
@import "./LibraryAuthoringPage";
|
||||
@import "./units";
|
||||
|
||||
.library-cards-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -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<string> | null;
|
||||
insideComponents: PathMatch<string> | null;
|
||||
insideUnits: PathMatch<string> | null;
|
||||
insideUnit: PathMatch<string> | 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,
|
||||
};
|
||||
};
|
||||
|
||||
138
src/library-authoring/units/LibraryUnitBlocks.tsx
Normal file
138
src/library-authoring/units/LibraryUnitBlocks.tsx
Normal file
@@ -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<LibraryBlockMetadata[]>([]);
|
||||
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 <Loading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
// istanbul ignore next
|
||||
return <ErrorAlert error={error} />;
|
||||
}
|
||||
|
||||
/* 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) => (
|
||||
<IframeProvider key={block.id}>
|
||||
<SortableItem
|
||||
id={block.id}
|
||||
componentStyle={null}
|
||||
actions={(
|
||||
<>
|
||||
<Stack direction="horizontal" gap={2} className="font-weight-bold">
|
||||
<Icon src={getItemIcon(block.blockType)} />
|
||||
{block.displayName}
|
||||
</Stack>
|
||||
<ActionRow.Spacer />
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
{block.hasUnpublishedChanges && (
|
||||
<Badge
|
||||
className="px-2 pt-1"
|
||||
variant="warning"
|
||||
>
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<Icon className="mb-1" size="xs" src={Description} />
|
||||
<FormattedMessage {...messages.draftChipText} />
|
||||
</Stack>
|
||||
</Badge>
|
||||
)}
|
||||
<TagCount size="sm" count={block.tagsCount} onClick={openManageTagsDrawer} />
|
||||
<ComponentMenu usageKey={block.id} />
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
actionStyle={{
|
||||
borderRadius: '8px 8px 0px 0px',
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#FBFAF9',
|
||||
borderBottom: 'solid 1px #E1DDDB',
|
||||
}}
|
||||
isClickable
|
||||
onClick={() => handleComponentSelection(block)}
|
||||
>
|
||||
<div className={classNames('p-3', {
|
||||
'container-mw-md': block.blockType === blockTypes.video,
|
||||
})}
|
||||
>
|
||||
<LibraryBlock
|
||||
usageKey={block.id}
|
||||
version={showOnlyPublished ? 'published' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</SortableItem>
|
||||
</IframeProvider>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="library-unit-page">
|
||||
<DraggableList itemList={orderedBlocks} setState={setOrderedBlocks} updateOrder={handleReorder}>
|
||||
{renderedBlocks}
|
||||
</DraggableList>
|
||||
<ContentTagsDrawerSheet
|
||||
id={componentId}
|
||||
onClose={onTagSidebarClose}
|
||||
showSheet={isManageTagsDrawerOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
115
src/library-authoring/units/LibraryUnitPage.test.tsx
Normal file
115
src/library-authoring/units/LibraryUnitPage.test.tsx
Normal file
@@ -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('<LibraryUnitPage />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
const renderLibraryUnitPage = (unitId?: string, libraryId?: string) => {
|
||||
const libId = libraryId || mockContentLibrary.libraryId;
|
||||
const uId = unitId || mockGetContainerMetadata.containerId;
|
||||
render(<LibraryLayout />, {
|
||||
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());
|
||||
});
|
||||
});
|
||||
194
src/library-authoring/units/LibraryUnitPage.tsx
Normal file
194
src/library-authoring/units/LibraryUnitPage.tsx
Normal file
@@ -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 (
|
||||
<div className="header-actions">
|
||||
<Button
|
||||
className="normal-border"
|
||||
iconBefore={InfoOutline}
|
||||
variant="outline-primary rounded-0"
|
||||
onClick={handleOnClickInfoSidebar}
|
||||
>
|
||||
{intl.formatMessage(messages.infoButtonText)}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-2"
|
||||
iconBefore={Add}
|
||||
variant="primary rounded-0"
|
||||
disabled={readOnly}
|
||||
>
|
||||
{intl.formatMessage(messages.newContentButton)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 <Loading />;
|
||||
}
|
||||
|
||||
if (!libraryData || !unitData) {
|
||||
return <NotFoundAlert />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
// istanbul ignore next
|
||||
return <ErrorAlert error={error} />;
|
||||
}
|
||||
|
||||
const breadcrumbs = (
|
||||
<Breadcrumb
|
||||
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
|
||||
links={[
|
||||
{
|
||||
label: libraryData.title,
|
||||
to: `/library/${libraryId}`,
|
||||
},
|
||||
// Adding empty breadcrumb to add the last `>` spacer.
|
||||
{
|
||||
label: '',
|
||||
to: '',
|
||||
},
|
||||
]}
|
||||
linkAs={Link}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div className="flex-grow-1">
|
||||
<Helmet><title>{libraryData.title} | {process.env.SITE_NAME}</title></Helmet>
|
||||
<Header
|
||||
number={libraryData.slug}
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
isLibrary
|
||||
containerProps={{
|
||||
size: undefined,
|
||||
}}
|
||||
/>
|
||||
<Container className="px-0 mt-4 mb-5 library-authoring-page bg-white">
|
||||
<div className="px-4 bg-light-200 border-bottom mb-2">
|
||||
<SubHeader
|
||||
title={<SubHeaderTitle title={unitData.displayName} />}
|
||||
headerActions={<HeaderActions />}
|
||||
breadcrumbs={breadcrumbs}
|
||||
hideBorder
|
||||
/>
|
||||
</div>
|
||||
<Container className="px-4 py-4">
|
||||
<LibraryUnitBlocks />
|
||||
</Container>
|
||||
</Container>
|
||||
</div>
|
||||
{!!sidebarComponentInfo?.type && (
|
||||
<div
|
||||
className="library-authoring-sidebar box-shadow-left-1 bg-white"
|
||||
data-testid="library-sidebar"
|
||||
>
|
||||
<LibrarySidebar />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
src/library-authoring/units/index.scss
Normal file
32
src/library-authoring/units/index.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
1
src/library-authoring/units/index.tsx
Normal file
1
src/library-authoring/units/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { LibraryUnitPage } from './LibraryUnitPage';
|
||||
26
src/library-authoring/units/messages.ts
Normal file
26
src/library-authoring/units/messages.ts
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user