angularjs-ng-elm

AngularJS directive for Elm modules with interoperation via ports

Usage no npm install needed!

<script type="module">
  import angularjsNgElm from 'https://cdn.skypack.dev/angularjs-ng-elm';
</script>

README

AngularJS directive to include Elm modules

Use Elm modules in AngularJS applications, with ports interoperation.

ng-elm tangram

Usage

In your AngularJS template, put an HTML tag like this where you want the Elm application to live.

<ng-elm module="..."></ng-elm>

Only the module attribute is required. Fill in ... with the name of your Elm module.

Embed an insular Elm module

Say you have this simple Elm module.

module Hello exposing (main)

import Html exposing (text)


main =
    text "Hello from Elm!"

Compile it on the command line with elm make Hello.elm --output=elm.js and put that script in your AngularJS application's index.html. Then you can include the Hello module in any AngularJS HTML template.

<ng-elm module="Hello"></ng-elm>

It works with deeply nested module names too.

<ng-elm module="User.Support.Chat"></ng-elm>

These module names must appear under the global Elm object in JavaScript.

Interfacing with AngularJS

The ng-interface HTML attribute is used both for passing program flags to initialize your Elm app, and for communicating via ports.

There's a technical reason why it's rigged this way.

With flags

If your Elm app does not use ports but requires only one flag, for instance a random seed, or user name, then you could pass that one value along.

<ng-elm module="Game.Shuffler" ng-interface="gameCtrl.randomSeed"></ng-elm>
<ng-elm module="Numberator" ng-interface="33.3"></ng-elm>
<ng-elm module="FortuneTeller" ng-interface="mysticalController.user.name"></ng-elm>

For multiple flags pass an object, which could very well be JSON, your controller, or $scope if that's how you roll.

<ng-elm module="Complex.Thingy" ng-interface="complexCtrl"></ng-elm>
<ng-elm module="Some.Gizmo" ng-interface="gizmoCtrl.$scope"></ng-elm>
<ng-elm module="Jsonifier" ng-interface='{"Sebben": 7, "hybrid": "beefalo", "yes?": true}'></ng-elm>

Communicate with AngularJS via ports

Track properties and use subscription callbacks on a controller.

<ng-elm module="My.Elm.Module" ng-interface="myAngularController"></ng-elm>

On a more deeply nested object.

<ng-elm module="My.Elm.Module" ng-interface="myAngularController.$scope.elmPorts"></ng-elm>

Ports Interoperation

The object or controller you reference via the ng-interface HTML attribute must have properties that match the names of ports that send information to your Elm module. Similarly, to subscribe to updates coming out of your Elm module, you'll need callbacks whose names match each Elm-to-JS port.

For the following examples imagine an Elm module within your application that tracks the severity level of an alert. We'll set it up to communicate directly with an existing AngularJS controller like this:

<ng-elm module="Alert" ng-interface="alertController"></ng-elm>
JS to Elm

If your Elm Alert module has an inbound port named severity that accepts an Int,

port severity : (Int -> msg) -> Sub msg

then your AngularJS alertController must have a property on it called severity that's an integer.

this.severity = 0

The ng-elm directive watches that severity property on your AngularJS controller. When severity changes, if your Elm module is subscribing to that port, then it will update with that information.

Sending a Maybe

If you want to send a value to Elm that's the Nothing half of a Maybe then send null from JavaScript, not undefined. This directive ignores undefineds.

Elm to JS

If your Elm Alert module has an outbound port named updatedSeverity that produces integers,

port updatedSeverity : Int -> Cmd msg

then your AngularJS controller must have a callback on it named updatedSeverity that accepts an integer.

this.updatedSeverity = function(newSeverity) {
  this.severity = newSeverity
}

Both flags and ports

If your module uses both of these things to do its job, then the names of the ports and the flags' record keys must match.

port greeting : (String -> msg) -> Sub msg
port cheerfulnessFactor : (Float -> msg) -> Sub msg

type alias Flags =
    { greeting : String, cheerfulnessFactor : Float }

Sorry, but this is due to a technical limitation.

Installation

Run this on the command line.

npm install --save angularjs-ng-elm

Include the directive script in your index.html.

<script src="node_modules/angularjs-ng-elm/angularjs-ng-elm.js"></script>

Add the Elm dependency to your AngularJS app.

var app = angular.module('app', ['Elm', 'other.dependency', ...])

Support

Only version 0.19 of Elm is supported. Future versions of Elm may cause breaking changes. This will not work with pre-0.19 versions of Elm.

Only AngularJS 1.x is supported. What that x is, I dunno. Assume it's 1.7 or later. But it's not the new Angular.

Limitations

You can only have one instance of a given Elm module running at a time. So this won't work in the same HTML template.

<ng-elm module="MultiplyMe"></ng-elm>
<ng-elm module="MultiplyMe"></ng-elm>

But you can have as many different Elm modules as you want. This is fine.

<ng-elm module="Buenos.Dias"></ng-elm>
<ng-elm module="Konnichiwa"></ng-elm>
<ng-elm module="Guten.Tag"></ng-elm>
<ng-elm module="Bonjour"></ng-elm>

Browsers

If you support IE 8, you're gonna have a bad time.

Check Elm's browser support if you support browsers older than Internet Exploder 9.

This doesn't have to do with the AngularJS directive, which should have the same browser compatibility as the version of AngularJS you're using.

Intent

AngularJS is getting old. There are lots of mission-critical applications out there built with this technology, and they would benefit from modernization with a goal of higher maintainability. As experience has taught me, those applications are probably pock-marked with ugly bits of buggy code that nobody wants to refactor for fear of breaking things. They probably also have insignificant test coverage because AngularJS can be cumbersome to test.

Elm addresses both of these problems in ways TypeScript simply can't, but Elm needed an easy way to integrate itself into AngularJS applications.

So next time you encounter some AngularJS code that meets these criteria:

  • Has at least one bug
  • Makes all the developers in the room laugh at it (not with it)
  • Needs updating because product management has bigger plans for it

then fix that problem with Elm.

Technical Stuff

Elm apps usually don't die. That's a good thing, but in our case we'd like to kill an Elm app every time its <ng-elm> gets destroyed. That can easily happen if you switch routes or an ng-if turns off. If we kept creating Elm apps without killing them we'd have a memory leak.

Killing an Elm app won't be implemented any time soon because there are workarounds. The workaround this directive uses is to keep the Elm apps running, but append them to a different part of the DOM that doesn't display.

The quirk here is that your Elm app will only run its init function once. That sounds normal, but when it goes away and comes back again any information needed to set its state can't come from program flags this time, but from ports.

This directive has no way of knowing whether you've intentionally put two of the same Elm module on a page, or whether it's going to unwittingly leak memory, especially because an AngularJS template could re-render itself multiple times when loading information. That limits you to one instance of an Elm module on a page. This directive will yell at you in the console otherwise. If you need the HTML it generates to be repeated, then have Elm do that repetition instead of AngularJS. That means pulling any ng-repeat containing your Elm module into your Elm module.

Credit

Originally created by virasak for Elm 0.18.

Ported to Elm 0.19, modified with breaking changes, and memory leak addressed by Ethan B. Martin.

Special thanks to Paul for the logo.