-
Notifications
You must be signed in to change notification settings - Fork 4
Creating a new plugin app
The first step in creating a new plugin for the SciGateway frontend is to create a new Git repository. You will also need the same tooling as for the parent app specified here
Once you have this then you can run create react app
to make a new app and push to your new repo.
To make a new react app with Typescript support run:
yarn create react-app my-app --template typescript
For an example of the code for a plugin see https://github.com/ral-facilities/daaas-frontend-demo-plugin, the modifications made to that app to make it a plugin are described below.
To modify a react app to work as a plugin then the webpack config needs to be modified to change the target to be a library. To do this we need to install @craco/craco
; this allows us to modify the webpack config without doing a full blown eject on the react app.
yarn add --dev @craco/craco
Then add a craco.config.js
to the root of the codebase with the code:
module.exports = {
webpack: {
configure: (webpackConfig, { env, paths }) => {
webpackConfig.externals = {
react: 'React', // Case matters here
'react-dom': 'ReactDOM', // Case matters here
};
if (env === 'production' && !process.env.REACT_APP_E2E_TESTING) {
webpackConfig.output.library = 'demo_plugin';
webpackConfig.output.libraryTarget = 'window';
webpackConfig.output.filename = '[name].js';
webpackConfig.output.chunkFilename = '[name].chunk.js';
delete webpackConfig.optimization.splitChunks;
webpackConfig.optimization.runtimeChunk = false;
}
return webpackConfig;
},
},
};
where demo_plugin
should be changed for the name of your plugin. Note the webpack externals - this will mean that the plugin uses external versions of React and ReactDOM. Add the following to your index.html
:
<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
inside the body tag. If you need a dev version then you can change these to react.development.js
and react-dom.developement.js
temporarily.
The parent app runs Single-SPA and so expects certain hooks to be able to load a plugin (i.e. a bootstrap
, mount
and unmount
method).
If the plugin uses React then you can install single-spa-react
:
yarn add [email protected] @types/[email protected]
and modify index.tsx
to have the required hooks:
{ other imports }
import singleSpaReact from 'single-spa-react';
...
function domElementGetter(): HTMLElement {
// Make sure there is a div for us to render into
let el = document.getElementById('demo_plugin');
if (!el) {
el = document.createElement('div');
}
return el;
}
const reactLifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: () => document.getElementById('demo_plugin') ? <App /> : null,
domElementGetter,
});
const render = (): void => {
let el = document.getElementById('demo_plugin');
// attempt to re-render the plugin if the corresponding div is present
if (el) {
ReactDOM.render(<App />, document.getElementById('demo_plugin'));
}
};
/* eslint-disable @typescript-eslint/no-explicit-any */
// Single-SPA bootstrap methods have no idea what type of inputs may be
// pushed down from the parent app
export function bootstrap(props: any): Promise<void> {
return reactLifecycles.bootstrap(props);
}
export function mount(props: any): Promise<void> {
return reactLifecycles.mount(props);
}
export function unmount(props: any): Promise<void> {
return reactLifecycles.unmount(props);
}
/* eslint-enable @typescript-eslint/no-explicit-any */
where demo_plugin
should be swapped for the name of your plugin. Normally, as part of the library we would also want to remove the ReactDOM.render(...)
line in index.tsx
but we still want to be able to develop the plugin locally and in isolation, therefore we should only render to the screen if in development mode:
if (process.env.NODE_ENV === `development`) {
render();
}
This also means updating the line with <div id="root"></div>
in index.html
to
<div id="demo_plugin"></div>
where again demo_plugin
is the name of your plugin. This simulates how it will be mounted by the parent app (i.e. in to a div with the corresponding ID).
Additionally, you will want to implement the componentDidCatch
lifecycle method in your root component (App
in this case). This ensures that if the plugin throws an unhandled error, this method is called and the plugin is not unmounted due to single-spa
not being able to deal with the error. Additionally, you can use this method to log the error and use it to display a fallback UI (in the example this is done by setting a state variable hasError
). An example implementation of this is shown below:
public componentDidCatch(error: Error | null): void {
this.setState({ hasError: true });
log.error(`demo_plugin failed with error: ${error}`);
}
To read more about this see React Error Boundaries
You will also want to ensure that your root component (App
) listens for plugin rerender requests from SciGateway. Add the following code to your App
class
handler(e: Event): void {
// attempt to re-render the plugin if we get told to
const action = (e as CustomEvent).detail;
if (action.type === RequestPluginRerenderType) {
this.forceUpdate();
}
}
public componentDidMount(): void {
document.addEventListener(MicroFrontendId, this.handler);
}
public componentWillUnmount(): void {
document.removeEventListener(MicroFrontendId, this.handler);
}
You will also need to bind this
to the handler
function in App
's constructor: this.handler = this.handler.bind(this);
The parent application should now be able to load your plugin, but it currently won't link to it in the sidebar. To get it to do this, you need to send a message to the parent telling it to do so.
In index.tsx
, place the following code: (replacing capitalised values with your own settings). This code needs to be ran in index.tsx
in order for SciGateway to be able to load the plugin correctly, as index.tsx
is ran first when SciGateway loads the plugin's code, and SciGateway needs to know what route(s) to load your plugin on before it can mount your root component.
document.dispatchEvent(
new CustomEvent('scigateway', {
detail: {
type: 'scigateway:api:register_route',
payload: {
section: 'Test',
link: '/YOUR-PLUGIN-LOCATION',
plugin: 'YOUR-PLUGIN-NAME',
displayName: 'YOUR PLUGIN DISPLAY NAME',
order: 0,
helpText: 'A BRIEF DESCRIPTION THAT APPEARS IN THE USER TOUR WHEN YOUR PLUGIN IS HIGHLIGHTED IN THE SIDEBAR',
}
}
})
);
Read more about messaging here: Messaging
Finally, you need to build your plugin
yarn build
and serve it so the parent application can load it - see Setting-up-a-dev-environment#serving-the-plugin (remember to replace the demo_plugin
name with your plugin name!)
For ease of serving your plugin, you will probably want to set up a "build and serve" command like the demo_plugin
has. To do this, you need to install a simple CLI server utility, serve
yarn add --dev serve
(the --dev
argument saves the package as a development dependency)
We need to ensure the start
and build
commands in package.json
is called through craco in order for it to correctly run and build the plugin. You can add a start
, build
and serve:build
command to your package.json
file under the scripts
section:
package.json
{
...
"scripts": {
"start": "craco start",
"build": "craco build",
"serve:build": "yarn build & serve -l 5001 build",
}
}
You can now build the plugin and serve it through the command we added by running the following on the command line:
yarn serve:build
You will see the served plugin (from the build output folder) running on port 5001.
If you plugin uses Material-UI, then you will need to wrap your application code in a <StylesProvider>
and pass in some options to ensure that the styles applied to the plugin don't accidentally apply to other plugins.
import { generateClassName, StylesProvider } from "@material-ui/core";
const generateClassName = createGenerateClassName({
productionPrefix: 'demo' // this should be unique to your plugin but preferably short, since this is applied to every class so fewer characters = smaller code bundle
disableGlobal: true,
});
...
class App extends React.Component<{}, { hasError: boolean }> {
...
public render(): React.ReactNode {
...
return (
<StylesProvider generateClassName={generateClassName}>
// your app goes here
<StylesProvider />
)
}
}
This is essentially saying that when in production, all your classes will be named demo-{some number}
rather than the material UI default prefix jss
- this ensures your custom classes don't override in production. The disableGlobal feature applies to Material UI core styles, and just tells Material UI whether it can assume it has total control over the CSS namespace. Since we're potentially loading multiple instances of Material UI, we tell it no and this stops default styles from clashing.
Note that this will make class names essentially random and non-deterministic, which is good for production but potentially bad for e2e tests. To help with this, if you create an environment variable that is set when your e2e tests run, you can use this to disable the disableGlobal
feature like so:
const generateClassName = createGenerateClassName({
productionPrefix: 'dgwt',
// Only set disable when we are in production and not running e2e tests;
// ensures class selectors are working on tests.
disableGlobal:
process.env.NODE_ENV === 'production' && !process.env.REACT_APP_E2E_TESTING,
});
-
Architecture
-
Dev environment
-
Developing a plugin
-
Deployment
- Deploying SciGateway
- SciGateway Settings
- Deploying plugins
-
Releasing
-
Plugins
-
Continuous Integration
-
UX
-
Feedback