Filtering and Sorting with Ransack

Part 15 of building a Rails 7 application

At the moment the index pages for the application show all available records and have no way to filter, search or sort at all. In this blog I'll tackle the filtering and sorting aspects with a tool I have used before called Ransack. I have not seen a compelling replacement for it as yet so if you know of any alternatives add a comment below.

Searching will be a separate topic. I'm not sure it is needed as yet, but if it is there are some alternatives that will come into play including Postgres full text indexing, services such as Elasticsearch and Solr or even re-using Ransack for the task.

To start with Ransack I need to add it to my Gemfile:

# Collection filtering and sorting
gem 'ransack'

The next thing to do is make the controller use Ransack to process filter and sort parameters being passed in via the request. I will separate this into a controller concern:

app/controllers/concerns/filtered_sorted.rb

module FilteredSorted
  extend ActiveSupport::Concern

  included do
    prepend_before_action :set_sorting, :set_filters, only: %i[index]
  end

  private

  def set_collection
    @collection = @q.result(distinct: true)
  end

  def set_filters
    @q = resource_class.ransack((params[:q] || {}).reverse_merge(default_filters))
  end

  def default_filters
    {}
  end

  def set_sorting
    @q.sorts = default_sort if @q.sorts.empty?
  end

  def default_sort
    'created_at desc'
  end
end

Note that order of the before_actions are important here which is why they are being added with a prepend. The filters and sort settings need to be configured prior to setting the collection for paging. I have also given myself a way to set any default filters or sort options when the page first loads.

Now include that into the ResourcesController:

class ResourcesController < ApplicationController
  include TurboFrameVariants
  include Resources
  include Actions
  include Urls
  include Parameters
  include FilteredSorted
  include Paginated
end

Let's take a look at sorting first. I already have a CollectionHeader::Component that takes a columns array. Currently this just pulls a name out of the hash and displays it as the column heading:

    <% @columns.each do |column| %>
      <th scope="col" class="<%= column[:classes] %> sticky top-0 z-10 border-b border-gray-300 bg-gray-50 bg-opacity-75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur backdrop-filter">
        <%= column[:name] %>
      </th>
    <% end %>

I can instead pass in a sorter attribute which will be a link generated by a Ransack helper called sort_link. And if there is no sorter then it can just display a label instead, something like this:

    <% @columns.each do |column| %>
      <th scope="col" class="<%= column[:classes] %> sticky top-0 z-10 border-b border-gray-300 bg-gray-50 bg-opacity-75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur backdrop-filter">
        <% if column[:sorter] %>
          <%= column[:sorter] %>
        <% else %>
          <%= column[:label] %>
        <% end %>
      </th>
    <% end %>

And then modify the index.html.erb where it renders this component:

<%= render Collection::Component.new do |collection_component| %>
      <%= collection_component.with_header(columns: [
          {
              sorter: sort_link(@q, :name, 'Name', default_order: :desc, class: 'group inline-flex'),
              classes: 'py-3.5 pl-4 pr-3 sm:pl-6 lg:pl-8'
          },
          { sorter: sort_link(@q, :canonical, 'Canonical', default_order: :desc, class: 'group inline-flex') },
          { sorter: sort_link(@q, :created_at, 'Created At', default_order: :desc, class: 'group inline-flex') },
          { sorter: sort_link(@q, :updated_at, 'Updated At', default_order: :desc, class: 'group inline-flex') }
      ]) %>
      ...
    <% end %>

All four columns of this page are now sortable by clicking on the header. In addition Ransack allows the icons to be customised so I'll do that as well. I want to use the heroicons for sort-ascending and sort-descending. I just need to add a config/ransack.rb initializer:

Ransack.configure do |c|
  c.custom_arrows = {
    down_arrow: '<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"></path></svg>',
    up_arrow: '<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9m-9 4h9m5-4v12m0 0l-4-4m4 4l4-4"></path></svg>'
  }
end

Loading the page now gives me something that looks like this:

image.png

The next part is adding filtering. Once again a new component can be added to achieve this. Recall the component layout I am using for this application so far:

image.png

The yellow "filter" component is what I want to build now. Here is what that component class looks like:

app/components/collection_filter/component.rb

module CollectionFilter
  class Component < ViewComponent::Base
    include Ransack::Helpers::FormHelper

    attr_reader :filter

    def initialize(filter:)
      super
      @filter = filter
    end
  end
end

It takes a single parameter called filter which will be the Ransack object responsible for the filtering. Note that it also includes the Ransack::Helpers::FormHelper helper so I can use the search_form_for helper that it provides. The component HTML looks like the this:

<div class="px-4 sm:px-6 lg:px-8">
  <div class="mt-8 flex flex-col">
    <div class="-my-2 -mx-4 sm:-mx-6 lg:-mx-8">
      <div class="inline-block min-w-full py-2 align-middle">
        <div class="shadow-sm ring-1 ring-black ring-opacity-5">
          <%= search_form_for(filter) do |form| %>
            <table class="min-w-full border-separate" style="border-spacing: 0">
              <tbody class="bg-gray-50" data-controller="collection-filter--component" data-collection-filter--component-search-form-id-value="<%= form.id %>">
              <tr>
                <%= render partial: 'filters', locals: { form: form } %>
              </tr>
              </tbody>
            </table>
          <% end %>
        </div>
      </div>
    </div>
  </div>
</div>

This builds a form using the passed in filter and then renders a partial called filters where I will specify what individual attributes need to be filtered on. An example of that partial looks like this:

app/views/item_sell_packs/_filters.html.erb

<%= render CollectionFilter::ContainsComponent.new(form: form, attribute: :name) %>
<%= render CollectionFilter::ToggleComponent.new(form: form, attribute: :canonical) %>

At the moment I'm supporting two different types of filters, the Contains which will use the *_cont Ransack matcher, and the Toggle which will be for boolean values and end up using a mix of the *_true and *_not_true matchers.

Here is what the ContainsComponent HTML looks like:

<td scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-gray-50 bg-opacity-75 py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 backdrop-blur backdrop-filter sm:pl-6 lg:pl-8">
  <div>
    <%= form.label :"#{attribute}_cont" %>
    <%= form.search_field :"#{attribute}_cont", class: "max-w-lg block w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:max-w-xs sm:text-sm border-gray-300 rounded-md", data: { action: 'keydown->collection-filter--component#filter' } %>
  </div>
</td>

and here is the HTML for the ToggleComponent:

<td scope="col" class="sticky top-0 z-10 hidden border-b border-gray-300 bg-gray-50 bg-opacity-75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur backdrop-filter sm:table-cell">
  <%= form.label attribute.to_sym %>
  <div>
    <%= form.hidden_field(true_field_name, value: params.dig(:q, true_field_name) || '1') %>
    <%= form.hidden_field(not_true_field_name, value: params.dig(:q, not_true_field_name) || '0') %>
    <button
      type="button"
      class="<%= (params.dig(:q, true_field_name) || '1') == '1' ? 'bg-indigo-600' : 'bg-gray-200' %> relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
      role="switch"
      aria-checked="false"
      data-action="click->collection-filter--component#toggle"
      data-collection-filter--component-true-param="<%= true_field_name %>"
      data-collection-filter--component-not-true-param="<%= not_true_field_name %>"
      data-collection-filter--component-toggle-id-param="<%= toggle_id %>"
    >
      <span class="sr-only">Canonical</span>
      <!-- Enabled: "translate-x-5", Not Enabled: "translate-x-0" -->
      <span id="<%= toggle_id %>" aria-hidden="true" class="<%= (params.dig(:q, true_field_name) || '1') == '1' ? 'translate-x-5' : 'translate-x-0' %> pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
    </button>
  </div>
</td>

Both of these components take two parameters, the form which is the Ransack search_form_for and an attribute which is the model attribute to be filtered on.

Lastly I need some Stimulus JavaScript to drive what happens when the user does anything with the filters. I can add a controller for the CollectionFilter::Component:

app/components/collection_filter/component_controller.js

import { Controller } from "@hotwired/stimulus"
import cash from "cash-dom"

export default class extends Controller {
  static values = {
    searchFormId: String
  }

  filter(event) {
    if (event.key === 'Enter') {
      event.preventDefault()
      const form = cash(`#${this.searchFormIdValue}`)[0]

      form.requestSubmit()
    }
  }

  toggle(event) {
    const toggle = cash(`#${event.params.toggleId}`)
    const button = toggle.parent('button')
    const trueField = cash(`#q_${event.params.true}`)
    const notTrueField = cash(`#q_${event.params.notTrue}`)
    const form = cash(`#${this.searchFormIdValue}`)[0]

    this.toggleFields(trueField, notTrueField)
    this.toggleClasses(toggle, button)

    form.requestSubmit()
  }

  toggleClasses(toggle, button) {
    toggle.toggleClass('translate-x-0 translate-x-5')
    button.toggleClass('bg-gray-200 bg-indigo-600')
  }

  toggleFields(trueField, notTrueField) {
    trueField[0].value = 1 - parseInt(trueField[0].value)
    notTrueField[0].value = 1 - parseInt(notTrueField[0].value)
  }
}

It provides two methods, filter and toggle. Both of these will eventually submit the form that Ransack created for us which then triggers a call to the index action in the corresponding controller, passing in whatever filters the user had interacted with. These methods are being called by my individual filter components, for example in the ContainsComponent it was here:

<%= form.search_field :"#{attribute}_cont", data: { action: 'keydown->collection-filter--component#filter' } %>

I'm using an npm package called cash-dom as a lightweight way to locate elements on the page. To use it I need to pin it in my config/importmap.rb:

pin 'cash-dom', to: 'https://ga.jspm.io/npm:cash-dom@8.1.1/dist/cash.js'

I've created the CollectionFilter::Component now but it needs to be placed into the index.html.erb:

<div class="w-full">
  ...
  <%= render PageHeading::Component.new(title: 'Item Sell Packs', description: 'These are the names for how a supplier sells a complete package') do |c| %>
    <%= c.with_actions do %>
      <%= link_to 'New item sell pack', new_item_sell_pack_path, class: "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" %>
    <% end %>
  <% end %>

  <%= render CollectionFilter::Component.new(filter: @q) %>

  <%= turbo_frame_tag "page_handler" %>
  <div id="collection" class=min-w-full">
    <%= render Collection::Component.new do |collection_component| %>
    ... 
    <% end %>
  </div>
</div>

I have omitted some of the file for brevity, but observe the CollectionFilter::Component being rendered passing in a filter where the value is @q. Recall that this is being set in the controller:

  def set_filters
    @q = resource_class.ransack((params[:q] || {}).reverse_merge(default_filters))
  end

The page now looks like this and mostly works:

image.png

There are two problems that need to be resolved though. The first is the navigation sub menu is broken. Notice how it is no longer marking the sub menu item as active. I need to write a test for this scenario and add a fix:

test/system/navigation_test.rb

  test 'marking sub menu as active when there are query parameters' do
    visit item_sell_packs_url(q: { name_cont: '' })
    assert_selector(:css, '#item_measures--navigation.text-gray-300')
    assert_selector(:css, '#item_sell_packs--navigation.bg-gray-900.text-white')
    assert_selector(:css, '#item_packs--navigation.text-gray-300')
    assert_selector(:css, '#brands--navigation.text-gray-300')
  end

The fix is straightforward, just exclude the query parameters when comparing the current page address to that expected by the menu item:

Changing:

<div data-navigation-current-path-value="<%= request.original_url %>" data-controller="navigation" class="space-y-1">

To:

<div data-navigation-current-path-value="<%= request.original_url.split('?').first %>" data-controller="navigation" class="space-y-1">

The second problem is that when paging is triggered the application is losing any filtering or sorting parameters the user had applied. So again I'll write a test for this scenario:

test/components/collection_pager_component_test.rb

  test 'renders filtering and sorting parameters in link' do
    params = ActionController::Parameters.new({
      q: { name_cont: 'test' }
    })

    assert_equal(
      %(<div id="collection_pager" class="min-w-full my-8 flex justify-between">
  <a class="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" data-turbo-frame="page_handler" data-controller="collection-pager--component" href="/item_sell_packs?page=2&amp;q%5Bq%5D%5Bname_cont%5D=test">Load More</a>
</div>),
      render_inline(
        CollectionPager::Component.new(
          paginator: Paginator.new(next: 2),
          collection_path_method: :item_sell_packs_path,
          filter_params: params
        )
      ).css('#collection_pager').to_html
    )
  end

And then fix the CollectionPager::Component so it takes an additional parameter which holds the filter query parameters (defaulting to nil if there are none provided):

module CollectionPager
  class Component < ViewComponent::Base
    attr_reader :paginator, :collection_path_method, :filter_params

    def initialize(paginator:, collection_path_method:, filter_params: nil)
      super
      @paginator = paginator
      @collection_path_method = collection_path_method
      @filter_params = filter_params
    end
  end
end

And then corresponding HTML for the component:

<% if paginator.next %>
<div id="collection_pager" class="min-w-full my-8 flex justify-between">
  <%= link_to(
          "Load More",
          public_send(collection_path_method, page: paginator.next, q: filter_params&.to_unsafe_h),
          class: "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto",
          data: {
              turbo_frame: "page_handler",
              controller: "collection-pager--component"
          }
      ) %>
</div>
<% end %>

When calling the collection_path_method now it will also include the q parameter. In the index.html.erb this change needs to be applied too for when the pager is first created:

<%= collection_component.with_pager(paginator: @pagy, collection_path_method: :item_sell_packs_path, filter_params: params[:q]) %>

With all of that in place here it is in action:

activerecord-hackery.github.io/ransack/gett..