Skip to content

Creating a new plugin app

Louise Davies edited this page Oct 13, 2023 · 30 revisions

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

Modifying the app to be a plugin

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.

Building as a library

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.

Adding Single-SPA hooks

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:

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);

Loading your plugin in the parent application

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.

Making sure Material-UI styles don't clash

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,
});
Clone this wiki locally