From 6106b6571413ea3012a20e60f9014a8a4fc5a27a Mon Sep 17 00:00:00 2001 From: Jansen Kantor Date: Mon, 20 Oct 2025 12:03:07 -0400 Subject: [PATCH 1/3] feat: add plugin slot for content iframe error component (#1771) * feat: add plugin slot for content iframe error component * style: quality * fix: copilot suggestions --- .../course/sequence/Unit/ContentIFrame.jsx | 8 +++- .../ContentIFrameErrorSlot/README.md | 39 +++++++++++++++++++ .../ContentIFrameErrorSlot/index.tsx | 15 +++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/plugin-slots/ContentIFrameErrorSlot/README.md create mode 100644 src/plugin-slots/ContentIFrameErrorSlot/index.tsx diff --git a/src/courseware/course/sequence/Unit/ContentIFrame.jsx b/src/courseware/course/sequence/Unit/ContentIFrame.jsx index 03c0b32d..622e4c13 100644 --- a/src/courseware/course/sequence/Unit/ContentIFrame.jsx +++ b/src/courseware/course/sequence/Unit/ContentIFrame.jsx @@ -1,10 +1,10 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { ErrorPage } from '@edx/frontend-platform/react'; import { StrictDict } from '@edx/react-unit-test-utils'; import { ModalDialog } from '@openedx/paragon'; import { ContentIFrameLoaderSlot } from '../../../../plugin-slots/ContentIFrameLoaderSlot'; +import { ContentIFrameErrorSlot } from '../../../../plugin-slots/ContentIFrameErrorSlot'; import * as hooks from './hooks'; @@ -68,7 +68,11 @@ const ContentIFrame = ({ return ( <> {(shouldShowContent && !hasLoaded) && ( - showError ? : + showError ? ( + + ) : ( + + ) )} {shouldShowContent && (
diff --git a/src/plugin-slots/ContentIFrameErrorSlot/README.md b/src/plugin-slots/ContentIFrameErrorSlot/README.md new file mode 100644 index 00000000..e3bd0b42 --- /dev/null +++ b/src/plugin-slots/ContentIFrameErrorSlot/README.md @@ -0,0 +1,39 @@ +# Content iFrame Error Slot + +### Slot ID: `org.openedx.frontend.learning.content_iframe_error.v1` + +### Parameters: `courseId` + +## Description + +This slot is used to replace/modify the content iframe error page. + +## Example + +The following `env.config.jsx` will replace the error page with emojis. + +```js +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.learning.content_iframe_error.v1': { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_error_page', + type: DIRECT_PLUGIN, + RenderWidget: ({courseId}) => ( +

🚨🤖💥

+ ), + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/ContentIFrameErrorSlot/index.tsx b/src/plugin-slots/ContentIFrameErrorSlot/index.tsx new file mode 100644 index 00000000..1202e4a3 --- /dev/null +++ b/src/plugin-slots/ContentIFrameErrorSlot/index.tsx @@ -0,0 +1,15 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import { ErrorPage } from '@edx/frontend-platform/react'; + +interface Props { + courseId: string; +} + +export const ContentIFrameErrorSlot : React.FC = ({ courseId }: Props) => ( + + + +); From 2728d5d4e97208b8c963572736d8e20b5caed19a Mon Sep 17 00:00:00 2001 From: Diana Villalvazo Date: Mon, 28 Jul 2025 10:48:53 -0600 Subject: [PATCH 2/3] test: deprecate react-unit-test-utils 1/2 (#1750) --- .../course/sequence/Unit/ContentIFrame.jsx | 6 +- .../sequence/Unit/ContentIFrame.test.jsx | 149 +++++++----------- .../sequence/Unit/UnitSuspense.test.jsx | 54 +++---- .../Unit/{constants.js => constants.ts} | 19 ++- .../sequence/Unit/hooks/useExamAccess.js | 8 +- 5 files changed, 95 insertions(+), 141 deletions(-) rename src/courseware/course/sequence/Unit/{constants.js => constants.ts} (57%) diff --git a/src/courseware/course/sequence/Unit/ContentIFrame.jsx b/src/courseware/course/sequence/Unit/ContentIFrame.jsx index 622e4c13..44139cb1 100644 --- a/src/courseware/course/sequence/Unit/ContentIFrame.jsx +++ b/src/courseware/course/sequence/Unit/ContentIFrame.jsx @@ -1,7 +1,5 @@ import PropTypes from 'prop-types'; -import React from 'react'; -import { StrictDict } from '@edx/react-unit-test-utils'; import { ModalDialog } from '@openedx/paragon'; import { ContentIFrameLoaderSlot } from '../../../../plugin-slots/ContentIFrameLoaderSlot'; import { ContentIFrameErrorSlot } from '../../../../plugin-slots/ContentIFrameErrorSlot'; @@ -22,10 +20,10 @@ export const IFRAME_FEATURE_POLICY = ( 'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *; autoplay *' ); -export const testIDs = StrictDict({ +export const testIDs = { contentIFrame: 'content-iframe-test-id', modalIFrame: 'modal-iframe-test-id', -}); +}; const ContentIFrame = ({ iframeUrl, diff --git a/src/courseware/course/sequence/Unit/ContentIFrame.test.jsx b/src/courseware/course/sequence/Unit/ContentIFrame.test.jsx index 6ef5f08a..2a147202 100644 --- a/src/courseware/course/sequence/Unit/ContentIFrame.test.jsx +++ b/src/courseware/course/sequence/Unit/ContentIFrame.test.jsx @@ -1,25 +1,11 @@ -import React from 'react'; +import { render, screen } from '@testing-library/react'; -import { ErrorPage } from '@edx/frontend-platform/react'; -import { ModalDialog } from '@openedx/paragon'; -import { shallow } from '@edx/react-unit-test-utils'; - -import PageLoading from '@src/generic/PageLoading'; - -import { ContentIFrameLoaderSlot } from '@src/plugin-slots/ContentIFrameLoaderSlot'; import * as hooks from './hooks'; -import ContentIFrame, { IFRAME_FEATURE_POLICY, testIDs } from './ContentIFrame'; +import ContentIFrame, { IFRAME_FEATURE_POLICY } from './ContentIFrame'; -jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: 'ErrorPage' })); +jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: () =>
ErrorPage
})); -jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils') - .mockComponents({ - ModalDialog: { - Body: 'ModalDialog.Body', - }, - })); - -jest.mock('@src/generic/PageLoading', () => 'PageLoading'); +jest.mock('@src/generic/PageLoading', () => jest.fn(() =>
PageLoading
)); jest.mock('./hooks', () => ({ useIFrameBehavior: jest.fn(), @@ -67,14 +53,13 @@ const props = { title: 'test-title', }; -let el; describe('ContentIFrame Component', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('behavior', () => { beforeEach(() => { - el = shallow(); + render(); }); it('initializes iframe behavior hook', () => { expect(hooks.useIFrameBehavior).toHaveBeenCalledWith({ @@ -89,61 +74,61 @@ describe('ContentIFrame Component', () => { }); }); describe('output', () => { - let component; describe('if shouldShowContent', () => { describe('if not hasLoaded', () => { it('displays errorPage if showError', () => { hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true }); - el = shallow(); - expect(el.instance.findByType(ErrorPage).length).toEqual(1); + render(); + const errorPage = screen.getByText('ErrorPage'); + expect(errorPage).toBeInTheDocument(); }); it('displays PageLoading component if not showError', () => { - el = shallow(); - [component] = el.instance.findByType(ContentIFrameLoaderSlot); - expect(component.props.loadingMessage).toEqual(props.loadingMessage); + render(); + const pageLoading = screen.getByText('PageLoading'); + expect(pageLoading).toBeInTheDocument(); }); }); describe('hasLoaded', () => { it('does not display PageLoading or ErrorPage', () => { hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, hasLoaded: true }); - el = shallow(); - expect(el.instance.findByType(PageLoading).length).toEqual(0); - expect(el.instance.findByType(ErrorPage).length).toEqual(0); + render(); + const pageLoading = screen.queryByText('PageLoading'); + expect(pageLoading).toBeNull(); + const errorPage = screen.queryByText('ErrorPage'); + expect(errorPage).toBeNull(); }); }); it('display iframe with props from hooks', () => { - el = shallow(); - [component] = el.instance.findByTestId(testIDs.contentIFrame); - expect(component.props).toEqual({ - allow: IFRAME_FEATURE_POLICY, - allowFullScreen: true, - scrolling: 'no', - referrerPolicy: 'origin', - title: props.title, - id: props.elementId, - src: props.iframeUrl, - height: iframeBehavior.iframeHeight, - onLoad: iframeBehavior.handleIFrameLoad, - 'data-testid': testIDs.contentIFrame, - }); + render(); + const iframe = screen.getByTitle(props.title); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute('id', props.elementId); + expect(iframe).toHaveAttribute('src', props.iframeUrl); + expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY); + expect(iframe).toHaveAttribute('allowfullscreen', ''); + expect(iframe).toHaveAttribute('scrolling', 'no'); + expect(iframe).toHaveAttribute('referrerpolicy', 'origin'); }); }); describe('if not shouldShowContent', () => { it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => { - el = shallow(); - expect(el.instance.findByType(PageLoading).length).toEqual(0); - expect(el.instance.findByType(ErrorPage).length).toEqual(0); - expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0); + render(); + expect(screen.queryByText('PageLoading')).toBeNull(); + expect(screen.queryByText('ErrorPage')).toBeNull(); + expect(screen.queryByTitle(props.title)).toBeNull(); }); }); it('does not display modal if modalOptions returns isOpen: false', () => { - el = shallow(); - expect(el.instance.findByType(ModalDialog).length).toEqual(0); + render(); + const modal = screen.queryByRole('dialog'); + expect(modal).toBeNull(); }); describe('if modalOptions.isOpen', () => { const testModalOpenAndHandleClose = () => { - test('Modal component isOpen, with handleModalClose from hook', () => { - expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose); + it('closes modal on close button click', () => { + const closeButton = screen.getByTestId('modal-backdrop'); + closeButton.click(); + expect(modalIFrameData.handleModalClose).toHaveBeenCalled(); }); }; describe('fullscreen modal', () => { @@ -153,14 +138,13 @@ describe('ContentIFrame Component', () => { ...modalIFrameData, modalOptions: { ...modalOptions.withBody, isFullscreen: true }, }); - el = shallow(); - [component] = el.instance.findByType(ModalDialog); + render(); }); it('displays Modal with div wrapping provided body content if modal.body is provided', () => { - const content = component.findByType(ModalDialog.Body)[0].children[0]; - expect(content.matches(shallow( -
{modalOptions.withBody.body}
, - ))).toEqual(true); + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + const modalBody = screen.getByText(modalOptions.withBody.body); + expect(modalBody).toBeInTheDocument(); }); testModalOpenAndHandleClose(); }); @@ -171,55 +155,42 @@ describe('ContentIFrame Component', () => { ...modalIFrameData, modalOptions: { ...modalOptions.withUrl, isFullscreen: true }, }); - el = shallow(); - [component] = el.instance.findByType(ModalDialog); + render(); + }); + it('displays Modal with iframe to provided url if modal.body is not provided', () => { + const iframe = screen.getByTitle(modalOptions.withUrl.title); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY); + expect(iframe).toHaveAttribute('src', modalOptions.withUrl.url); }); testModalOpenAndHandleClose(); - it('displays Modal with iframe to provided url if modal.body is not provided', () => { - const content = component.findByType(ModalDialog.Body)[0].children[0]; - expect(content.matches(shallow( -