Creating a Search Page with Faceting

What is faceting?

Faceting is a technique for ordering and filtering search results by specific aspects or dimensions, the facets. It allows users, e.g. the visitors of larger online shops, to specify item properties they are particularily interested in, which are then applied as filters. This reduces the amount of items presented, accelerating the decision making process. So, faceting combines classical text-based searching with property-based searching to improve the user experience and - with shops - the sales figures.

Since faceted searching is based on properties, having a well-maintained inventory of properly categorized content objects is a must. With Scrivito, you can equip your object classes with attributes of various types (e.g. string, stringlist, enum, or multienum) for categorizing your content according to your or your customers' needs.

In this article, we illustrate how one could offer a product search. We'll do this in two steps. The first one covers the full-text-search part which is then supplemented by tag-based restriction of the search result in the second step.

What makes a product?

To begin with, a simple “ProductPage” class with just three attributes, a title, a description and an image should suffice. Let's generate the files:

Copy
$ rails g scrivito:page ProductPage
      create  app/models/product_page.rb
      create  app/controllers/product_page_controller.rb
      create  app/views/product_page/index.html.erb
      create  app/views/product_page/details.html.erb
      create  app/views/product_page/thumbnail.html.erb

Edit the model file and provide the attributes mentioned above. The generator has already added a couple of attributes, so changing their name and type should be all that's required:

Copy
# app/models/product_page.rb

class ProductPage < Obj
  attribute :title, :string
  attribute :description, :string
  attribute :image, :reference
end

Searching – and displaying the hits

In the real world, products are much more complex, of course, but here it's all about searching, not selling. So let's edit the generated “ProductPage” controller and define its “search” action:

Copy
# app/controllers/product_page_controller.rb

class ProductPageController < CmsController
  def search
    @query = params[:query]

    @enumerator = if @query.present?
      ProductPage.where(:*, :contains, @query)
    else
      ProductPage.all
    end

    @product_pages = @enumerator.to_a
  end
end

The idea behind this code is straightforward: check whether the request includes a query and, if so, determine the product pages containing the search term in any of their attributes (:*). If no query is given, determine the list of all product pages. where and all operate on object classes and their parent class, Obj, and return a SearchEnumerator, enumerator in this example, which is then converted to the product_pages array.

The search functionality presented to visitors is contained in the template for displaying the search form and the product_pages resulting from the query:

Copy
<!-- app/views/product_page/search.html.erb -->

<!-- SEARCH FORM START -->
<%= form_tag :products, method: :get do %>
  <%= text_field_tag :query, @query %>
  <%= submit_tag 'Search' %>
<% end %>
<!-- SEARCH FORM END -->

<!-- PRODUCT LIST START -->
<% if @product_pages.any? %>
  <ul>
    <% @product_pages.each do |product_page| %>
      <li>
      <%= link_to scrivito_path(product_page) do %>
        <h3><%= product_page.title %></h3>

        <div class="row">
          <div class="col-md-3">
            <%= scrivito_image_tag product_page, :image, {}, transform: {
              height: 100, width: 100, fit: :crop
            } %>
          </div>

          <div class="col-md-9">
            <%= scrivito_tag :p, product_page, :description%>
          </div>
        </div>

      <% end %>
      </li>
    <% end %>
  </ul>
<% else %>
  <p>No products found.</p>
<% end %>
<!-- PRODUCT LIST END -->

Well, that's a bit lengthy, but note that we've applied the Bootstrap CSS classes, making use of the CSS framework Scrivito supports out of the box.

The first part of the view renders the search form using the standard Rails helpers. The second part acts on the presence of product pages and renders their title, image and description attributes, linking each title to the corresponding dedicated product page we'll create in a minute. The image is transformed to create a bandwidth-saving thumbnail from it.

Next, make the search page accessible to the visitor by defining a /products route pointing to the search action of the product page controller:

Copy
# config/routes.rb

Rails.application.routes.draw do
  get '/products', to: 'product_page#search', as: :products

  scrivito_route '/', using: 'homepage'
  scrivito_route '(/)(*slug-):id', using: 'slug_id'
  scrivito_route '/*permalink', using: 'permalink', format: false
end

Displaying a product page

In the search template above, the title of a product is linked to the corresponding product page. The generated view doesn't display the attributes of the product, so let's change it. We can reuse the search template code that renders a single search result and even leave the search form where it is:

Copy
<!-- app/views/product_page/index.html.erb -->

<!-- SEARCH FORM START -->
<%= form_tag :products, method: :get do %>
  <%= text_field_tag :query, @query %>
  <%= submit_tag 'Search' %>
<% end %>
<!-- SEARCH FORM END -->

<!-- SINGLE PRODUCT START -->
<h3><%= @obj.title %></h3>

<div class="row">
  <div class="col-md-3">
    <%= scrivito_image_tag @obj, :image, {}, transform: {
      height: 100, width: 100, fit: :crop
      } %>
  </div>

  <div class="col-md-9">
    <%= scrivito_tag :p, @obj, :description %>
  </div>
</div>
<!-- SINGLE PRODUCT END -->

Now, start the Rails server and create a couple of product pages, then direct your browser to /products and admire what you've accomplished. The search page with a couple of hits on it should be structured somewhat like this:

Adding faceting to the search

You surely agree that it would be nice to be able to search products by their category or specific properties. To achieve this, we require a means to specify such properties. So let's add a tags attribute to the product page model:

Copy
class ProductPage < Obj
  # ...
  attribute :tags, :stringlist
end

By the way, Scrivito's Content Browser has out-of-the-box support for tagging; the only thing to keep in mind is that the attribute used for storing tags must be of the stringlist type, and its name must be tags, like in the model class above.

To be able to perform tag-based searches, a tag needs to be passed to and handled by our above product page controller. The extension to the controller looks like this:

Copy
class ProductPageController < CmsController
  def search
    # ...

    # Respect +tag+ parameter if it's present:
    @tag = params[:tag]
    if @tag.present?
      @enumerator.and(:tags, :contains, @tag)
    end

    # Fetch the facets:
    @facets = @enumerator.facet(:tags)

    # ...
  end
end

Faceting is done by calling the facet method of a SearchEnumerator instance and passing to it the name of the attribute to inspect. In fact, the above call to facet is the short form that inspects a single attribute. The extended form lets you fan out up to ten attributes; see the Scrivito SDK documentation for details.

So the above code first reduces the result set to the product pages that have the specified tag, then it fetches the complete set of tags – the facets – distributed over the remaining CMS objects. Thus, the items in the result set have at least the searched-for tag in common, and you're enabled to filter the search result by one of the tags they additionally have.

Let's add code to the search template that renders the facets:

Copy
<div class="row">
  <!-- SEARCH FORM START -->
  ...
  <!-- SEARCH FORM END -->

  <!-- FACETS START -->
  <div class="col-md-3">
    <% if @facets.any? %>
      <h3>Show results for:</h3>

      <ul>
        <% @facets.each do |facet| %>
          <li><%= link_to "#{facet.name} (#{facet.count})", query: @query, tag: facet.name %></li>
        <% end %>
      </ul>
    <% end %>
  </div>
  <!-- FACETS END -->

  <div class="col-md-9">
    <!-- PRODUCT LIST START -->
    ...
    <!-- PRODUCT LIST END -->
  </div>
</div>

Note that we wrapped the simple version of the view into a Bootstrap row with two columns, the left one for the search form and the facet list, and the right one for the search result. The facet list is generated simply by iterating over the facets array and using each item as a query parameter for a link pointing to the same page.

If you also agree that having a neat search status bar would round out the picture, here it is:

Copy
<div class="row">
  <!-- SEARCH FORM START -->
  ...
  <!-- SEARCH FORM END -->  
  
  <h4>
    <% if @query || @tag %>
      <% if @tag && @query %>
        <%= @enumerator.size %> result(s) for <b><%= @tag %></b>: "<%= @query %>"
      <% elsif @query %>
        <%= @enumerator.size %> result(s) for "<%= @query %>"
      <% elsif @tag %>
        <%= @enumerator.size %> result(s) for <b><%= @tag %></b>
      <% end %>
      | <%= link_to 'Show all products', :products %>
    <% else %>
      <%= @enumerator.size %> products
    <% end %>
  </h4>

  <hr />

  <!-- FACETS START -->  
  ...
  <!-- FACETS END -->

  <div class="col-md-9">
    <!-- PRODUCT LIST START -->
    ...
    <!-- PRODUCT LIST END -->
  </div>
</div>

This code displays the number of hits, the search term plus the tag filter if present, and a link to the complete list of products.

Making the tags editable

Hold on, there is one thing missing: we should enable editors to assign tags to the products to give a meaning to the effort we've put into all this.

Scrivito comes with a comfortable tag list editor the scrivito_tag helper automatically activates as it renders the tags in Edit mode. Put the following code into the details view of product pages:

Copy
<!-- app/views/product_page/details.html.erb -->

<%= scrivito_details_for 'Tags' do %>
  <%= scrivito_tag :div, @obj, :tags %>
<% end %>
That's all there is to it.

Go searching!

Finally, fire up your Rails server, assign a few appropriate tags to your product pages, and start searching.

What's next?

A lot. As pointed out above, the main goal is to make searching as efficient as possible. So how about enabling the search page to handle more than just one tag to allow narrowing down the hits even further? Offering often searched-for “tag bundles” as dedicated search links might be nice to have, too.

Also, as the number of products increases, the search results page could use pagination plus maybe a means to order the hits by some criterion. But now we're getting off-topic.