How to add Node packages to your Magento 2 site

Sometimes while working on the Magento 2 front end you can think to yourself “there has to be a easier way to do this”. In the world of modern Javascript, the natural reflex is to just “find a package that does it for you”. While Magento 2 took major steps to include “modern” Javascript features, these features were included in ways that makes the framework difficult to integrate into. Things like Grunt and Gulp are pretty easy to integrate to use as task managers during development. But what about other packages you want to be able to use on the frontend of the site?

Why you need a Node package

In a previous post I showed how to use the Slick slider to add a carousel to your site. While this Javascript library is available as a Node package it also works as a direct download. You place the .js file in your theme and you are good to go. But not all Javascript libraries work this way. There are some that have additional requirements and wont work without them. To grab those scripts and then try to link them manually is error prone and can take a long time.

What would be much easier is to link the Node package in our theme (or module) directly with Require JS and let the script handle the asset management for us. I played around with a few ways of doing this, and here are my solutions.

How to include a Node package

I’m going to be doing this in the theme of my Magento 2 site, but this will work in a module’s directory as well.

To get a self contained Node module, we need a package.json file. This will localize the node_modules directory for us. In my opinion this is best done in the web directory (app/design/frontend/<vendor_name>/<theme_name>/web). But it can also be in the theme’s root directory. Once in this directory, we can run npm init to create that package.json file. It will ask a few question for us, but don’t worry about that as we aren’t going to publishing this work and it can be ignored. You can also create this file manually instead of using npm, the choice is up to you.

With that file created, we should have something like this:

//File: app/design/frontend/Circlesix/theme/web/package.json
{
  "name": "Magento 2 package test",
  "version": "1.0.0",
  "description": "this is a test, this is only a test",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Circle Six",
  "license": "ISC"
}

We can now add in the package we are looking to include. In this article i’m going to be using Shuffle.js but this will work for any NPM package as they all use the same syntax:

npm install shufflejs

This will create a node_modules directory and update our package.json file with:

"dependencies": {
  "shufflejs": "^5.3.0"
}

Hooking your new package up with Magento 2

If you have read any of my other articles, you know that Require JS rules all things Magento/Javascript. Using a Node package is no different. You HAVE to use Require JS. But luckily it is super simple.

//File: app/design/frontend/Circlesix/theme/requirejs-config.js
var config = {
    map: {
        '*': {
            collectionsShuffle:'js/collections-shuffle',
        }
    },
    paths: {
        shufflejs: './node_modules/shufflejs/dist/shuffle'
    }
};

Since our config file lives in the root of the theme, we have to point to the node_modules directory. If you place that directory anywhere else, just make sure it’s path is correct.

With that we now have an alias to use anywhere in Magento 2 (as Require JS is a global registry).

Using our new package

I have come up with on contrived example to show how to use this new package on a Magento 2 site. It’s not particularly useful, but will be instructive on how to use the package that I imported. It will be specific to Shuffle.js, but the concepts will apply to any package you want to incorporate.

I have chosen to shuffle the category list view (app/design/frontend/Circlesix/theme/Magento_Catalog/templates/product/list.phtml) based on an attribute. The first thing we need to do is import the script file we will use to initialize Shuffle.js and set it’s options:

// File: app/design/frontend/Circlesix/theme/Magento_Catalog/templates/product/list.phtml
<script type="text/x-magento-init">
    {
        "*": {
            "collectionsShuffle": {}
        }
    }
</script>

This points to the file we have already registered in our requirejs-config.js file. Next we need to set the data attribute that Shuffle.js will use to sort our collection. I have included this in the existing <li> element:

//File: app/design/frontend/Circlesix/theme/Magento_Catalog/templates/product/list.phtml
<li class="item product product-item" data-groups='["<?php echo $_product-?>getAttributeText("erin_recommends"); ??>"]'?>

And lastly we need to create our filter button and add in a targeting ID for Shuffle.js to wrap around:

//File: app/design/frontend/Circlesix/theme/Magento_Catalog/templates/product/list.phtml
<div class="btn-group filter-options">
   <button data-group="Yes">Erin Recommends</button>
</div>
<ol class="products list items product-items" id="shuffle-grid">

With the template file edited, we can add in our script file (app/design/frontend/Circlesix/theme/web/js/collections-shuffle.js). This is almost entirely pulled from the demo file on the Shuffle.js site and i’m going to post that in it’s entirety now, but i want to highlight some Magento specific differences.

//File: app/design/frontend/Circlesix/theme/web/js/collections-shuffle.js
require([
    'jquery',
    'shufflejs',
    'domReady!'
], function ($, shufflejs) {
    'use strict';

    var Shuffle = shufflejs;

    var ShuffleTest = function (element) {
        this.element = element;

        this.shuffle = new Shuffle(element, {
            itemSelector: '.product-item'
        });

        this._activeFilters = [];

        this.addFilterButtons();

        this.mode = 'exclusive';
    };

    ShuffleTest.prototype.addFilterButtons = function () {
        var options = document.querySelector('.filter-options');

        if (!options) {
            return;
        }

        var filterButtons = Array.from(options.children);

        filterButtons.forEach(function (button) {
            button.addEventListener('click', this._handleFilterClick.bind(this), false);
        }, this);
    };

    ShuffleTest.prototype._handleFilterClick = function (evt) {
        var btn = evt.currentTarget;
        var isActive = btn.classList.contains('active');
        var btnGroup = btn.getAttribute('data-group');

        if (this.mode === 'additive') {
            // If this button is already active, remove it from the list of filters.
            if (isActive) {
                this._activeFilters.splice(this._activeFilters.indexOf(btnGroup));
            } else {
                this._activeFilters.push(btnGroup);
            }

            btn.classList.toggle('active');

            this.shuffle.filter(this._activeFilters);

        } else {
            this._removeActiveClassFromChildren(btn.parentNode);

            var filterGroup;
            if (isActive) {
                btn.classList.remove('active');
                filterGroup = Shuffle.ALL_ITEMS;
            } else {
                btn.classList.add('active');
                filterGroup = btnGroup;
            }

            this.shuffle.filter(filterGroup);
        }
    };

    ShuffleTest.prototype._removeActiveClassFromChildren = function (parent) {
        var children = parent.children;
        for (var i = children.length - 1; i >= 0; i--) {
            children[i].classList.remove('active');
        }
    };

    ShuffleTest.prototype._handleSortChange = function (evt) {
        // Add and remove `active` class from buttons.
        var buttons = Array.from(evt.currentTarget.children);
        buttons.forEach(function (button) {
            if (button.querySelector('input').value === evt.target.value) {
                button.classList.add('active');
            } else {
                button.classList.remove('active');
            }
        });

        // Create the sort options to give to Shuffle.
        var value = evt.target.value;
        var options = {};

        function sortByDate(element) {
            return Date.parse(element.getAttribute('data-date-created'));
        }

        function sortByTitle(element) {
            return element.getAttribute('data-title').toLowerCase();
        }

        if (value === 'date-created') {
            options = {
                reverse: true,
                by: sortByDate,
            };
        } else if (value === 'title') {
            options = {
                by: sortByTitle,
            };
        }

        this.shuffle.sort(options);
    };

    window.shuffletest = new ShuffleTest(document.getElementById('shuffle-grid'));
});

This first thing to know here is we have to wrap the script in the RequireJS header:

//File: app/design/frontend/<vendor_name>/<theme_name>/web/js/collections-shuffle.js
require([
    'jquery',
    'shufflejs',
    'domReady!'
], function ($, shufflejs) {

When we registered Shuffle.js above, we created an alias to use, this is where that get’s used. shufflejs is “required” and then passed into the constructor function. This is important as Magento 2 will know (and register) the package as this name. If we want to use the package, this has to be passed into the script.

The next thing we do is set the var Shuffle = shufflejs; to that value. This is what will connect the outside package to the inside script. We then create a variable var ShuffleTest to attach the rest of the functionality too. And lastly we set all that onto the window for the package to use:

//File: app/design/frontend/<vendor_name>/<theme_name>/web/js/collections-window.shuffletest = new ShuffleTest(document.getElementById('shuffle-grid'));

shuffle-grid being the ID we set in the template file.

You should now have a grid that will sort with a fancy animation when you click the button at the top.

The Unsorted View
The Sorted View

How not to include a Node package

Now that I have shown a way to connect to a Node package, I want to talk about some other ideas out there i have found. While it is 100% true that there is no “one way” to do anything in Magento 2, there are some ways that add on overhead or unneeded complexity.

One thing i have see a lot of people mention is sym linking from the theme to a node_modules folder. This is brought up here and here. As of right now, i have found no advantages to using a sym link. Even though the posts do place the directory in the theme’s web directory, it really doesn’t help you. The sym link doesn’t provide anything that just pointing to the actual directory does. It also doesn’t help with the tracking of files. Git can play poorly with sym links and moving the code from one place to another becomes a hassle.

The next thing i have seen is including the Node package in the Magento root’s package.json file and linking that directory with a sym link in the theme. This causes all kinds of problems. With the sym linked directory in the theme, when you run a task manager like Grunt to compile Less files during development, Grunt will see that link and try and compile the Less in any of the installed Node packages. This can lead to all kinds of issues.

The advantage here is that you are tracking a sym link in Git and not all the installed packages in you theme’s web folder (as you know, these directories can balloon to insane sizes). But this introduces so many other issues that unless you have a very specific use case, this method will be much more work than it’s worth.

Wrapping up

With this short post, i’m hoping to have shown that adding in npm packages is actually simple in Magento 2, given you know a little about the structure of it’s JavaScript system. This opens up a lot of options for you as a front end developer to include great projects that have improve and innovate on the existing UI.