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
- Magento fetches the
dropdownDialog
RequireJS module - The
dropdownDialog
module uses jQuery.widget to define a widget - The
dropdownDialog
module returns the widget definition object - 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
- Setup a javascript function (via a RequireJS module) that Magento will call immediately after loading a specific RequireJS module
-
Magento will pass this function the value returned by that specific RequireJS module
-
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
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.