In this first tutorial, we're going to create a new Niagara Module with a very simple Widget that will render seamlessly in both Workbench and the browser.
Outline
Our new Niagara Module will have the following directory structure. (When creating a bajaux module from scratch, please consider using grunt-init-niagara - it will walk you through creating all the boilerplate files and let you get right to coding.)
+- myFirstModule
+- myFirstModule-ux
+- src
| +- com
| | +- companyname
| | +- myFirstModule
| | +- ux
| | +- BMyFirstWidget.java
| +- rc
| +- MyFirstWidget.js
+- myFirstModule-ux.gradle.kts
All the code for a bajaux Widget goes into the -ux module subdirectory. (Workbench and Hx Views belong in -wb.)
BMyFirstWidget.java: a Java class to register our JavaScript with the Niagara Framework.MyFirstWidget.js: the JavaScript bajauxWidget.myFirstModule-ux.gradle.kts: used to build the JAR file.
MyFirstWidget.js
Let's make our first attempt at implementing a Widget! This will be a very simple Widget that displays the value of a Ramp Component from the kitControl palette.
/**
* A module defining `MyFirstWidget`.
* @module nmodule/myFirstModule/rc/MyFirstWidget
*/
define([
'bajaux/mixin/subscriberMixIn',
'bajaux/Widget',
'underscore' ], function (
subscriberMixIn,
Widget,
_) {
'use strict';
/**
* An editor for working with `kitControl:Ramp` instances.
*
* @class
* @extends module:bajaux/Widget
* @alias module:nmodule/myFirstModule/rc/MyFirstWidget
*/
return class MyFirstWidget extends Widget {
constructor() {
super(...arguments);
subscriberMixIn(this);
}
/**
* Describe how your `Widget` does its initial setup of the DOM.
*
* @param {JQuery} dom - The DOM element into which to initialize this `Widget`
*/
doInitialize(dom) {
const initialValue = 'value goes here';
// when building HTML directly, remember to perform proper escaping to prevent XSS vulnerabilities.
// _.escape() is only one of many ways to do this. Calling .text() instead of .html() on a
// jQuery object is another.
dom.html(`<input type="text" value="${ _.escape(initialValue) }" >`);
}
/**
* Describe how your `Widget` loads in a value.
*
* Thanks to `subscriberMixIn`, we can subscribe to changes to the Ramp
* component to ensure that the DOM is always kept up to date.
*
* @param {baja.Component} ramp - an instance of `kitControl:Ramp`.
*/
doLoad(ramp) {
const update = () => {
this.jq().children('input').val(ramp.getOut().getValueDisplay());
};
// Call update whenever a Property changes
this.getSubscriber().attach('changed', update);
// Call update for the first time.
update();
}
};
});
Even if you're new to JavaScript, this code shouldn't look too strange to you. It simply loads a Niagara value into a JavaScript Widget.
- The JavaScript code is wrapped in a
define()function. This is how we write modular JavaScript code. - The JavaScript defines a new
MyFirstWidgetconstructor that extends fromWidget. doInitializeinjects some HTML when theWidgetis created.- The
domobject being passed in is a DOM element embedded in a jQuery wrapper.
- The
doLoadupdates the HTML when the ramp is passed into theWidget.- The value is a subscribed BajaScript proxy Component of the Ramp that sits in the Station. It's already subscribed because the
Widgetuses a subscriberMixIn. - The value will be automatically unsubscribed when the
Widgetis destroyed. - The subscriberMixIn automatically adds the
getSubscribermethod to theWidget. This is used to attach a listener forchangedevents. - Every time a property on the Ramp Component changes, the
updatemethod will be called to update the user interface. Note that we call it the first time ourselves - otherwise the user wouldn't see any data at all until achangedevent came through.
- The value is a subscribed BajaScript proxy Component of the Ramp that sits in the Station. It's already subscribed because the
This looks like a pretty solid first attempt. But there's a potential problem in this implementation of doLoad(). Can you spot it? Remember: load() can be called multiple times, with different values each time. Take a second look...
...and you may find that the subscriber does not remove any previous event handlers! If you were to unload the current Ramp and load in a new one, like this:
baja.Ord.make('station:|slot:/Ramp1').get()
.then((ramp1) => myWidget.load(ramp1))
.then(() => baja.Ord.make('station:|slot:/Ramp2').get())
.then((ramp2) => myWidget.load(ramp2));
then when Ramp2 fired a changed event, the old event handler would still fire once (with the previous, wrong values) before firing a second time with the new values! Due to the nature of JavaScript closures, every time we load a Ramp, we pass a function with a permanent reference to that Ramp that will be fired whenever the subscriber receives a changed event. Subscriber gets a changed event from Ramp2 but it still fires the event handler we loaded from Ramp1!
What can we do?
One option is to keep a reference to the event handler when we add it, and pass it to getSubscriber().detach() before attaching the new handler. This would work, but it would be a bit clunky.
I recommend this approach. (I'll also replace the boilerplate JSDoc from the first example for this final version.)
/**
* A module defining `MyFirstWidget`.
* @module nmodule/myFirstModule/rc/MyFirstWidget
*/
define([
'bajaux/mixin/subscriberMixIn',
'bajaux/Widget',
'underscore' ], function (
subscriberMixIn,
Widget,
_) {
'use strict';
/**
* An editor for working with `kitControl:Ramp` instances.
*
* @class
* @extends module:bajaux/Widget
* @alias module:nmodule/myFirstModule/rc/MyFirstWidget
*/
return class MyFirstWidget extends Widget {
constructor() {
super(...arguments);
subscriberMixIn(this);
}
/**
* Creates a text input to show the current value and subscribes for future updates.
* @param {JQuery} dom
*/
doInitialize(dom) {
const initialValue = 'value goes here';
dom.html(`<input type="text" value="${ _.escape(initialValue) }" >`);
this.getSubscriber().attach('changed', () => this.$updateDom(this.value()));
}
/**
* Shows the Ramp's current value in the DOM.
* @param {baja.Component} ramp - an instance of `kitControl:Ramp`.
*/
doLoad(ramp) {
this.$updateDom(ramp);
}
/**
* @private
* @param {baja.Component} ramp - an instance of `kitControl:Ramp`.
*/
$updateDom(ramp) {
this.jq().children('input').val(ramp.getOut().getValueDisplay());
}
}
});
We've moved the attachment to the changed event from doLoad() to doInitialize(). This works better for a few reasons.
doInitialize()only runs once, so it's a good place to arm event handlers, such aschanged.- The methods are smaller and easier to read.
- The code to update the DOM based on the Ramp's current values is well-defined, and has its own
method.
When using subscriberMixIn, there is only one Subscriber instance that lives for the life of your Widget, and it's always subscribed to the Component that is currently loaded. Therefore, it's safe to arm a changed handler, and it will get fired whenever the currently loaded Component fires a changed event. And this.value() will always return the currently loaded Component!
We've also moved the actual implementation of updating the DOM into a separate, private $updateDom() method. Why didn't we just keep everything in doLoad() and then just call doLoad() directly from our changed event handler? Remember the note about do methods from the introduction: call load(), implement doLoad(); don't implement load(), don't call doLoad(). As a best practice, if you want to reuse the logic of doLoad() (or any do method), don't call it directly; refactor it out to a shared function that can be called directly.
On the last point: for best testability, I recommend that you minimize the amount of code inside event handlers. In a test, it can be complicated to hit all the code branches inside an event handler because you can't call it directly. But we've refactored the actual logic out of the event handler. Now the event handler is just "glue code" - its only purpose is to link the component event to the $updateDom() method. Testing the glue connection is easy (trigger the event and verify $updateDom() got called); and testing the logic itself is easy - just call $updateDom() directly!
BMyFirstWidget.java
package com.companyname.myFirstModule.ux;
import javax.baja.naming.BOrd;
import javax.baja.nre.annotations.AgentOn;
import javax.baja.nre.annotations.NiagaraSingleton;
import javax.baja.nre.annotations.NiagaraType;
import javax.baja.sys.BSingleton;
import javax.baja.sys.Context;
import javax.baja.sys.Sys;
import javax.baja.sys.Type;
import javax.baja.web.BIFormFactorMax;
import javax.baja.web.js.BIJavaScript;
import javax.baja.web.js.JsInfo;
@NiagaraType(agent = @AgentOn(types = "kitControl:Ramp"))
@NiagaraSingleton
public final class BMyFirstWidget extends BSingleton implements BIJavaScript, BIFormFactorMax
{
private BMyFirstWidget() {}
public JsInfo getJsInfo(Context cx) { return JS_INFO; }
private static final JsInfo JS_INFO =
JsInfo.make(BOrd.make("module://myFirstModule/rc/MyFirstWidget.js"));
}
- The class implements
BIJavaScript. This means that the purpose of this Java class is just to register the existence of a JavaScript module with the Niagara Framework. The Java class does not have any behavior of its own. - The
JsInfotells the framework that the JavaScript file that contains the actual implementation isMyFirstWidget.js. In real life, ourJsInfowould also reference aBJsBuild. More information about this in the Building JavaScript Applications help document. - The class implements
javax.baja.web.BIFormFactorMax. This tells the framework that it represents abajauxWidget to be rendered as a fullscreen View in Workbench and the HTML5 Profile. (If we wanted a field editor, we would useBIFormFactorMini.) - The class extends
javax.baja.sys.BSingletonso a new instance of it doesn't need to be created each time. Note that it isfinalso it cannot be extended. - The class is an agent on
kitControl:Ramp. This tells the framework that when we navigate to a Ramp instance, it should render aMyFirstWidgetto show it to us. Note that it uses the@NiagaraTypeand@AgentOnannotations, so the agent registration is automatically added for us when we build the module - we no longer have to editmodule-include.xmlby hand.
myFirstModule-ux.gradle.kts
Add the following to the gradle file to ensure all relevant files are included in the built module. This snippet includes every file in the rc/ directory, which is appropriate for most use cases, but you can change it to be more selective if needed.
tasks.named<Jar>("jar") {
from("src") {
include("rc/")
}
}
Results
Building this module will result in the Ramp Component having a new pure HTML5 View available in Workbench, Hx, and the HTML5 Profile.
- The same code is used in both Workbench and browser environments.
- As it's a View, it can be accessed directly or added to a Px page.
- The code for the
Widgetis embedded in a distributable JAR file.
Next
See our Saving Modifications to Station tutorial.