feat: handle edit modals from advanced xblocks (#1445)

Adds new message types, updates message handlers, and implements a new modal iframe for legacy XBlock editing.
This commit is contained in:
Peter Kulko
2025-02-20 12:05:48 -08:00
committed by GitHub
parent 2befd82e51
commit 7e4ecff4e8
15 changed files with 353 additions and 25 deletions

View File

@@ -78,6 +78,16 @@ export const REGEX_RULES = {
noSpaceRule: /^\S*$/,
};
/**
* Feature policy for iframe, allowing access to certain courseware-related media.
*
* We must use the wildcard (*) origin for each feature, as courseware content
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
* block that iframes external course content.
* This policy was selected in conference with the edX Security Working Group.
* Changes to it should be vetted by them (security@edx.org).
*/
export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *'
);

View File

@@ -233,6 +233,126 @@ describe('<CourseUnit />', () => {
});
});
it('displays an error alert when a studioAjaxError message is received', async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.studioAjaxError, {
error: 'Some error text...',
});
});
expect(getByTestId('saving-error-alert')).toBeInTheDocument();
});
it('renders XBlock iframe and opens legacy edit modal on editXBlock message', async () => {
const { getByTitle } = render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.editXBlock, { id: blockId });
const legacyXBlockEditModalIframe = getByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).toBeInTheDocument();
});
});
it('closes the legacy edit modal when closeXBlockEditorModal message is received', async () => {
const { getByTitle, queryByTitle } = render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.closeXBlockEditorModal, { id: blockId });
const legacyXBlockEditModalIframe = queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
});
});
it('closes legacy edit modal and updates course unit sidebar after saveEditedXBlockData message', async () => {
const { getByTitle, queryByTitle, getByTestId } = render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.saveEditedXBlockData);
const legacyXBlockEditModalIframe = queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
has_changes: true,
published_by: userName,
});
await waitFor(() => {
const courseUnitSidebar = getByTestId('course-unit-sidebar');
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(sidebarMessages.releaseStatusTitle.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarBodyNote.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).queryByRole('button', {
name: sidebarMessages.actionButtonPublishTitle.defaultMessage,
}),
).toBeInTheDocument();
});
});
it('updates course unit sidebar after receiving refreshPositions message', async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.refreshPositions);
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
has_changes: true,
published_by: userName,
});
await waitFor(() => {
const courseUnitSidebar = getByTestId('course-unit-sidebar');
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(sidebarMessages.releaseStatusTitle.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarBodyNote.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).queryByRole('button', {
name: sidebarMessages.actionButtonPublishTitle.defaultMessage,
}),
).toBeInTheDocument();
});
});
it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => {
const {
getByTitle, getByText, queryByRole, getAllByRole, getByRole,

View File

@@ -69,4 +69,10 @@ export const messageTypes = {
addXBlock: 'addXBlock',
scrollToXBlock: 'scrollToXBlock',
handleViewXBlockContent: 'handleViewXBlockContent',
editXBlock: 'editXBlock',
closeXBlockEditorModal: 'closeXBlockEditorModal',
saveEditedXBlockData: 'saveEditedXBlockData',
completeXBlockEditing: 'completeXBlockEditing',
studioAjaxError: 'studioAjaxError',
refreshPositions: 'refreshPositions',
};

View File

@@ -318,3 +318,20 @@ export function patchUnitItemQuery({
}
};
}
export function updateCourseUnitSidebar(itemId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(hideProcessingNotification());
handleResponseErrors(error, dispatch, updateSavingStatus);
}
};
}

View File

@@ -175,34 +175,52 @@ describe('useLoadBearingHook', () => {
});
describe('useMessageHandlers', () => {
let handlers;
let result;
beforeEach(() => {
handlers = {
courseId: 'course-v1:Test+101+2025',
navigate: jest.fn(),
dispatch: jest.fn(),
setIframeOffset: jest.fn(),
handleDeleteXBlock: jest.fn(),
handleDuplicateXBlock: jest.fn(),
handleScrollToXBlock: jest.fn(),
handleManageXBlockAccess: jest.fn(),
handleShowLegacyEditXBlockModal: jest.fn(),
handleCloseLegacyEditorXBlockModal: jest.fn(),
handleSaveEditedXBlockData: jest.fn(),
handleFinishXBlockDragging: jest.fn(),
};
({ result } = renderHook(() => useMessageHandlers(handlers)));
});
it('calls handleScrollToXBlock after debounce delay', () => {
const mockHandleScrollToXBlock = jest.fn();
const courseId = 'course-v1:Test+101+2025';
const navigate = jest.fn();
const dispatch = jest.fn();
const setIframeOffset = jest.fn();
const handleDeleteXBlock = jest.fn();
const handleDuplicateXBlock = jest.fn();
const handleManageXBlockAccess = jest.fn();
const { result } = renderHook(() => useMessageHandlers({
courseId,
navigate,
dispatch,
setIframeOffset,
handleDeleteXBlock,
handleDuplicateXBlock,
handleScrollToXBlock: mockHandleScrollToXBlock,
handleManageXBlockAccess,
}));
act(() => {
result.current[messageTypes.scrollToXBlock]({ scrollOffset: 200 });
});
jest.advanceTimersByTime(3000);
expect(mockHandleScrollToXBlock).toHaveBeenCalledTimes(1);
expect(mockHandleScrollToXBlock).toHaveBeenCalledWith(200);
expect(handlers.handleScrollToXBlock).toHaveBeenCalledTimes(1);
expect(handlers.handleScrollToXBlock).toHaveBeenCalledWith(200);
});
it.each([
[messageTypes.editXBlock, { id: 'test-xblock-id' }, 'handleShowLegacyEditXBlockModal', 'test-xblock-id'],
[messageTypes.closeXBlockEditorModal, {}, 'handleCloseLegacyEditorXBlockModal', undefined],
[messageTypes.saveEditedXBlockData, {}, 'handleSaveEditedXBlockData', undefined],
[messageTypes.refreshPositions, {}, 'handleFinishXBlockDragging', undefined],
])('calls %s with correct arguments', (messageType, payload, handlerKey, expectedArg) => {
act(() => {
result.current[messageType](payload);
});
expect(handlers[handlerKey]).toHaveBeenCalledTimes(1);
if (expectedArg !== undefined) {
expect(handlers[handlerKey]).toHaveBeenCalledWith(expectedArg);
}
});
});

View File

@@ -7,6 +7,10 @@ export type UseMessageHandlersTypes = {
handleScrollToXBlock: (scrollOffset: number) => void;
handleDuplicateXBlock: (blockType: string, usageId: string) => void;
handleManageXBlockAccess: (usageId: string) => void;
handleShowLegacyEditXBlockModal: (id: string) => void;
handleCloseLegacyEditorXBlockModal: () => void;
handleSaveEditedXBlockData: () => void;
handleFinishXBlockDragging: () => void;
};
export type MessageHandlersTypes = Record<string, (payload: any) => void>;

View File

@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import { debounce } from 'lodash';
import { handleResponseErrors } from '../../../generic/saving-error-alert/utils';
import { copyToClipboard } from '../../../generic/data/thunks';
import { updateSavingStatus } from '../../data/slice';
import { messageTypes } from '../../constants';
import { MessageHandlersTypes, UseMessageHandlersTypes } from './types';
@@ -20,16 +22,25 @@ export const useMessageHandlers = ({
handleDuplicateXBlock,
handleScrollToXBlock,
handleManageXBlockAccess,
handleShowLegacyEditXBlockModal,
handleCloseLegacyEditorXBlockModal,
handleSaveEditedXBlockData,
handleFinishXBlockDragging,
}: UseMessageHandlersTypes): MessageHandlersTypes => useMemo(() => ({
[messageTypes.copyXBlock]: ({ usageId }) => dispatch(copyToClipboard(usageId)),
[messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId),
[messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`),
[messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId),
[messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId),
[messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 3000),
[messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000),
[messageTypes.toggleCourseXBlockDropdown]: ({
courseXBlockDropdownHeight,
}: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight),
[messageTypes.editXBlock]: ({ id }) => handleShowLegacyEditXBlockModal(id),
[messageTypes.closeXBlockEditorModal]: handleCloseLegacyEditorXBlockModal,
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
[messageTypes.studioAjaxError]: ({ error }) => handleResponseErrors(error, dispatch, updateSavingStatus),
[messageTypes.refreshPositions]: handleFinishXBlockDragging,
}), [
courseId,
handleDeleteXBlock,

View File

@@ -8,17 +8,20 @@ import { useNavigate } from 'react-router-dom';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import ModalIframe from '../../generic/modal-iframe';
import { IFRAME_FEATURE_POLICY } from '../../constants';
import supportedEditors from '../../editors/supportedEditors';
import { useIframe } from '../context/hooks';
import { updateCourseUnitSidebar } from '../data/thunk';
import {
useMessageHandlers,
useIframeContent,
useIframeMessages,
useIFrameBehavior,
} from './hooks';
import { formatAccessManagedXBlockData, getIframeUrl } from './utils';
import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils';
import messages from './messages';
import { messageTypes } from '../constants';
import {
XBlockContainerIframeProps,
@@ -39,10 +42,12 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const [iframeOffset, setIframeOffset] = useState(0);
const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null);
const [configureXBlockId, setConfigureXBlockId] = useState<string | null>(null);
const [showLegacyEditModal, setShowLegacyEditModal] = useState<boolean>(false);
const iframeUrl = useMemo(() => getIframeUrl(blockId), [blockId]);
const legacyEditModalUrl = useMemo(() => getLegacyEditModalUrl(configureXBlockId), [configureXBlockId]);
const { setIframeRef } = useIframe();
const { setIframeRef, sendMessageToIframe } = useIframe();
const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl });
useIframeContent(iframeRef, setIframeRef);
@@ -96,6 +101,25 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
});
};
const handleShowLegacyEditXBlockModal = (id: string) => {
setConfigureXBlockId(id);
setShowLegacyEditModal(true);
};
const handleCloseLegacyEditorXBlockModal = () => {
setConfigureXBlockId(null);
setShowLegacyEditModal(false);
};
const handleSaveEditedXBlockData = () => {
sendMessageToIframe(messageTypes.completeXBlockEditing, { locator: configureXBlockId });
dispatch(updateCourseUnitSidebar(blockId));
};
const handleFinishXBlockDragging = () => {
dispatch(updateCourseUnitSidebar(blockId));
};
const messageHandlers = useMessageHandlers({
courseId,
navigate,
@@ -105,12 +129,22 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleDuplicateXBlock,
handleManageXBlockAccess,
handleScrollToXBlock,
handleShowLegacyEditXBlockModal,
handleCloseLegacyEditorXBlockModal,
handleSaveEditedXBlockData,
handleFinishXBlockDragging,
});
useIframeMessages(messageHandlers);
return (
<>
{showLegacyEditModal && (
<ModalIframe
title={intl.formatMessage(messages.legacyEditModalIframeTitle)}
src={legacyEditModalUrl}
/>
)}
<DeleteModal
category="component"
isOpen={isDeleteModalOpen}

View File

@@ -6,6 +6,11 @@ const messages = defineMessages({
defaultMessage: 'Course unit iframe',
description: 'Title for the xblock iframe',
},
legacyEditModalIframeTitle: {
id: 'course-authoring.course-unit.legacy.modal.xblock-edit.iframe.title',
defaultMessage: 'Legacy xBlock edit modal',
description: 'Title for the legacy xblock edit modal iframe',
},
xblockIframeLabel: {
id: 'course-authoring.course-unit.xblock.iframe.label',
defaultMessage: '{xblockCount} xBlocks inside the frame',

View File

@@ -31,3 +31,14 @@ export const formatAccessManagedXBlockData = (
* @returns {string} - The generated iframe URL.
*/
export const getIframeUrl = (blockId: string): string => `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`;
/**
* Generates the legacy edit modal URL for the given block ID.
*
* @param {string | null} blockId - The unique identifier of the block.
*
* @returns {string} - The generated URL for editing the XBlock in the legacy modal.
*/
export const getLegacyEditModalUrl = (
blockId: string | null,
): string => (blockId ? `${getConfig().STUDIO_BASE_URL}/xblock/${blockId}/action/edit` : '');

View File

@@ -0,0 +1,34 @@
import { render } from '@testing-library/react';
import { IFRAME_FEATURE_POLICY } from '../../constants';
import ModalIframe, { SANDBOX_OPTIONS } from '.';
describe('ModalIframe Component', () => {
const title = 'Legacy Edit Modal';
const src = 'edit/xblock';
it('renders without crashing', async () => {
const { getByRole } = render(<ModalIframe title={title} src={src} />);
expect(getByRole('dialog')).toBeInTheDocument();
});
it('renders iframe with correct src', () => {
const { getByTitle } = render(<ModalIframe title={title} src={src} />);
const iframe = getByTitle(title);
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('src', src);
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
expect(iframe).toHaveAttribute('class', 'modal-iframe');
expect(iframe).toHaveAttribute('referrerpolicy', 'origin');
expect(iframe).toHaveAttribute('sandbox', SANDBOX_OPTIONS);
expect(iframe).toHaveAttribute('scrolling', 'no');
});
it('does not render when showLegacyEditModal is false', () => {
const { container } = render(<ModalIframe title={title} src={src} />);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,13 @@
.modal-iframe {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: $zindex-modal;
iframe {
width: inherit;
height: inherit;
}
}

View File

@@ -0,0 +1,43 @@
import { forwardRef, ForwardedRef, IframeHTMLAttributes } from 'react';
import classNames from 'classnames';
import { IFRAME_FEATURE_POLICY } from '../../constants';
interface ModalIframeProps extends IframeHTMLAttributes<HTMLIFrameElement> {
title: string;
className?: string;
}
export const SANDBOX_OPTIONS = [
'allow-forms',
'allow-modals',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-presentation',
'allow-same-origin',
'allow-scripts',
'allow-top-navigation-by-user-activation',
].join(' ');
const ModalIframe = forwardRef<HTMLIFrameElement, ModalIframeProps>(
({ title, className, ...props }, ref: ForwardedRef<HTMLIFrameElement>) => (
<iframe
title={title}
className={classNames('modal-iframe', className)}
allow={IFRAME_FEATURE_POLICY}
referrerPolicy="origin"
frameBorder="0"
scrolling="no"
ref={ref}
role="dialog"
sandbox={SANDBOX_OPTIONS}
{...props}
/>
),
);
ModalIframe.defaultProps = {
className: '',
};
export default ModalIframe;

View File

@@ -39,6 +39,7 @@ const SavingErrorAlert = ({
<AlertMessage
show={showAlert}
variant="danger"
data-testid="saving-error-alert"
icon={WarningIcon}
title={intl.formatMessage(messages.warningTitle)}
description={

View File

@@ -13,3 +13,4 @@
@import "./configure-modal/ConfigureModal";
@import "./drag-helper/SortableItem";
@import "./block-type-utils";
@import "./modal-iframe"