Skip to content

Commit

Permalink
feat: errorboundary for PluginContainer
Browse files Browse the repository at this point in the history
  • Loading branch information
MaxFrank13 committed Oct 22, 2024
1 parent 60d367d commit d57e022
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 41 deletions.
62 changes: 53 additions & 9 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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
````````````````
Can be used when setting a fallback for the slot that will be used for all of its child plugins. To configure, set the `slotErrorFallbackComponent` prop in the `PluginSlot` to a React component. This will replace the default `<ErrorPage />` from frontend-platform.

.. code-block::
<PluginSlot
id='my-plugin-slot'
slotErrorFallbackComponent={<MyCustomFallbackComponent />}
/>
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.
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/PluginContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ PluginContainer.propTypes = {
/** Options passed to the PluginSlot */
slotOptions: PropTypes.shape(slotOptionsShape),
/** Error fallback component for the PluginSlot */
slotErrorFallbackComponent: PropTypes.elementType,
slotErrorFallbackComponent: PropTypes.node,
};

PluginContainer.defaultProps = {
config: null,
slotOptions: {},
slotErrorFallbackComponent: React.Fragment,
slotErrorFallbackComponent: undefined,
};
36 changes: 10 additions & 26 deletions src/plugins/PluginContainer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,16 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import PluginContainer from './PluginContainer';
import { IFRAME_PLUGIN, DIRECT_PLUGIN } from './data/constants';
import PluginContainerDirect from './PluginContainerDirect';
import MockErrorBoundary from '../test/MockErrorBoundary';

jest.mock('./PluginContainerIframe', () => jest.fn(() => 'Iframe plugin'));

jest.mock('./PluginContainerDirect', () => jest.fn(() => 'Direct plugin'));

// TODO: figure out how to mock <Error Page /> that is imported into the ErrorBoundary component
// This is causing issues with i18n when it tries to render
// Options:
// mock the whole ErrorBoundary component and have it return a mockErrorPage instead
// find if there is a way to mock the import from <Error Page /> that happens in <ErrorBoundary />

// Feels perhaps best to just mock the ErrorBoundary here in FPF
// IF this were an MFE, we could use the initializeMockApp helper function from frontend-platform
// since FPF is not an MFE, that mock won't work for us here

// There may be use cases in the future for testing this ErrorBoundary so perhaps there is value in mocking it here

// jest.mock('@edx/frontend-platform/react', () ({
// ...jest.requireActual,
// ErrorBoundary: ({children}) =>
// }))
jest.mock('@edx/frontend-platform/react', () => ({
...jest.requireActual,
ErrorBoundary: (props) => <MockErrorBoundary {...props} />,
}));

jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
Expand All @@ -38,7 +27,7 @@ const mockConfig = {
errorFallbackComponent: undefined,
};

function PluginContainerWrapper({ type = DIRECT_PLUGIN, config = mockConfig, slotErrorFallbackComponent = false }) {
function PluginContainerWrapper({ type = DIRECT_PLUGIN, config = mockConfig, slotErrorFallbackComponent }) {
return (
<IntlProvider locale="en">
<PluginContainer
Expand All @@ -50,18 +39,13 @@ function PluginContainerWrapper({ type = DIRECT_PLUGIN, config = mockConfig, slo
}

describe('PluginContainer', () => {
// TODO: test for each error boundary
// it renders from the JS config if it exists
// it renders from the PluginSlot if it exists
// it renders the default if no config or PluginSlot fallback are provided

it('renders a PluginContainerIframe when passed IFRAME_PLUGIN in the configuration', () => {
it('renders a PluginContainerIframe when passed the IFRAME_PLUGIN type in the configuration', () => {
const { getByText } = render(<PluginContainerWrapper type={IFRAME_PLUGIN} />);

expect(getByText('Iframe plugin')).toBeInTheDocument();
});

it('renders a PluginContainerDirect when passed DIRECT_PLUGIN in the configuration', () => {
it('renders a PluginContainerDirect when passed the DIRECT_PLUGIN type in the configuration', () => {
const { getByText } = render(<PluginContainerWrapper type={DIRECT_PLUGIN} />);

expect(getByText('Direct plugin')).toBeInTheDocument();
Expand Down Expand Up @@ -112,8 +96,8 @@ describe('PluginContainer', () => {
});

it('renders default fallback <ErrorPage /> when there is no fallback set in configuration', () => {
const { getByRole } = render(<PluginContainerWrapper />);
expect(getByRole('button', { name: 'Try Again' })).toBeInTheDocument();
const { getByText } = render(<PluginContainerWrapper />);
expect(getByText('Try again')).toBeInTheDocument();
});
});
});
8 changes: 4 additions & 4 deletions src/plugins/PluginSlot.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const PluginSlot = forwardRef(({
id,
pluginProps,
slotOptions,
errorFallbackComponent,
slotErrorFallbackComponent,
...props
}, ref) => {
/** the plugins below are obtained by the id passed into PluginSlot by the Host MFE. See example/src/PluginsPage.jsx
Expand Down Expand Up @@ -82,7 +82,7 @@ const PluginSlot = forwardRef(({
key={pluginConfig.id}
config={pluginConfig}
loadingFallback={finalLoadingFallback}
errorFallbackComponent={errorFallbackComponent}
slotErrorFallbackComponent={slotErrorFallbackComponent}
slotOptions={slotOptions}
{...pluginProps}
/>
Expand Down Expand Up @@ -128,13 +128,13 @@ PluginSlot.propTypes = {
/** Options passed to the PluginSlot */
slotOptions: PropTypes.shape(slotOptionsShape),
/** Error fallback component to use for each plugin */
errorFallbackComponent: PropTypes.elementType,
slotErrorFallbackComponent: PropTypes.node,
};

PluginSlot.defaultProps = {
as: React.Fragment,
children: null,
pluginProps: {},
slotOptions: {},
errorFallbackComponent: undefined,
slotErrorFallbackComponent: undefined,
};
49 changes: 49 additions & 0 deletions src/test/MockErrorBoundary.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/** FOR TESTING PURPOSES ONLY */
/** This mock is used to mock the ErrorBoundary component to avoid having to deal with <ErrorPage /> in testing */

import React, { Component } from 'react';
import PropTypes from 'prop-types';

import { logError } from '@edx/frontend-platform/logging';

/**
* Error boundary component used to log caught errors and display the error page.
*
* @memberof module:React
* @extends {Component}
*/
class MockErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError() {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}

componentDidCatch(error, info) {
logError(error, { stack: info.componentStack });
}

render() {
if (this.state.hasError) {
// Render "Try again" instead of <Error Page /> from frontend-platform
return this.props.fallbackComponent || <div>Try again</div>;
}
return this.props.children;
}
}

MockErrorBoundary.propTypes = {
children: PropTypes.node,
fallbackComponent: PropTypes.node,
};

MockErrorBoundary.defaultProps = {
children: null,
fallbackComponent: undefined,
};

export default MockErrorBoundary;

0 comments on commit d57e022

Please sign in to comment.