Two ways to ajaxify and decouple Drupal Commerce add to cart and wishlist actions

There are a couple of modules out there to ajaxify the add to cart form of Drupal Commerce, but I'll introduce to one that on top offers a real decoupled solution. Additionally I'll show how you can achieve a simpler version of that with only a few lines of custom JS code.

Commerce Cart Flyout

The Commerce Cart Flyout module is my clear recommendation when it comes to what of the existing Drupal contrib modules to use, when you want to have an ajaxified add to cart form. It provides a sidebar which will "flyout", when either the cart block is clicked or the add to cart button was pressed. The flyout allows to view the cart's contents, change quantities or remove order items entirely. The block includes an item counter as well.

The module is based on Commerce Cart API and provides a progressively decoupled cart experience. Both modules are maintained by Commerce co-maintainer Matt Glaman, who works for Commerce Guys - the company responsible for developing and maintaining Drupal Commerce, which is another pro argument in choosing this approach. Another plus is that Cart Flyout is highly customizable, offering nine different Twig templatetes for the output of different parts of the module. The JS logic is cleanly structured and built on top of Backbone to offer a wonderful separation between models and views. Thanks to Drupal's library management, you could even override single JS parts of this module with ease.

Custom coding

I really like Cart Flyout very much and also use it in certain projects. But these days I've decided to rather use custom coding to achieve an ajaxified add to cart experience. And here I'm showing you how and why.

Why I haven't used Cart Flyout in that project

Well, in the end, it was the sum of a couple of small factors that led me into this direction. First one was that the project was built entirely without an ajaxified add to cart logic. The site wasn't live at that moment, but development work was already finished by about 99.9%. So we already had a custom block showing cart and wishlist icons with item counters inside. Cart Flyout needs its own cart block, so I'd have to entirely swap my block, fully customize the Twig template of the new block and find a way to include the wishlist icon as well. I'd have to swap out one JS model of the Flyout module as well, including a quite small change. And the main driver behind the decision was that we are using Commerce Wishlist as well. As the flyout module entirely swaps the add to cart form, we would have had to hook into that one and re-add the wishlist functionality. Not a huge impact, but also requiring some changes, was the fact that we are currently using Commerce Add To Cart Link module for all product teaser views (overview pages, search results, etc)

So the sum of all these factors and the curiosity about playing around by myself with the Cart API were the reason, why I've decided in favour of a custom solution in that project. And that shouldn't be a very tough task to achieve. Given the fact, that this is developed for a specific project, where you know your existing markup, CSS selectors and the new markup you want to create, you can leave the overhead of making everything customizable via config and templates behind you and ease the path.

I also didn't need the flyout sidebar containing the full cart info. Instead I just wanted a simple modal overlay showing the information that product XY was added to cart. As we are using Zurb Foundation as our frontend framework, we wanted to use Foundation's Reveal component for showing this modal.

Of course I was also building up on the great Commerce Cart API module, as well as I've created the wishlist counterpart Commerce Wishlist API to achieve all my goals.

First, I've defined the MYTHEME/ajax_add_to_cart JS library in my theme, which looks like this (and despite the name handles both the add links and forms for both cart and wishlist). Please note that this code also involves updating of a item counter that is part of the navigation bar (updateCart and updateWishlist) - this could also be skipped for even more minimal solutions.


(function ($, Drupal, drupalSettings) {
  Drupal.behaviors.ajaxAddToCart = {
    getCsrfToken: function(callback) {
      $.get(Drupal.url('rest/session/token'))
          .done(function (data) {
            callback(data);
          });
    },

    addToCart: function (csrfToken, purchasedEntityType, purchasedEntityId, qty) {
      $('body').append('<div class="add-to-cart-ajax-throbber ajax-progress ajax-progress-fullscreen"></div>');
      $.ajax({
        url: Drupal.url('cart/add?_format=json'),
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrfToken
        },
        data: JSON.stringify([{
          purchased_entity_type: purchasedEntityType,
          purchased_entity_id: purchasedEntityId,
          quantity: qty
        }]),
        success: function(data) {
          var orderItem = data[0];
          var $overlay = $('#add-to-cart-overlay');
          $overlay.find('.purchased-entity').text(orderItem.title);
          $overlay.foundation('open');
          Drupal.behaviors.ajaxAddToCart.updateCart();
          $('.add-to-cart-ajax-throbber').remove();
        }
      });
    },

    updateCart: function() {
      var $cartCount = $('.store-action--cart .store-action__link__count');
      if ($cartCount.length) {
        $.ajax({
          url: Drupal.url('cart?_format=json'),
          method: 'GET',
          headers: {
            'Content-Type': 'application/json',
          },
          success: function(data) {
            var cart = data[0];
            var count = cart.order_items.reduce(function (previousValue, currentValue) {
              return previousValue + parseInt(currentValue.quantity);
            }, 0);
            $cartCount.text(count);
          }
        });
      }
    },

    addToWishlist: function (csrfToken, purchasableEntityType, purchasableEntityId, qty) {
      $('body').append('<div class="add-to-cart-ajax-throbber ajax-progress ajax-progress-fullscreen"></div>');
      $.ajax({
        url: Drupal.url('wishlist/add?_format=json'),
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrfToken
        },
        data: JSON.stringify([{
          purchasable_entity_type: purchasableEntityType,
          purchasable_entity_id: purchasableEntityId,
          quantity: qty
        }]),
        success: function(data) {
          var wishlistItem = data[0];
          var $overlay = $('#add-to-wishlist-overlay');
          $overlay.find('.purchasable-entity').text(wishlistItem.title);
          $overlay.foundation('open');
          Drupal.behaviors.ajaxAddToCart.updateWishlist();
          $('.add-to-wishlist-ajax-throbber').remove();
        }
      });
    },

    updateWishlist: function() {
      var $wishlistCount = $('.store-action--wishlist .store-action__link__count');
      if ($wishlistCount.length) {
        $.ajax({
          url: Drupal.url('wishlist?_format=json'),
          method: 'GET',
          headers: {
            'Content-Type': 'application/json',
          },
          success: function(data) {
            var wishlist = data[0];
            var count = wishlist.wishlist_items.reduce(function (previousValue, currentValue) {
              return previousValue + parseInt(currentValue.quantity);
            }, 0);
            $wishlistCount.text(count);
          }
        });
      }
    },

    attach: function attach(context) {
      $(context).find('.add-to-cart-link').once('add-to-cart-link-init').each(function () {
        $(this).on('click', function (e) {
          e.preventDefault();
          var variationId = $(this).data('variation');
          Drupal.behaviors.ajaxAddToCart.getCsrfToken(function (csrfToken) {
            Drupal.behaviors.ajaxAddToCart.addToCart(csrfToken, 'commerce_product_variation', variationId, 1);
          });
        });
      });

      $(context).find('form.add-to-cart-form').once('add-to-cart-form-init').each(function () {
        $(this).on('click', '.form-submit', function(e) {
          var isWishlistButton = $(this).hasClass('add-to-wishlist-button');
          $(this).parents('form').data('button-clicked', isWishlistButton ? 'wishlist' : 'cart');
        });
        $(this).on('submit', function (e) {
          e.preventDefault();
          var buttonClicked = $(this).data('button-clicked');
          var purchasedEntityType = $(this).data('purchased-entity-type');
          var purchasedEntityId = $(this).data('purchased-entity-id');
          var qty = $(this).find('input[name="quantity[0][value]"]').val();
          Drupal.behaviors.ajaxAddToCart.getCsrfToken(function (csrfToken) {
            if (buttonClicked === 'wishlist') {
              Drupal.behaviors.ajaxAddToCart.addToWishlist(csrfToken, purchasedEntityType, purchasedEntityId, qty);
            }
            else {
              Drupal.behaviors.ajaxAddToCart.addToCart(csrfToken, purchasedEntityType, purchasedEntityId, qty);
            }
          });
        });
      });

      $(context).find('.add-to-wishlist-link').once('add-to-wishlist-link-init').each(function () {
        $(this).on('click', function (e) {
          e.preventDefault();
          var variationId = $(this).data('variation');
          Drupal.behaviors.ajaxAddToCart.getCsrfToken(function (csrfToken) {
            Drupal.behaviors.ajaxAddToCart.addToWishlist(csrfToken, 'commerce_product_variation', variationId, 1);
          });
        });
      });
    }
  };

})(jQuery, Drupal, drupalSettings);

Then I've added this library to the add to cart form, as well as to the add to cart links. Also I've added a class to the <form> tag for easier targeting and set the data-purchased-entity-type and data-purchased-entity-id attributes.

/**
 * Implements hook_form_BASE_FORM_ID_alter() for commerce_order_item_add_to_cart_form.
 */
function MYTHEME_form_commerce_order_item_add_to_cart_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  $form['#attached']['library'][] = 'MYTHEME/ajax_add_to_cart';
  // Add custom class to form.
  $form['#attributes']['class'][] = 'add-to-cart-form';
  /** @var \Drupal\commerce_cart\Form\AddToCartForm $form_object */
  $form_object = $form_state->getFormObject();
  /** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */
  $order_item = $form_object->getEntity();
  $purchased_entity = $order_item->getPurchasedEntity();
  $form['#attributes']['data-purchased-entity-type'] = $purchased_entity->getEntityTypeId();
  // The order item's purchased entity reference won't be up to date, when a
  // different product variation was selected and updated via Ajax. The form
  // state stores it to 'selected_variation' then. Our product set entity on the
  // other side, does not store such a value, but is the purchasable entity on
  // its own. So we can safely take the one from the order item.
  $purchased_entity_id = $purchased_entity instanceof ProductVariationInterface ? $form_state->get('selected_variation') : $purchased_entity->id();
  $form['#attributes']['data-purchased-entity-id'] = $purchased_entity_id;
}

/**
 * Implements template_preprocess_commerce_add_to_cart_link().
 */
function MYTHEME_preprocess_commerce_add_to_cart_link(array &$variables) {
  $variables['#attached']['library'][] = 'MYTHEME/ajax_add_to_cart';
}

And finally, I've simply added the add to cart and add to wishlist to the page template, which will be hidden, until it get's the command to show up via JS. So I've added these lines to the very end of the page.html.twig (of course I've could also have chosen the html.html.twig file) (sorry that this code example is looking bad in the blog post - it got distracted somehow by CKEditor - you can also view it on a Gist, I'll mentioning in the end of the post):


<div id="add-to-cart-overlay" class="reveal" data-reveal data-close-on-click="false">
  <p><span class="purchased-entity"></span> wurde Ihrem Warenkorb hinzugefügt.</p>
  <div>
    <a class="button" href="{{ path('commerce_cart.page') }}">zum Warenkorb</a><br/>
    <a href="#" data-close>Weiter einkaufen</a>
  </div>
  <button class="close-button" data-close aria-label="Close modal" type="button">
    <span aria-hidden="true">&times;</span>
  </button>
</div>
<div id="add-to-wishlist-overlay" class="reveal" data-reveal data-close-on-click="false">
  <p><span class="purchasable-entity"></span> wurde Ihrer Merkliste hinzugefügt.</p>
  <div>
    <a class="button" href="{{ path('commerce_wishlist.page') }}">zur Merkliste</a><br/>
    <a href="#" data-close>Weiter einkaufen</a>
  </div>
  <button class="close-button" data-close aria-label="Close modal" type="button">
    <span aria-hidden="true">&times;</span>
  </button>
</div>

That's it :) I've summarized the above code in a Github Gist snippet for easier viewing.

Neuen Kommentar schreiben