A recipe for adding searching and filtering to your Rails app.
Why
Provide faster feedback for users. Increase the chance that they’ll find what they want.
Ingredients
The meal
Feature
This feature is testing the non-JavaScript path through the app. I’m not going to show any JavaScript testing in this recipe.
Scenario: Search by fieldnotes
Given a project exists with a name of "Big Dig"
And the following reports exist:
| name | fieldnotes | project |
| Traffic Control | Barricade | name: Big Dig |
| Gases, Vapors | Dust | name: Big Dig |
When I sign in as a safety manager of "Big Dig"
And I go to the "Traffic Control" report page
And I search for "Dust"
Then I should be on the reports page
And I should see "Gases, Vapors"
And I should not see "Traffic Control"
And the search field should contain "Dust"
I know I’m going to be writing multiple search scenarios so I created some step definitions for search:
When /^I search for "([^\"]*)"$/ do |query|
When %{I fill in "search-field" with "#{query}"}
And %{I press "Search"}
end
Then /^the search field should contain "([^\"]*)"$/ do |query|
field_with_id("search-field").value.should == query
end
I know I want search-field
because the designer has already sliced the HTML
and CSS.
HTML
The search box:
- form_tag reports_path, method: :get do
= text_field_tag 'query', params[:query], id: 'search-field'
= submit_tag 'Search', id: 'search-reports-button'
Only thing that might be of note is using params[:query]
to get the ‘And the
search field should contain “Dust”‘ test to pass.
The filters:
#filter-list{ style: 'display: none;' }
%form#filters
/ a bunch of checkboxes
The results:
%tbody.striped#reports-tbody
= render partial: 'report', collection: @reports
Controller
The index action:
def index
@reports = Report.search(params)
if request.xhr?
render partial: '/reports/report', collection: @reports
end
end
There’s some authorization around scoping results to the current user’s project that I’ve removed for brevity.
Model specs
There’s about a dozen different ways this data can be filtered. There’s specs for each kind that look something like this:
it 'finds reports assigned to any of a set of safety inspectors' do
first = create(:safety_inspector)
second = create(:safety_inspector)
third = create(:safety_inspector)
find_me = create(:report, safety_inspector: first)
me_too = create(:report, safety_inspector: second)
not_me = create(:report, safety_inspector: third)
reports = Report.search(safety_inspectors: "#{first.id},#{second.id}")
expect(reports).to include(find_me)
expect(reports).to include(me_too)
expect(reports).to_not include(not_me)
end
There’s one for sending a text query, and a giant one that tests combinations.
Searchlogic
The method through which all searching and filtering passes:
def self.search(params)
full_text_search(params[:query].to_s).
location_in(params[:locations].to_s).
safety_inspector_in(params[:safety_inspectors].to_s).
safety_category_in(params[:safety_categories].to_s).
created_after(params[:from].to_s).
created_before(params[:to].to_s).
severity_in(params[:severities].to_s).
state_in(params[:states].to_s).
descend_by_severity_and_created_at.
distinct
end
Most items in this chain are custom class methods that wrap Searchlogic:
def self.location_in(locations)
if locations.blank?
scoped
else
location_id_equals_any(locations.to_array_of_ints)
end
end
def self.distinct
select("distinct reports.*")
end
The scoped
is a way of maintaining chainability in the common cases where
most searching and filtering criteria is blank.
The array of integers is a custom String extension:
class String
def to_array_of_ints
split(',').map { |integer| integer.to_i }
end
end
The idea here is to do all filtering on highly indexed integers, which Postgres handles quickly. It’s also easy to pass in comma-separated ids from jQuery as we’ll see later.
“Full text search” is just SQL
ILIKE
ing while avoiding SQL
injection:
def self.full_text_search(query)
if query.blank?
scoped
else
text_search(query)
end
end
def self.text_search(query)
join_sql = <<-SQL
INNER JOIN locations ON locations.id = reports.location_id
INNER JOIN users ON users.id IN (
reports.safety_inspector_id,
reports.supervisor_id,
reports.subcontractor_id
)
SQL
condition_sql = <<-SQL
(reports.fieldnotes ILIKE :query) OR
(reports.name ILIKE :query) OR
(locations.name ILIKE :query) OR
(users.name ILIKE :query)
SQL
joins(join_sql).where(condition_sql, { query: "#{query}%" })
end
jQuery
Bind all the necessary events:
$(document).ready(function() {
$('#clear-filters-btn').click(function() {
$('#filters :checked').attr('checked', false);
$('#slider').slider('values', 0, $('#slider').slider('option', 'min'));
$('#slider').slider('values', 1, $('#slider').slider('option', 'max'));
searchReports();
});
$("#filter-list-btn").click(function(){
$(this).toggleClass("active");
$("#filter-list").slideToggle("500");
return false;
});
$('#search-field').keyup(searchReports);
$('#filters :checkbox').click(searchReports);
$('#filters :text').focus(searchReports);
});
Build the Ajax call with jQuery.param and call it:
function searchReportsURL(){
var params = {
'query' : escape($('#search-field').val()),
'locations' : checkedIdsForFilter('location'),
'supervisors' : checkedIdsForFilter('supervisor'),
'safety_categories' : checkedIdsForFilter('category'),
'severities' : checkedIdsForFilter('severity'),
'states' : checkedIdsForFilter('state'),
'risk_profiles' : checkedIdsForFilter('risk-profile'),
'from' : $('.left-handle').text(),
'to' : $('.right-handle').text()
}
return '/reports?' + $.param(params) + '&' + (new Date()).getTime();
}
var searchReportsTimeout = null;
function searchReports() {
if (searchReportsTimeout) {
clearTimeout(searchReportsTimeout);
}
searchReportsTimeout = setTimeout(function() {
$.get(searchReportsURL(),
function(data) {
$('#reports-tbody').html(data);
$(document).trigger('stripeRows');
}
);
}, 500);
}
For brevity, I’ve omitted some helpers like checkedIdsForFilter
that build a
comma-separated list of ids for each criteria. You can figure that out based on
your own markup and JavaScript’s replace()
method.
I kept the timeout, however, because I think it’s important for the user
experience. Without it, the search will happen too fast on every keyup()
,
resulting in a herky-jerky experience. There’s a half-second pause now, but
that’s preferred over “strobe-lighting” the user.
Appending the date to the end of the URL is a cache-buster for Internet Explorer.
Bon appétit!