Building Composite Widgets With spandrel

The spandrel API is new in Niagara 4.10. Its API status is Development.

In many cases, Widgets will consist of several child UI controls. For instance, imagine an editor for a User object that looks like this:

class User {
  /**
   * @param {string} name
   * @param {boolean} enabled
   */
  constructor(name, enabled) {
    this.name = name;
    this.enabled = enabled;
  }
}

You'd most likely want a text editor for the user's name, and a checkbox for whether it's enabled or not. You could implement your Widget in pure HTML:

class UserEditor extends Widget {
  doInitialize(dom) {
    dom.html('Name: <input type="text"> Enabled: <input type="checkbox">');
  }
  getTextInput() { return this.jq().find('input[type=text]');
  getCheckbox() { return this.jq().find('input[type=checkbox]');

  doLoad(user) {
    this.getTextInput().val(user.name);
    this.getCheckbox().prop('checked', user.enabled);
  }
  
  doRead() {
    return new User(this.getTextInput().val(), this.getCheckbox().prop('checked'));
  }      
}

But when developing a more complex user interface, you'll need to edit strings and booleans quite often, and you probably won't want to re-implement this logic each and every time. It would be much easier to implement a fully-featured String editor, and a fully-featured Boolean editor, and then build your UserEditor by simply putting those together. As your UI grows more complex, you can easily reuse these individual Widgets and re-assemble them into a wide variety of composite Widgets.

You could always do that in pure bajaux by manually instantiating and loading Widgets. In addition, the fe module in webEditors provided a way of looking up Widgets and managing the workflow of building those Widgets. In Niagara 4.10, bajaux itself now contains a number of APIs to make the process of building dynamic, composite Widgets easy.

Defining a Widget Workflow

The process of building a Widget to show a particular piece of data can be broken down into a series of questions:

  • Do I need to edit a particular data value? (Is it a String? A Boolean? A Baja Component?)
  • What kind of Widget do I need to show? (Do I know ahead of time what kind of Widget I need? Or do I need to dynamically choose the right kind of Widget for the data I'm editing?)
  • How should the Widget be configured? (Should I assign Properties to it? Should it be readonly? What form factor should it have - large or small?)
  • Where should I put the Widget? (Do I have a jQuery element to put it in? A raw HTMLElement?)

We could actually define the answers (or lack of answers) to all of these questions in the form of a JavaScript object.

const buildParams = {
  value: user, // I want to edit this User object,
  type: UserEditor, // using an instance of UserEditor,
  properties: { caseSensitive: true }, // with these Properties,
  dom: $('#userGoesHere'), // and putting it in this DOM element.
};

const Ctor = buildParams.type;
const widget = new Ctor({ properties: buildParams.properties });
return widget.initialize(buildParams.dom)
  .then(() => widget.load(buildParams.value);

This should look pretty familiar if you've previously used the fe module. This workflow and its configuration are now defined in bajaux itself using the WidgetManager API.

const manager = new WidgetManager();
return manager.buildFor(buildParams)
  .then((widget) => { /* widget is initialized and loaded */ });

One of WidgetManager's jobs is to dynamically look up what kind of Widget to build based purely on what value is getting loaded. For instance, if value is a String, then I want a StringEditor; if it's a Boolean I want a BooleanEditor etc. In Niagara world this is handled via agent registration lookups in the station registry, and the webEditors module has its own WidgetManager implemented to perform these lookups. But bajaux itself provides all the APIs needed for you to implement your own widget lookup logic.

WidgetManager itself is fairly simple, but it's what underpins the next API we'll examine: spandrel.

Building Composite Widgets With spandrel

spandrel allows you to define your Widgets as a structure of context objects as described above. Rather than manually implementing the how in JavaScript by managing your child widgets by hand, it allows you to declare the what and let it manage everything behind the scenes. Let's start with an example: I want a Widget that just creates a label with the text Hello. First, the existing way:

class HelloWidget extends Widget {
  doInitialize(dom) {
    dom.html('<label>Hello</label>');
  }
}

We would have had to imperatively call the .html() method to set the HTML contents of the Widget's DOM element to our desired HTML. But with spandrel, we do it declaratively by just telling it what we want the contents of our Widget to be.

const dom = document.createElement('div');

const HelloWidget = spandrel('<label>Hello</label>');

new WidgetManager().buildFor({ type: HelloWidget, dom })
  .then(() => {
    console.log(dom);
    // <div>
    //   <label>Hello<label>
    // </div>
  });

spandrel accepts an argument that indicates what you want the structure of your Widget to be. For this case, we give it a string defining what we want the contents of our Widget to be: a single label. spandrel can accept simple strings of HTML, which it will treat the same as a Widget config. If we want the Widget to contain more than one child element, we can return an array as well.

Because the structure of this widget will be the same every time, we will refer to it as a static widget.

For our next example, we'll add a child Widget, into which we load the String value 'World'.

const dom = document.createElement('div');

const HelloStringWidget = spandrel([
  '<label>Hello</label>',
  { dom: '<span></span>', value: 'World' }
]);

new WidgetManager().buildFor({ type: HelloStringWidget, dom })
  .then((w) => {
    console.log(dom);
    // <div>
    //   <label>Hello</label>
    //   <span>World</span>
    // </div>
    console.log(w.queryWidget(1).value()); // 'World'
  });

When the argument to spandrel is an array, each element indicates a child of your widget. The first argument is still simply a label, but now the second argument is a build context: I want a widget to show the value 'World', and put it in a span. By default, if you don't specify a widget type, spandrel will use a ToStringWidget, which simply shows the value as a string.

Take a look at queryWidget(1): what is happening there? Well, Spandrel keeps track of all its child widgets by key. When built using an array, those keys are array indices. So queryWidget(1) gets us the Widget at index 1 in the array: our 'World' widget, which has the value World loaded into it.

Speaking of widget keys: the argument to spandrel can also be an object literal, where the keys map to your widget's children. This makes it much more intuitive to query the widgets back out:

const dom = document.createElement('div');

const HelloStringWidget = spandrel({
  hello: '<label>Hello</label>',
  world: { dom: '<span></span>', value: 'World' }
});

new WidgetManager().buildFor({ type: HelloStringWidget, dom })
  .then((w) => {
    console.log(w.queryWidget('world').value()); // 'World'
  });

The argument to spandrel can also be a function that returns your config:

const HelloStringWidget = spandrel(() => {
  return {
    hello: '<label>Hello</label>',
    world: { dom: '<span></span>', value: 'World' }
  };
});

This, in and of itself, is not interesting - until you consider that the argument to that function is the value being loaded. Therefore, your widget can dynamically define its own structure depending on the value! The easy example here is a Niagara Component.

const dom = document.createElement('div');
const user = baja.$('baja:User');

const SlotListWidget = spandrel((component) => {
  const spandrelConfig = {};
  component.getSlots().each((slot) => {
    spandrelConfig[slot] = { dom: '<div></div>', value: slot };
  });
  
  // our argument to spandrel is an object literal as in the previous
  // example, with the slot names as the spandrel keys.
  return spandrelConfig;
});

new WidgetManager().buildFor({ type: SlotListWidget, value: user, dom })
  .then((w) => {
    console.log(dom);
    // <div>
    //   <div>fullName</div>
    //   <div>enabled</div>
    //   <div>expiration</div>
    //   ...
    console.log(w.queryWidget('fullName').value()); // fullName Slot
  });

We're already creating dynamic, composite widgets. But consider that spandrel widgets can be nested, too. This can be done using the kids property on a config object, as shown below. (We'll also split the function out to a reusable function, for clarity.)

function componentToSpandrelConfig(component) {
  const spandrelConfig = {};
  component.getSlots().properties().each((slot) => {
    // again, each slot name is a spandrel key. at that key we have a div,
    // with four child widgets underneath it.
    spandrelConfig[slot] = {
      dom: '<div></div>',
      kids: {
        nameLabel: '<span> Name: </span>',
        nameValue: { dom: '<span></span>', value: slot.getName() },
        typeLabel: '<span> Type: </span>',
        typeValue: { dom: '<span></span>', value: slot.getType() }
      }
    };
  });
  return spandrelConfig;
}

const PropertyInfoWidget = spandrel(componentToSpandrelConfig);

new WidgetManager().buildFor({ type: PropertyInfoWidget, value: user, dom })
  .then((w) => {
    console.log(w.queryWidget('enabled/typeValue').value()); // baja:Boolean
  });

As you can see, the queryWidget function works on nested keys, separated by slashes. It and its plural counterpart, queryWidgets, also support wildcards, which makes reading out the info you want a snap:

class PropertyInfoWidget extends spandrel(componentToSpandrelConfig) {
  /**
   * @returns {Array.<string>} an array of all the slot names
   */
  doRead() {
    return this.queryWidgets('*/nameValue').map((w) => w.value());
  }
}

In the example above, note the class extends spandrel() - this will be a common way to define spandrel-derived Widget constructors with their own read/save behavior.

How spandrel renders and updates your widgets

When implementing a vanilla bajaux Widget, it's up to you to work directly with the DOM. When initializing or loading a value, your Widget has to update its own DOM: adding classes, creating or removing elements, or arming event handlers. Compare this with other libraries like React, where you would create a virtual DOM, and React itself would diff that virtual DOM against the real DOM, applying changes only where needed.

spandrel walks a middle ground. We have a huge library of first- and third-party Widgets that are already built on the paradigm of direct DOM manipulation, so it's not feasible to completely switch over to a virtual-DOM-based approach. But direct DOM manipulation can still result in convoluted, inefficient code. spandrel's approach is to diff the configuration, not the DOM itself. You declare your DOM structure, and where you want other bajaux Widgets within that structure; spandrel will tweak the DOM in-place where possible, changing the Properties, readonly, and enabled states of Widgets, and load new values in where they are different. It may destroy or create new Widgets over time as your spandrel structure changes. spandrel minimizes the amount of DOM manipulation you, as the developer, need to worry about, even through bajaux won't ever get completely out of the DOM manipulation business itself.

Although you won't need to worry about it most of the time, keep in mind the fact that the DOM you create might sometimes get tweaked in-place instead of being rebuilt. <img> tags, if given an onerror, might need a corresponding onsuccess - that sort of thing. But the vast majority of the time, spandrel and our existing library of bajaux Widgets should handle these sorts of details for you.

Details about spandrel's diffing process

When a spandrel widget updates itself, it tries to follow a unidirectional data flow. The widget will generate an intermediate representation of what it should look like - what DOM elements and Widgets should make up its structure. Then spandrel will diff that against its actual current structure, and make whatever changes are necessary to bring it up to date.

This may sound similar to React's approach. But React's intermediate representation of itself takes the form of a virtual DOM, while spandrel's consists of a tree of JSON objects which define a structure of DOM elements and Widgets. Each of these JSON objects has a number of properties which may change over time. spandrel's response to changes in these properties are described below. (Note that static widgets do not change their structure, so this applies only to dynamic widgets.)

  • dom: If the element's tag name changes (e.g. from a div to a span), the whole Widget will be destroyed and rebuilt. Otherwise, the element's classes, styles, and attributes will be updated in-place.
  • enabled: The Widget will be enabled or disabled, and it will be re-rendered.
  • formFactor: The Widget's form factor will be changed, and it will be re-rendered.
  • properties: The Widget's Properties will be updated, and it will be re-rendered.
  • readonly: The Widget will be set readonly or writable, and it will be re-rendered.
  • type: The old Widget will be destroyed, and a new Widget instance of the new type will be constructed in its place.
  • value: The new value will be loaded into the Widget, and it will be re-rendered.

Usage of state in spandrel

Widgets themselves have state: what Properties does this Widget have? Is it currently readonly or disabled? What form factor is it set to? This information can also be described in an object: WidgetState.

As spandrel is dynamically constructing the configuration for a Widget, the Widget's own state is passed as the second argument to the spandrel function. This allows you to change the widget structure in response to the current state of the widget. In most cases, you'd want the enabled/readonly state of the parent widget to propagate to the children. For instance, if UserEditor is readonly, then you also want the editors for user.name and user.enabled to be readonly:

const UserEditor = spandrel((user, state) => {
  const { enabled, readonly } = state;
  return {
    name: { dom: '<span/>', value: user.name, enabled, readonly },
    enabled: { dom: '<span/>', value: user.enabled, enabled, readonly },
  };
});

But because this is such a common use case, spandrel will default the child widgets to inherit the readonly/enabled state from the parent. So if you're only using enabled/readonly to propagate them down, you can leave them out! The below example is completely equivalent to the one above.

const UserEditor = spandrel((user) => {
  return {
    // these will be enabled/readonly based on the parent
    name: { dom: '<span/>', value: user.name },
    enabled: { dom: '<span/>', value: user.enabled },
  };
});

spandrel.jsx

spandrel provides a custom JSX pragma that will let you use JSX to make it even easier to define your Widget structures. Simply insert the pragma:

/** @jsx spandrel.jsx */

and you can use JSX to define your HTML and widget structures.

It's important to understand that JSX is not React and the use of spandrel.jsx does not incorporate React into your application. It does not make use of a virtual DOM. It simply provides a more intuitive way of defining your HTML and Widget structure than a tree of JSON objects.

Please note that JSX requires Babel transpilation to function. The easiest way to incorporate Babel into your module will be to use grunt-niagara version 2 or higher.

Using spandrel.jsx to create Widgets

JSX can be used only when your Widget is dynamic. This is because JSX will always generate the DOM elements needed at runtime.

Let's take a look at the simplest example: a Widget that consists only of HTML - no child Widgets.

const HelloWorldWidget = spandrel(() => <span>Hello world!</span>);

The contents of the Widget will now be a span containing the string "Hello World". At build time, the JSX itself will be compiled out, and the spandrel.jsx function will convert it into valid spandrel data. You can consider the example above to be roughly equivalent to:

const HelloWorldWidget = spandrel(() => [ {
  dom: '<span>Hello world!</span>'
} ]);

One difference between bajaux Widgets and React components is that React components generate their own top-level DOM element, while bajaux Widgets are mounted in an existing, empty DOM element and generate the contents of that element. As such, the spandrel render function can actually return an array of elements - the children of your Widget's own element.

const HelloWorldWithLabelWidget = spandrel(() => [
  <label>My message is...</label>,
  <span>Hello world!</span>
]);

Defining the attributes of a DOM element works much the same as in React. Use className instead of class. style can be either a string, or an object literal (if an object literal, falsy values are ignored). Using JavaScript values instead of strings can be done by surrounding them with curly braces.

const StyledHelloWorldWidget = spandrel(() => (
  <label className="helloWorldLabel" style="padding: 5px;">
    <span style={{ color: 'red', backgroundColor: calculateBackground() }}>
      Hello world!
    </span>
  </label>
));
function calculateBackground() { return 'yellow'; }

In this example, note that the child elements of a DOM element can be denoted as a JavaScript array of JSX elements, using curly braces.

const SlotList = spandrel((component) => (
  <table className="slot-list-table">
    <tr><td>Slot Name</td><td>Slot Display</td></tr>
    {
      component.getSlots().toArray().map((slot) => {
        return <tr>
          <td>{ slot.getName() }</td>
          <td>{ component.getDisplay(slot) }</td>
        </tr>;
      })
    }
  </table>
));

Each element doesn't only have to be a DOM element though - it can be a Widget! You can embed bajaux Widgets right into your JSX alongside your DOM elements. The tag name of the element just needs to correspond to a Widget constructor available in your code. The configuration of the Widget element will look very similar to what gets passed to WidgetManager or fe.buildFor - it supports properties, enabled, formFactor, etc. className and style are supported too - they will be applied to the DOM element created to house the Widget. By default, the element will be a div - but you can specify what kind of element you want with the tagName attribute.

(If value is omitted and your widget is a dynamic spandrel widget, it will still render with a value of null.)

const NumberInput = spandrel((number, { properties }) => {
  const { min = '', max = '' } = properties;
  return <input type="number" value={ number } min={ min } max={ max }/>;
);

const PercentageInput = spandrel((percent) => {
  return [
    <NumberInput
     tagName="span"
     value={ percent }
     properties={{ min: 0, max: 100 }}
     formFactor="mini" />,
    <span>%</span>
  ];
});

Remember how each member of a spandrel config has a key (an array index or a property name on an object literal)? The key can be explicitly provided using the spandrelKey attribute as well. This makes the process of querying widgets quite straightforward:

class UserEditor extends spandrel((user) => (
  <div class="userEditor-wrapper" spandrelKey="wrapper">
    <StringEditor value={ user.name } spandrelKey="name" />,
    <BooleanEditor value={ user.enabled } spandrelKey="enabled" />
  </div>
)) {
  doRead() {
    // without the explicit keys, we'd query "0/0" and "0/1".
    return Promise.all([
      this.queryWidget('wrapper/name').read(),
      this.queryWidget('wrapper/enabled').read()
    ])
      .then(([ name, enabled ]) => ({ name, enabled }));
  }
}

Remember - your JSX data is not an actual DOM element, but it will be used to create an actual DOM element. Sometimes you will want to make changes to the actual DOM element before it is finally rendered and inserted into the actual document. This can be done using the $init attribute, which is a function that receives a DOM element and may make changes to it before it is inserted. (Note that $init must be synchronous.)

const StyledLabel = spandrel((string, { properties }) => {
  const { background } = properties;
  return (
    <label $init={ (el) => background.applyBackgroundToElement(el) }>
      { string }
    </label>
  );
});

return new WidgetManager().buildFor({
  type: StyledLabel,
  value: 'Hello World',
  properties: { background: Brush.make('yellow') }
});

Frequently Asked Questions

Is the old bajaux API going away?

Not at all. spandrel is just an additional API, on top of bajaux itself, that eases the construction and updates of nested trees of Widgets. Your existing Widgets will continue to function with no changes.

How can I debug what spandrel is doing under the covers?

Turn up the bajaux.spandrel log to FINEST. (Set the DebugService's Remote Logging property to true to apply this setting in the browser.) You will start getting debug information in the browser.

Can I use React components in conjunction with spandrel?

At the moment, spandrel only supports bajaux Widgets. But it is completely possible to create a "wrapper" Widget that mounts your React component inside of its own DOM element, and that wrapper Widget will work with spandrel.

Why is my spandrel widget not rendering anything at all?

Remember a dynamic widget renders itself according to the value that is loaded. Therefore, until load() is called, it will not render anything. Ensure you are calling load(), or providing a defined value argument to buildFor().

Definitions

Dynamic spandrel Widget: a spandrel Widget whose structure is determined by its current properties, and what value is loaded in. Its structure will be built in doLoad because it changes based on the value.

JSX: a library that converts HTML in .js files into JavaScript code. It works at compile time. spandrel uses it to convert HTML strings into spandrel data.

render: describes the full cycle of a spandrel widget, from when it generates spandrel data (either statically, or dynamically, in response to a value being loaded), to when spandrel itself uses that data to update the document.

re-render: when a dynamic widget changes (such as when a new value is loaded in, or its Properties change), its render function will be called again to generate an updated set of spandrel data, and spandrel will update the document itself, so the user sees the newest changes. Note that if the newly-generated spandrel data is exactly equal to the previous spandrel data, no changes will be made to the document at all.

const Span = spandrel((string) => {
  // my render function
  return <span>{ string }</span>;
});

new WidgetManager.buildFor({ type: Span, value: 'hello' })
  .then((ed) => {
    // when we load a new value, it triggers a re-render, which re-runs the
    // render function and updates the DOM.
    return ed.load('world');
  });

render function: the function passed to spandrel that defines a dynamic widget. It will be called whenever a value is loaded in. It must resolve valid spandrel data, which spandrel itself will use to update the actual document.

const Label = spandrel((string) => {
  // this is the _render function_
  return <label>{ string }</label>;
});

spandrel data: describes the data returned from a render function. It defines a tree or array of Widgets and the DOM elements in which they should be initialized. This structure closely resembles the data passed to fe.buildFor, but allows for nesting.

const Label = spandrel((string) => {
  // this is the _spandrel data_ being returned from the _render function_
  const spandrelData = {
    dom: '<label></label>',
    kids: [ `<span>${ string }</span>` ]
  };
  return spandrelData;
});

Static spandrel Widget: a spandrel Widget whose structure is the same in every single instance. It is not determined by a value loaded in. Its structure will be built in doInitialize because it never needs to change.