Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ErrorBoundary for PluginContainer #96

Merged
merged 6 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
````````````````
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 `<ErrorPage />` component 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
19 changes: 17 additions & 2 deletions src/plugins/PluginContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 (
<ErrorBoundary fallbackComponent={finalFallback}>
{renderer}
</ErrorBoundary>
);
}

export default PluginContainer;
Expand All @@ -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,
};
101 changes: 101 additions & 0 deletions src/plugins/PluginContainer.test.jsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div>{children}</div>,
}));

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 (
<PluginContainer
config={{ type, ...config }}
slotErrorFallbackComponent={slotErrorFallbackComponent}
/>
);
}

describe('PluginContainer', () => {
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 the DIRECT_PLUGIN type in the configuration', () => {
const { getByText } = render(<PluginContainerWrapper type={DIRECT_PLUGIN} />);

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

describe('ErrorBoundary', () => {
beforeAll(() => {
const ExplodingComponent = () => {
throw new Error('an error occurred');
};
PluginContainerDirect.mockReturnValue(<ExplodingComponent />);
});
it('renders fallback component from JS config if one exists', () => {
function CustomFallbackFromJSConfig() {
return (
<div>
JS config fallback
</div>
);
}

const { getByText } = render(
<PluginContainerWrapper
config={{
...mockConfig,
errorFallbackComponent: <CustomFallbackFromJSConfig />,
}}
/>,
);
expect(getByText('JS config fallback')).toBeInTheDocument();
});

it('renders fallback component from PluginSlot props if one exists', () => {
function CustomFallbackFromPluginSlot() {
return (
<div>
PluginSlot props fallback
</div>
);
}

const { getByText } = render(
<PluginContainerWrapper
slotErrorFallbackComponent={<CustomFallbackFromPluginSlot />}
/>,
);
expect(getByText('PluginSlot props fallback')).toBeInTheDocument();
});

it('renders default fallback <ErrorPage /> when there is no fallback set in configuration', () => {
const { getByRole } = render(<PluginContainerWrapper />);
expect(getByRole('button', { name: 'Try again' })).toBeInTheDocument();
});
});
});
9 changes: 7 additions & 2 deletions src/plugins/PluginSlot.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = (
Expand Down Expand Up @@ -81,6 +82,7 @@ const PluginSlot = forwardRef(({
key={pluginConfig.id}
config={pluginConfig}
loadingFallback={finalLoadingFallback}
slotErrorFallbackComponent={slotErrorFallbackComponent}
slotOptions={slotOptions}
{...pluginProps}
/>
Expand Down Expand Up @@ -125,11 +127,14 @@ 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 = {
as: React.Fragment,
children: null,
pluginProps: {},
slotOptions: {},
slotErrorFallbackComponent: undefined,
};
3 changes: 2 additions & 1 deletion src/plugins/PluginSlot.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down