Skip to content

Commit cb9d251

Browse files
feat: ErrorBoundary for PluginContainer (#96)
Co-authored-by: Jason Wesson <[email protected]>
1 parent 8b0e10c commit cb9d251

10 files changed

+180
-14
lines changed

README.rst

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,12 @@ If you need to use a plugin operation (e.g. Wrap, Hide, Modify) on default conte
168168
Note: The default content will have a priority of 50, allowing for any plugins to appear before or after the default content.
169169

170170
Plugin Operations
171-
`````````````````
171+
=================
172172

173173
There are four plugin operations that each require specific properties.
174174

175175
Insert a Direct Plugin
176-
''''''''''''''''''''''
176+
``````````````````````
177177

178178
The Insert operation will add a widget in the plugin slot. The contents required for a Direct Plugin is the same as
179179
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
196196
}
197197
198198
Insert an iFrame Plugin
199-
'''''''''''''''''''''''
199+
```````````````````````
200200

201201
The Insert operation will add a widget in the plugin slot. The contents required for an iFrame Plugin is the same as
202202
is demonstrated in the Default Contents section above.
@@ -220,7 +220,7 @@ is demonstrated in the Default Contents section above.
220220
}
221221
222222
Modify
223-
''''''
223+
``````
224224

225225
The Modify operation allows us to modify the contents of a widget, including its id, type, content, RenderWidget function,
226226
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
248248
}
249249
250250
Wrap
251-
''''
251+
````
252252

253253
Unlike Modify, the Wrap operation adds a React component around the widget, and a single widget can receive more than
254254
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
276276
}
277277
278278
Hide
279-
''''
279+
````
280280

281281
The Hide operation will simply hide whatever content is desired. This is generally used for the default content.
282282

@@ -292,14 +292,58 @@ The Hide operation will simply hide whatever content is desired. This is general
292292
widgetId: 'some_undesired_plugin',
293293
}
294294
295-
Using a Child Micro-frontend (MFE) for iFrame-based Plugins and Fallback Behavior
296-
---------------------------------------------------------------------------------
295+
Using a Child Micro-frontend (MFE) for iFrame-based Plugins
296+
-----------------------------------------------------------
297297

298-
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
298+
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
299299
as an iFrame-based plugin via a route.
300300
This component communicates (via ``postMessage``) with the Host MFE and resizes its content to match the dimensions
301301
available in the Host's plugin slot.
302302

303+
Fallback Behavior
304+
-----------------
305+
306+
Setting a Fallback component
307+
''''''''''''''''''''''''''''
308+
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.
309+
310+
PluginSlot props
311+
````````````````
312+
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.
313+
314+
.. code-block::
315+
<PluginSlot
316+
id='my-plugin-slot'
317+
slotErrorFallbackComponent={<MyCustomFallbackComponent />}
318+
/>
319+
320+
JS configuration
321+
````````````````
322+
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.
323+
324+
.. code-block::
325+
const config = {
326+
pluginSlots: {
327+
my_plugin_slot: {
328+
keepDefault: false,
329+
plugins: [
330+
{
331+
op: PLUGIN_OPERATIONS.Insert,
332+
widget: {
333+
id: 'this_is_a_plugin',
334+
type: DIRECT_PLUGIN,
335+
priority: 60,
336+
RenderWidget: ReactPluginComponent,
337+
errorFallbackComponent: MyCustomFallbackComponent,
338+
},
339+
},
340+
],
341+
},
342+
},
343+
};
344+
345+
iFrame-based Plugins
346+
''''''''''''''''''''
303347
It's notoriously difficult to know in the Host MFE when an iFrame has failed to load.
304348
Because of security sandboxing, the host isn't allowed to know the HTTP status of the request or to inspect what was
305349
loaded, so we have to rely on waiting for a ``postMessage`` event from within the iFrame to know it has successfully loaded.

src/plugins/PluginContainer.jsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import React from 'react';
44
import PropTypes from 'prop-types';
5+
import { ErrorBoundary } from '@edx/frontend-platform/react';
56

67
import PluginContainerIframe from './PluginContainerIframe';
78
import PluginContainerDirect from './PluginContainerDirect';
@@ -12,7 +13,9 @@ import {
1213
} from './data/constants';
1314
import { pluginConfigShape, slotOptionsShape } from './data/shapes';
1415

15-
function PluginContainer({ config, slotOptions, ...props }) {
16+
function PluginContainer({
17+
config, slotOptions, slotErrorFallbackComponent, ...props
18+
}) {
1619
if (!config) {
1720
return null;
1821
}
@@ -41,7 +44,16 @@ function PluginContainer({ config, slotOptions, ...props }) {
4144
break;
4245
}
4346

44-
return renderer;
47+
// Retrieve a fallback component from JS config if one exists
48+
// Otherwise, use the fallback component specific to the PluginSlot if one exists
49+
// Otherwise, default to fallback from frontend-platform's ErrorBoundary
50+
const finalFallback = config.errorFallbackComponent || slotErrorFallbackComponent;
51+
52+
return (
53+
<ErrorBoundary fallbackComponent={finalFallback}>
54+
{renderer}
55+
</ErrorBoundary>
56+
);
4557
}
4658

4759
export default PluginContainer;
@@ -51,9 +63,12 @@ PluginContainer.propTypes = {
5163
config: PropTypes.shape(pluginConfigShape),
5264
/** Options passed to the PluginSlot */
5365
slotOptions: PropTypes.shape(slotOptionsShape),
66+
/** Error fallback component for the PluginSlot */
67+
slotErrorFallbackComponent: PropTypes.node,
5468
};
5569

5670
PluginContainer.defaultProps = {
5771
config: null,
5872
slotOptions: {},
73+
slotErrorFallbackComponent: undefined,
5974
};

src/plugins/PluginContainer.test.jsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/* eslint-disable react/prop-types */
2+
import React from 'react';
3+
import '@testing-library/jest-dom';
4+
import { render } from '@testing-library/react';
5+
6+
import PluginContainer from './PluginContainer';
7+
import { IFRAME_PLUGIN, DIRECT_PLUGIN } from './data/constants';
8+
import PluginContainerDirect from './PluginContainerDirect';
9+
10+
jest.mock('./PluginContainerIframe', () => jest.fn(() => 'Iframe plugin'));
11+
12+
jest.mock('./PluginContainerDirect', () => jest.fn(() => 'Direct plugin'));
13+
14+
jest.mock('@edx/frontend-platform/i18n', () => ({
15+
getLocale: jest.fn(),
16+
getMessages: jest.fn(),
17+
FormattedMessage: ({ defaultMessage }) => defaultMessage,
18+
IntlProvider: ({ children }) => <div>{children}</div>,
19+
}));
20+
21+
jest.mock('@edx/frontend-platform/logging', () => ({
22+
logError: jest.fn(),
23+
}));
24+
25+
const mockConfig = {
26+
id: 'test-plugin-container',
27+
errorFallbackComponent: undefined,
28+
};
29+
30+
function PluginContainerWrapper({ type = DIRECT_PLUGIN, config = mockConfig, slotErrorFallbackComponent }) {
31+
return (
32+
<PluginContainer
33+
config={{ type, ...config }}
34+
slotErrorFallbackComponent={slotErrorFallbackComponent}
35+
/>
36+
);
37+
}
38+
39+
describe('PluginContainer', () => {
40+
it('renders a PluginContainerIframe when passed the IFRAME_PLUGIN type in the configuration', () => {
41+
const { getByText } = render(<PluginContainerWrapper type={IFRAME_PLUGIN} />);
42+
43+
expect(getByText('Iframe plugin')).toBeInTheDocument();
44+
});
45+
46+
it('renders a PluginContainerDirect when passed the DIRECT_PLUGIN type in the configuration', () => {
47+
const { getByText } = render(<PluginContainerWrapper type={DIRECT_PLUGIN} />);
48+
49+
expect(getByText('Direct plugin')).toBeInTheDocument();
50+
});
51+
52+
describe('ErrorBoundary', () => {
53+
beforeAll(() => {
54+
const ExplodingComponent = () => {
55+
throw new Error('an error occurred');
56+
};
57+
PluginContainerDirect.mockReturnValue(<ExplodingComponent />);
58+
});
59+
it('renders fallback component from JS config if one exists', () => {
60+
function CustomFallbackFromJSConfig() {
61+
return (
62+
<div>
63+
JS config fallback
64+
</div>
65+
);
66+
}
67+
68+
const { getByText } = render(
69+
<PluginContainerWrapper
70+
config={{
71+
...mockConfig,
72+
errorFallbackComponent: <CustomFallbackFromJSConfig />,
73+
}}
74+
/>,
75+
);
76+
expect(getByText('JS config fallback')).toBeInTheDocument();
77+
});
78+
79+
it('renders fallback component from PluginSlot props if one exists', () => {
80+
function CustomFallbackFromPluginSlot() {
81+
return (
82+
<div>
83+
PluginSlot props fallback
84+
</div>
85+
);
86+
}
87+
88+
const { getByText } = render(
89+
<PluginContainerWrapper
90+
slotErrorFallbackComponent={<CustomFallbackFromPluginSlot />}
91+
/>,
92+
);
93+
expect(getByText('PluginSlot props fallback')).toBeInTheDocument();
94+
});
95+
96+
it('renders default fallback <ErrorPage /> when there is no fallback set in configuration', () => {
97+
const { getByRole } = render(<PluginContainerWrapper />);
98+
expect(getByRole('button', { name: 'Try again' })).toBeInTheDocument();
99+
});
100+
});
101+
});

src/plugins/PluginSlot.jsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const PluginSlot = forwardRef(({
1818
id,
1919
pluginProps,
2020
slotOptions,
21+
slotErrorFallbackComponent,
2122
...props
2223
}, ref) => {
2324
/** 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(({
4041

4142
const finalPlugins = React.useMemo(() => organizePlugins(defaultContents, plugins), [defaultContents, plugins]);
4243

43-
// TODO: APER-3178 — Unique plugin props
44-
// https://2u-internal.atlassian.net/browse/APER-3178
44+
// TODO: Unique plugin props
45+
// https://github.com/openedx/frontend-plugin-framework/issues/72
4546
const { loadingFallback } = pluginProps;
4647

4748
const defaultLoadingFallback = (
@@ -81,6 +82,7 @@ const PluginSlot = forwardRef(({
8182
key={pluginConfig.id}
8283
config={pluginConfig}
8384
loadingFallback={finalLoadingFallback}
85+
slotErrorFallbackComponent={slotErrorFallbackComponent}
8486
slotOptions={slotOptions}
8587
{...pluginProps}
8688
/>
@@ -125,11 +127,14 @@ PluginSlot.propTypes = {
125127
pluginProps: PropTypes.shape(),
126128
/** Options passed to the PluginSlot */
127129
slotOptions: PropTypes.shape(slotOptionsShape),
130+
/** Error fallback component to use for each plugin */
131+
slotErrorFallbackComponent: PropTypes.node,
128132
};
129133

130134
PluginSlot.defaultProps = {
131135
as: React.Fragment,
132136
children: null,
133137
pluginProps: {},
134138
slotOptions: {},
139+
slotErrorFallbackComponent: undefined,
135140
};

src/plugins/PluginSlot.test.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ const pluginContentOnClick = jest.fn();
5454
const defaultContentsOnClick = jest.fn();
5555
const mockOnClick = jest.fn();
5656

57-
// TODO: APER-3119 — Write unit tests for plugin scenarios not already tested for https://2u-internal.atlassian.net/browse/APER-3119
57+
// TODO: https://github.com/openedx/frontend-plugin-framework/issues/73
58+
5859
const content = { text: 'This is a widget.' };
5960
function DefaultContents({ className, onClick, ...rest }) {
6061
const handleOnClick = (e) => {

0 commit comments

Comments
 (0)