From cb9d25184ca713c08ed32995f802318be6f197e7 Mon Sep 17 00:00:00 2001 From: Maxwell Frank <92897870+MaxFrank13@users.noreply.github.com> Date: Fri, 1 Nov 2024 11:05:08 -0400 Subject: [PATCH] feat: ErrorBoundary for PluginContainer (#96) Co-authored-by: Jason Wesson --- README.rst | 62 +++++++++-- .../PluginSlotWithInsert.jsx | 0 .../PluginSlotWithModifyDefaultContents.jsx | 0 .../PluginSlotWithModifyWrapHide.jsx | 0 .../PluginSlotWithModularPlugins.jsx | 0 .../PluginSlotWithoutDefault.jsx | 0 src/plugins/PluginContainer.jsx | 19 +++- src/plugins/PluginContainer.test.jsx | 101 ++++++++++++++++++ src/plugins/PluginSlot.jsx | 9 +- src/plugins/PluginSlot.test.jsx | 3 +- 10 files changed, 180 insertions(+), 14 deletions(-) rename example/src/{pluginSlots => plugin-slots}/PluginSlotWithInsert.jsx (100%) rename example/src/{pluginSlots => plugin-slots}/PluginSlotWithModifyDefaultContents.jsx (100%) rename example/src/{pluginSlots => plugin-slots}/PluginSlotWithModifyWrapHide.jsx (100%) rename example/src/{pluginSlots => plugin-slots}/PluginSlotWithModularPlugins.jsx (100%) rename example/src/{pluginSlots => plugin-slots}/PluginSlotWithoutDefault.jsx (100%) create mode 100644 src/plugins/PluginContainer.test.jsx diff --git a/README.rst b/README.rst index 3494872b..21e4c60b 100644 --- a/README.rst +++ b/README.rst @@ -168,12 +168,12 @@ If you need to use a plugin operation (e.g. Wrap, Hide, Modify) on default conte Note: The default content will have a priority of 50, allowing for any plugins to appear before or after the default content. Plugin Operations -````````````````` +================= There are four plugin operations that each require specific properties. Insert a Direct Plugin -'''''''''''''''''''''' +`````````````````````` The Insert operation will add a widget in the plugin slot. The contents required for a Direct Plugin is the same as is demonstrated in the Default Contents section above, with the ``content`` key being optional. @@ -196,7 +196,7 @@ is demonstrated in the Default Contents section above, with the ``content`` key } Insert an iFrame Plugin -''''''''''''''''''''''' +``````````````````````` The Insert operation will add a widget in the plugin slot. The contents required for an iFrame Plugin is the same as is demonstrated in the Default Contents section above. @@ -220,7 +220,7 @@ is demonstrated in the Default Contents section above. } Modify -'''''' +`````` The Modify operation allows us to modify the contents of a widget, including its id, type, content, RenderWidget function, or its priority. The operation requires the id of the widget that will be modified and a function to make those changes. @@ -248,7 +248,7 @@ or its priority. The operation requires the id of the widget that will be modifi } Wrap -'''' +```` Unlike Modify, the Wrap operation adds a React component around the widget, and a single widget can receive more than one wrap operation. Each wrapper function takes in a ``component`` and ``id`` prop. @@ -276,7 +276,7 @@ one wrap operation. Each wrapper function takes in a ``component`` and ``id`` pr } Hide -'''' +```` The Hide operation will simply hide whatever content is desired. This is generally used for the default content. @@ -292,14 +292,58 @@ The Hide operation will simply hide whatever content is desired. This is general widgetId: 'some_undesired_plugin', } -Using a Child Micro-frontend (MFE) for iFrame-based Plugins and Fallback Behavior ---------------------------------------------------------------------------------- +Using a Child Micro-frontend (MFE) for iFrame-based Plugins +----------------------------------------------------------- -The Child MFE is no different than any other MFE except that it can define a component that can then be pass into the Host MFE +The Child MFE is no different than any other MFE except that it can define a `Plugin` component that can then be pass into the Host MFE as an iFrame-based plugin via a route. This component communicates (via ``postMessage``) with the Host MFE and resizes its content to match the dimensions available in the Host's plugin slot. +Fallback Behavior +----------------- + +Setting a Fallback component +'''''''''''''''''''''''''''' +The two main places to configure a fallback component for a given implementation are in the PluginSlot props and in the JS configuration. The JS configuration fallback will be prioritized over the PluginSlot props fallback. + +PluginSlot props +```````````````` +This is ideally used when the same fallback should be applied to all of the plugins in the `PluginSlot`. To configure, set the `slotErrorFallbackComponent` prop in the `PluginSlot` to a React component. This will replace the default `` component from frontend-platform. + + .. code-block:: + } + /> + +JS configuration +```````````````` +Can be used when setting a fallback for a specific plugin within a slot. Set the `errorFallbackComponent` field for the specific plugin to the custom fallback component in the JS configuration. This will be prioritized over any other fallback components. + + .. code-block:: + const config = { + pluginSlots: { + my_plugin_slot: { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'this_is_a_plugin', + type: DIRECT_PLUGIN, + priority: 60, + RenderWidget: ReactPluginComponent, + errorFallbackComponent: MyCustomFallbackComponent, + }, + }, + ], + }, + }, + }; + +iFrame-based Plugins +'''''''''''''''''''' It's notoriously difficult to know in the Host MFE when an iFrame has failed to load. Because of security sandboxing, the host isn't allowed to know the HTTP status of the request or to inspect what was loaded, so we have to rely on waiting for a ``postMessage`` event from within the iFrame to know it has successfully loaded. diff --git a/example/src/pluginSlots/PluginSlotWithInsert.jsx b/example/src/plugin-slots/PluginSlotWithInsert.jsx similarity index 100% rename from example/src/pluginSlots/PluginSlotWithInsert.jsx rename to example/src/plugin-slots/PluginSlotWithInsert.jsx diff --git a/example/src/pluginSlots/PluginSlotWithModifyDefaultContents.jsx b/example/src/plugin-slots/PluginSlotWithModifyDefaultContents.jsx similarity index 100% rename from example/src/pluginSlots/PluginSlotWithModifyDefaultContents.jsx rename to example/src/plugin-slots/PluginSlotWithModifyDefaultContents.jsx diff --git a/example/src/pluginSlots/PluginSlotWithModifyWrapHide.jsx b/example/src/plugin-slots/PluginSlotWithModifyWrapHide.jsx similarity index 100% rename from example/src/pluginSlots/PluginSlotWithModifyWrapHide.jsx rename to example/src/plugin-slots/PluginSlotWithModifyWrapHide.jsx diff --git a/example/src/pluginSlots/PluginSlotWithModularPlugins.jsx b/example/src/plugin-slots/PluginSlotWithModularPlugins.jsx similarity index 100% rename from example/src/pluginSlots/PluginSlotWithModularPlugins.jsx rename to example/src/plugin-slots/PluginSlotWithModularPlugins.jsx diff --git a/example/src/pluginSlots/PluginSlotWithoutDefault.jsx b/example/src/plugin-slots/PluginSlotWithoutDefault.jsx similarity index 100% rename from example/src/pluginSlots/PluginSlotWithoutDefault.jsx rename to example/src/plugin-slots/PluginSlotWithoutDefault.jsx diff --git a/src/plugins/PluginContainer.jsx b/src/plugins/PluginContainer.jsx index 3f7ab403..16d09319 100644 --- a/src/plugins/PluginContainer.jsx +++ b/src/plugins/PluginContainer.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { ErrorBoundary } from '@edx/frontend-platform/react'; import PluginContainerIframe from './PluginContainerIframe'; import PluginContainerDirect from './PluginContainerDirect'; @@ -12,7 +13,9 @@ import { } from './data/constants'; import { pluginConfigShape, slotOptionsShape } from './data/shapes'; -function PluginContainer({ config, slotOptions, ...props }) { +function PluginContainer({ + config, slotOptions, slotErrorFallbackComponent, ...props +}) { if (!config) { return null; } @@ -41,7 +44,16 @@ function PluginContainer({ config, slotOptions, ...props }) { break; } - return renderer; + // Retrieve a fallback component from JS config if one exists + // Otherwise, use the fallback component specific to the PluginSlot if one exists + // Otherwise, default to fallback from frontend-platform's ErrorBoundary + const finalFallback = config.errorFallbackComponent || slotErrorFallbackComponent; + + return ( + + {renderer} + + ); } export default PluginContainer; @@ -51,9 +63,12 @@ PluginContainer.propTypes = { config: PropTypes.shape(pluginConfigShape), /** Options passed to the PluginSlot */ slotOptions: PropTypes.shape(slotOptionsShape), + /** Error fallback component for the PluginSlot */ + slotErrorFallbackComponent: PropTypes.node, }; PluginContainer.defaultProps = { config: null, slotOptions: {}, + slotErrorFallbackComponent: undefined, }; diff --git a/src/plugins/PluginContainer.test.jsx b/src/plugins/PluginContainer.test.jsx new file mode 100644 index 00000000..da46812d --- /dev/null +++ b/src/plugins/PluginContainer.test.jsx @@ -0,0 +1,101 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; + +import PluginContainer from './PluginContainer'; +import { IFRAME_PLUGIN, DIRECT_PLUGIN } from './data/constants'; +import PluginContainerDirect from './PluginContainerDirect'; + +jest.mock('./PluginContainerIframe', () => jest.fn(() => 'Iframe plugin')); + +jest.mock('./PluginContainerDirect', () => jest.fn(() => 'Direct plugin')); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + getLocale: jest.fn(), + getMessages: jest.fn(), + FormattedMessage: ({ defaultMessage }) => defaultMessage, + IntlProvider: ({ children }) =>
{children}
, +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +const mockConfig = { + id: 'test-plugin-container', + errorFallbackComponent: undefined, +}; + +function PluginContainerWrapper({ type = DIRECT_PLUGIN, config = mockConfig, slotErrorFallbackComponent }) { + return ( + + ); +} + +describe('PluginContainer', () => { + it('renders a PluginContainerIframe when passed the IFRAME_PLUGIN type in the configuration', () => { + const { getByText } = render(); + + expect(getByText('Iframe plugin')).toBeInTheDocument(); + }); + + it('renders a PluginContainerDirect when passed the DIRECT_PLUGIN type in the configuration', () => { + const { getByText } = render(); + + expect(getByText('Direct plugin')).toBeInTheDocument(); + }); + + describe('ErrorBoundary', () => { + beforeAll(() => { + const ExplodingComponent = () => { + throw new Error('an error occurred'); + }; + PluginContainerDirect.mockReturnValue(); + }); + it('renders fallback component from JS config if one exists', () => { + function CustomFallbackFromJSConfig() { + return ( +
+ JS config fallback +
+ ); + } + + const { getByText } = render( + , + }} + />, + ); + expect(getByText('JS config fallback')).toBeInTheDocument(); + }); + + it('renders fallback component from PluginSlot props if one exists', () => { + function CustomFallbackFromPluginSlot() { + return ( +
+ PluginSlot props fallback +
+ ); + } + + const { getByText } = render( + } + />, + ); + expect(getByText('PluginSlot props fallback')).toBeInTheDocument(); + }); + + it('renders default fallback when there is no fallback set in configuration', () => { + const { getByRole } = render(); + expect(getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/plugins/PluginSlot.jsx b/src/plugins/PluginSlot.jsx index 8bd87a35..7bb55eda 100644 --- a/src/plugins/PluginSlot.jsx +++ b/src/plugins/PluginSlot.jsx @@ -18,6 +18,7 @@ const PluginSlot = forwardRef(({ id, pluginProps, slotOptions, + slotErrorFallbackComponent, ...props }, ref) => { /** the plugins below are obtained by the id passed into PluginSlot by the Host MFE. See example/src/PluginsPage.jsx @@ -40,8 +41,8 @@ const PluginSlot = forwardRef(({ const finalPlugins = React.useMemo(() => organizePlugins(defaultContents, plugins), [defaultContents, plugins]); - // TODO: APER-3178 — Unique plugin props - // https://2u-internal.atlassian.net/browse/APER-3178 + // TODO: Unique plugin props + // https://github.com/openedx/frontend-plugin-framework/issues/72 const { loadingFallback } = pluginProps; const defaultLoadingFallback = ( @@ -81,6 +82,7 @@ const PluginSlot = forwardRef(({ key={pluginConfig.id} config={pluginConfig} loadingFallback={finalLoadingFallback} + slotErrorFallbackComponent={slotErrorFallbackComponent} slotOptions={slotOptions} {...pluginProps} /> @@ -125,6 +127,8 @@ PluginSlot.propTypes = { pluginProps: PropTypes.shape(), /** Options passed to the PluginSlot */ slotOptions: PropTypes.shape(slotOptionsShape), + /** Error fallback component to use for each plugin */ + slotErrorFallbackComponent: PropTypes.node, }; PluginSlot.defaultProps = { @@ -132,4 +136,5 @@ PluginSlot.defaultProps = { children: null, pluginProps: {}, slotOptions: {}, + slotErrorFallbackComponent: undefined, }; diff --git a/src/plugins/PluginSlot.test.jsx b/src/plugins/PluginSlot.test.jsx index 2a487059..e18244ac 100644 --- a/src/plugins/PluginSlot.test.jsx +++ b/src/plugins/PluginSlot.test.jsx @@ -54,7 +54,8 @@ const pluginContentOnClick = jest.fn(); const defaultContentsOnClick = jest.fn(); const mockOnClick = jest.fn(); -// TODO: APER-3119 — Write unit tests for plugin scenarios not already tested for https://2u-internal.atlassian.net/browse/APER-3119 +// TODO: https://github.com/openedx/frontend-plugin-framework/issues/73 + const content = { text: 'This is a widget.' }; function DefaultContents({ className, onClick, ...rest }) { const handleOnClick = (e) => {