The bajaux framework enables developers to create widgets for Niagara using HTML5 and JavaScript.
Why bajaux?
bajaui is the existing, Java-based, Niagara UI framework for use in Workbench. It has been in use since the very first release of Niagara AX. It is still in widespread use and will be supported for some time. But it has two primary shortcomings that bajaux solves.
With bajaux, Workbench is no longer a requirement.
If you write a view using bajaui, then the only way your customer can access it is through Workbench or a standalone Java application. In many cases, we want them to be able to just open their laptop or phone and start using the software right away, in their web browser. Because bajaux is based on HTML5 and JavaScript, it works natively in the web browser - no Java required.
Write your view once, run it anywhere.
Previously, if you wanted your UI to work natively in both Workbench and the browser, you had to write it at least twice: once using bajaui for use in Workbench, and once using hx for use in the browser. (If you wanted to target the now-deprecated Niagara Mobile framework as well, make that three times!)
Now, bajaux works natively in the web browser. But it also works natively in Workbench! Workbench supports bajaux views through the use of a web browser embedded directly into Workbench. You can write one bajaux view and have it work in Workbench, Px, and the browser. This reduces development effort and ensures that your users get a consistent user experience.
Not only will your widget display in Workbench, it will also integrate into the given environment. For instance, commands defined for any widgets will render in Workbench's Java toolbar. Also, users can drag and drop from the nav tree onto a bajaux widget, both in Workbench and the browser!
Widget
Widget is the core API of bajaux. Adhering to the Widget APIs will ensure your UI works consistently in all supported environments.
A Widget has a well-defined lifecycle. It is created, performs its needed functionality, and then is destroyed. Because bajaux strives to be as unopinionated as possible, you have the freedom to decide exactly what your Widget will do at each point in its lifecycle.
Understanding these points is key to understanding how Widgets work. Let's take a look at the points in a Widget lifecycle in some more detail.
Construct
A Widget is a JavaScript object like any other. You create an instance of it using the new keyword, and define your own behaviors by subclassing it. Here's a very simple example.
class MyWidget extends Widget {
constructor(params) {
super({ params, defaults: { properties: { foo: 'bar' } } });
}
}
const myWidget = new MyWidget({ properties: { baz: 'buzz' } });
Don't worry, we'll examine the actual API in more detail a bit later. The key takeaways from this code snippet are:
- A Widget can have default values for its attributes when it is constructed. (Consider how Niagara Components can have default values for their frozen Slots.)
- You can set actual values for the Widget's attributes by passing them to the constructor function.
Initialize
Each Widget instance is bound to a single DOM element and remains bound to that DOM element for its entire lifecycle. The moment at which a Widget is bound to a DOM element is called initialization.
class MyWidget extends Widget {
doInitialize(dom) {
dom.text('Hello world!');
}
}
const myWidget = new MyWidget();
const dom = $('#myWidgetGoesHere');
myWidget.initialize(dom)
.then(() => {
console.log('initialized!');
console.log(myWidget.jq().text()); // Hello world!
console.log(Widget.in(dom)); // MyWidget
});
Let's see what we can learn from this code snippet.
First, see how the dom parameter is a jQuery object. bajaux uses jQuery for all its DOM interactions. (However, there is still a live DOM HTMLElement within that jQuery object, so you are free to use any method of interacting with an HTMLElement you choose.)
Next, note how my Widget subclass overrode the doInitialize() method, and then to bind my Widget instance to a DOM element, I called the initialize() method. This is a key pattern that you will see in every Widget. If the method starts with do, you override that method to implement your own behavior, but you do not call it! If the method does not start with do, then that is a method you call.
Implement doInitialize(), call initialize(). You'll see this pattern repeated throughout the framework.
Initialization is a one-time operation. Once you've initialized a Widget into a DOM element, that Widget will remain bound to that DOM element until it is destroyed. A DOM element can only have one Widget initialized into it at a time, and a Widget can only be initialized into one DOM element at a time. It's a one-to-one relationship.
(If you are familiar with other UI frameworks such as React, you may have seen other UI components create their own DOM element in which to live. bajaux Widgets do not create their own DOM element. They are given an existing DOM element, and then they can build out the contents (child nodes) of that DOM element. This is a key difference that is important for understanding bajaux.)
Because it's a one-time operation, you can implement doInitialize to set up any invariant (unchanging) conditions for your Widget. A couple of things you might do:
- If your Widget is to edit a string, you could create an
<input type="text">to hold that String. - If your Widget needs to respond to user events, you could arm event handlers such as an
onClickon your DOM element.
Once initialization is complete, you can get the Widget that is bound to any particular DOM element using the Widget.in() function. The widget's .jq() method is its inverse: it returns the DOM element (as jQuery) that the Widget is bound to.
Finally, note that initialize() returns a Promise. Most bajaux operations are asynchronous, since they may need to make network calls to retrieve information, such as Lexicon text, from the station. doInitialize(), like all methods that start with do, can optionally return a Promise to indicate when its work is complete, or (like our example) it may be implemented asynchronously.
Load
Most UI components are implemented to allow the user to view or interact with a piece of data. A few examples are:
- Display a string for the user to read.
- Provide a text editor for the user to edit a string.
- Provide child editors for the user to edit different properties of an object.
In all cases, doLoad() is the override point to allow a piece of data to be loaded into a Widget. Let's look at an example in which we load in a string and simply display it to the user.
class MyWidget extends Widget {
doInitialize(dom) {
dom.html('<span>Your string is: </span><span class="myString"/>');
}
doLoad(string) {
this.jq().children('.myString').text(string);
}
}
const myWidget = new MyWidget();
const dom = $('#myWidgetGoesHere');
myWidget.initialize(dom)
.then(() => myWidget.load('hello world!'))
.then(() => {
console.log(dom.text()); // Your string is: hello world!
return myWidget.load('hello again!');
})
.then(() => {
console.log(dom.text()); // Your string is: hello again!
console.log(myWidget.value()); // hello again!
});
We use the one-time operation, doInitialize(), to set up the widget to receive a value to be loaded later.
Again, load() is called, not implemented; doLoad() is implemented, not called. doLoad() receives the value being loaded in and updates the DOM to reflect that loaded value. load() can be called as many times as needed, over the lifecycle of the Widget, to reflect different values.
Take a look at the .value() method. This returns the Widget's currently loaded value; that is, the last value passed to the Widget's load() method.
Also, note the use of .text() instead of .html(). bajaux itself does not perform any XSS sanitization. It is up to you to ensure that any user-provided values are not placed directly into the DOM without being sanitized, as this can open up a reflected XSS attack. Using .text() is one good way to do this; there are many others.
Although this widget example is display-only, many widgets that load a value are implemented to allow the user to change that value, and read out a new one.
Read
doRead() answers the question: "what value has the user entered?"
As the user interacts with your widget, they will change its current value. They may type in new strings, check or uncheck checkboxes, move sliders, and so on. It's your Widget's responsibility to look at the current state of the DOM, and implement doRead() to resolve an actual value that represents what the user has entered. This is different from .value(), which returns the last value loaded in.
If you are creating a field editor, then doRead() should typically resolve a value of a type that is compatible with what was loaded in. If your widget accepts a baja:String to its load() method, it would typically resolve a baja:String from its read() method. This is because field editors for Simples often pass the result of read() right back to load(), so reading out a value of a different type from what was loaded can result in some unexpected behavior. Fullscreen views have more latitude.
class MyWidget extends Widget {
doInitialize(dom) {
dom.html('<input type="text">');
dom.on('change', 'input', () => this.setModified(true));
}
doLoad(string) {
this.$getTextInput().val(string);
}
doRead() {
return this.$getTextInput().val();
}
$getTextInput() { // see note about the $ prefix below
return this.jq().children('input');
}
}
const myWidget = new MyWidget();
const dom = $('#myWidgetGoesHere');
dom.on(events.MODIFY_EVENT, (e, ed) => {
ed.read()
.then((userTypedString) => {
let msg = 'User typed: ' + userTypedString;
if (userTypedString !== ed.value()) {
msg += ' (different)';
}
console.log(msg);
})
.catch((err) => console.error(err));
});
myWidget.initialize(dom)
.then(() => myWidget.load('initial value'));
Here is a straightforward example: this widget creates a text input for the user to type in, and doRead() reads out the value the user has typed.
(Take a quick look at the method $getTextInput(). The $ prefix, by convention, indicates a private method. If you are inspecting an object in the console, and see a method prefixed with $, consider that a private method and don't call it externally. You can mark your own private methods with the @private JSDoc tag.)
This example also introduces a couple new Widget behaviors: modification and events.
Modification
Take a look at doInitialize(). doInitialize() is a good place to arm DOM event handlers because it only runs once. Here, we arm a handler to listen for a change event from the input tag, which will be fired every time the user types a character. When we get a change event, we mark the widget as modified by calling setModified(true).
Marking a widget as modified is very important. One reason why is that when a widget is marked modified, then if you try to navigate away without saving your changes, the profile can show the "do you want to save changes?" dialog to ensure that the user changes are not lost. This functionality only works if the widget correctly marks itself as modified, so isModified() returns true, when user changes are made.
Another reason is that calling setModified(true) will trigger a corresponding event.
Events
At different moments in a widget's lifecycle, it will emit different events. These events are enumerated in the bajaux/events module. These are triggered like any other jQuery event, and you can arm event handlers to respond when a widget further down in the DOM triggers one of these events.
In the preceding example, we listen for MODIFY_EVENT so we know when the widget is modified. The event handler for a bajaux event always receives the Event object itself, followed by the Widget that triggered it. Our event handler then calls .read() to read out the currently entered value and log it to the console. Also note how it can be compared against the result of .value() to see if the user has actually entered a different string.
Also in this example, note how we do not return the Promise created by the call to .read(). 99% of the time, when performing an asynchronous operation, you should return the Promise so that the caller of the function can know when the work is completed and handle any errors. This is one notable exception to that rule: with jQuery event handlers, the caller of the function is jQuery itself, and jQuery does not know how to handle a returned Promise. Specifically for jQuery event handlers, we do not return the Promise, and we catch and log the error ourselves.
There will be a bit more detail on this in the upcoming section on asynchronous programming.
Validate
Your widget may need constraints on what values are acceptable for the user to enter. For instance, say we want the user to enter a percentage between 0% and 100%. For this, we can use a validator function. To add validators, simply call validators().add(validatorFunction).
class PercentInput extends Widget {
constructor() {
super(...arguments);
this.validators().add((percent) => {
if (percent < 0 || percent > 100) {
throw new Error('Must be valid percentage [0-100]');
}
});
}
doInitialize(dom) {
dom.html('<input type="text"><span>%</span>');
dom.on('change', 'input', () => this.setModified(true));
}
doLoad(number) {
this.jq().children('input').val(String(number));
}
doRead() {
const text = this.jq().children('input').val();
const number = parseFloat(text);
if (isNaN(num)) {
throw new Error('Not a valid number: ' + text);
}
return number;
}
}
const myWidget = new PercentInput();
const dom = $('#myWidgetGoesHere');
dom.on(events.MODIFY_EVENT, (e, ed) => {
ed.validate()
.then((percent) => console.log('valid percent: ' + percent))
.catch((err) => console.error(err));
});
myWidget.initialize(dom)
.then(() => myWidget.load(0));
In this example, we create an input for the user to type a number into, with the constraint that it be a number between 0 and 100. If the value is not actually a number, then we throw an error from doRead() indicating that we couldn't read a number at all (note we could also return a rejected Promise).
For validation, the validator function we add in the constructor will receive the value resolved from doRead() (a number) and check that it is between 0 and 100. If not, it throws an error (again, could also be a rejected Promise). We trigger the validation by calling validate(): this will read() the value out first, and then pass it to the validator function. This answers the question: does the user have a valid value entered?
You may be wondering: why are we checking the value in both doRead() and the validator? Couldn't we do both checks in doRead()? The answer is yes, we could, but it depends on your own use case. If doRead() rejects, it's like the widget is saying, "my current state is so messed up, I have no idea what the user was even trying to enter." If doRead() rejects, so will read(). But if doRead() resolves but validate() fails, it's like saying, "I know what the user has entered, but it's not a correct value for my use case." In our example, this makes sense: if the user has an actual number entered, we can know that, and subsequently validate its numeric value. If we wanted to, we could do this:
ed.validate()
.catch(() => {
return ed.read()
.then((invalidValue) => {
console.log('user entered ' + invalidValue + ' but it failed validation.');
});
});
This would not be possible if read() itself rejected.
Save
When the user-entered changes are satisfactory, call .save() to save those changes. The behavior will be defined, again, by implementing doSave().
You can typically think of this as mutating the current .value(). Whatever value I last passed to .load() will be mutated when I call .save(). Your implementation may vary, but this is the most common pattern to follow.
It follows that if you just load in an immutable simple value like a String, calling .save() does not make sense because there is no way to mutate that String. But if your widget loads a mutable value like an object or a Component, .save() can commit changes to that value.
class UserNamer extends Widget {
doInitialize(dom) {
dom.html('<input type="text">');
dom.on('change', 'input', () => this.setModified(true));
}
doLoad(user) {
this.getTextInput().val(user.name);
}
doRead() {
return this.getTextInput().val();
}
doSave(name) {
this.value().name = name;
}
getTextInput() {
return this.jq().find('input');
}
}
const myWidget = new UserNamer();
const dom = $('#myWidgetGoesHere');
const user = { name: 'Alice' };
myWidget.initialize(dom)
.then(() => myWidget.load(user))
.then(() => {
myWidget.getTextInput().val('Bob');
return myWidget.save();
})
.then(() => console.log(user.name)); // Bob
Important things to note about the .save() process:
.validate()will be called first, so if the currently entered value is not valid, it cannot be saved.- The validated value is the first argument to
.doSave(), so you do not need to call.read()again. - Once
.save()completes,.isModified()will revert tofalse.
Destroy
When you are done with a widget, it's best to .destroy() it before simply removing its DOM element from the document. Widgets can acquire and hold resources such as:
- Component subscriptions
- Global event handlers
- Database connections
doDestroy() is a place for the widget to relinquish any resources it is currently holding so that they can be freed. If your widget does not release these resources in doDestroy(), they can stick around after the widget is gone, consuming memory, bandwidth, and CPU cycles unnecessarily.
Attributes
Widgets also have the following attributes.
.isModified()
As described above, reflects whether the widget has any user-entered changes. In most cases, it is the responsibility of your widget to listen for events that indicate user modification, and call this.setModified(true).
.isEnabled()
A widget can be enabled or disabled. The implementation of how it responds to changes in its enabled state is .doEnabled().
You may call .setEnabled() on your own widgets to enable or disable them. The framework itself may also call .setEnabled() on your widget in response to various conditions, such as the enabled property if your widget is embedded on a Px page.
.isReadonly()
A widget can be readonly or writable. The implementation on how it responds to changes in its readonly state is .doReadonly().
You may call .setReadonly() on your own widgets to make them readonly or writable. The framework itself may also call .setReadonly() on your widget in response to various conditions, such as if it is a field editor on a Component slot and that slot has the READONLY slot flag.
Note that there can be significant overlap between readonly and enabled, so you may want to consider the state of both attributes when implementing doEnabled() or doReadonly(). In fact, many widgets respond exactly the same whether being set to disabled or readonly. The Building Composite Widgets With spandrel API provides the writable flag to ease this confusion.
.properties()
A Widget has a Properties API which allows it to declare a public set of properties and their values. These Properties will typically be used internally by the Widget to configure its behavior. For instance, when a Widget creates a DOM element of <input type="range">, it may declare properties of min and max to define the range of the input. But the Properties declared by a Widget also form a type of public API for that Widget, one that is used in many ways:
- When your Widget is embedded in a Px page, its Properties can be edited by the page designer.
- When your Widget is used to edit a Slot value, it will receive the Slot Facets as Properties.
- When a parent Widget creates child Widgets, it can configure the Properties of the children for customized behavior.
- When a Widget is placed on a Dashboard, it can declare Properties as dashboardable, so they can be saved per-user.
There is one special Property, rootCssClass, that can be specified on a Widget. It will be used in initialize() to add that CSS class to its DOM element when it is initialized. This is typically used to easily identify instances of that Widget, either by selecting them out of the DOM, or styling them in CSS. A common pattern is shown below:
class MyWidget extends Widget {
constructor(params) {
super({
params,
defaults: { properties: { rootCssClass: '-myCompany-MyWidget' } }
});
}
}
//...
const allMyWidgets = [ ...dom.find('.-myCompany-MyWidget') ].map(Widget.in);
.getFormFactor()
In Niagara AX...
- A View is something that occupies most of the UI area in Workbench.
- A Field Editor is quite small and appears alongside other Field Editors on a Property Sheet.
However, these extend from different classes and inherit different sets of functionality. In bajaux, we've separated out the form factor from the capabilities of the Widget. The form factor describes the kind of element in which a Widget may be initialized, not the behavior of that Widget. This allows Widgets to alter their behavior and layout to function appropriately, whether taking up the full browser window or just one row in a property sheet.
For instance, in bajaux...
- The form factor
maxis for widgets that function as fullscreen views, like Property Sheet or User Manager. - The form factor
miniis for widgets that function as field editors, such as in one individual row in a Property Sheet or embedded as a Property on a Px Page. - The form factor
compactis for widgets that function in between - larger than a field editor but smaller than a fullscreen view. Most commonly this is a modal dialog, such as when you invoke an Action that takes a parameter.
You can implement a widget that supports all of these form factors, or just one.
.toDisplayName(), .toDescription(), .toIcon()
Widgets have display name, description, and icon attributes that come into play with Workbench interop, described below.
Subscription
One of the nice features of Niagara AX is BWbComponentView. When extending this Java class, any subclasses automatically get Component subscription/unsubscription handled for them.
The bajaux framework has the same concept. A Widget can be made into a Subscriber Widget by a special Subscriber MixIn. A Subscriber MixIn will use BajaScript to automatically ensure a Component is subscribed and ready before it's loaded. It will also ensure any Components are unsubscribed when the UI is destroyed.
Commands
A Command is a function that belongs to a Widget, but can be seen and invoked by the user. It is very similar to the existing Command API in bajaui. Some examples are a Save Command, which allows the user to save changes to the Widget, or the Property Sheet's Add Slot Command.
Each Command has metadata to define how it is displayed to the user:
- Display name: formatted in the user's locale.
- Enabled/Disabled: commands that cannot be currently invoked are disabled.
- Flags: a
Commandcan have some flags to indicate whether it should appear in a menu bar, toolbar or both. - Icon: gives the
Commandan icon to make it easily identifiable by the user.
As well as a standard Command, there's also ToggleCommand. A ToggleCommand can have its state toggled. Think of it like a tick box that can be turned on or off.
A number of Command and ToggleCommand objects can be arranged into a tree-like structure by use of a CommandGroup.
Interop / Integration
bajaux Widgets work in many environments.
- The HTML5 Profile, used in the web browser, will instantiate the appropriate Widget for the component you are viewing.
- Workbench itself can load your
bajauxWidgets using Java/JavaScript interop. - Widgets can be embedded in Px pages and viewed either in Workbench or the browser.
The environment in which the Widget is instantiated can access certain attributes of that Widget to more tightly integrate that Widget into the user interface. When developing a Widget, it can pay to correctly define these attributes to make its functionality more accessible to the user.
- Any
Commands in the Widget'sCommandGroup(e.g. a Property Sheet's "Add Slot" command), with the appropriate flags, will be shown as buttons in the toolbar in both Workbench and browser. Workbench can also show them in the menu bar. - The Widget's display name and icon will be used in the view selector dropdown, and in Workbench's menu bar.
Asynchronous Programming
Any modern JavaScript framework has to deal with asynchronous behavior. The bajaux framework has been designed with this in mind from the ground up. Therefore, most of the callbacks that can be overridden can optionally return a promise that can perform an asynchronous operation before resolving. For instance, let's say some extra network calls have to be made during a widget's loading process. The widget's doLoad method can return a promise. The promise can then be resolved when the network calls have completed.
The Promise API also defines how any asynchronous errors, like a failed network call, are handled. As a general rule (not just for bajaux), if you write a function that creates a Promise to perform some asynchronous work, you should return that Promise and document it accordingly, so that the caller can know to either return that Promise again, or handle any errors.
/**
* @returns {Promise} this function returns a Promise, so be sure to handle it correctly!
*/
function retrieveNetworkData() {
return makeNetworkCallTo('/myRestEndpoint');
}
// now, as the caller of this function, I can be sure to return that Promise...
/** @returns {Promise} */
function processNetworkData() {
return retrieveNetworkData()
.then((networkData) => {
return doSomethingWith(networkData);
});
}
// ...or I will at least know I need to handle any rejections.
retrieveNetworkData()
.then((networkData) => {
return doSomethingWith(networkData);
})
.catch((err) => logSevere(err));
If a Promise is not returned, and no one handles a rejection via a .catch() block, then any errors will "disappear" - no one will know anything went wrong. Keep an eye on how your Promises are created and returned, and you can be sure that all your errors are handled!
Responsive Layout
Many Widgets need some sort of responsive behavior: they'll need to lay themselves out differently depending on the width and height allotted to them. A Widget in a desktop browser at fullscreen will likely look very different from when it is displayed on a mobile phone.
Most responsive behavior can be handled through pure CSS, which every Widget supports. But one shortcoming of the CSS approach is that one common technique - Media Queries - won't always work. This is because Media Queries target the screen size of the device, while the dimensions of a Widget vary independent of the screen size. For instance, your Widget might be embedded on a Px page, or within the resizable main view pane of Workbench.
Where Media Queries fall short, or just where more complex layout calculations are needed, responsiveMixIn makes it easy to implement responsive behavior in any scenario.
Composition
Many Widgets will want to add additional child Widgets for richer functionality. Although the core Widget API has the built-in capability to initialize child Widgets, the spandrel API makes it even easier to assemble child Widgets and HTML. This increases code reuse and improves maintainability.
Getting Started
Click here to get started!
Also try opening the docDeveloper palette in Niagara to start working with our sample bajaux playground components!