By default, the images shown for items on a product list page (PLP) will be whatever is the item’s default. This is fine in most cases, but what if a shopper refines by a color? Shouldn’t a search for, say, red dresses show images of red dresses, rather than just the default? This article will take you through the steps to using the extensibility API to make an extension to make that a reality.

The solution for this functionality lies in both the PLP component and the layout component. We can use a method called addToViewContextDefinition(). When we invoke this method, we can easily pass the existing context object, which means anything the view shares with the template will automatically be available to use to manipulate. This includes the image the template is told to render.

What this method allows to do is add or change context properties; in our case, we want to override the thumbnail value so that it has the image URL for a specific color. There is a downside, however, in that the PLP component does not recognize the view that we want to modify, Facets.ItemCell.View, as a PLP view. The upside is that in the R2 version of Aconcagua (2018.1), we introduced the layout component, which empowers us to use the same method but in a way that is applicable to every view on a site. Handy.

An annoying downside is that Facets.ItemCell.View does not pass itself the item model as a property, which means we can't query it. Nonetheless, we can work around that by matching the item ID to all of the items in PLP.getItemsInfo() and work with the copy of the model in there. No biggy.

Let's look at this now. The functions we are going to write will resemble the ones we created in the previous blog post, but there will be some new approaches to problem solving. We will look at an additional bit of functionality: rewriting the link URL so that when a shopper filters by color, they are linked to a version of the PDP that has pre-selected that color.

By the end, you should have something that looks like this:

An animated GIF showing an example product list page with this functionality enabled. The default search result list shows items in all kinds of colors. When a user selects the orange refinement, the product list applies the refinement and switches all of the product images to their orange versions. When they user further selects blue as a color refinement, some of the shirts show their blue options.

You’re encouraged to code along, otherwise you can download the full source of this sample extension in GitHub.

Basic Setup

For the purposes of this tutorial, I'm using an extension, but there's no reason why this functionality couldn't be a standard SuiteCommerce Advanced customization. It does, however, rely on the extensibility API, so you'll need to be running Aconcagua or newer.

When setting up this extension or module, make it only available to the shopping application (it's not required in the checkout or customer account applications).

The structure is simple and looks like this:

PLPColorImages

  • Modules
    • Configuration
      • PLPColorImages.json
    • JavaScript
      • PLPColorImages.js
  • manifest.json

And with that, we can start the coding process by creating the entrypoint file.

Create the Entrypoint

In JavaScript, create PLPColorImages.js. In it, put:

define('PLPColorImages'
, [
  ]
, function
  (
  )
{
  'use strict';

  return {
    mountToApp: function mountToApp (container)
    {
      var PLP = container.getComponent('PLP')
    , Environment = container.getComponent('Environment')
    , Layout = container.getComponent('Layout')
    , customColorId = Environment.getConfig('plpColorImages.customColorId') ? Environment.getConfig('plpColorImages.customColorId') : '';

      if (customColorId)
      {
        Layout.addToViewContextDefinition('Facets.ItemCell.View', 'thumbnail', 'string', function thumbnail (context)
        {
          var model = _.find(PLP.getItemsInfo(), function (item)
          {
            return item.internalid == context.itemId
          })
        , thumbnail = context.thumbnail
        , images = model.itemimages_detail ? model.itemimages_detail : ''
        , filters = _.find(PLP.getFilters(), function (filter)
          {
            return filter.id == customColorId
          });

          // Note the `media` property/object is due to how my test account handles image naming -- this may be different on your site
          if (images && images.media && filters && filters.value[0] && images.media[filters.value[0]])
          {
            _.each(filters.value, function (filter)
            {
              if (images.media[filter] && images.media[filter].urls)
              {
                thumbnail = images.media[filter].urls[0]
              }
            });
          }
          return thumbnail
        });

        Layout.addToViewContextDefinition('Facets.ItemCell.View', 'url', 'string', function url (context)
        {
          var model = _.find(PLP.getItemsInfo(), function (item)
          {
            return item.internalid == context.itemId
          })
        , filters = _.find(PLP.getFilters(), function (filter)
          {
            return filter.id == customColorId
          })
        , existingUrl = context.url
        , fields = model.itemoptions_detail ? model.itemoptions_detail.fields : ''
        , fieldColors = fields ? _.find(fields, function (option)
          {
            return option.sourcefrom == customColorId
          }) : ''
        , fieldColorValues = fieldColors ? fieldColors.values : '';

          if (fieldColorValues && filters && filters.value[0])
          {
            var url = '';
            _.each(filters.value, function (filter)
            {
              if (!url)
              {
                url = _.find(fieldColorValues, function (value)
                {
                  return value.label == filter
                })
              }
            });

            /* If you want to match the behavior of the above images, then you could implement something like this (this way the URL colors match the color of the thumbnails)
            _.each(fieldColorValues, function (value)
            {
              _.each(filters.value, function (filter)
              {
                if (value.label == filter)
                {
                  url = value
                }
              })
            });
            */

            if (url) {return url.url}
          }
          else {return existingUrl}
        });
      }
    }
  }
});

Note the media property/object is due to how my test account handles image naming – this may be different on your site.

Perhaps the most striking thing about this file is that we have zero AMD dependencies. All of the functionality we are relying on within this file come from the extensibility API. You may think that we need the configuration object (SC.Configuration), but in Aconcagua R2, we released the environment component and one of its features is a method that lets us pluck out configuration options.

You'll see that after defining our variables for the components, we call on plpColorImages.customColorId using getConfig(). The method takes a path to the property you want, or you can pass nothing to get the entire configuration object. We're going to use this field to find the active filters on our site, but you'll notice that we're also using it as a check before running the code to make sure it exists. Note that this is going to be different from the custom column field PDPs use when you add an item to cart. On my site, this is custitem31. We need to specify this because we're going to look at the active filters and see if any of them are color related.

Once we've checked that the administrator has set this value, we can proceed with the main part of the module. Remember that we created a variable for the layout component? The reason for this is important: we want to use the addToViewContextDefinition() method.

As the name suggests, what this method does it lets us modify the context object of a view without having to rely on traditional customization methods (ie, wrapping the view's prototype, adding your value and returning it back). While it's available on visual components, its scope is limited. Specifically, ones on the PDP and PLP, for example, are restricted to its immediate child views; unfortunately for us, this does not include the specific view we want to tangle with: Facets.ItemCell.View. Luckily for us, the layout component allows us to modify any view we like. So we're just going to use that instead 🤷🏻‍♂️.

Take a look at how we're using it in our code (and in our documentation):

Layout.addToViewContextDefinition('Facets.ItemCell.View', 'thumbnail', 'string', function thumbnail (context)
{
  ...
});

We're passing it four things:

  1. The view we want to tinker with
  2. The property we want to add (or overwrite if it exists already)
  3. The type of value our function (callback) is going to return
  4. The function or callback itself — note that it is called with the existing context object as an argument (super handy)

Whatever the function returns will be set as the value for the specified property. In our example, we know that the context object already has a thumbnail property, so by using this method we know that we're going to overwrite it with whatever we return in our function.

After specifying the value type we're returning we pass our callback. Note that you don't have to pass a function here, per se, you can code a method elsewhere and then just call that here if you like. As mentioned, a super handy thing about this is that the existing context object is automatically passed to the function when it runs. What this means is that if there is existing context information you need, you can pluck it out.

Build Up Useful Variables

On that note, we do need one thing from the context: the item ID of the item we're currently tinkering with. Unfortunately, this view does not have its model passed to its context object so we will have to find alternative means for getting the model data.

Fortunately for us, we have PLP.getItemsInfo() which is copy of the entire collection data for the current product list page. Using our old friend _.find(), we can find our model using the item ID, which, lucky for us, is passed to . Just keep in mind that this a copy: setting a value in this object will have no effect on the actual item model.

From there, we build up a series of other useful variables. Firstly, the existing thumbnail. Remember, we're overwriting the existing value so we have return something, and it is entirely possible that after running all this code, the thumbnail will not need to change, so what we want to do is 'default' to the existing one. To do this, we simply keep a copy of it and, if all else fails, we just return that instead.

Next, we want to get all the item's images. These are all available in an object in the model.

Then, finally, we check the active filters for colors. This is where the PLP component comes back into use: the getFilters() method will return all active filters. When we have those, we compare each filter's ID against our custom color field ID to find the one we need. When we have those, we can pluck the names of the active color filters out of it.

Note that if you allow multiple color refinements at once you will get back an array of colors in alphabetical order. This might not bother you, but it can be problematic. Let's say a shopper searches for dresses, filters by black: we know that we should show images of black dresses. Now what happens if they filter by black, and then red — what color do we show them? Black or red? If your answer is, "Well, whatever they refined by last", you're going to be sore: the refinement order is not preserved — we don't know what the last refinement is. So, how you handle this is up to you. The most basic options are to simply go with the first one in the list, or the last one.

Set the Thumbnail

Anyway, we now have all we need to set a new thumbnail image: we know what color(s) are active and we have all the item's images.

After performing some basic checks to make sure we have all the right data (in case our site administrators haven't been fastidious with their product imagery) we can start a loop to go through each active color.

What this code is going to do is go through each one and check whether the current item has a set of images for the current filter color and, if it does, set the thumbnail to the first URL value of that set. In this case, it'll overwrite the thumbnail URL each time it finds a match. So, what this means is that if black and red are active colors, it'll set the thumbnail to the black image, and then the red image (so the red image will be returned).

The alternative 'basic' approach is to loop through until it finds one, and then stop. In this case, it will return the first one it meets — and not be overwritten by later ones. We'll look at this later.

Anyway, once we have the thumbnail set, we just return from the function. And this is our new thumbnail!

An animated GIF showing an example product list page with this functionality enabled. The default search result list shows items in all kinds of colors. When a user selects the orange refinement, the product list applies the refinement and switches all of the product images to their orange versions. When they user further selects blue as a color refinement, some of the shirts show their blue options.

Looking good! Remember, if you do support multiple color options then they act like an OR (||) operator, so when I search by both blue and orange, it returns items that have either of those options. But note that when I have a product that offers both blue and orange variants, it selects the orange one: this is because, as we said, it will always select the last color when sorted alphabetically. Have a think about how you want the colors to handle precendence and code that in yourself!

Change the URL

You know what would be cool? If we could set the URL so that when a shopper visits the PDP for an item it automatically selects the color options that they filtered by. Well, it just so happens that we expose these URLs to the item models in the itemoptions_detail property on each item's model. So, having written some code that goes through the model and matches them based on color options, can we do the same thing with this? Sure can!

Add the following code to the file, within the mountToApp method:

Layout.addToViewContextDefinition('Facets.ItemCell.View', 'url', 'string', function url (context)
{
  var model = _.find(PLP.getItemsInfo(), function (item)
  {
    return item.internalid == context.itemId
  })

, filters = _.find(PLP.getFilters(), function (filter)
  {
    return filter.id == customColorId
  })

, existingUrl = context.url

, fields = model.itemoptions_detail ? model.itemoptions_detail.fields : ''

, fieldColors = fields ? _.find(fields, function (option)
  {
    return option.sourcefrom == customColorId
  }) : ''

, fieldColorValues = fieldColors ? fieldColors.values : '';

  if (fieldColorValues && filters && filters.value[0])
  {
    var url = '';
    _.each(filters.value, function (filter)
    {
      if (!url)
      {
        url = _.find(fieldColorValues, function (value)
        {
          return value.label == filter
        })
      }
    });

    if (url)
    {
      return url.url
    }
  }
  else
  {
    return existingUrl
  }
});

You can see that we're using the same addToViewContextDefinition method on the layout to overwrite the url property. From there we do the same sorts of things we did before: we're building up some variables, including the existing URL and the item option fields. Notice that we connect the custom field used for colors on product list pages with the one used for item options by referring to the sourcefrom property, which keeps track of this stuff for us.

Now, onto the crescendo, where we set the URL's value. This time, instead of looping through and overwriting the URL each time, we're checking first to see if we've done this before by checking the value of url — this value gets set on the loop's first successful run. So, seeing as the filters are sorted alphabetically, this means we will stop on the first hit.

In the above code, it's perhaps obvious that you probably shouldn't mix and match these like I have. At the moment, my site will show an orange shirt and then send the shopper to a PDP with blue pre-selected: so pick one and stick with it! For example, if you want to have your URLs link to the last color (ie what we currently have for the image), you can do something simple like this:

_.each(fieldColorValues, function (value)
{
  _.each(filters.value, function (filter)
  {
    if (value.label == filter)
    {
      url = value
    }
  })
});

The final bit of code returns the URL — whether changed or unchanged — and this will be set on the context object using our component's method.

An animated GIF of a web store running this customization. After refining by color, the user then clicks on the Quick View button for an item. When the modal opens, the color the user has refined by has been pre-selected.

And that's it! We change the thumbnail color and URL based on the shopper's selection.

Troubleshooting

The Thumbnails Don't Change

If no errors are returned but it doesn't work, the chances are that there's a conditional statement not returning true — therefore, you need to assess each of the conditions in them.

For example, take the first one:

if (images && images.media && filters && filters.value[0] && images.media[filters.value[0]])

Test each of these by logging their values to the console and see which one returns false.

I know from experience with one customer was that their image naming convention was different to mine, which meant that images.media returned false. The media object is created on my site because of the convention [UPC Code].{Category 1}.{Category 2}.[EXT] (eg, OL104.media.black.01.jpg). If you log images to your console and it just returns objects for each color, then you can eliminate images.media from the conditional.

For example:

// From this
if (images && images.media && filters && filters.value[0] && images.media[filters.value[0]])

// To this
if (images && filters && filters.value[0] && images[filters.value[0]])

Note that if this is the case, then you'll also need to update the loop too, for example:

// From this
if (images.media[filter] && images.media[filter].urls)
{
  thumbnail = images.media[filter].urls[0]
}
// To this
if (images[filter] && images[filter].urls)
{
  thumbnail = images[filter].urls[0]
}

Another cause might be that you only allow one color filter at a time, rather than multiple ones. You see, when you allow multiple colors to be applied at once (eg orange, green, black), the color filter returns an array of strings, even if it's just one. Thus, we call it by doing filters.value[0]. However, on single-select sites, a simple string is returned; thus filters.value[0] will return just the first letter of that string.

To fix this, you will need to change filters.value[0] to filter.value.

Note that this also means the loop for filters is redundant as there is only one value, so you must eliminate that too.

The URLs Don't Change

We resolved this issue for one customer because of the use of URL components.

If you use named URL components (eg 'Colors' for the color facet) then you'll need to put that into your configuration; however, this won't be the same ID as the source list for the colors.

return option.sourcefrom == customColorId

If this is you, you'll need to account for this by adding another configuration option where the source list ID is.