Custom Pages
Customers could decide to extend witboost by adding custom pages, which will be embedded inside the user interface.
Right now there are two main extension points that customers can use to add custom pages: the marketplace's project page and the marketplace's consumable interface page. In the future, witboost can expand this mechanism to let customers add custom pages to more parts of the platform's user interface leveraging the same mechanism.
Microfrontend Overview
With extensions, we mean a paradigm that helps you integrate your custom pages inside witboost. Generally speaking, an extension could be everything that can be integrated inside a React app, from an HTML page to an entire React application. The supported way to create extensions with your custom pages is using microfrontends.
With Microfrontend we mean a simple standalone application with a dedicated UI that displays details regarding a specific entity. These regular applications, if configured accordingly, can be embedded directly inside witboost as microfrontends.
This solution represents a suitable way of performing an integration without the need to edit witboost code or restarting it to make changes. It is based on two actors: a Container (witboost) and a remote application (microfrontend). When applying this solution, keep in mind the following principles:
- Each Frontend is developed independently from the container integrating it.
- The development of remote frontend is technology agnostic, so each frontend can be developed in React, Angular, Ajax or whatever technology. The following guide is focused on React applications, which are fully supported at the moment.
- Every time a remote microfrontend is modified, the developers don't have to coordinate with the Container which has to only reload the page to have everything up-to-date.
- The remote frontend application must be up and running to be displayed inside witboost.
The following guide will show you how to create a custom page by creating a React application from scratch, and how to integrate it into witboost.
Microfrontend app example
Let's see how we can create a microfrontend React application from scratch.
To generate a new microfrontend repository, we propose a guide using npx
. To install npx, just run:
npm install -g npx
First of all, create a new react typescript application with:
npx create-react-app microfrontend --template typescript
Add a new config-overrides.js
file to the project root to change the default webpack configuration:
//config-overrides.json
module.exports = {
webpack: (config, env) => {
config.optimization.runtimeChunk = false;
config.optimization.splitChunks = {
cacheGroups: {
default: false,
},
};
config.output.filename = 'static/js/[name].js';
config.plugins[5].options.filename = 'static/css/[name].css';
config.plugins[5].options.moduleFilename = () => 'static/css/main.css';
return config;
},
};
The lines above tell webpack where to put all the compilation results and the static files. These are made available to the Container application which extends its context with this remote frontend. The Container communicates with this remote frontend by using the window object. So we need to expose to the container a method that renders the microfrontend and one to remove it.
To do this, change the src/index.tsx
file by replacing its content with:
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
export type MicroFrontendContract = {
containerId: string;
theme: DefaultTheme;
token: string;
};
declare global {
interface Window {
renderApplication: (params: MicroFrontendContract) => void;
unmountApplication: (containerId: string) => void;
}
}
window.renderApplication = params => {
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById(containerId),
);
};
window.unmountApplication = containerId => {
const el = document.getElementById(containerId);
if (!el) {
return;
}
ReactDOM.unmountComponentAtNode(el);
};
This will add two new functions to the window
. Here we added the functions renderApplication
and unmountApplication
, but you should use more on-point names, which represent better the application rendering/unmounting. E.g. if your microfrontend is an observability dashboard, the <application>
name could be observability-dashboard
and the two functions in the index.tsx
could be called renderObservabilityDashboard
and unmountObservabilityDashboard
.
Then, in the public/index.html
file, you need to add the following code to the body:
<!--public/index.html-->
...
<body>
<div id="root"></div>
<script type="text/javascript">
window.onload = () => {
window.renderApplication('root');
};
</script>
</body>
Also here you need to change the function name accordingly (in the example above window.renderObservabilityDashboard('root')
).
To start the development server, run PORT=4200 npm start
.
Open your browser and navigate to http://localhost:4200. The manifest is available at http://localhost:4200/asset-manifest.json.
The port 4200 is chosen arbitrarily, you can change it if it's already in use by some other application.
Using the Witboost theme
By default, witboost passes a parameter to the mount function that is specified in the configuration containing the witboost's theme.
It is strongly recommended to use witboost's theme inside your microfrontend app, and to do so, you must install the following dependencies:
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
Using MUI v4 requires React version 17 to work, so you need to update the other dependencies properly. Here's a working example:
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.41",
"@types/react": "^17",
"@types/react-dom": "^17",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
Then, you can import witboost's theme which is passed as a field of the parameter to your mount function, by changing the src/index.tsx
file:
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { DefaultTheme } from '@material-ui/styles';
export type MicroFrontendContract = {
containerId: string;
theme: DefaultTheme;
token: string;
};
declare global {
interface Window {
renderApplication: (params: MicroFrontendContract) => void;
unmountApplication: (containerId: string) => void;
}
}
window.renderApplication = params => {
ReactDOM.render(
<React.StrictMode>
<App theme={params.theme} />
</React.StrictMode>,
document.getElementById(containerId),
);
};
window.unmountApplication = containerId => {
const el = document.getElementById(containerId);
if (!el) {
return;
}
ReactDOM.unmountComponentAtNode(el);
};
and overriding the default theme using a <ThemeProvider>
in the src/App.tsx
file.
This can be done by adding the <ThemeProvider>
and the <StylesProvider>
.
The final App.tsx
file should look something like:
// src/App.tsx
import React from 'react';
import './App.css';
import { DefaultTheme } from '@material-ui/styles';
import {
StylesProvider,
createGenerateClassName,
} from '@material-ui/core/styles';
import { ThemeProvider } from '@material-ui/core';
const generateClassName = createGenerateClassName({
seed: 'MyMicrofrontendExample',
});
interface Props {
theme: DefaultTheme;
}
function App({ theme }: Props) {
return (
<StylesProvider injectFirst generateClassName={generateClassName}>
<ThemeProvider theme={theme}>
<div className="App">App Content</div>
</ThemeProvider>
</StylesProvider>
);
}
export default App;
For the style provider, we need to define a class prefix for the microfronted, that you can customize with a seed as shown above. Replace the 'MyMicrofrontendExample' seed with a value that uniquely identifies your microfrontend.
Please note that the StylesProvider
also change the CSS injection priority. If this is not set, the application could be rendered in the wrong way, since CSS priority will not be under the application's control and other components could collide with the generated classes.
For the same reason, avoid putting explicit CSS files, and use the MUI's makeStyles
and createStyles
instead.
Passing additional parameters
Apart from the theme, the application will receive additional standard parameters, like the container identifier and the witboost token (that can be used to validate access requests).
Furthermore, using the window
function, it's possible to pass additional custom parameters and use them inside the microfrontend. Be sure to declare them inside the global Window
interface and update the witboost's configuration accordingly.
First, you can define the new parameters in the scr/index.tsx
file:
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { DefaultTheme } from '@material-ui/styles';
import { createTheme } from '@material-ui/core';
export type MicroFrontendContract = {
containerId: string;
theme: DefaultTheme;
token: string;
parameters: {
additionalParam1: any;
additionalParam2: any;
/*
... other additional parameters
*/
};
};
declare global {
interface Window {
renderApplication: (params: MicroFrontendContract) => void;
unmountApplication: (containerId: string) => void;
}
}
window.renderApplication = params => {
// Now you can use the additional parameters inside your microfrontend app
ReactDOM.render(
<React.StrictMode>
<App
theme={params?.theme}
param1={params?.parameters?.additionalParam1}
param2={params?.parameters?.additionalParam2}
/>
</React.StrictMode>,
document.getElementById(containerId),
);
};
window.unmountApplication = containerId => {
const el = document.getElementById(containerId);
if (!el) {
return;
}
ReactDOM.unmountComponentAtNode(el);
};
and then use them in the src/App.tsx
file:
import React from "react";
import "./App.css";
import { DefaultTheme } from "@material-ui/styles";
import { StylesProvider, createGenerateClassName } from "@material-ui/core/styles";
import { ThemeProvider } from "@material-ui/core";
...
export interface Props {
theme: DefaultTheme;
param1: any;
param2: any;
}
export function App(props: Props) {
const { theme, param1, param2 } = props;
return (...);
}
Adding the microfrontend to witboost
As introduced above, witboost currently supports a few integration points where microfrontend custom pages can be added: a new page in the marketplace menu,a new tab in the marketplace project's page, and a new tab in the marketplace consumable interface's page.
All of them can be set up using the same configuration keys:
- to add a new custom page in the marketplace module's menu add the configuration to the array
mesh.customPages.marketplace.page
- to add a new custom page to the marketplace project's page add the configuration to the array
mesh.customPages.marketplace.projectPage
- to add a new custom page to the marketplace consumable interface's page add the configuration to the array
mesh.customPages.marketplace.consumableInterfacePage
The configurations above are arrays that will contain one element for each custom page that you want to add.
Each custom page is configured in the following way:
- title: 'Microfrontend project test'
url: 'http://localhost:4200'
route: '/test-microfrontend'
identifier: 'test-microfrontend'
mountFunction: 'renderApplication'
unmountFunction: 'unmountApplication'
parameters:
- name: 'name'
valuePath: 'name'
- name: 'descriptor'
valuePath: 'descriptor'
Let's see what each configuration key represents:
Configuration | Description | Rules |
---|---|---|
title | Name of the page as displayed in the tab | Non-empty string |
url | URL of the microfrontend, used to fetch the manifest | Valid URL string |
route | The witboost's route that the page will be loaded at | Non-empty string starting with "/" |
identifier | Unique identifier of the content | Non-empty string with only alphanumeric characters, "-", and "_" (must comply with the regex: ^[a-zA-Z0-9_-]*$) |
mountFunction | Name of the microfrontend's function that loads the content | Non-empty string (name of the mount function as defined in the index.tsx file) |
unmountFunction | Name of the microfrontend's function that removes the content | Non-empty string (name of the unmount function as defined in the index.tsx file) |
parameters | Array of parameters that will be passed as the microfrontend's input | Array of configurations, one for each parameter |
parameters.name | The parameter's name | Non-empty string |
parameters.valuePath | The path of the input object that is used to extract the parameter's value | Non-empty string that represents an object path with ".". If the configured path does not exist, the parameter will be undefined |
Passing parameters to the microfrontend
As shown above, you can pass multiple input parameters to your microfrontend. Those parameters can then be used by the microfrontend itself to render different details depending on where the microfrontend is invoked. But how do these parameters work?
When the microfrontend is invoked, depending on the page where it will be displayed, witboost will have a different object to pass as input: for the marketplace project's page it will be the selected project, while for the marketplace consumable interface's page, it will be the selected consumable interface. When adding a custom page to the marketplace's menu instead, the parameters will be ignored, since no input parameter is passed to it.
Since we don't want to pass too many unused values to the microfrontend, in the configuration you can choose which fields of the input objects your microfrontend will need.
The parameters will take values from the relative input object's fields, hence you need to know which structure you can use when configuring the parameters' value paths to extract the fields. In particular:
- The custom pages for the project page will have as their input project objects like:
{
"id": 1,
"version": "1.0.0",
"descriptor": { ... },
"private_descriptor": { ... },
"published_at": "2022-05-06T12:37:06.897+00:00",
"name": "finance.financecustomer.1",
"display_name": "Finance Customer",
"domains": [
{
"data": {
"name": "Finance",
"external_id": "urn:dmb:dmn:finance"
}
}
],
"description": "Finance representation of the customer. This is a partial view of the customer coming only from financial contracts. Raw storage is refreshed hourly and it is not available between 1 e 2 AM CET.",
"owner": "user:paolo.platter_agilelab.it",
"owner_display_name": "Paolo Platter",
"external_id": "finance.financecustomer.1",
"environment": {
"id": 3,
"name": "prod",
"priority": 0
}
}
- The custom pages for the consumable interface page will have as their input consumable interface objects like:
{
"id": 124,
"description": "",
"name": "FinanceCustomer_FullView",
"display_name": "FinanceCustomer_FullView",
"version": "1.0.1",
"descriptor": { ... },
"external_id": "urn:dmb:cmp:finance:financecustomer:1:fullview",
"shoppable": "SHOPPABLE",
"system": [
{
"data": {
"id": 1,
"name": "finance.financecustomer.1",
"display_name": "Finance Customer",
"descriptor": { ... },
"version": "1.0.0",
"domain": [
{
"data": {
"name": "Finance",
"external_id": "urn:dmb:dmn:finance"
}
}
],
"environment": {
"id": 3,
"name": "prod",
"priority": 0
}
}
}
]
}
So, if you want to receive as input for your consumable interface the name and the descriptor, you can just add the configurations:
parameters:
- name: 'name'
valuePath: 'name'
- name: 'descriptor'
valuePath: 'descriptor'
Note that the name will be passed as a string, while the descriptor will be passed as a complete object.