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:
Navin Karkera
2025-04-11 18:50:40 +00:00
committed by GitHub
parent 01365d080e
commit a43027b328
56 changed files with 1206 additions and 519 deletions

View File

@@ -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:

View File

@@ -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',
};

View File

@@ -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';

View File

@@ -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 = ({

View File

@@ -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,
}),

View File

@@ -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',

View File

@@ -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);
}

View File

@@ -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);
});

View File

@@ -1,2 +1 @@
export { default as CourseUnit } from './CourseUnit';
export { IframeProvider } from './context/iFrameContext';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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,

View File

@@ -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';

View File

@@ -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';

View File

@@ -1,5 +1 @@
export { useIframeMessages } from './useIframeMessages';
export { useIframeContent } from './useIframeContent';
export { useMessageHandlers } from './useMessageHandlers';
export { useIFrameBehavior } from './useIFrameBehavior';
export { useLoadBearingHook } from './useLoadBearingHook';

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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,
};
};

View File

@@ -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);

View File

@@ -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

View File

@@ -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}

View File

@@ -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}
>

View File

@@ -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,
};

View File

@@ -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]);

View 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);
});
});

View 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
View 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;
}

View File

@@ -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}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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
/>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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();
});
});

View File

@@ -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]);

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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);
};
/**

View File

@@ -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);
}

View File

@@ -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();
});

View File

@@ -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!),
})
);

View File

@@ -2,6 +2,7 @@
@import "./components/BaseCard";
@import "./generic";
@import "./LibraryAuthoringPage";
@import "./units";
.library-cards-grid {
display: grid;

View File

@@ -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,
};
};

View 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>
);
};

View 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());
});
});

View 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>
);
};

View 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;
}
}

View File

@@ -0,0 +1 @@
export { LibraryUnitPage } from './LibraryUnitPage';

View 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;