The
spandrelAPI 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.
Performing dynamic widget lookups
One of the core values of the WidgetManager API is the ability to perform a dynamic lookup of a widget that's compatible with a particular piece of data. For instance, if you were implementing a Property Sheet, you wouldn't want to manually import every possible field editor, and manually choose one for each slot based on a giant if-else. You'd just ask the framework: for each slot on my component, find me a field editor that's compatible with the value at that slot.
spandrel supports this as well, because it uses the WidgetManager API to build out its widget structures. You can specify what kind of WidgetManager your spandrel widget should use by passing it as a manager parameter:
class MySpecialWidgetManager extends WidgetManager {}
const MySpecialWidget = spandrel((component) => {
return buildMyDomStructure(component);
}, { manager: new MySpecialWidgetManager() });
spandrel can also support pre-baked widget lookup strategies, so you don't have to manually inject one of the Niagara-specific widget managers. If your widget needs to use the Niagara registry to find widgets and field editors for Niagara types, you can pass strategy: 'niagara' to let spandrel inject that behavior. Note that this will introduce a dependency on the webEditors-ux module.
const MySpecialWidget = spandrel((component) => {
return buildMyDomStructureWithNiagaraFieldEditors(component);
}, { strategy: 'niagara' });
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 though 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 adivto aspan), 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 },
};
});
You can also make explicit that readonly and enabled are inherited from their parents by setting them to the string inherit. This is only for readability purposes, and this example is functionally equivalent to the two above:
const UserEditor = spandrel((user) => {
return {
name: { dom: '<span/>', value: user.name, readonly: 'inherit', enabled: 'inherit' },
enabled: { dom: '<span/>', value: user.enabled, readonly: 'inherit', enabled: 'inherit' },
};
});
You can also set properties to 'inherit'! This will cause the child widget to inherit the properties from its parent, with one notable exception: a uxFieldEditor property will never be inherited. If it did, then if you had specified a uxFieldEditor slot facet to choose a particular editor type, that would then propagate down and cause all of your child editors to be set to the same type, and you'd get caught in an infinite loop.
Please note: when setting properties, readonly, or enabled to 'inherit', their values will inherit from the owner (the widget who's rendering the spandrel data) - not necessarily the widget directly above it in the tree. In this example, they inherit from UserEditor, no matter how deep in the tree 'inherit' is declared.
One notable attribute of the state object is self. This is passed so that your spandrel function can still reference your own Widget instance and call methods on it, while still being implemented as an arrow function.
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
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 using an object literal, then the values can be either strings or numbers. Any non-numeric falsy values will be 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(), opacity: calculateOpacity() }}>
Hello world!
</span>
</label>
));
function calculateBackground() { return 'yellow'; }
function calculateOpacity() { return 0; }
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" min={ min } max={ max } value={ number } />;
});
const PercentageInput = spandrel((percent) => {
return [
<NumberInput
tagName="span"
value={ percent }
properties={{ min: 0, max: 100 }}
formFactor="mini" />,
<span>%</span>
];
});
When adding a Widget as an element as above, it requires you to have already imported or defined that widget's constructor. But what about one of spandrel's core functions: dynamically looking up a widget based on the value? This can be achieved with the special <any> tag. When spandrel encounters the <any> tag, it will perform a dynamic lookup based on the tag's value and other properties, find the appropriate Widget constructor, and construct an instance in its place.
Be sure to specify a lookup strategy, or provide a WidgetManager instance, so that spandrel will know how to perform the dynamic lookup.
const UserProperties = spandrel((user) => {
return <table>
<tr>
<td>Username:</td>
<td>
<any value={ user.username } />
</td>
</tr>
<tr>
<td>Enabled:</td>
<td>
<any value={ user.enabled } />
</td>
</tr>
</table>;
}, { strategy: 'niagara' });
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 real live HTMLElement 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({
dom: $('#labelGoesHere'),
type: StyledLabel,
value: 'Hello World',
properties: { background: Brush.make('yellow') }
});
Incorporating event handlers (new in Niagara 4.12)
Event handlers can be included directly in your JSX. There are several ways this can be done:
DOM Events
Standard DOM events can be listened for using onclick, onchange, etc.
const ButtonClicker = spandrel(() => {
return <button type="button" onclick={() => alert('click')}>Click me</button>;
});
bajaux events
bajaux events like LOAD_EVENT and MODIFY_EVENT can be armed using onUxLoad, onUxModify, etc. The names of these event handlers start with onUx, and the rest of the name is derived from the actual event name. See module:bajaux/events for a listing of all available bajaux events. ENABLE_EVENT can be listened for with onUxEnable; LOAD_FAIL_EVENT can be listened for with onUxLoadFail, and so on.
const AlwaysValidating = spandrel((value) => {
return <any value={value} onUxModify={(e, ed) => ed.validate()} />;
}, { strategy: 'niagara' });
The handlers for these events will receive the event as the first argument and the Widget that triggered the event as the second argument. Certain events will cause a third argument or more to be included; for instance onUxLoadFail will get the error that caused the failure as the third argument.
There is one special event handler included to solve a very common use case. When a child widget is modified, often we want to do something with the newly entered value. onUxModifiedValue will receive the new value as the first argument, saving us a call to read().
const ModificationLogger = spandrel((value) => {
return <any value={value} onUxModifiedValue={(newValue) => log(newValue)} />;
}, { strategy: 'niagara' });
Arbitrary events
Certain other events may have special characters in their names that make them impossible to translate to an HTML attribute like onclick or onUxLoad. Or you may want to refer to them by variable name instead of remember their actual event name. For these, you can use the generic on attribute and pass it handlers for events with arbitrary names.
One way to do this is via an object literal. The keys of the object are the event names, and the values are the event handlers. Again, the arguments to the handler are the event, the Widget that triggered the event, and any additional arguments to the event.
const { CELL_ACTIVATED_EVENT } = Table;
const TableContentViewer = spandrel((tableModel) => {
return <Table value={tableModel}
on={{
[CELL_ACTIVATED_EVENT]: (e, table, row) => showDetailsDialog(row.getSubject())
}} />;
});
If you need to add a selector to perform event delegation, you can set the value of on to an array with three members: the event name, the spandrel selector, and the event handler function. (If you need more than one of these delegated handlers, set on to an array of these arrays.)
const DelegatedButtonListener = spandrel((component) => {
const EVENT_NAME = 'click';
// note that the selector is a *spandrel* selector as used in queryWidget(), not a CSS selector.
return <div on={[ EVENT_NAME, '*/slotDisplay', (e, ed) => alert('slot value: ' + ed.value()) ]}>
{
component.getSlots().properties().toArray()
.map((prop, i) => (
<div spandrelKey={ `row${ i }`}>
<label spandrelKey="slotDisplay" value={component.get(prop)}>{prop.getName()}</label>
</div>
))
}
</div>;
});
Notes on spandrel event handlers
spandrel event handlers look similar to jQuery event handlers at first glance, but they are not the same.
jQuery event handlers are synchronous, which means that they cannot respond to a returned promise. Any asynchronous error handling in a jQuery event handler must be done manually:
dom.on('click', () => {
doSomethingAsync()
.catch((err) => logSevere(err));
});
The only thing that can be meaningfully returned from a jQuery event handler is false, which indicates that the event should be cancelled.
When performing async work in a spandrel event handler, you can return a Promise. spandrel will wait for the promise to be settled, and it will log any rejections to the bajaux.spandrel log.
spandrel(() => {
return <div onclick={() => doSomethingAsync()} />;
});
A spandrel event handler will also respect a return false and cancel the event, the same way jQuery does.
Inline validation (new in Niagara 4.13)
A spandrel widget will often place constraints on its child widgets, like so:
class PercentPicker extends spandrel((number) => {
return <any value={number} properties={{ min: 0, max: 100 }}/>;
}) {}
But by default, spandrel will not just automatically validate every child widget in its whole structure. Before inline validation, you would need to manually specify a validator function:
class PercentPicker extends spandrel((number) => {
return <any spandrelKey="number" value={number} properties={{ min: 0, max: 100 }}/>;
}) {
constructor() {
super(...arguments);
this.validators().add(() => this.queryWidget('number').validate());
}
}
As of 4.13, you can simply add the validate keyword to child widgets that you wish to validate. This can simply be a boolean validate property, which will tell spandrel that the child widget must validate using its own built-in validation behavior. For example, a numeric editor will typically validate against the min and max properties:
class PercentPicker extends spandrel((number) => {
return <any value={number} validate properties={{ min: 0, max: 100 }}/>;
}) {}
Or, you can set validate to a validator function. This will both indicate to spandrel that this editor must be validated, and adds additional validation. This can throw an error or return a rejected Promise to fail validation. Please note that this function will not replace any existing validation on the widget - it only adds additional validation.
class EvenPercentPicker extends spandrel((number) => {
return <any value={number}
properties={{ min: 0, max: 100 }}
validate={(val) => {
// the 0-100 check is already handled by the editor's built-in validation
// that respects the min/max properties.
if (val % 2) {
return Promise.reject(new Error(val + ' must be an even number'));
}
}} />;
}) {}
Extending a spandrel superclass
The general wisdom is that composition is preferable to inheritance, notably when it comes to UI elements. This also holds true for spandrel. However, you may sometimes need to inherit from a spandrel class. A couple of possible reasons why:
- Since the Widget-based mechanics of
bajauxare different from many other front-end frameworks, it may sometimes be difficult to use composition without introducing unnecessary DOM elements. - You might be implementing Widgets to mirror a Java-based class hierarchy, and inheritance makes it easier to reason about that one-to-one relationship between
bajauxandbajauiWidgets.
There are two reasons to extend a spandrel superclass: to render differently, or to override methods.
To extend a spandrel class for the purposes of overriding methods, there's no magic here - simply extend the class the JavaScript way.
class Button extends spandrel((text, { self }) => {
return <button type="button" onClick={() => self.handleClick()}>{ text }</button>;
}) {
handleClick() {
alert('you clicked it!');
}
}
class SuperButton extends Button {
handleClick() {
alert('you SUPER clicked it!');
}
}
But sometimes you might want to extend a spandrel class in such a way that causes it to render differently. In this case, spandrel needs to get the superclass baked into its render process, and simply extending the class isn't quite enough. As part of the second argument to spandrel itself - the same object that receives other configuration like manager and strategy described above - you'll need to add the extends property, with the superclass constructor:
const Label = spandrel(myLabelRenderFunction);
const SpecialLabel = spandrel(mySpecialLabelRenderFunction, {
extends: Label
});
By extending it in this way, you'll get the ability to actually tweak the superclass's render function. As part of the state parameter (the second parameter passed to the spandrel function), you'll receive a renderSuper function, which renders the spandrel data as implemented by the superclass. As a parameter to renderSuper, you can pass a function that will receive the widget state. You can make tweaks to this state and return a new state to cause the superclass to render differently. Here's an example:
const StyledLabel = spandrel((text, { properties }) => {
const { background } = properties;
return <label style={{ background }}>
{ text }
</label>;
});
const YellowLabel = spandrel((text, { renderSuper }) => {
return renderSuper((state) => {
state.properties.background = 'yellow';
return state;
});
}, { extends: StyledLabel });
In addition, renderSuper will resolve the actual spandrel data as rendered by the superclass, so you can change it directly. In general, this is not recommended - if you need to customize the actual HTML as rendered by the superclass, it's better to add support for more properties in the superclass so that the behavior can be configured purely through widget state.
Customized Widget State (New in Niagara 4.12)
Dynamic spandrel widgets are designed to update themselves whenever new data is available through a re-rendering process. This process can be severely simplified and described with the following code snippet:
spandrelWidget.read()
.then((currentValue) => spandrelWidget.render(currentValue));
The current value is read out, then fed directly back into the widget's render process to cause it regenerate all of its spandrel data and to update itself in its entirety.
But in many cases, the currently loaded value is not enough information to fully update the widget. For example, take a look at the field editor for a gx:Font. If you load in the font 12pt Arial bold, the field editor knows enough to load Arial into the name field, 12 into the size field, and to check the Bold checkbox. Now, take a look at the Null checkbox. If you check that, it indicates you've chosen the null font (that is, no font specified at all). This causes the name, size, bold, italic, and underline checkboxes to blank themselves out, and the field editor to read out the font null. So far so good.
Now, uncheck the Null checkbox. What happens now? Because we blanked everything out in the previous step, if we simply read out the current font and passed it directly back into the rerender process, as described above, spandrel would have forgotten your previous entries (Arial, 12, bold). This is because during the rerender, after reading out Font.NULL, when it got passed back in, nowhere in that value is Arial, 12, or Bold. The editor would have to start over with some default settings. This would be irritating to your user if, out of curiosity, they checked and unchecked Null - they'd lose all their work. To get around this, there needs to be some other place than "the currently loaded value" to store the state of the widget.
Niagara 4.12 introduces the concept of state to store additional, current information about the widget, on top of the currently loaded value. State binding can be done regardless of what kind of data you are returning from the spandrel function (whether array, object literal, or JSX) - but it's easiest to reason about when using JSX, so all the following examples will use JSX.
Take another look at the second argument to the spandrel function - the one that includes information about the widget itself:
const UserEditor = spandrel((user, { readonly, properties, formFactor }) => {
const { trueText, falseText } = properties;
return [
<StringEditor readonly={ readonly } value={ user.value } />,
<BooleanEditor readonly={ readonly } value={ user.enabled } properties={{ trueText, falseText }} />
]
});
As described above, this argument provides information about the current state of the widget - its readonly status, Properties, form factor, etc. But with the State API, you can define and store any kind of state data you want! The widget state is a great place to store information about what values are currently loaded into child widgets, because the state persists across new values as they are loaded, and across rerenders as well. If the user makes a change to a child widget, and you store the newly entered value in the state, and then a rerender causes that child widget to disappear or be overwritten - that value remains stored in the state for you to use later.
The Font editor uses the state exactly for this purpose. If you type Arial into the name field, then name: 'Arial' goes into the widget state, and stays there even if the Null checkbox causes it to be blanked out. When Null is unchecked, name: 'Arial' comes back out of the widget state, so the user can pick up where they left off.
Updating the state can be done manually, as shown below:
const { MODIFY_EVENT } = events; // bajaux/events
class FontEditor extends spandrel((font, { name, enabled, properties }) => {
// note the "name" property is not part of core widget state - this is specific to FontEditor.
return /* some html */;
}) {
doInitialize(dom) {
dom.on(MODIFY_EVENT, '.name-editor', (e, ed) => {
ed.read()
.then((name) => {
// store the name the user typed in state.
// now we have it for reference even if the
// "null" checkbox wipes out the name editor.
return this.state({ name });
})
.then(() => this.rerender())
.catch(logSevere);
});
}
}
But spandrel provides some built-in API for automatically binding child widgets to state, so you have much less code to think about.
State Binding
By applying state binding to a child widget, you are asking spandrel to continually keep the current value of that widget in state. As the user makes changes to that child widget, its current value will be applied to state, even as they are typing and clicking. As the widget then rerenders itself in response to changes in data, the most up-to-date changes from the user will be known (even if they are hidden or overwritten in the UI itself).
Another way to think about this: if a widget will change the way it renders itself in response to new values that the user enters into child widgets, then bind those child widgets to state, and the most recently entered values will always be known (and entered into state) at render time. (Otherwise, you'd have to manually call read() on every widget whose value you care about!)
For an example, let's consider a highly simplified version of the Font field editor - one that only has the font name and the Null checkbox.
class FontEditor extends spandrel((font) => {
const isNull = font.isNull();
return [
<StringEditor value={ isNull ? '' : font.getName() } enabled={ !isNull } spandrelKey="name" />,
<BooleanEditor className="nullEditor" value={ isNull } spandrelKey="isNull" />
];
}) {
doInitialize(dom) {
dom.on(MODIFY_EVENT, '.nullEditor', () => {
this.rerender();
});
}
doRead() {
return Promise.all([
this.queryWidget('name').read(),
this.queryWidget('isNull').read()
])
.then(([ name, isNull ]) => isNull ? Font.NULL : Font.make({ name }));
}
}
This has the problem of immediately forgetting the user-entered font name as soon as the Null checkbox is checked. In addition, we have to manually rerender whenever the Null checkbox is checked or unchecked. By using state binding, we can solve both of these problems.
With state binding, we can bind both child editors to values that live in the widget state. This means that changes to either bound editors will result in the widget state being updated, and then the widget being rerendered.
class FontEditor extends spandrel((font, { name, isNull }) => {
return [
<StringEditor value={ isNull ? '' : font.getName() } enabled={ !isNull } bind spandrelKey="name" />,
<BooleanEditor value={ isNull } bind spandrelKey="isNull" />
];
}) {
doRead() {
return Promise.all([
this.queryWidget('name').read(),
this.queryWidget('isNull').read()
])
.then(([ name, isNull ]) => isNull ? Font.NULL : Font.make({ name }));
}
}
Observe the bind keyword on each child editor. By itself, bind tells spandrel to set the state value to the same key as spandrelKey. Because the StringEditor has bind spandrelKey="name", the name property of the widget state will get set every time it is modified. If you wished to use a different state key than the spandrelKey, you could specify bindKey instead.
However, this editor still won't work. Remember, the widget state is an entirely separate data structure than the value that gets loaded in (in this case, the Font). So as it is, the state will be unpopulated on first render, and we'll just get a blank/broken editor! How can we initialize the widget state so we can render correctly the first time?
The answer is the toState function. This function receives the value being loaded in (the Font) and returns the initial state of the widget based on that value. It will automatically be called for you as part of the spandrel lifecycle - all you need to do is implement it.
Similarly, we're doing a significant amount of extra work with doRead. We shouldn't need to query the widgets out and read their values - we already know those values from the state (because the widgets are bound!). Can't we just read the widget's current info out of the state? You guessed it - fromState is the callback to go in the opposite direction. It takes the widget's current state and allows you to build the value to resolve doRead with. Again, just implement it - if you do, it takes the place of doRead.
All together:
class FontEditor extends spandrel((font, { name, isNull }) => {
return [
<StringEditor value={ isNull ? '' : name } enabled={ !isNull } bind spandrelKey="name" />,
<BooleanEditor value={ isNull } bind spandrelKey="isNull" />
];
}) {
toState(font) {
return { name: font.getName(), isNull: font.isNull() };
}
fromState({ name, isNull }) {
return isNull ? Font.NULL : Font.make({ name });
}
}
Now you have a Font editor that is impervious to data loss as the string editor gets wiped out, and has the current data values always at hand in the widget state!
Note that you may want to avoid the bind keyword on some child widgets if that widget requires a network request on load. In those cases, it is better to override doRead() and avoid those extra network requests that would come with a widget modification when the bind keyword is present.
Modified widgets, dirty changes, and the lax keyword
So here's a problem: spandrel widgets are very likely to continually rerender themselves as the user makes changes. As rerenders occur, new data values get loaded into your spandrel widget's children. If the user is currently typing in one of those child widgets, what happens if a new value gets loaded in? The user would lose their changes right at the moment they were typing, which would be extremely annoying!
Because of this, there are two cases where spandrel will decline to load a new value into a widget.
The first case: if a widget has focus - i.e., the user's cursor is currently in this widget, and they're in the process of typing something. spandrel will not overwrite the value of a modified, focused widget - no matter what. (Otherwise, a user could be actively in the process of typing and see their changes get immediately wiped out, which would be a terrible experience.)
The second case: if the widget is modified. By default, spandrel will not load a new value into a widget that has user-entered changes, as in most cases, the user will not want to see those changes overwritten.
However, there are many cases where you will want changes to apply even if the target widget is modified. For instance, in our FontEditor above: if the user types in a font name, but then checks the Null checkbox, we still want that font name editor to blank out, even though it's modified! Fortunately, we as developers have several ways to make that happen.
Call load(). spandrel only skips loading modified widgets during a rerender. If you have a brand-new value to load into your spandrel widget, just call yourSpandrelWidget.load(newValue) and that value will be loaded in - even if the user has made changes.
Make a "dirty change." To update state, you don't always have to use state binding: you can also just set the state yourself by calling the state() function.
this.state({ foo: 'bar' });
By manually setting the state like this, it's like you, as the developer, are saying "I want the widget bound to this state key to receive this value - even if it's modified." This marks that particular state key as "dirty." On the next re-render (which will be triggered automatically by the call to this.state()), the widget bound to that key is going to get that new value, modifications or no. This applies for one rerender exactly - so if you want to overwrite that modified widget a second time, you'll have to call this.state() again.
(Note that this only works for bound state keys. If "foo" were not bound to a widget, this would still set the state, but wouldn't overwrite any widgets or trigger a rerender.)
Mark it lax.
This is a little more "shotgun" approach than the previous, but works in many circumstances. Simply add the lax attribute to your JSX widgets (or lax: true if building the spandrel data by hand). This will mark that widget as open for overwriting, regardless of whether it's modified or not:
class FontEditor extends spandrel((font, { name, isNull }) => {
return [
<StringEditor value={ isNull ? '' : name } lax bind spandrelKey="name" />,
<BooleanEditor value={ isNull } bind spandrelKey="isNull" />
];
}) { /* ... */}
Mark it unmodified.
If neither of the above approaches works for you, your use case may call for child widgets to be overwritten if modified... some times, and not others. In this case, you will simply need to explicitly mark the widgets as unmodified at the appropriate time by calling setModified(false) on them.
(If you have other use cases that you feel spandrel could do a better job of handling, please let us know!)
Broken state: when widgets fail to read
When you bind a child widget to state, it's possible for that widget to completely fail to read out a value. For instance, the user might type "hello world" into a numeric editor - there's no way to read a numeric value from "hello world", so spandrel will fail to propagate that value into state, and your state will become inconsistent.
In this case, state will still work - but if you attempt to read that bound value from the state object, an error will be thrown. Just be aware of this, in case you want to completely ignore the value of certain widgets on read. If you try to pull that bound value out of state when you don't really need it, your widget might fail to read out a value when it might otherwise work.
class AgePrompt extends spandrel((user, { age, sharesAge }) => {
return <div>
<div>
<span>I wish to share my age:</span>
<BooleanEditor bind spandrelKey="sharesAge"
value={ sharesAge }
properties={{ trueText: 'Yes', falseText: 'No' }} />
</div>
<div>
<span>My age is:</span>
<NumericEditor bind spandrelKey="age" value={ age } enabled={ sharesAge } />
</div>
</div>;
}) {
toState(user) {
// data structure of a user object: { sharesAge: [boolean], age: [number] }
return user;
}
fromState(state) {
const { sharesAge } = state;
const user = { sharesAge };
// if the user typed "hello world" into the numeric field,
// then accessing state.age here will throw an error.
// if they choose not to share their age, we don't want to
// force them to type a valid number for it anyway.
user.age = sharesAge ? state.age : null;
return user;
}
}
Do I really need to use state?
In many cases, no. If the currently loaded value is enough information to fully capture the state of your widget, then the regular rerender process will do just fine. But if the widget keeps more information than can be captured in the loaded value, then state is a great place to store it.
A typical, prominent use case for when state will come in handy is:
- Your widget has multiple child widgets.
- A change to one child widget will also cause changes to other child widgets.
- You do not want to lose any user-entered changes to those other child widgets that would be overwritten by those changes.
Those other user-entered changes can then be socked away in state to persist across rerenders.
Creating Widget Containers with UxModel (new in Niagara 4.14)
(Note: the RequireJS model to import is nmodule/bajaui/rc/model/UxModel in 4.14. It was moved to bajaux/model/UxModel in 4.15.)
Certain widgets have the task of serving as a container for other widgets, adding on additional layout logic or other behavior. Most of these widget containers fall into the "Pane" category - we'll consider "Pane" and "widget container" to be synonymous in this section, although you may certainly create a non-Pane widget that contains other widgets.
Some examples are:
- A BorderPane contains a widget of the user's choice and shows a border around it.
- A ScrollPane contains a widget of the user's choice and allows the user to scroll if the widget is larger than the ScrollPane itself.
- A SplitPane contains two widgets of the user's choice and allows the user to move the divider between them.
In all of these cases, someone (the user) has to indicate to the Pane exactly what their choice of widget is. In bajaux, one well-supported way to pass these instructions to the widget container is UxModel.
This section of the spandrel tutorial will not dive deep into UxModel itself - that is already covered in the section on UxMedia. It's enough to understand that a UxModel instance represents instructions on how to instantiate and load a Widget into the browser. A Pane will execute these instructions and build the requested Widget, adding additional functionality as needed.
Building a User-Configured Widget Container
We'll step through a simplified implementation of BorderPane for this example. BorderPane's job will be to consume a UxModel with instructions from the user on what widget should be shown in the BorderPane, and build it in a container that shows a border.
As a consumer of a UxModel, you'll mostly be concerned with the children of that UxModel: these are the child widgets that the user is requesting to be built. These child widgets will be addressable by name, just like children of Niagara Components. They can be accessed by simply calling model.get(name).
A common name for the content of a Pane that displays a single child widget is content. We'll follow this convention here.
So: our task for BorderPane breaks down as follows:
- Create an HTML element with a border.
- Get the
contentchild of the UxModel, which represents the information: what kind of content widget should the BorderPane build? - Use those instructions to build the content widget into that HTML element.
We can implement this as follows:
/** @jsx UxModel.jsx */
define([ 'bajaux/spandrel', 'bajaux/model/UxModel' ], function (spandrel, UxModel) {
return class BorderPane extends spandrel((uxModel) => {
return <widget src={uxModel.get('content')}
style={{ border: '3px solid red' }}
tagName="div"
className="content-widget"/>;
}) {};
});
First, note the new @jsx tag which sets the JSX pragma to UxModel.jsx. This works just the same as spandrel.jsx, but introduces even more functionality to simplify the use of UxModels. This tag must be accompanied by an import of bajaux/model/UxModel to compile correctly.
Next, note that the render function receives a UxModel instance. This object contains instructions on how the user wishes for the BorderPane to build out its contents. We'll see in the next step how this input UxModel might be created.
Finally, notice the unfamiliar tag widget. Its src attribute is set to the content child of the input UxModel. The widget tag is syntactic sugar introduced by UxModel.jsx: it indicates that spandrel should build a Widget in this HTML element, and it should use the instructions specified by src to do it. Since UxModel represents instructions on how to build a Widget, spandrel will build that content Widget into the HTML element as required.
We're done! We've implemented a BorderPane, who receives instructions on how to build a content Widget, and builds that Widget into an element surrounded by a border.
Next: we'll take a look at how you, as a user of BorderPane, can pass those instructions to BorderPane so that it builds the widget of your choice.
Passing a UxModel to a Widget Container
When using UxModel.jsx, any JSX element that is 1) a Widget constructor and not an HTML tag, and 2) has child elements, will have those child elements converted to a UxModel and passed in to that Widget to be loaded and rendered.
You don't have to use JSX to use UxModel - you can load() a UxModel instance directly - but JSX significantly simplifies this process, so we will make use of it.
All we must do to populate our BorderPane is to embed our content Widget as a child of the <BorderPane> tag.
/** @jsx UxModel.jsx */
define([
'bajaux/spandrel',
'bajaux/model/UxModel',
'nmodule/bajaui/rc/ux/Label',
'nmodule/myModule/rc/BorderPane' ], function (
spandrel,
UxModel,
Label,
BorderPane) {
return class LabelWithBorder extends spandrel(
<BorderPane>
<Label name="content" properties={{ text: 'I have a border' }} />
</BorderPane>
) {};
});
In our example, the content widget is a Label. It could be any Widget constructor, a widget or any tag, or a plain HTML tag.
Also, notice that the name attribute is set to content. This is how UxModel.jsx knows to create the content child of the UxModel that the BorderPane will receive.
The children of a UxModel can be arbitrarily named. We use the content name here specifically because that is how BorderPane receives its instructions on how to build its content Widget. You might compare with the bajaui version - BBorderPane - and how it declares a public frozen Property named content. This pattern provides a sort of public API: as a user of BorderPane (or BBorderPane) you tell it what content widget it should show by giving it a value named content.
UxModel children can also be configured without names. GridPane, for instance, will ignore the names of the child UxModels and simply arrange them all into a grid layout.
Putting it together
To clarify: there is a straight line between the <Label name="content"/> we declare in the LabelWithBorder widget, and the uxModel.get('content') we consume in the BorderPane widget. We are using JSX to create UxModel instances that define the children of a Widget container, and the Widget container turns those UxModels into actual Widgets!
<BorderPane>
<Label name="content" properties={{ text: 'I have a border' }} /> <!-- this... -->
</BorderPane>
class BorderPane extends spandrel((uxModel) => {
return <widget src={uxModel.get('content')} // <-- *IS* this!
// uxModel.get('content').getProperties().text will be 'I have a border'.
style={{ border: '3px solid red' }} />;
}) {}
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().
I'm manually modifying the DOM after rendering. Why doesn't a rerender wipe my changes?
spandrel's diffing process compares the DOM as returned by the first call to the render function, against the DOM as returned by the second call to the render function. It figures out what the differences are, and applies those differences to the real DOM.
If you apply your own manual modifications to the DOM, independent of the render process, then spandrel won't know you've made them. Your manual modifications will persist, unless the render process's own changes overwrite them.
The best way to address this is to perform your manual modifications in the doLayout callback, and to ensure that they are consistently applied regardless of whether spandrel applies any changes to the DOM or not. doLayout will be called after each render.
(Note: This behavior was introduced in 4.11. In 4.10 and previous, spandrel would often completely remove the style and/or class of DOM elements, regardless of what individual changes were made, so manual modifications would often be wiped on a rerender. This caused a number of problems with child widgets, so now only the results of the render function are diffed.)
I'm rerendering, but widgets won't update if they've been modified by the user.
This is by design. Due to the asynchronous nature of bajaux Widgets, widgets that have been modified by the user can't be re-rendered on every modification (attempting to do so results in some very jumpy and/or broken behavior). Therefore, by default, any modified widgets (such as string editors you're currently typing in) won't respond to re-render requests.
To address this, see the lax property and other approaches, as described in the State Binding section.
Some of my HTML attributes aren't applying correctly!
When using JSX to create DOM elements, spandrel does not apply the attributes all at once: it applies them one after another. Therefore, in some situations, the order of attributes matters, even when it would make no difference when parsing a simple HTML string.
Here's an example:
<input type="range" min="0" max="1000" value="500"/>
In both regular HTML and spandrel's JSX implementation, this works fine. Now compare it with this:
<input type="range" min="0" value="500" max="1000"/>
Because spandrel sets attributes sequentially, when the value attribute is set, the min attribute has been set but max has not. An input tag with a min but no max will default the max to 100 (MDN). So the value gets set to 100, not 500! This is a case where ordering matters: the max must be set first.
I'm calling load() with new data, but spandrel is not actually loading my new values.
When deciding whether values have changed, spandrel will diff "the value returned by the previous render" against "the value returned by this render." Most of the time this is straightforward:
const NumberEditor = spandrel((num) => <span>
<label>Enter number:</label><any value={num} />
</span>);
numberEditor.load(1)
.then(() => numberEditor.load(2));
The first time it renders, the value being loaded into the child editor will be 1, and the second time it renders, it will be 2 - so spandrel knows that the new value is different and needs to be reloaded into the child editor.
Where it can get dicey is when objects are instance-equal. Consider this example, in which our intention is to load up a user with a name property, change their name, and reload to reflect the new values.
// the implementation of NameEditor is not interesting.
const NameEditor = spandrel((name) => <span>
<label>First name:</label><any value={name.first} />
<label>Last name:</label><any value={name.last} />
</span>);
// examine the behavior of UserEditor.
const UserEditor = spandrel((user) => <span>
<NameEditor value={user.name} />
</span>);
const user = { name: { first: 'Moe', last: 'Howard' } };
return userEditor.load(user)
.then(() => {
user.name.first = 'Larry';
user.name.last = 'Fine';
return userEditor.load(user);
});
It sure looks like it should reload the editor with the new values, but it won't! When the UserEditor does the diff to see if the value to load into the NameEditor has changed, remember it is checking "the value returned by the previous render" against "the value returned by the current render." They're the same value - in this example, user.name is always instance-equal. When we set the first and last properties of user.name, we're not just changing "the value returned by the current render" - we're also changing "the value returned by the previous render"! So when spandrel does the diff, the values are the same, and it will not see any reason to load anything into the NameEditor.
Any one of the following approaches will work instead:
// we could ensure that user.name refers to a brand new instance when passing it to UserEditor.
return userEditor.load(user)
.then(() => {
user.name = { first: 'Larry', last: 'Fine' };
return userEditor.load(user);
});
// even better, we could pass a fresh user altogether (think "stateless"):
return userEditor.load({ name: { first: 'Moe', last: 'Howard' } })
.then(() => {
return userEditor.load({ name: { first: 'Larry', last: 'Fine' } });
});
// we could even make it UserEditor's job to ensure that NameEditor receives fresh values
// no matter what happens to the user object externally.
const UserEditor = spandrel((user) => {
const { first, last } = user.name;
return <span><NameEditor value={{ first, last }} /></span>;
});
I'm calling value() from within doRead(), and it's breaking my rerendering!
When you call load() on a bajaux Widget to load in a new value, this.value() starts to immediately return that new value - even before any of the loading work is done! If your spandrel widget is calling this.value() inside of doRead(), your diffing process may not work correctly when a new value is loaded in.
Instead of referencing "static" parts of the widget's value by calling this.value(), consider storing that information in state instead. This way, when you load a new value, the widget knows its previous values when it rerenders, and the diffing process will work correctly.
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.