Skip to content

Custom JS Advanced Search Faceting

Patrick Perkins edited this page Oct 21, 2024 · 1 revision

At the time of writing this guide, there is a significant bug in how we limit facets that exceed the default limit of 10. The issue #3236 here goes into more detail about the specifics of this problem but - if you've found this wiki page, chances are you've run into the problem yourself.

The gist is this: the "more" modal that pops up on the advanced search page is the same one that it used for basic search. However, the advanced search facets differ from the basic search facets because they are multi-select, resulting in a functional inconsistency. Clicking on a facet within this modal links to a search instead of checking a checkbox.

I chose to implement a custom JS solution that would give the user a clean, searchable interface that falls in line with what one would expect from a multi-select dropdown. For my implementation, I chose TomSelect - It's small, functional, and has a Bootstrap 5 theme that blends nicely with our catalog. It's likely that you could implement another JS dropdown fairly easily, but this guide will focus on TomSelect specifically.

Example of TomSelect advanced search faceting

Removing the Facet Limit

The first step in this implementation is to remove the default limit of 10 facets on the advanced search page. Without doing this, only the first 10 facets will show up in our dropdown. For this guide, I'm using Blacklight 8.3.0, which has Advanced Search built in. Because this built-in version is slightly less configurable than the Advanced Search plugin, we need to add some configuration options to our search builder.

# This class extends Blacklight::SearchBuilder to add additional functionality
class SearchBuilder < Blacklight::SearchBuilder
  include Blacklight::Solr::SearchBuilderBehavior

  self.default_processor_chain += [:facets_for_advanced_search_form]

  # Merge the advanced search form parameters into the solr parameters
  # @param [Hash] solr_p the current solr parameters
  # @return [Hash] the solr parameters with the additional advanced search form parameters
  def facets_for_advanced_search_form(solr_p)
    return unless search_state.controller&.action_name == 'advanced_search' &&
                  blacklight_config.advanced_search[:form_solr_parameters]

    solr_p.merge!(blacklight_config.advanced_search[:form_solr_parameters])
  end
end

We add a new step to our default_processor_chain that merges in some new advanced search parameters to our existing Solr parameters. With this configuration in place, we can add the following configuration to our catalog_controller.rb:

# Remove facet limits on the advanced search form; if we limit these, we see the modal that does not allow for
# multiple selection, which is essential to the advanced search facet functionality.
config.advanced_search = Blacklight::OpenStructWithHashAccess.new(
  enabled: true,
  form_solr_parameters: {
    'facet.field': %w[access_facet format_facet language_facet library_facet
                      location_facet classification_facet recently_published_facet],
    'f.access_facet.facet.limit': '-1',
    'f.format_facet.facet.limit': '-1',
    'f.language_facet.facet.limit': '-1',
    'f.library_facet.facet.limit': '-1',
    'f.location_facet.facet.limit': '-1',
    'f.classification_facet.facet.limit': '-1',
    'f.recently_published_facet.facet.limit': '-1'
  }
)

By setting the facet limit to -1, we effectively disable the limit altogether and receive a comprehensive list of all facets with our query. In this example, I'm setting these params for the specific facets that I chose to include on my advanced search form. You'll have to decide which facets you'd like to include and update this config accordingly.

Creating a Custom Component

We'll need a custom component for our multi-select facet. I chose to name mine MultiSelectFacetComponent. Here's what mine looks like:

module Catalog
  module AdvancedSearch
    # Multi select facet component using TomSelect
    class MultiSelectFacetComponent < Blacklight::Component
      def initialize(facet_field:, layout: nil)
        @facet_field = facet_field
        @layout = layout == false ? FacetFieldNoLayoutComponent : Blacklight::FacetFieldComponent
      end

      # @return [Boolean] whether to render the component
      def render?
        presenters.any?
      end

      # @return [Array<Blacklight::FacetFieldPresenter>] array of facet field presenters
      def presenters
        return [] unless @facet_field.paginator

        return to_enum(:presenters) unless block_given?

        @facet_field.paginator.items.each do |item|
          yield @facet_field.facet_field
                            .item_presenter
                            .new(item, @facet_field.facet_field, helpers, @facet_field.key, @facet_field.search_state)
        end
      end

      # @return [Hash] HTML attributes for the select element
      def select_attributes
        {
          class: "#{@facet_field.key}-select",
          name: "f_inclusive[#{@facet_field.key}][]",
          placeholder: I18n.t('facets.advanced_search.placeholder'),
          multiple: true,
          data: {
            controller: 'multi-select',
            multi_select_plugins_value: select_plugins.to_json
          }
        }
      end

      # @return [Hash] HTML attributes for the option elements within the select element
      def option_attributes(presenter:)
        {
          value: presenter.value,
          selected: presenter.selected? ? 'selected' : nil
        }
      end

      # TomSelect functionality can be expanded with plugins. `checkbox_options`
      # allow us to use the existing advanced search facet logic by using checkboxes.
      # More plugins can be found here: https://tom-select.js.org/plugins/
      #
      # @return [Array<String>] array of TomSelect plugins
      def select_plugins
        %w[checkbox_options caret_position input_autogrow clear_button]
      end
    end
  end
end

The presenters, render, and initialize method are copied over from the default Blacklight FacetFieldCheckboxesComponent which can be found here. Let's expand on some of the other methods.

# @return [Hash] HTML attributes for the select element
def select_attributes
	{
	  class: "#{@facet_field.key}-select",
	  name: "f_inclusive[#{@facet_field.key}][]",
	  placeholder: I18n.t('facets.advanced_search.placeholder'),
	  multiple: true,
	  data: {
		controller: 'multi-select',
		multi_select_plugins_value: select_plugins.to_json
	  }
	}
end

As you might be able to tell, these are attributes on the select element in the generated HTML. The most important value here is the name - in order for the facets to work correctly, we have to name this parameter properly. This allows us to properly facet on form submission. class and placeholer will depend on the specifics of your implementation. multiple should always be enabled if we want the select box to work as a multi-select. data allows us to connect to our Stimulus controller (called multi_select_controller.js), which we'll go over later. TomSelect allows for expanded functionality using plugins - I chose to specify these in the component like this:

def select_plugins
  %w[checkbox_options caret_position input_autogrow clear_button]
end

These values will be read into the Stimulus controller and used when we instantiate TomSelect.

The View

<%= render(@layout.new(facet_field: @facet_field)) do |component| %>  
  <% component.with_label do %>  
    <%= @facet_field.label %>  
  <% end %>  
  <% component.with_body do %>  
    <div class="facet-multi-select">  
      <select <%= tag.attributes(select_attributes) %>>  
        <% presenters.each do |presenter| %>  
          <option <%= tag.attributes(option_attributes(presenter: presenter)) %>>  
            <%= "#{presenter.label} (#{number_with_delimiter(presenter.hits)})" %>  
          </option>  
        <% end %>  
      </select>  
    </div>  
  <% end %>  
<% end %>

This code should be mostly self-explanatory. We pass the attributes mentioned before to the select element with tag.attributes, which takes that hash and turns it into HTML attributes.

After this component has been created, we must tell Blacklight to use our component instead of the default one. This can be done with a simple configuration in our catalog_controller.rb. This is just one facet, but this must be done for each facet on the advanced search page that you'd like to use the custom component for:

config.add_facet_field :language_facet, label: I18n.t('facets.language'), limit: true do |field|  
  field.advanced_search_component = Catalog::AdvancedSearch::MultiSelectFacetComponent  
end

The Javascript

Lastly, we need the actual JS to make this work. For this example, we're utilizing importmap-rails. We'll download the TomSelect JS to our /vendor/javascript folder with the following console command:

./bin/importmap pin tom-select

We'll pin it in importmap.rb:

pin 'tom-select', preload: true # @2.3.1

And import it in our application.js:

import "tom-select";

To use the Bootstrap 5 theme, we'll need to pull that CSS in separately into our application.scss:

@import url("https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.bootstrap5.min.css");

I'm trying to recall why exactly we chose to download the JS and pull in the CSS - I think our CSS pre-processor didn't like the vendor SCSS - this may become easier in the future with new Rails asset pipeline stuff in the works.

The Stimulus Controller

Using Stimulus, we'll connect the select element on the page to a JS controller that will instantiate the TomSelect behavior. I'm going to post the whole controller below and break it down:

import { Controller } from "@hotwired/stimulus";
import TomSelect from "tom-select";

export default class extends Controller {
  static values = {
    plugins: Array,
  };

  connect() {
    this.select = new TomSelect(this.element, {
      plugins: this.pluginsValue,
      // Passing the `item` function to the `render` arg allows us to customize what the selected item looks like when it's
      // added to the list of selected items. In this case, we're just wrapping the value (coming from the data set) in a
      // div to remove the count that's displayed in the option list.
      render: {
        item: function (data, escape) {
          return `<div>${escape(data.value)}</div>`;
        },
      },
    });
  }

  disconnect() {
    this.select?.destroy();
  }
}

After importing the Stimulus code and pulling in the TomSelect element, we instantiate the plugins array that we passed in as part of our MultiSelectFacetComponent. The connect lifecycle method is called when the Stimulus controller connects to the DOM element (in this case, it's attached to the <select> element). Here's what happens:

  1. We assign this.select to be a new TomSelect which takes the element that our Stimulus controller is attached to (in this case, the <select> element) as the first argument and a configuration object as its second argument.
  2. In this configuration, we tell it to use the plugins that we specified in our component.
  3. We also tell it to render each item (when selected) as just the bare data.value - or the facet value - without the result counter.
  4. On disconnect, we destroy the custom select element. You can find all the configuration options on the TomSelect documentation page.

That's it! You should have a working custom JS multi-select on your Advanced Search page. If you're having trouble with setting this up yourself or just need some assistance in your own implementation, feel free to send me an email at pperkins@upenn.edu.

Clone this wiki locally