Creating a Products Widget

Wouldn't it be nice to be able to place one or more Solidus products onto a page managed by Scrivito? Let's create a widget for this!

The basics

Run rails g scrivito:widget SolidusProductsWidget to create the widget model and its views. Then open the model file and add a stringlist attribute named product_ids. Also, provide it with a products method that returns Spree::Product instances of the products stored in product_ids. This is what the model file should finally contain:

# app/models/solidus_products_widget.rb

class SolidusProductsWidget < Widget
  attribute :product_ids, :stringlist

  def products
    return [] unless product_ids.present?

    spree_products = Spree::Product.find(product_ids)

    # Spree API may return products in different order!
    product_ids.map { |id| spree_products.detect { |p| p.id.to_s == id } }
  end
end

For letting editors specify the IDs of the products the widget should render, extend the details view of the SolidusProductsWidget so that the product_ids attribute becomes editable. The view should finally contain the following:

<!-- app/views/solidus_products_widget/details.html.erb -->

<%= scrivito_medium_dialog do %>
  <%= scrivito_details_for SolidusProductsWidget.description_for_editor do %>
    <%= scrivito_details_for "Products" do %>
      <%= scrivito_tag :div, widget, :product_ids %>
    <% end %>
  <% end %>
<% end %>

To have a SolidusProductsWidget instance render the products, set the contents of its show view to the following:

<!-- app/views/solidus_products_widget/show.html.erb -->

<% if widget.products.any? %>
  <%= render partial: 'spree/shared/products', locals: { products: widget.products } %>
<% else %>
  <p>
    No products selected. Edit widget properties to select products to display here.
  </p>
<% end %>

As you can see, we simply utilize the spree/shared/products partial, which is provided by Solidus/Spree.

Auto-completion

We would like to be able to auto-complete Solidus products on the details page of a SolidusProductsWidget instance. For this, we provide a custom inplace editor. This editor uses the API provided by Solidus/Spree.

First, add the following gems to your Gemfile (if they are not present yet):

gem 'underscore-rails'
gem 'coffee-rails', '~> 4.2'

Run bundle install. Then extend the widget's details view so that it renders the spree_api_key of the current user:

<%= scrivito_tag :div, widget, :product_ids, {data: {solidus_api_key: current_spree_user.spree_api_key}} %>

The details view should finally look like this (we also changed the dialog size from medium to large):

<!-- app/views/solidus_products_widget/details.html.erb -->

<%= scrivito_large_dialog do %>
  <%= scrivito_details_for SolidusProductsWidget.description_for_editor do %>
    <%= scrivito_details_for "Products" do %>
      <%= scrivito_tag :div, widget, :product_ids, {data: {solidus_api_key: current_spree_user.spree_api_key}} %>
    <% end %>
  <% end %>
<% end %>

The Spree API can be configured to not require an API key for read-only access: Just set config.requires_authentication to false during Spree::Api::Config.configure. In this case it is, of course, not necessary to render the user's API key as shown above.

Next, create app/assets/javascripts/products_editor.js.coffee and set its contents to:

# app/assets/javascripts/products_editor.js.coffee

class ProductCache
  constructor: -> @cache = {}
  add_raw: (product) ->
    id = String(product.id)
    @cache[id] =
      id: id
      value: product.name + ' (' + product.id + ')'
  find_by_id: (id) -> @cache[id]
  find_by_value: (value) ->
    _(@cache).detect (item) -> item.value == value

# fetch products via solidus API and add them to the cache
fetch_products = (elem, cache, data) ->
  $.ajax(
    url: '/api/products',
    dataType: 'json',
    beforeSend: (xhr) -> xhr.setRequestHeader('X-Spree-Token', elem.data('solidus-api-key')),
    data: data,
  ).then (data) ->
    data.products.map (product) -> cache.add_raw(product)

init_editor = (elem, cache, initial_values) ->
  elem.tagEditor # jQuery tagEditor
    initialTags: initial_values
    forceLowercase: false
    autocomplete:
      source: (request, response) ->
        fetch_products(elem, cache, 'q[name_cont]': request.term).done (products) ->
          response(products.map (product) -> product.value)
    onChange: (field, editor, values) ->
      product_ids = values.map (value) -> cache.find_by_value(value).id
      field.scrivito('save', product_ids)

products_editor =
  can_edit: (element) ->
    $(element).data('scrivito-field-name') == 'product_ids' &&
    $(element).data('scrivito-field-obj-class') == 'SolidusProductsWidget'
  activate: (element) ->
    elem = $(element)
    cache = new ProductCache()

    current_item_ids = elem.scrivito('content')
    if current_item_ids.length
      fetch_products(elem, cache, ids: current_item_ids.join(',')).then (products) ->
        # reiterate current_item_ids, since fetch_products does not response in the correct order
        intial_values = current_item_ids.map (item_id) -> cache.find_by_id(item_id).value
        init_editor(elem, cache, intial_values)
    else
      init_editor(elem, cache, [])

scrivito.on 'load', () ->
  scrivito.define_editor 'products_editor', products_editor
  scrivito.select_editor (element, editor) ->
    editor.use('products_editor')

Then, add the following to app/assets/javascripts/cms.js:

//= require underscore
//= require products_editor

It is important that the JavaScript of the products_editor is inserted after //= require scrivito.

If you restart your server now, you should be able to auto-complete products in the properties dialog of any SolidusProductsWidget instance. Just start typing, e.g. "Spa". You can also reorder the products in the list.

Note: The instructions do not include error handling in the case of an invalid product being added. This was consciously done in an effort to keep the tutorial simple.