Photo by Sophie Elvis on Unsplash
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_action
s 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:
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:
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:
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&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: