Javascript Components

While IDOM is a great tool for displaying HTML and responding to browser events with pure Python, there are other projects which already allow you to do this inside Jupyter Notebooks or in standard web apps. The real power of IDOM comes from its ability to seamlessly leverage the existing Javascript ecosystem. This can be accomplished in different ways for different reasons:

Integration Method

Use Case

Dynamically Loaded Components

You want to quickly experiment with IDOM and the Javascript ecosystem.

Custom Javascript Components

You want to create polished software that can be easily shared with others.

Dynamically Loaded Components

Note

This method is not recommended in production systems - see Distributing Javascript Components for more info. Instead, it’s best used during exploratory phases of development.

IDOM makes it easy to draft your code when you’re in the early stages of development by using a CDN to dynamically load Javascript packages on the fly. In this example we’ll be using the ubiquitous React-based UI framework Material UI.

import idom


mui = idom.web.module_from_template("react", "@material-ui/core@^5.0", fallback="⌛")
Button = idom.web.export(mui, "Button")

idom.run(
    idom.component(
        lambda: Button({"color": "primary", "variant": "contained"}, "Hello World!")
    )
)

So now that we can display a Material UI Button we probably want to make it do something. Thankfully there’s nothing new to learn here, you can pass event handlers to the button just as you did when Getting Started. Thus, all we need to do is add an onClick handler to the component:

import json

import idom


mui = idom.web.module_from_template("react", "@material-ui/core@^5.0", fallback="⌛")
Button = idom.web.export(mui, "Button")


@idom.component
def ViewButtonEvents():
    event, set_event = idom.hooks.use_state(None)

    return idom.html.div(
        Button(
            {
                "color": "primary",
                "variant": "contained",
                "onClick": lambda event: set_event(event),
            },
            "Click Me!",
        ),
        idom.html.pre(json.dumps(event, indent=2)),
    )


idom.run(ViewButtonEvents)

Custom Javascript Components

For projects that will be shared with others, we recommend bundling your Javascript with Rollup or Webpack into a web module. IDOM also provides a template repository that can be used as a blueprint to build a library of React components.

To work as intended, the Javascript bundle must export a function bind() that adheres to the following interface:

type EventData = {
    target: string;
    data: Array<any>;
}

type LayoutContext = {
    sendEvent(data: EventData) => void;
    loadImportSource(source: string, sourceType: "NAME" | "URL") => Module;
}

type bind = (node: HTMLElement, context: LayoutContext) => ({
    create(type: any, props: Object, children: Array<any>): any;
    render(element): void;
    unmount(): void;
});

Note

  • node is the HTMLElement that render() should mount to.

  • context can send events back to the server and load “import sources” (like a custom component module).

  • type``is a named export of the current module, or a string (e.g. ``"div", "button", etc.)

  • props is an object containing attributes and callbacks for the given component.

  • children is an array of elements which were constructed by recursively calling create.

The interface returned by bind() can be thought of as being similar to that of React.

It will be used in the following manner:

// once on mount
const binding = bind(node, context);

// on every render
let element = binding.create(type, props, children)
binding.render(element);

// once on unmount
binding.unmount();

The simplest way to try this out yourself though, is to hook in a simple hand-crafted Javascript module that has the requisite interface. In the example to follow we’ll create a very basic SVG line chart. The catch though is that we are limited to using Javascript that can run directly in the browser. This means we can’t use fancy syntax like JSX and instead will use htm to simulate JSX in plain Javascript.

from pathlib import Path

import idom


file = Path(__file__).parent / "super_simple_chart.js"
ssc = idom.web.module_from_file("super-simple-chart", file, fallback="⌛")
SuperSimpleChart = idom.web.export(ssc, "SuperSimpleChart")

idom.run(
    idom.component(
        lambda: SuperSimpleChart(
            {
                "data": [
                    {"x": 1, "y": 2},
                    {"x": 2, "y": 4},
                    {"x": 3, "y": 7},
                    {"x": 4, "y": 3},
                    {"x": 5, "y": 5},
                    {"x": 6, "y": 9},
                    {"x": 7, "y": 6},
                ],
                "height": 300,
                "width": 500,
                "color": "royalblue",
                "lineWidth": 4,
                "axisColor": "silver",
            }
        )
    )
)
import { h, render } from "https://unpkg.com/preact?module";
import htm from "https://unpkg.com/htm?module";

const html = htm.bind(h);

export function bind(node, config) {
  return {
    create: (component, props, children) => h(component, props, ...children),
    render: (element) => render(element, node),
    unmount: () => render(null, node),
  }
}

export function SuperSimpleChart(props) {
  const data = props.data;
  const lastDataIndex = data.length - 1;

  const options = {
    height: props.height || 100,
    width: props.width || 100,
    color: props.color || "blue",
    lineWidth: props.lineWidth || 2,
    axisColor: props.axisColor || "black",
  };

  const xData = data.map((point) => point.x);
  const yData = data.map((point) => point.y);

  const domain = {
    xMin: Math.min(...xData),
    xMax: Math.max(...xData),
    yMin: Math.min(...yData),
    yMax: Math.max(...yData),
  };

  return html`<svg
    width="${options.width}px"
    height="${options.height}px"
    viewBox="0 0 ${options.width} ${options.height}"
  >
    ${makePath(props, domain, data, options)} ${makeAxis(props, options)}
  </svg>`;
}

function makePath(props, domain, data, options) {
  const { xMin, xMax, yMin, yMax } = domain;
  const { width, height } = options;
  const getSvgX = (x) => ((x - xMin) / (xMax - xMin)) * width;
  const getSvgY = (y) => height - ((y - yMin) / (yMax - yMin)) * height;

  let pathD = "M " + getSvgX(data[0].x) + " " + getSvgY(data[0].y) + " ";
  pathD += data.map((point, i) => {
    return "L " + getSvgX(point.x) + " " + getSvgY(point.y) + " ";
  });

  return html`<path
    d="${pathD}"
    style=${{
      stroke: options.color,
      strokeWidth: options.lineWidth,
      fill: "none",
    }}
  />`;
}

function makeAxis(props, options) {
  return html`<g>
    <line
      x1="0"
      y1=${options.height}
      x2=${options.width}
      y2=${options.height}
      style=${{ stroke: options.axisColor, strokeWidth: options.lineWidth * 2 }}
    />
    <line
      x1="0"
      y1="0"
      x2="0"
      y2=${options.height}
      style=${{ stroke: options.axisColor, strokeWidth: options.lineWidth * 2 }}
    />
  </g>`;
}

Distributing Javascript Components

There are two ways that you can distribute your Custom Javascript Components:

  • Using a CDN

  • In a Python package via PyPI

These options are not mutually exclusive though, and it may be beneficial to support both options. For example, if you upload your Javascript components to NPM and also bundle your Javascript inside a Python package, in principle your users can determine which work best for them. Regardless though, either you or, if you give then the choice, your users, will have to consider the tradeoffs of either approach.

  • Distributing Javascript via CDN - Most useful in production-grade applications where its assumed the user has a network connection. In this scenario a CDN’s edge network can be used to bring the Javascript source closer to the user in order to reduce page load times.

  • Distributing Javascript via PyPI - This method is ideal for local usage since the user can server all the Javascript components they depend on from their computer without requiring a network connection.

Distributing Javascript via CDN

Under this approach, to simplify these instructions, we’re going to ignore the problem of distributing the Javascript since that must be handled by your CDN. For open source or personal projects, a CDN like https://unpkg.com/ makes things easy by automatically preparing any package that’s been uploaded to NPM. If you need to roll with your own private CDN, this will likely be more complicated.

In either case though, on the Python side, things are quite simple. You need only pass the URL where your package can be found to module_from_url() where you can then load any of its exports:

import idom

your_module = ido.web.module_from_url("https://some.cdn/your-module")
YourComponent = idom.web.export(your_module, "YourComponent")

Distributing Javascript via PyPI

This can be most easily accomplished by using the template repository that’s been purpose-built for this. However, to get a better sense for its inner workings, we’ll briefly look at what’s required. At a high level, we must consider how to…

  1. bundle your Javascript into an ECMAScript Module)

  2. include that Javascript bundle in a Python package

  3. use it as a component in your applciation using IDOM

In the descriptions to follow we’ll be assuming that:

  • NPM is the Javascript package manager

  • The components are implemented with React

  • Rollup bundles the Javascript module

  • Setuptools builds the Python package

To start, let’s take a look at the file structure we’ll be building:

your-project
|-- js
|   |-- src
|   |   \-- index.js
|   |-- package.json
|   \-- rollup.config.js
|-- your_python_package
|   |-- __init__.py
|   \-- widget.py
|-- Manifest.in
|-- pyproject.toml
\-- setup.py

index.js should contain the relevant exports (see Custom Javascript Components for more info):

import * as React from "react";
import * as ReactDOM from "react-dom";

export function bind(node, config) {
    return {
        create: (component, props, children) =>
            React.createElement(component, props, ...children),
        render: (element) => ReactDOM.render(element, node),
        unmount: () => ReactDOM.unmountComponentAtNode(node),
    };
}

// exports for your components
export YourFirstComponent(props) {...};
export YourSecondComponent(props) {...};
export YourThirdComponent(props) {...};

Your package.json should include the following:

{
  "name": "YOUR-PACKAGE-NAME",
  "scripts": {
    "build": "rollup --config",
    ...
  },
  "devDependencies": {
    "rollup": "^2.35.1",
    "rollup-plugin-commonjs": "^10.1.0",
    "rollup-plugin-node-resolve": "^5.2.0",
    "rollup-plugin-replace": "^2.2.0",
    ...
  },
  "dependencies": {
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "idom-client-react": "^0.8.5",
    ...
  },
  ...
}

Getting a bit more in the weeds now, your rollup.config.js file should be designed such that it drops an ES Module at your-project/your_python_package/bundle.js since we’ll be writing widget.py under that assumption.

Note

Don’t forget to ignore this bundle.js file when committing code (with a .gitignore if you’re using Git) since it can always rebuild from the raw Javascript source in your-project/js.

import resolve from "rollup-plugin-node-resolve";
import commonjs from "rollup-plugin-commonjs";
import replace from "rollup-plugin-replace";

export default {
  input: "src/index.js",
  output: {
    file: "../your_python_package/bundle.js",
    format: "esm",
  },
  plugins: [
    resolve(),
    commonjs(),
    replace({
      "process.env.NODE_ENV": JSON.stringify("production"),
    }),
  ]
};

Your widget.py file should then load the neighboring bundle file using module_from_file(). Then components from that bundle can be loaded with export().

from pathlib import Path

import idom

_BUNDLE_PATH = Path(__file__).parent / "bundle.js"
_WEB_MODULE = idom.web.module_from_file(
    # Note that this is the same name from package.json - this must be globally
    # unique since it must share a namespace with all other javascript packages.
    name="YOUR-PACKAGE-NAME",
    file=_BUNDLE_PATH,
    # What to temporarilly display while the module is being loaded
    fallback="Loading...",
)

# Your module must provide a named export for YourFirstComponent
YourFirstComponent = idom.web.export(_WEB_MODULE, "YourFirstComponent")

# It's possible to export multiple components at once
YourSecondComponent, YourThirdComponent = idom.web.export(
    _WEB_MODULE, ["YourSecondComponent", "YourThirdComponent"]
)

Note

When idom.config.IDOM_DEBUG_MODE is active, named exports will be validated.

The remaining files that we need to create are concerned with creating a Python package. We won’t cover all the details here, so refer to the Setuptools documentation for more information. With that said, the first file to fill out is pyproject.toml since we need to declare what our build tool is (in this case Setuptools):

[build-system]
requires = ["setuptools>=40.8.0", "wheel"]
build-backend = "setuptools.build_meta"

Then, we can creat the setup.py file which uses Setuptools. This will differ substantially from a normal setup.py file since, as part of the build process we’ll need to use NPM to bundle our Javascript. This requires customizing some of the build commands in Setuptools like build, sdist, and develop:

import subprocess
from pathlib import Path

from setuptools import setup, find_packages
from distutils.command.build import build
from distutils.command.sdist import sdist
from setuptools.command.develop import develop

PACKAGE_SPEC = {}  # gets passed to setup() at the end


# -----------------------------------------------------------------------------
# General Package Info
# -----------------------------------------------------------------------------


PACKAGE_NAME = "your_python_package"

PACKAGE_SPEC.update(
    name=PACKAGE_NAME,
    version="0.0.1",
    packages=find_packages(exclude=["tests*"]),
    classifiers=["Framework :: IDOM", ...],
    keywords=["IDOM", "components", ...],
    # install IDOM with this package
    install_requires=["idom"],
    # required in order to include static files like bundle.js using MANIFEST.in
    include_package_data=True,
    # we need access to the file system, so cannot be run from a zip file
    zip_safe=False,
)


# ----------------------------------------------------------------------------
# Build Javascript
# ----------------------------------------------------------------------------


# basic paths used to gather files
PROJECT_ROOT = Path(__file__).parent
PACKAGE_DIR = PROJECT_ROOT / PACKAGE_NAME
JS_DIR = PROJECT_ROOT / "js"


def build_javascript_first(cls):
    class Command(cls):
        def run(self):
            for cmd_str in ["npm install", "npm run build"]:
                subprocess.run(cmd_str.split(), cwd=str(JS_DIR), check=True)
            super().run()

    return Command


package["cmdclass"] = {
    "sdist": build_javascript_first(sdist),
    "build": build_javascript_first(build),
    "develop": build_javascript_first(develop),
}


# -----------------------------------------------------------------------------
# Run It
# -----------------------------------------------------------------------------


if __name__ == "__main__":
    setup(**package)

Finally, since we’re using include_package_data you’ll need a MANIFEST.in file that includes bundle.js:

include your_python_package/bundle.js

And that’s it! While this might seem like a lot of work, you’re always free to start creating your custom components using the provided template repository so you can get up and running as quickly as possible.