Categories


Archives


Recent Posts


Categories


Modifying a jQuery Widget in Magento 2

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.

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

Like its brethren plugin and module, the word widget has the unfortunate distinction of being a popular way to describe a bunch of computer code without a corresponding strict definition of what a widget is.

Magento 2 continues this tradition and adds their typical distinct spin. Like Magento 1, Magento 2 has a “CMS Widget” system that allows developers to create user interfaces for data entry of structured content blocks. While an interesting system, that’s not what we’re here to talk about today.

Instead, we’re going to talk about Magento’s use of jQuery Widgets. The jQuery widget system is a part of jQuery UI. It’s marketed as a way to develop your own user interface elements for jQuery UI, but long time readers of this website will recognize it for what it is: An object system built on top of javascript.

Why should you care about jQuery widgets?

A good chunk of Magento’s default user interface is built-out using custom jQuery widgets — so much so that earlier evangelism efforts often confused jQuery widgets as Magento Widgets. Also, a good chunk of Magento’s underlying javascript systems were built with jQuery widgets in mind, but proved useful enough that other systems (UI Components, etc) piggy backed on them — creating more confusion. Also also, because all Magento javascript is (or should be) bootstrapped through RequireJS, it’s not always clear how/where widgets are defined, or how we can use them as Magento 2 developers.

Finally, as Magento 2 developers, it’s usually our job to change the behavior of the default system in some subtle way. While it’s possible to do this with jQuery widgets, it’s not always obvious how to do this in Magento 2.

Today we’re setting out with the end goal of developing a systematic approach for replacing a method defined on a jQuery widget in Magento 2. In order to do that we’ll need to briefly discuss what jQuery widgets are, how Magento typically defines them, and the extra systems Magento’s introduced for using them.

While this article is intended for anyone working with the Magento 2 system, it will help if you’ve already worked your way through our Magento 2 Advanced Javascript series (especially the javascript init and javascript mixins articles), the Serving Frontend Files, Adding Frontend Files to your Module, and RequireJS articles in our Magento 2 for PHP MVC developers series, and jQuery’s five part Widget Factory series.

What are jQuery Widgets?

If you’ve done any significant jQuery programming, you’ve probably encountered a third party extension or plugin that added a custom function to jQuery. Something you’d use like this

jQuery('#some-id').calender({/* ... config for calendar ...*/});

If you’re a very long time reader, you might remember my pre-Magento four part series on developing a jQuery plugin.

The jQuery widget system is an attempt to further formalize plugin development, help prevent namespace collisions in plugins, and give developers state management and full object lifecycle methods for their plugins. We’re not going to cover widgets in full — jQuery does a good job of that themselves. However, we are going to frame our discussion of widgets.

The widget system is, on one level, just another javascript object system. In jQuery, you create a widget definition with code that looks something like this

jQuery.widget('ournamespace.ourPluginMethod', {
    _create:function(){
        //widget initilization code here, widget has
        //access to things like this.options, this.element
        //to access configuration and the matched dom node
    },
    hello:function(){
        console.log("Say Hello");
    }
});

The above code would make a method named ourPluginMethod available for jQuery client programmers.

//instantiate a widget instance
jQuery('.some-node').ourPluginMethod({/* ... initial config ...*/});

When we call jQuery.widget — we’re creating a widget definition. This is similar to creating a class definition file in a traditional object system. When a developer says jQuery('.some-node').ourPluginMethod, this is similar to a developer instantiating an object using a class definition file. The jQuery widget system even allows you to call through to widget methods via a (slightly weird) API

var widgetInstasnce = jQuery('#the-node').ourPluginMethod({/* ... initial config ...*/});

//call the `hello` method
widgetInstasnce.ourPluginMethod('hello');

One of the more confusing things about widgets are the namespace — ournamespace below

jQuery.widget('ournamespace.ourPluginMethod',

This namespace is not something that client programmers are normally exposed to — as we said, all they need to do is call the ourPluginMethod method. Instead, the namespace is there so jQuery has a key to store the widget definition object by. If you peek at the global jQuery object, you’ll find your widget definition object stored under your namespace.

console.log(jQuery.ournamespace.ourPluginMethod)

Widgets are a powerful and complex system — if you’re going to customize how the default Magento theme(s) behave you’ll want to learn them inside and out. However, for today, the most important thing to understand about widgets is they’re just another javascript object system.

Magento 2 and jQuery Widgets

So, that’s plain jQuery widgets without Magento. Magento 2 offers users a number of custom widgets built using the jQuery UI pattern. However, Magento 2’s reliance on the RequireJS module loader means using these widgets isn’t as straight forward as you may be used to.

Magento defines widgets inside RequireJS modules. For example, Magento’s core code defines the list widget in the mage/list module.

//File: lib/web/mage/list.js
define([
    "jquery",
    'mage/template',
    "jquery/ui"
], function($, mageTemplate){
    "use strict";

    $.widget('mage.list', {        /*...*/});
    /*...*/
    return $.mage.list;
})

As you can see, the mage/list module defines a widget in the mage namespace, with a name of list. This means if you want to use the list widgets in your jQuery programs, you need to do something like this

requirejs([
    'jquery',
    'mage/list'
], function($, listWidget){
    $('#some-node').list({/* ... */});
})

The above RequireJS based program has two dependencies. The first is the jQuery library itself, and the second is the list widget. You’ll notice we never actually use the listWidget variable in our program. We need to load the mage/list module so that the widget gets defined. However, once defined, we don’t have any need for the actual widget objects returned by the mage/list module. We access the list method directly via the jQuery object.

This is the general pattern Magento widgets follow. However — Magento being Magento — there are times where the core code strays from this simple “one widget, one RequireJS module” pattern. For example, the Menu and Navigation widgets are both defined in the mage/menu RequireJS module.

//File: vendor/magento/magento2-base/lib/web/mage/menu.js
define([
    "jquery",
    "matchMedia",
    "jquery/ui",
    "jquery/jquery.mobile.custom",
    "mage/translate"
], function ($, mediaCheck) {
    'use strict';

    $.widget(/*...*/);


    $.widget(/*...*/);

    return {
        menu: $.mage.menu,
        navigation: $.mage.navigation
    };
});

The mage/menu module also offers another example of something to watch out for. Magento often aliases its jQuery-widget-defining-RequireJS-modules. For example, you can see mage/menu aliased as menu here

//File: vendor/magento/module-theme/view/frontend/requirejs-config.js
    "menu":                   "mage/menu",

This means the following two programs are equivalent

requirejs([
    'jquery',
    'mage/menu'
], function($, menu){
});

requirejs([
    'jquery',
    'menu'
], function($, menu){
});

Most (but not all) of Magento’s jQuery-widget-defining-RequireJS-modules are aliased like this.

Finally, sometimes Magento defies all convention. Consider the calendar widget. So far, an astute reader might assume that the calendar widget is defined via a RequireJS module named mage/calendar. They’d be right so far in that there’s a lib/web/mage/calendar.js file that Magento invokes as a RequireJS module named mage/calendar. You can see an example of that here.

//File: vendor/magento/module-ui/view/base/web/js/lib/knockout/bindings/datepicker.js
define([
    /* ... */
    'mage/calendar'
    /* ... */        
],
/* ... */

However, the calendar.js file is not actually a RequireJS module. Instead it’s an immediately invoked anonymous callback function that defines both the mage.dateRange and mage.calendar widget.

//File: vendor/magento/magento2-base/lib/web/mage/calendar.js

(function (factory) {
    'use strict';

    if (typeof define === 'function' && define.amd) {
        define([
            'jquery',
            'jquery/ui',
            'jquery/jquery-ui-timepicker-addon'
        ], factory);
    } else {
        factory(window.jQuery);
    }
}(function ($) {
    /* ... */
    return {
        dateRange:  $.mage.dateRange,
        calendar:   $.mage.calendar
    };        
}));

This callback style allows a developer to use the lib/web/mage/calendar.js file as both a RequireJS module or as a bog-standard <script src=""></script> javascript include. This comes at the cost of some confusion for developers coming along later (i.e. us).

Instantiating Widgets with Magento 2

As we previously mentioned — when a developer calls the jQuery.widget method

$.widget('foo.someWidget', /*...*/);

they’re creating a widget’s definition — similar to a PHP/Java/C# developer defining a class. When a developer uses the widget

$(function(){
    /* ... */
    $('#someNode').someWidget(/*...*/);   
});

they’re telling jQuery to use the foo.someWidget definition to create or instantiate the widget, similar to how a PHP/Java/C# developer might instantiate an object from a class

$object = new Object;

While it’s possible to use these Magento 2 defined widgets in the same way

requirejs([
    'jquery',
    'mage/list'
], function($, listWidget){
    $('#some-node').list({/* ... config ... */});
})

Magento 2 offers two new ways of instantiating widget objects — and that’s the data-mage-init attributes, and the x-magento-init script tags. We covered both in our Javascript Init Scripts article. It turns out that both data-mage-init and the x-magento-init form with a DOM node (not the * form) are widget compatible. That is, you can say

<div id="some-node" data-mage-init='{"mage/list":{/* ... config ... */}}'></div>

and it’s equivalent to

$('#some-node').list({/* ... config ... */});    

This works because the mage/list module (and other Magento 2 “widget modules”) returns the widget callback that jQuery creates ($.mage.list below)

//File: lib/web/mage/list.js
define([
    "jquery",
    'mage/template',
    "jquery/ui"
], function($, mageTemplate){
    "use strict";

    $.widget('mage.list', {        /*...*/});
    /*...*/
    return $.mage.list;
})

and the data-mage-init and x-magento-init techniques expect a RequireJS module that returns a function with the same signature as a jQuery widget callback. In fact, it’s probably safe to say that both data-mage-init and x-magento-init were designed to work with widgets initially, and it was only later that they were adopted (by the UI Component system, for one) as a way of invoking javascript with server side rendered JSON objects.

Magento’s default themes (and the third party themes that use them as a base) use these data-mage-init widgets all over the place. Here’s one example from the home page

<ul class="dropdown switcher-dropdown" data-mage-init='{"dropdownDialog":{
        "appendTo":"#switcher-currency > .options",
        "triggerTarget":"#switcher-currency-trigger",
        "closeOnMouseLeave": false,
        "triggerClass":"active",
        "parentClass":"active",
        "buttons":null}}'> 

    <!-- ... -->
    </ul>

This data-mage-init attribute invokes the dropdownDialog RequireJS module. The dropdownDialog is actually an alias to the mage/dropdown RequireJS module.

//File: vendor/magento/module-theme/view/frontend/requirejs-config.js

var config = {
    map: {
        "*": {
            /* ... */
            "dropdownDialog":         "mage/dropdown",
            /* ... */
        }
    },
    /* ... */
};

and if we look at the source for the mage/dropdown module

//File: vendor/magento/magento2-base/lib/web/mage/dropdown.js

define([
    "jquery",
    "jquery/ui",
    "mage/translate"
], function($){
    'use strict';

    var timer = null;
    /**
     * Dropdown Widget - this widget is a wrapper for the jQuery UI Dialog
     */
    $.widget('mage.dropdownDialog', $.ui.dialog, {/* ... */});

    return $.mage.dropdownDialog;
});

we see this module both defines, and then returns, the mage.dropdownDialog widget. So the data-mage-init version is equivalent to jQuery code that looks like the following

jQuery('#someSelectorToSelectTheUi').dropdownDialog({
        "appendTo":"#switcher-currency > .options",
        "triggerTarget":"#switcher-currency-trigger",
        "closeOnMouseLeave": false,
        "triggerClass":"active",
        "parentClass":"active",
        "buttons":null});

One last important thing to note before we move on. In our example above, the jQuery widget’s full name is mage.dropdownDialog. This means the jQuery method name will be dropdownDialog. While the name of the RequireJS module alias is also dropdownDialog, there’s nothing in the system that formally connects this module name and the widget name. Magento could just as easily have aliased mage/dropdown as dropdownDialogLaDeDa or not aliased it all and things will still work. Keep in mind that not all Magento core widgets will have RequireJS aliases or module names that line up nicely with the widget names.

Replacing Widget Methods

Since the earliest days, Magento development has always been about making slight, stable changes to the stock Magento system that will implement the new functionality you want without changing the behavior of other core systems. The stabler your changes, the more success you’d have. The less stable your changes, the greater the chance your system would fail/crash.

The features in Magento 2’s systems continue that tradition. The PHP backend has class preferences and plugins, and the front-end systems have RequireJS’s many aliasing techniques.

What’s tricky with Magento’s jQuery widgets is, they’re an object system within another object system. What we mean is, jQuery widgets by themselves have a simple and elegant method for replacing methods — you just redefine the widget using the jQuery widget’s inheritance system

jQuery.widget('namespace.methodName', {/* ... initial method definitions ... */});

/* ... */

jQuery.widget('namespace.methodName', jquery.namespace.methodName, 
    {/*... new method definitions here ...*/});

So long as you define the widget before it’s instantiated, you can add and redefine methods to it all day long. The widget system even offers you the ability to call parent methods via _super and _superApply methods (see the official docs for more information). So long as you redefine the widget before instantiating a widget instance, everything will work out great.

That, unfortunately, is a problem for Magento. You’ll recall that, via the data-mage-init attribute, Magento allows you to instantiate a widget inline.

<ul class="dropdown switcher-dropdown" data-mage-init='{"dropdownDialog":{
        "appendTo":"#switcher-currency > .options",
        "triggerTarget":"#switcher-currency-trigger",
        "closeOnMouseLeave": false,
        "triggerClass":"active",
        "parentClass":"active",
        "buttons":null}}'> 

    <!-- ... -->
    </ul>

The way this works is

  1. Magento fetches the dropdownDialog RequireJS module
  2. The dropdownDialog module uses jQuery.widget to define a widget
  3. The dropdownDialog module returns the widget definition object
  4. The Magento core code that implements data-mage-init uses the returned widget object to instantiate a widget

In other words, the data-mage-init technique both defines and instantiates a widget in one go. This makes changing widget behavior less than straight forward.

There are, of course, inelegant/brute-force methods. As we learned earlier the dropdownDialog symbol is an alias for the mage/dropdown module, which lives in the file ./lib/web/mage/dropdown.js. We could edit this file directly, or replace this file in our custom theme with one that had our changes. Working Magento developers do this every day.

While this might implement the functionality we need, we risk our changes (both intentional changes and non-obvious changes) breaking the system in some other place. We also end up needing to manually merge (and remembering to manually merge) our changes with changes in each new point release of Magento.

When you’re changing the behavior of Magento, you want your changes to be slight. It’s better to write 5 lines of code and spend the rest of the day figuring out how to elegantly insert them than jamming in 50 new lines of code with 500 copy/pasted lines of code however you can.

To elegantly replace a method in a core Magento jQuery widget, the system Magento calls javascript-mixins are probably our best bet.

Using Mixins to Redefine Widgets

We’ve discussed Magento’s “mixins” in a previous article. While this system allows developers to implement mixins in Magento’s RequireJS modules, the system itself is better thought of as a “RequireJS module-loader listener system”. This system allows us, as developers, to

  1. Setup a javascript function (via a RequireJS module) that Magento will call immediately after loading a specific RequireJS module

  2. Magento will pass this function the value returned by that specific RequireJS module

  3. Magento will replace that value by whatever our function returns.

Using mixins, we can listen for Magento’s loading of a RequireJS widget loading module. Then, we can redefine the jQuery widget. Finally, we can return our newly instantiated widget definition.

If that didn’t make sense, an example should clear things up. We’re going to redefine the open method on the dropdownDialog widget.

//File: vendor/magento/magento2-base/lib/web/mage/dropdown.js

$.widget('mage.dropdownDialog', $.ui.dialog, {
    /* ... */
    open: function () {
        /* ... */
    }
    /* ... */        
});    

We’ll change this method to write a line of output to the javascript console, and then call its parent open method. i.e. We’ll make the widget do one extra thing, and then do whatever it was originally going to do. While our example is a little silly, this pattern is at the heart of any stable Magento customization.

Creating our Mixin

The first thing we’ll want to do is create a new Magento module with a requirejs-config.js file. This is Magento’s standard mechanism that lets developers add to the existing, standard, RequireJS configuration. We’re going to use pestle to do this, but feel free to use whatever module creating technique you prefer.

$ pestle.phar magento2:generate:module Pulsestorm Logdropdown 0.0.1
Created: /path/to/magento/app/code/Pulsestorm/Logdropdown/etc/module.xml
Created: /path/to/magento/app/code/Pulsestorm/Logdropdown/registration.php

Once you’ve created your module, add the following requirejs-config.js file.

//File: app/code/Pulsestorm/Logdropdown/view/base/requirejs-config.js
var config = {
    "config": {
        "mixins": {
            "mage/dropdown": {
                'Pulsestorm_Logdropdown/js/dropdown-mixin':true
            }
        }
    }
};

Then, create a Pulsestorm_Logdropdown/js/dropdown-mixin RequireJS module that matches the following.

//File: app/code/Pulsestorm/Logdropdown/view/base/web/js/dropdown-mixin.js
define(['jquery'], function(jQuery){
    return function(originalWidget){
        alert("Our mixin is hooked up.");
        console.log("Our mixin is hooked up");

        return originalWidget;
    };
});

With the above in place, enable your module and run setup:upgrade

$ php bin/magento module:enable Pulsestorm_Logdropdown          
$ php bin/magento setup:upgrade

This should automatically clear your cache as well. Once you’ve done that, load any page that uses the dropdownWidget widget (home page, catalog listing page, etc), and you should see an alert and a console.log message that says Our mixin is hooked up.

So far, all we’ve done is setup the scaffolding for our mixin. In the requirejs-config.js file

//File: app/code/Pulsestorm/Logdropdown/view/base/requirejs-config.js
var config = {
    "config": {
        "mixins": {
            "mage/dropdown": {
                'Pulsestorm_Logdropdown/js/dropdown-mixin':true
            }
        }
    }
};

we’re telling Magento we want a mixin for the mage/dropdown RequireJS module, and that we’ve implemented the mixin in the Pulsestorm_Logdropdown/js/dropdown-mixin RequireJS module. This needs to be the real module name (mage/dropdown) and not the alias name (dropdownDialog).

As for the definition of Pulsestorm_Logdropdown/js/dropdown-mixin

//File: app/code/Pulsestorm/Logdropdown/view/base/web/js/dropdown-mixin.js
define(['jquery'], function(jQuery){
    return function(originalWidget){
        alert("Our mixin is hooked up.");
        console.log("Our mixin is hooked up");

        return originalWidget;
    };
});

We’ve imported the jquery module because we’ll need it later. The RequireJS module returning a function is how Magento’s javascript mixins are implemented. The originalWidget parameter holds the original return value of mage/dropdown. All we’ve done above is return the originalWidget variable after running our debugging statements. This means the system should behave identically as before.

Once you have your scaffolding up and running, we’ll be ready to actually change the widget definition.

Changing a Widget

OK, one last bit of code to go! We’re going to replace our module definition from above with the following

//File: app/code/Pulsestorm/Logdropdown/view/base/web/js/dropdown-mixin.js
define(['jquery'], function(jQuery){
    return function(originalWidget){
        // if you want to get fancy and pull the widget namespace
        // and name from the returned widget definition        
        // var widgetFullName = originalWidget.prototype.namespace + 
        //     '.' + 
        //     originalWidget.prototype.widgetName;


        jQuery.widget(
            'mage.dropdownDialog',              //named widget we're redefining            

            //jQuery.mage.dropdownDialog
            jQuery['mage']['dropdownDialog'],   //widget definition to use as
                                                //a "parent" definition -- in 
                                                //this case the original widget
                                                //definition, accessed using 
                                                //bracket syntax instead of 
                                                //dot syntax        

            {                                   //the new methods
                open:function(){                    
                    //our new code here
                    console.log("I opened a dropdown!");

                    //call parent open for original functionality
                    return this._super();              

                }
            });                                

        //return the redefined widget for `data-mage-init`
        //jQuery.mage.dropdownDialog
        return jQuery['mage']['dropdownDialog'];
    };
});

With the above in place, clear your browser cache and reload the page. Then, click on any dropdown widget on the page — the currency/store-views are good candidates

screenshot of current/store-views dropdown

After clicking on these menus and confirming everything still works, take a look at your javascript console — you should see the I opened a dropdown! text successfully logged.

Congratulations! You just extended a Magento 2 jQuery widget.

As a reminder, our module needed to do two things: Redefine the jQuery widget, and then return the newly defined jQuery widget definition . We redefined the widget with the following call (comments stripped)

jQuery.widget('mage.dropdownDialog', jQuery['mage']['dropdownDialog'], {
    /* ... new methods here ... */
});

When used with three parameters, the jQuery.widget factory will create (or redefine) a widget named mage.dropdownDialog that uses the second argument as a parent widget definition (in this case, the same widget as stored on the global jQuery object), with the third parameter containing the new methods.

As for the new methods themselves

{                                   
    open:function(){    
        //our new code here
        console.log("I opened a dropdown!");

        //call parent open for original functionality
        return this._super();              
    }
}

Here we’ve redefined the open method, originally defined on the mage.dropdown widget

//File: vendor/magento/magento2-base/lib/web/mage/dropdown.js

$.widget('mage.dropdownDialog', $.ui.dialog, {
    /* ... */
    open: function () {
        /* ... */
    }
    /* ... */        
});    

Our call to this._super(); is a part of the jQuery widget system — it calls the parent open method. Whether your method definition returns the original return value and/or puts your code before/after the this._super() method will depend on what you’re trying to do. Standard OOP principles still apply!

Wrap Up

While there are other techniques that might work for replacing a jQuery widget’s method definition in Magento 2, Magento’s mixin-listeners offer the cleanest methods I’ve seen for doing so. Like most techniques in Magento 2, there’s no guarantees these will continue to work in future versions of Magento, or even work in all scenarios in current versions.

Like most real-world software problems, it’s best to understand how these techniques work so you can adapt and extend them yourself in the future. Despite opinions to the contrary, software is ultimately a logical, step by step progression through a set of rules. A working system is, ultimatly, knowable.

Series Navigation<< Knockout Observables for Javascript Programmers