DEV Community

Cover image for Build a "Pluggable" Widget for your Web App
Sooraj (PS)
Sooraj (PS)

Posted on • Edited on

Build a "Pluggable" Widget for your Web App

First of all, what is a widget? A widget is any applicaton that is a miniature version of the original application. You can make a widget out of any application you want.

You might have seen them in mobile and computers as small floating windows. For example, you have an application, a small floating widget of your favorite music application. This widget will not only make space for other widgets, but also give you access to a minimal version of the full blown application.

Widgets basically reduce the effort in interacting with the application. One of the use cases could be a “view only widget”, where all the “readonly” data is displayed on the widget and the modify or write actions are performed on the application. This way, you can provide your user a slimmed down version which is easier to use.

Let us create a simple widget app with 3 pages with Create, List and Update operations. We will be using the following

  • React as the UI Framework.
  • Typescript as the coding language.
  • Bootstrap for design.
  • Webpack to configure and build the app.
  • Local Storage of the browser for data storage.

First let us create a React app. For this tutorial we will be using this template code. To know how this template was created, be sure to check this out.

We will just clone this template and modify the code for the widget. Since our widget will be running inside an iframe, we will not be able to make use of react-routers. So in our case we will use conditional rendering using switch cases to render the components based on a page variable.

After cloning the template and installing the packages, let us start creating an entry point for our widget to initialize. Let us create a file called widget.ts under the src folder. This file will contain all the configuration for setting up and rendering the iframe.

So, it is just basically 2 things combined. You have your normal react app that will be run by the widget.ts inside an iframe and be pluggable anywhere. Since we know that we cannot communicate props directly between the window and an iframe, we need to use the postMessage function to talk between the iframe and window and exchange props or values.

All of these might sound confusing in the start, but will become easier once we go step by step.

Now we can start adding code to the widget.ts file. We will first create our widget object that will be used to configure and initialize from the webpage that is going to use the widget. Let’s do something simple.

widget.ts

const defaultStyles: any = {
 'border': 'none',
 'z-index': 2147483647,
 'height': '650px',
 'width': '350px',
 'display': 'block !important',
 'visibility': 'visible',
 'background': 'none transparent',
 'opacity': 1,
 'pointer-events': 'auto',
 'touch-action': 'auto',
 'position': 'fixed',
 'right': '20px',
 'bottom': '20px',
}

interface IConfig {
 readonly email: string;
}

interface IWidget {
 config: IConfig | null;
 iframe: HTMLIFrameElement | null;
 init: (config: IConfig) => void;
 setupListeners: () => void;
 createIframe: () => void;
 handleMessage: (event: MessageEvent) => void;
}

const Widget: IWidget = {
 iframe: null,
 config: null,
 init: function(config: IConfig) {
   this.config = config;
   this.createIframe()
 },
 createIframe: function() {
   this.iframe = document.createElement('iframe');
   let styles = '';
   for (let key in defaultStyles) { styles += key + ': ' + defaultStyles[key] + ';' }
   this.iframe.setAttribute('style', styles)
   this.iframe.src = 'http://localhost:9000';
   this.iframe.referrerPolicy = 'origin';
   document.body.appendChild(this.iframe);
   this.setupListeners();
 },
 setupListeners: function() {
   window.addEventListener('message', this.handleMessage.bind(this));
 },
 handleMessage: function(e) {
   e.preventDefault();
   if (!e.data || (typeof e.data !== 'string')) return;
   let data = JSON.parse(e.data);
   switch (data.action) {
     case 'init': {
       if (this.iframe) {
         this.iframe.contentWindow.postMessage(JSON.stringify(this.config), '*');
       }
       break;
     }
     default:
       break;
   }
 }
};

export default Widget;
Enter fullscreen mode Exit fullscreen mode

The init function will be used in the script tag and the rest is used to build and set up the widget. The handleMessage function will be used to communicate with the React application to pass data across both the iframe and the parent. So here we will just get the configuration that is passed at the script tag in the webpage that uses the widget and pass it in the config variable to the React app. Here we see that the iframe src is http://localhost:9000. This will be our React app server. Now in order to load the widget onto a page, we need to first configure our webpack file in a different way.

webpack.config.js

const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const TerserWebpackPlugin = require('terser-webpack-plugin');

const isProd = process.env.NODE_ENV === 'production';

const config = {
 mode: isProd ? 'production' : 'development',
 entry: {
   app: [
     'webpack-dev-server/client?http://0.0.0.0:9000/',
     'webpack/hot/only-dev-server',
     './src/index.tsx'
   ],
   Widget: ['./src/widget.ts']
 },
 output: {
   filename: '[name].js',
   path: resolve(__dirname, 'dist'),
   library: '[name]',
   libraryTarget: 'umd',
   libraryExport: 'default'
 },
 resolve: {
   extensions: ['.js', '.jsx', '.ts', '.tsx'],
 },
 module: {
   rules: [
     {
       test: /\.tsx?$/,
       use: 'babel-loader',
       exclude: /node_modules/,
     },
     {
       test: /\.css?$/,
       use: [
         'style-loader',
         { loader: 'css-loader', options: { importLoaders: 1 } },
         'postcss-loader'
       ]
     },
   ],
 },
 plugins: [
   new HtmlWebpackPlugin({
     template: './src/index.html',
     hash: true,
     filename: 'index.html',
     inject: 'body',
     excludeChunks: ['widget']
   }),
 ],
};

if (isProd) {
 config.optimization = {
   minimizer: [new TerserWebpackPlugin(),],
 };
} else {
 config.devServer = {
   port: 9000,
   open: true,
   hot: true,
   compress: true,
   stats: 'errors-only',
   overlay: true,
 };
}

module.exports = config;
Enter fullscreen mode Exit fullscreen mode

We will change the entry of our app to load the react app as app and the widget.ts as Widget. And in our HTMLPlugin we will tell webpack to exclude the widget from the chunks.

We are now ready to set up our server. We will run,

npm run dev
Enter fullscreen mode Exit fullscreen mode

Now if you go to http://localhost:9000/Widget.js, we will see our widget.ts compiled code there. If it does not show any error, then we are good to go. We are ready to move to the React App set up now.

Since we need to only load the widget if we receive the config value, we will need to listen for the postMessage.

index.tsx

import React from 'react';
import { render } from 'react-dom';
import App from './App';
import { IConfig } from './config/interfaces';
import { Context } from './context/context';
import './stylesheets/index.css';

window.addEventListener('DOMContentLoaded', (event) => {
 window.parent.postMessage(JSON.stringify({ action: 'init' }), '*');
 window.removeEventListener('DOMContentLoaded', () => null);
});

window.addEventListener('message', (event) => {
 event.preventDefault();
 if (!event.data || (typeof event.data !== 'string')) return;
 const config: IConfig = JSON.parse(event.data);
 return render(
   <Context.Provider value={JSON.stringify(config)}>
     <App />
   </Context.Provider>,
   document.body
 );
});
Enter fullscreen mode Exit fullscreen mode

Once the DOM is loaded, we will send a message to the iframe with the action init to tell the iframe that the react app has been loaded onto the DOM. The iframe checks the action in the handleMessage function used in widget.ts and sends back a message with the config data. The React app will listen to this message and call the render method if the config exists. This will ensure that the widget always loads only after the config is present.

Now that our React app is loaded, we will create our conditional routing in the App.tsx.

App.tsx

import React, { useContext, useState } from 'react';
import { IConfig } from './config/interfaces';
import { Context } from './context/context';
import Active from './components/Active';
import Completed from './components/Completed';
import NewTask from './components/NewTask';

const App: React.FC = (props) => {
 const config: IConfig = JSON.parse(useContext(Context));
 const [page, setPage] = useState<Number>(1);
  const renderHeader = () => {
   return (<h3 className="bg-dark p-3 m-0 text-white">Todo-List</h3>);
 }

 const renderLinks = () => {
   return (<div className="nav row m-0 bg-light">
     <a className="nav-link col-4 text-center" href="#" onClick={() => setPage(1)}>Active</a>
     <a className="nav-link col-4 text-center" href="#" onClick={() => setPage(2)}>New</a>
     <a className="nav-link col-4 text-center" href="#" onClick={() => setPage(3)}>Completed</a>
   </div>)
 }

 const renderComponent = () => {
   switch(page) {
     case 1: return <Active config={config}/>
     case 2: return <NewTask setPage={setPage}/>
     case 3: return <Completed config={config}/>
     default: return <Active config={config}/>
   }
 }

 return (<div className="h-100 w-100 border rounded">
   {renderHeader()}
   {renderLinks()}
   {renderComponent()}
 </div>);
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Here I have just made a simple Todo List App. For the full code please refer here. The current page is a state variable and is changed whenever the link is clicked. And the components for the respective pages are loaded based on a switch statement. After setting up all the pages, we will now call the widget method in our html page.

For the test I have created a file called index.html in the dist folder with the following code.

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <title>Webpack with React TS</title>
</head>
<body>
 <script src="http://localhost:9000/Widget.js"></script>
 <script>
   const config = { email: 'sooraj@skcript.com' };
   Widget.init(config);
 </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

And we are done setting up. Just run this file using the,

npm run start
Enter fullscreen mode Exit fullscreen mode

and open http://localhost:5000. Now we have the entire React app that you built rendered in an iframe and can be plugged into any site with the script above.

Here is a demo of the React App that was made as a widget.

Alt Text

Top comments (7)

Collapse
 
alvechy profile image
Alexander Vechy • Edited

Maybe I didn't understand some parts, but few comments on React usage:

  • please, don't use renderThing, here's the example of the discussion around this antipattern: renderThing. Even if App is rendered once (and hence all your renderX functions are created once as if they were defined outside render method as separate components), it's harder to reason about its internals.
  • your config manipulations are very confusing. As I understand, all you want is to rerender your app on config change
window.addEventListener('message', (event) => {
 event.preventDefault();
 if (!event.data || (typeof event.data !== 'string')) return;
 const config: IConfig = JSON.parse(event.data);
 return render(
   <App config={config} />, // why to stringify? why to use Context?
   document.body
 );
});

ReactDOM will automatically pick up the changes instead of rendering whole new thing in both cases.

So then it's very easy to work with the component:

const App: React.FC<{ config: IConfig }> = ({ config }) => {
  const [page, setPage] = useState<Number>(1);

  const handlePageChange = page => setPage(page)

  return (<div className="h-100 w-100 border rounded">
    <Header />
    <Links onPageChange={handlePageChange}/>
    <ActiveComponent config={config} onPageChange={handlePageChange} />
  </div>);
 }
Collapse
 
soorajsnblaze333 profile image
Sooraj (PS)

Thanks for pointing out these points Lex, appreciate you for the resources :).
But in your second point, I actually didn't aim to rerender the component on config change, I wanted to "render" the main component only if the config existed and render nothing if there was no config passed to the react app. Something like an init method. This config was from the recieved at the message event and then I rendered the component only once :)

Collapse
 
maulik profile image
Maulik

Nice idea and article Sooraj. Loved it

Collapse
 
soorajsnblaze333 profile image
Sooraj (PS)

Thank you so much Maulik. Glad you liked it :)

Collapse
 
yellow1912 profile image
yellow1912

Nice. I think alpine is a better solution here for simple website. I use vuejs mostly but also switch to alpine for something that does not need very complex logic and structure.

Collapse
 
soorajsnblaze333 profile image
Sooraj (PS)

Yes Alpine is good for simple logics :) Agreed, but here, we are just trying to use the same React code for both the main and widget apps with only small changes in the routing.

Collapse
 
nadeemkhanrtm profile image
Nadeem Khan

is it resolve cache busting problem?