Another quick post on another defaults
entry for Magento’s uiElement
javascript class/constructor function. Before we can talk about listens
though, we’ll need to quickly review imports
.
In your Magento backend, navigate to the customer listing grid at Customers -> All Customers
in Magento’s backend. Then, in your javascript console, try running the following commands
//import registry
reg = requirejs('uiRegistry');
//we check the `customer_listing.customer_listing` object in
//the registry for a name property. Not needed, but helps
//make it clear what `imports` does
cl = reg.get('customer_listing.customer_listing');
console.log(cl.name);
//base class/constructor function
UiElement = requirejs('uiElement');
//define a class/constructor using UiElement as base
//this class imports value from customer_listing registry
OurClass = UiElement.extend({
defaults:{
imports:{
foo:'customer_listing.customer_listing:name'
}
}
});
//instantiate the view model
viewModel = new OurClass;
If you’re familiar with Magento 2’s RequireJS and Knockout.js systems, the above program should be pretty straight forward. First we fetch an instance of the uiRegistry object and the uiElement
object (a class/constructor function). Normally this is done with a define
function and not a call to requirejs
. Then, we create a new class/constructor function named OurClass
, with an imports
default. Finally, we instantiate a new object from this class/constructor function. Because of our imports
configuration, this class/constructor function will have a value
console.log(viewModel.foo);
customer_listing.customer_listing
If anything in the above program confused you, you may want to review the UiClass Data Features articles, part of the UI Component series. A quick read through the Advanced Javascript series wouldn’t hurt either.
Listening to an Import
Today we’re interested in the listens
default. You can see this used in the Magento core in places like this
#File: vendor/magento/module-ui/view/base/web/js/grid/editing/record.js
defaults:{
/* ... */
listens: {
elems: 'updateFields',
data: 'updateState'
},
/* ... */
}
After our recent posts on observables, you may think that listens
is about setting up default observable properties for a javascript object. You’d be half right. As we’ll learn below, listens
in another feature of Magento javascript systems that appears to be in progress, and incomplete.
The listens
default might have been better named listen_imports
. That’s because it lets you setup a listener callback that fires when values from imports are initially assigned. If that didn’t make sense, give the following new class a try (running in the same scope as the initial program from above)
//define a class/constructor with a listener
OurClass2 = UiElement.extend({
defaults:{
imports:{
foo:'customer_listing.customer_listing:name',
},
listens:{
foo:'testListen'
}
},
testListen:function(importedValue){
console.log("Called Test Listen");
console.log("Imported Value: " + importedValue);
console.log(this);
this.a_new_value = 'Hey Look, something new';
}
});
object = new OurClass2;
console.log(object.a_new_value);
Above we’ve created a new class/constructor function. This class/constructor function will import the customer listing component’s name into the foo
property. We’ve also setup a listens
default
listens:{
foo:'testListen'
}
The key is the property whose import we’re listening for, and value is the callback method to call. In other words, the above configuration says
When a value is imported into
foo
, call thetestListen
method
We’ve also defined a testListen
callback as part of the class/constructor function.
testListen:function(importedValue){
console.log("Called Test Listen");
console.log("Imported Value: " + importedValue);
console.log(this);
this.a_new_value = 'Hey Look, something new';
}
This callback has a single parameter (importedValue
above) that will contain the value that was just imported into the object. Javascript’s this
value will be bound to the just instantiated object.
When you instantiate your object
object = new OurClass2;
Magento will import the value from customer_listing.customer_listing
. Then, because of our listens
default, Magento will call the testListen
callback.
Although not present in our example, the listens
default gives you the opportunity to do more complex things with the values you’re importing from other objects.
It’s also possible to setup multiple callbacks listening to the same property. You can see that in the Magento core here with the cancel
and updateActive
methods.
#File: vendor/magento/module-ui/view/base/web/js/grid/filters/filters.js
listens: {
active: 'updatePreviews',
applied: 'cancel updateActive'
},
Enter Observables
As we mentioned earlier, the listens
default only works for properties listed in imports
. Also, as we demonstrated, the listens
default will fire for any property, even if that property is not defined.
We may have been fibbing when we said that. If the property (set via another default) is an observable, then the listens
callback will fire whenever that observable’s value is updated. Consider the following
ko = requirejs('ko');
OurClass3 = UiElement.extend({
defaults:{
foo:ko.observable('default'),
imports:{
foo:'customer_listing.customer_listing:name',
},
listens:{
foo:'testListen'
}
},
testListen:function(importedValue){
console.log("Called Test Listen");
console.log("Imported Value: " + importedValue);
console.log(this);
this.a_new_value = 'Hey Look, something new';
}
});
object = new OurClass3;
object.foo("Updating the Value")
If you run the above program, you’ll notice the testListen
callback fires when we instantiate our object, and also when we update the value in the foo
observable.
Again, listens
won’t work with any observable. The property you want to listen to needs to be listed in the listens
object. For the systems programming curious, you can see why this is in the object system’s source code, (specifically the following line: data = parseData(owner.name, target, 'imports');
)
#File: vendor/magento/module-ui/view/base/web/js/lib/core/element/links.js
setListeners: function (listeners) {
var owner = this,
data;
_.each(listeners, function (callbacks, sources) {
sources = sources.split(' ');
callbacks = callbacks.split(' ');
sources.forEach(function (target) {
callbacks.forEach(function (callback) {
data = parseData(owner.name, target, 'imports');
if (data) {
setData(owner.maps, callback, data);
transfer(owner, data, callback);
}
});
});
});
return this;
},
If you’re not systems programming curious, just give the following a try.
OurClass4 = UiElement.extend({
defaults:{
listens:{
foo:'testListen'
}
},
testListen:function(importedValue){
console.log("Called Test Listen");
console.log("Imported Value: " + importedValue);
console.log(this);
this.a_new_value = 'Hey Look, something new';
}
});
object = new OurClass4;
object.foo = "Setting a new Value";
You’ll see the listener never fires without an imports
section.
Want to be even more confused? If we set up a listener without imports
, but with an observable
ko = requirejs('ko');
OurClass5 = UiElement.extend({
defaults:{
foo:ko.observable('default'),
listens:{
foo:'testListen'
}
},
testListen:function(importedValue){
console.log("Called Test Listen");
console.log("Imported Value: " + importedValue);
console.log(this);
this.a_new_value = 'Hey Look, something new';
}
});
object = new OurClass5;
object.foo("Updating the value");
We’ll see the listener does not fire when the object is instantiated. However, the listener does fire when we update the value. It’s not clear if these interactions with observables is intended behavior, a side effect of Magento’s current implementation, an incomplete listens
feature, or plain old bugs. The next section will make things even less clear.
Listens and the Registry
Just like with imports
and exports
, Magento will parse the listens
property for strings that look like ES6 template literals. You can see this in much of the core Magento code.
#File: vendor/magento/module-ui/view/base/web/js/dynamic-rows/dnd.js
listens: {
'${ $.recordsProvider }:elems': 'setCacheRecords'
},
You’ll notice the above template literal expands out to a string like the following
foo:elems
That is, a string with a colon in it. Again, just like with imports
and export
, this colon indicates that Magento should reach into the registry to setup a listener. In our fake example above, we’d be listening to the following registry property
requirejs('uiRegistry').get('foo').elems
The semantics here are super confusing. Up until now, the listens
default only operated on the object we are defining, and the left side (i.e. the key) was the object property we were listening for. When you use the :
registry syntax, you’re not setting up an imports
listener – objects in the registry are already instantiated and have had their values imported. You’re just setting up a plain old listener method.
If that didn’t make sense, consider the following
//get our RequireJS module -- normally done via define
ko = requirejs('ko');
UiElement = requirejs('uiElement');
reg = requirejs('uiRegistry');
//create an object with an observable
object = new UiElement;
object.foo = ko.observable("A Default Value");
//add that object to the registry
requirejs('uiRegistry').set('registry_item_for_testing', object)
//create a new class/constructor-function
OurClass6 = UiElement.extend({
defaults:{
foo:ko.observable('default'),
listens:{
'registry_item_for_testing:foo':'testListen'
}
},
testListen:function(importedValue){
console.log("Called Test Listen");
console.log("Imported Value: " + importedValue);
console.log(this);
this.a_new_value = 'Hey Look, something new';
}
});
//instantiate the object, no listener fires
object = new OurClass6;
//but update the object's observable property in the
//registry and notice our handler fires
requirejs('uiRegistry').get('registry_item_for_testing').foo("A new Value");
So, the listens
setup of
listens:{
'registry_item_for_testing:foo':'testListen'
}
Let us setup a listener method for the registry object.
Too Many Things
The main problem with the listens
default, from a system programming point of view, is it’s trying to do too many unrelated things, and those things completely invert the semantics of the configuration. With listens I’ve found you can
- Set up a one time listener that fires when a value is imported
- Set up an observable listener for a property that always fires
- Set up an observable listener for a property that always fires except when a value is initially imported via an
imports
default - Set up an observable listener on a property in a completely different object
As a responsible client programmer, because there’s no documentation and this is a completely new system, it’s unclear which of the above I can rely on. i.e. what behavior is intended, what behavior is a side effect, and what behavior is actually a bug.
As a system programmer, because none of this behavior was initially documented, I need to reckon with what less-responsible client programmers will do with this, or face the possibility that future bug fixes may break production code in the wild.
Good documentation and systems programming go hand in hand. I spend a lot of thing thinking about this passage from folklore.org
Pretty soon, I figured out that if Caroline had trouble understanding something, it probably meant that the design was flawed. On a number of occasions, I told her to come back tomorrow after she asked a penetrating question, and revised the API to fix the flaw that she had pointed out. I began to imagine her questions when I was coding something new, which made me work harder to get things clearer before I went over them with her.
A corollary to this might be,
If good documentation is not forthcoming from a system, it may point to uncorrected design flaws.
Whether its good design or not, understanding how listens
works is important to debugging and reasoning about Magento’s existing systems. That said, until Magento Inc. documents the behavior of this feature and/or refines it so its intention is clearer, I’d stay away from using it in your own javascript code unless there’s absolutely no other way to do what you want.