Saturday, October 14, 2017

dynamics, form programming and an eventbus

If you add multiple web resources to a form, you may need to communicate between the components.
You have a number of ways to do this. Let's assume that a grid that issues an "OnRecordSelect" event and you want "detail" WebResources to display some details of the selection. Hence, the detail components need to know which Contact was selected.
WebResources are loaded in iframes. The iframe structure changed slightly between the web and unified (UCI) UIs in dynamics. In general, form programming has always been a bit hit-or-miss with some of the APIs. v9.0 APIs help, but you may still want to clean up inter-WebResource communication.
A few ways you can handle the communication are:
  • Create a handler that responds to the selection change and sets the URL via .setSrc on a WebResource control (whose getObject() is an iframe in the web UI).
  • Create a handler that responds to the seelction change and calls a well-known function in the global space (hence attached to the widow) of the iframe.
  • Create a handler that responds to the selection change and calls an event bus function. WebResources register for event dispatches when they load into their iframe.
All of these methods work fine but the first two are a bit brittle. Note that setting the URL multiple times on a WebResource iframe refreshes the webresource and may cause flicker. Calling well known functions on the the iframe works well but may have undesirable consequences. It certainly does not scale well if you have multiple events to communicate back and forth.
An event bus can be easily created and attached to a well known location. WebResources can then find the event buss and subscribe to events. Its fairly easy to do. We can use typescript to do this and use some of the usual global variable patterns with namespaces to create a global variable that points to an eventbus.
Webpack can perform the code bundling using a "var" libarary target. As long as you ensure that an onLoad function is called to set the event bus into the proper window location e.g. window.parent.eventbus, you effectively set a "global" variable holding the eventbus into a well know location that subscribers can find. If you are creating a small amount of code and the rest of the project does not have any other large javascript components, webpack is overkill. But if you have alot of custom components and they are complex, webpack can make alot of sense.
WebResources are loaded into their own "client script" iframe which is not the toplevel. WebResources follow the same pattern. The iframe naming and positioning pattern changed slightly between the web and unified UIs. However, both types of client-side artifacts load as siblings and hence the parent iframe (iframe=window) is still a good place to attach a resource that is needed by multiple children. By attaching to the one-up window, you may not be attaching the eventbus instance to the topmost window but it does not matter as long as the sibling loaded scripts can find the eventbus in a well-known location.

Typescript EventBus

We can author a very quick event bus:
/**
 * Simple Dynamics event bus. Designed to be used for simple needs
 * and attached to a window that sub-iframes can access easily so their
 * WebResources can listen for events from across a Dynamics form 
 * e.g. a grid select needs.
 */

/** Subscriber takes a simple event object differentiated by type. */
export type Subscriber = ({type: string}) => void

/** A thunk that unsubscribes when called. */
export type Cancellable = () => void

/** Small pubsub, synchronous dispatch. */
export class EventBus {
    private subscribers: Array<Subscriber> = []

    public unsubscribe(key) {
        this.subscribers = this.subscribers.filter(s => s !== key)
    }
    
    /** Subscriber is just a thunk. Thunk returned unsubscribes. */
    public subscribe(subscriber: Subscriber) {
        if(subscriber) this.subscribers.push(subscriber)
        return(() => this.unsubscribe(subscriber))
    }

    /** Dispatch an event. All subscribers get all events like redux. */
    public dispatch(event) {
        this.subscribers.forEach(sub => {
            if(sub) sub(event)
        })
    }
}

export default EventBus

export interface Window {
    eventbus: EventBus;
}
That's it!

ES6 Module

We need a module to expose a single instance of the EventBus. Let's assume that we want to use capitals for the eventbus name (EventBus) in the global namespace and expose both a single instance usable for the entire form and a function that will set the event bus after form load. It's not critical to have an onLoad function but you may want to perform other processing in the load process such as issue an "load" event. We use an onLoad function below that must be called by the Dynamics form processor and is specified in the form properties event list.
/** 
 * Global event bus. This module should only be loaded once
 * for a Dynamics form typically as part of loading form
 * scripts. The eventbus can be set on the global window
 * using your bundler e.g. webpack with libraryTarget="var".
 */
import EventBus from "../Dynamics/EventBus"

/** Main instance for a form. */
export const eventbus = new EventBus()

/** 
 * Arrange to have this called after the script is loaded.
 * Place the form "global" instance into your designed location
 * Coordinate with your other form components
 * to find it at the designated location.
 */                                                          
export function onLoad(ctx:any): void {
    // @ts-ignore
    window.parent.eventbus = eventbus
}
We call this module EventBus.ts and place it a source directory called "src/form/EventBus.ts". The EventBuss class is located at another location, say, "src/Dynamics/EventBus.ts". The module above exports an eventbus instance and the onLoad event places the instance in lowercase "eventbus". Change this to suit your own conventions.
Our ES6 module exports both the instance from the module as well as a function for the onLoad handler.
Depending on your bundler, you can expose these ES6 module values in different ways. The typescript compiler acts as a simple bundler if you want but we will use webpack. Webpack can expose an object call EventBus (the module/namespace) in the window loads the script.

Webpack Config to implement a "Global"

Webpack can take a ES6 module and expose the content as a var in the global namespace. The webpack output needs to be set to a library type and we specify that a "var" should be created to whatever is exporte from the module above. There are a number of ways to do this in webpack that automatically attaches the module's exports to window.parent.EventBus but we will do it explicitly with a "var." As mentioned at the start, we want to attach the EventBus module exports to window.parent one up from the iframe load point. That's done in our onLoad function. WebResource siblings will need to access it at window.parent.eventbus.
The key part of webpack config is:
const configFragment = {
    entry: {
        "EventBus": path.join(srcdir, "form/EventBus.ts")
    },
    output: {
        path: path.join(__dirname, "dist/js"),
        filename: "[name].js",
        library: "[name]",
        libraryTarget: "var"
    }
})
The name (EventBus) is indicated in the entry point implies that the "var = ..." construct created by webpack will be "var EventBus = ...". I tend to use typescript only as a compiler, always using esnext, and use babel for transpiling down to my target javascript version:
{
    "compilerOptions": {
        "jsx": "react",
        "target": "esnext",
        "strictNullChecks": true
	,"allowJs": true
	,"importHelpers": false
	,"noEmitHelpers": true
	,"sourceMap": true
	,"experimentalDecorators": true
	,"allowSyntheticDefaultImports": true
	,"typeRoots": ["./typings", "./node_modules/@types/"]
	,"sourceMap": true
    }
    ,"exclude": ["node_modules"]
}
and by .babelrc
{
    "presets": [
        ["env", {
            "browsers": ["last 3 major Chrome versions"]
        }],
        "react",
	"flow"
    ],
    "plugins": [
        ["transform-runtime",
         {
             "useESModules": true
         }],
        "transform-object-rest-spread",
        "transform-class-properties"
    ]
}
I've not shown the loaders section but use babel-loader and ts-loader with ts-loader running first of course.
The result of running webpack will be a file that starts with:
var Eventbus = ...a whole bunch of webpack stuff...
Do not forget to run webpack with the -p option to automatically activate UglifyJSPlugin which reduces the size of the webpack output (the bundle). You'll always have a file larger than if you hand crafted the JS or used typescript namespaces, but you you get other benefits from using webpack bundling processing. But you need to choose how you deploy.

EventBus Publishing and WebResource Consumption

In the Dynamics form editor, we would include "publisher_/js/EventBus.js" (the output of the webpack bundling process) and set the onLoad event to call "EventBus.onLoad". We would do this once on the form. While this places the event bus into a well known location we still need to publish and consume events.
To publish, we would load another script that that responds to the select event. In our example we were responding to a grid selection event, say, from an editable grid:
// some other script that is specific to the control we want to broadcast changes about to our eventbus
onRecordChanged(ctx) {
  const fctx = ctx.getFormContext()
  const newId = fctx.data.entity.attributes("contactid").getValue()[0].id // from the grid context for example
  const bus = myeventbus()
  if(bus) {
    bus.dispatch({type:"CONTACT_CHANGED", contactId: newId})
  }
}
function myeventbus() { return window.parent.eventbus } // No nameclashes so could call this function eventbus().
We use a function to reference eventbus because based on the loading sequence, eventbus may not be defined at the time this script is loaded. There are no order guarantees in Dynamics for external form script loading. Notice that the eventbus instance (lowercase) is accessed at the well known location, one-up from the iframe that loads our publishing script.
Another webresource, the one that display details about the selected entity, needs to subscribe to the change event. Typically the widget will be created in code. For example, a UI.js file holds the exports myUI as a namespace and a function called setContactId. We need to arrange for setContactId to be called when an event is received.
<html>
...
<body>
...
<script src="./UI.js"></script>
</body>
...
<script>
if(window.parent.eventbus) {
  window.parent.eventbus.subscribe(event => {
   if(event.type === "CONTACT_CHANGED") { myUI.setContactId(event.contactId) }
  })
}
...
// other code to start your UI
...
</script>
...
</html>
It is possible that the eventbus script that attaches the eventbus instance to window.parent is not loaded at the time that the WebResource script part in the HTML file runs.
You can use jquery's $(document).ready() or window.load event listeners or some other tricks to help sequence the load. However, just like with Xrm, where order may be severely changed based on uncontrollable factors, you should really place subscribe code in a Promise that waits for the eventbus variable to become available. But that's another blog.

No comments:

Post a Comment