Blog
Web Development

How to Create an Embeddable JavaScript Widget

August 8, 2016
6 min read
How to Create an Embeddable JavaScript Widget - Featured Image
By
Antonio Ramirez Cobos

I had to create an embeddable and portable widget for one of our customers. What it seemed to be an easy task it had a number of challenges to overcome. On this blog post I am sharing those challenges and what I did to solve each one of them.

The third party JavaScript widget

Our customer wanted to have a calculator widget on its website for one its lab products. The widget had to be hosted in our servers and the calculation algorithm was to be somehow protected. As with any third party javascript widget, we face the following:

  • We don't own the DOM, so forget about modifying the container page's CSS or altering the page
  • We should avoid conflicts with site's script and its global scoped variables
  • To encapsulate the algorithm in an API, thus our API should support CORS

CSS Inline Injection

If you cannot modify the container's CSS there is only a couple of solutions available in order to avoid overlapping with their site's style. One of them is using a tool like cleanslate, which provides the functionality to reset your widget's stylesheet providing a top-level namespace (cleanslate), that you use to style the HTML of your widget. But I was in a rush (had to do the widget in less than three days) and I also wanted to use bootstrap style elements and I didn't want to include the full CSS, so the other option was to use a technique I call: CSS Inline Injection.

The technique is very simple, we create the HTML of the form (call me lazy but I used an online tool for this task too: https://bootsnipp.com/forms), and then we "inject" the styles that the HTML classes specify into their elements themselves. For that task, I used the excellent tool created by "TijsVerkoyen": CssToInlineStyles.

Assuming you are on your project root and having composer installed on your computer globally:

composer require tijsverkoyen/css-to-inline-styles

Then I created the following script:

1require('vendor/autoload.php'); // use composer vendor autoloader
2
3use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; 
4
5// create instance
6$cssToInlineStyles = new CssToInlineStyles();
7
8// get the HTML of your bootstrap form
9$html = file_get_contents(__DIR__ . '/form.html');
10// get the CSS contents
11$css = file_get_contents(__DIR__ .'/bootstrap.css');
12
13// and finally save the HTML of the form with the inline injected styles
14file_put_contents(__DIR__ . '/inline-form.html', $cssToInlineStyles->convert($html, $css));

After we do that, the form elements will have this nasty look:

1<input id="{INPUT-ID}" name="{INPUT-NAME}" type="text" placeholder="Enter value in liters"
2 class="form-control input-md"
3 style="margin: 0; font: inherit; color: #555; line-height: 1.42857143; -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; font-family: inherit; font-size: 14px; display: block; width: 100%; height: 34px; padding: 6px 12px; background-color: #fff; background-image: none; border: 1px solid #ccc; border-radius: 4px; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;"/>

It's ugly I know, but this way we do not need to add a big CSS file shipped with our widget. Nevertheless, I would say that my widget wasn't really big (only five inputs), if you need to create a bigger widget, I highly recommend you to check cleanslate. Very, very easy to use (wink).

Avoiding Script Conflicts

Here comes the fun part, we need to ensure that our code doesn't conflict with our beloved host. So, my first thought was to simply create a jquery module with a loader that ensures jQuery existed. The following is the pattern I was about to use:

1; (function (MYNAMESPACE, undefined) {
2    // private variables here
3    // ... 
4
5    // public methods
6    MYNAMESPACE.initialize = function () {
7        // initialization procedures here
8        initializejQuery();
9    };
10    MYNAMESPACE.scrollTo = function (id) {
11        jQuery('html,body').animate({ scrollTop: $("#" + id).offset().top - 120 }, 'slow');
12    };
13    // private methods    
14    // ensure jQuery existed?
15    function initializejQuery() {
16        if (window.jQuery === undefined || (MYNAMESPACE.jQueryLatest && window.jQuery.fn.jquery !== '3.1.0')) {
17            injectScript(getProtocol() + 'code.jquery.com/jquery-3.1.0.min.js', main);
18        } else {
19            jQuery = window.jQuery;
20            main();
21            }
22    }
23    function getProtocol() {
24        return ('https:' == document.location.protocol ? 'https://' : 'http://');
25    }
26    function injectScript(src, cb) {
27        var sj = document.createElement('script');
28        sj.type = 'text/javascript';
29        sj.async = true;
30        sj.src = src;
31        sj.addEventListener ? sj.addEventListener('load', cb, false) : sj.attachEvent('onload', cb);
32        var s = document.getElementsByTagName('script')[0];
33        s.parentNode.insertBefore(sj, s);
34    }
35    function main() {
36        // initialization magic
37    }
38})(window.MYNAMESPACE = window.MYNAMESPACE || {});

That module pattern was good but that implied the usage of a third call to our API to get the form's html. So, I decided to use requireJs as it makes the process of an embeddable script very simple and it has an optimizer tool called r.js that takes care of dependency handling, uglifying, minifiying, etc...

Setting up the development environment

We will need requirejs, bower and gulp. If you don't have any of them, then you need to install them using npm package manager - I am assuming that you have nodejs installed in your development machine. We are at the project's root folder, so lets start with our our package.json file:

1npm init

This will prompt us to answer a few questions and once completed, it will create a file in the root directory named package.json. This file provides information about the project and its dependencies. For more information, please see the great tutorial of Travis Maynard. Now, lets do the second step:

1# install global dependencies if we don't have them
2npm install -g requirejs 
3npm install -g bower 
4npm install -g gulp 
5# we also need to install gulp locally on our projects root
6npm install --save-dev gulp

The -g parameter means that you install them globally. If you don't want to do that, you are free to install them locally on your project's directory by removing that parameter. The commands will be placed on the node_modules/.bin directory.

After, I needed to install the required packages for my gulp.js file. My tasks were really simple: use the requirejs optimizer tool and move the built to another location. So, I only installed gulp-run pipe plugin. The resulting gulp.js file was the following:

1var gulp = require('gulp');
2
3// include plugins 
4var run = require('gulp-run');
5
6// use gulp-run to build requirejs optimizer
7gulp.task('build', function () {
8   return run('r.js -o build.js').exec()
9      .pipe(gulp.dest('output'))
10});
11
12// use watch to automatically build when the file has been changed
13gulp.task('watch', function () {
14      gulp.watch('js/*.js', ['build']);
15});
16
17gulp.task('move', ['build'], function () {
18      return run('cp ./dist/my-widget-min.js ~/path/api/web/js/').exec()
19         .pipe(gulp.dest('output'));
20});

That file created three tasks: build, watch and move. The only thing I had to do was to call gulp move to have my plugin compiled and moved to the location of my local api service to test it.

As you can see on the above code is the use of the build.js file. That file is required for the requirejs optimizer tool. For requirejs there is a couple of files to do (please visit requirejs.org for further information). There is a number of processes and files to be done prior having that build.js:

Install bower components

We need almond (an AMD replacement loader for RequireJS), requireJs-Text (a text loader plugin) and jquery.

1# https://github.com/requirejs/almond#almond
2bower install almond 
3# https://github.com/requirejs/text 
4bower install requirejs-text 
5bower install jquery

Those commands will install the project dependencies into the bower_components folder.

Create RequireJs config.js file

For more information about the configuration options available, please visit: requirejs.org/docs/optimization.html#basics

1var requirejs = {
2    paths: {
3        jquery: 'bower_components/jquery/dist/jquery',
4        text:'bower_components/text/text'
5    }
6};

Create RequireJs build.js file

And finally our build.js file. This file tells the r.js optimizer tool the options on the command line when building your optimized file. Mine looked like this:

1({
2    baseUrl:'',
3    mainConfigFile: 'config.js',
4    name: 'bower_components/almond/almond',
5    include: ['js/widget'],
6    out: 'dist/widget.min.js',
7    optimizeCss: 'standard', // if we need it
8    stubModules: ['text']
9})

The actual widget.js file

I created a new js directory and created my widget.js file there:

1require(["jquery", "js/app"], function($, app){
2   'use strict';
3    var config = {
4        // configuration parameters here
5    };
6    $(function() {
7        app.init(config);
8    });
9});

This file worked as an entry script to initialize settings for the actual widget, the one that had all the widget functionality and dynamics was the app.js:

1define(['jquery', 'text!template/form.html'], function ($, formHtml) {
2    'use strict';
3
4    // private variables here...
5	  var settings, $form;
6	
7    var app = {
8        init: function (config) {
9             // get the settings and make them available through the app
10            settings = config;
11            $(formHtml).insertAfter('element where to inject the form'); 
12            // get a reference of the form after is inserted
13            $form = $('#FORM-ID');
14            // call initialization methods
15            initializeEvents();
16        }
17    };
18
19    // example initialization 
20    function initializeEvents() {
21        // here I have used the $form pointer to initialize the events on the form
22    }
23
24    return app;
25});

My finished project folder structure look like this (dist is only created once I call gulp build or gulp move commands):

+- bower_components
+- node_modules 
+- js
    |-- widget.js 
    |-- app.js 
+ dist 
    |- widget.min.js 
+ template   
    |- form.html
build.js 
config.js 
gulpfile.js 
package.json 

I know it's not easy to follow, that's why I have created a template repository to ease the task to understand this process.

Embedding the widget

In order to embed the widget to the page, I opt for the following technique:

1<script>
2    (function (window, document) {
3        var loader = function () {
4            var script = document.createElement("script"), tag = document.getElementsByTagName("script")[0];
5            script.src = ('https:' == document.location.protocol ? 'https://' : 'http://') + 'example.js/widget.min.js';
6            tag.parentNode.insertBefore(script, tag);
7        };
8        window.addEventListener ? window.addEventListener("load", loader, false) : window.attachEvent("onload", loader);
9    })(window, document);
10</script>

Add CORS support to your API

There are many ways to do it, but remember, I had no much time so I decided for the easiest, configuring our .htaccess file:

<IfModule mod_headers.c>
    Header always set Access-Control-Allow-Origin *
    Header always set Access-Control-Allow-Methods "POST,GET,DELETE,PUT,PATCH,DELETE,HEAD"
    Header always set Access-Control-Max-Age 86400
    Header always set Access-Control-Allow-Headers "Content-Type, Access-Control-Allow-Headers, 
    Authorization, X-Requested-With, Origin, Accept, Client-Security-Token"
</IfModule>

RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule . blank.html

You probably asking why I added all possible methods on my headers, that means all end points of my api do handle those methods? Well, the answer is no. My API is built upon request filters (or middlewares) that permit only certain methods. For example, my calculator API only permitted POST, so even if my headers are set to all possible methods, my API only support POST for that specific controller.

Why did I do it that way? The reason was a matter of speed and by adding that line on my .htaccess file I didn't have to provide an extra method on my API for each endpoint that specifies which methods were allowed. If I was to create a public API I would've changed it but for a private usage, I thought that by adding OAuth2 + Filtering options that was more than enough.

One interesting part is the last one. Let's check it again:

RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule . blank.html

I created a blank.html page and redirected all requests calls to OPTIONS to that page. So that request will always returned the allowed headers and doesn't need to go throughout the filtering process of my API routes. The reason for that call from the jQuery.ajax of your widget is because of preflighted calls: Unlike simple requests, "preflighted" requests first send an HTTP OPTIONS request header to the resource on the other domain, in order to determine whether the actual request is safe to send.

References

Accelerate Your Career with 2am.tech

Join our team and collaborate with top tech professionals on cutting-edge projects, shaping the future of software development with your creativity and expertise.

Get Started

Don't miss out on
our latest insights
– Subscribe Now!

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Share This Post
Back to Blog
Don't miss out on
our latest insights
– Subscribe Now!
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Navigate
Start Now