Categories


Archives


Recent Posts


Categories


Understanding uiElement’s `listens` default

astorm

Frustrated by Magento? Then you’ll love Commerce Bug, the must have debugging extension for anyone using Magento. Whether you’re just starting out or you’re a seasoned pro, Commerce Bug will save you and your team hours everyday. Grab a copy and start working with Magento instead of against it.

Updated for Magento 2! No Frills Magento Layout is the only Magento front end book you'll ever need. Get your copy today!

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 the testListen 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

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.

Copyright © Alan Storm 1975 – 2019 All Rights Reserved

Originally Posted: 21st November 2016