#370 Ransack
- Download:
- source codeProject Files in Zip (59.2 KB)
- mp4Full Size H.264 Video (29 MB)
- m4vSmaller H.264 Video (12.7 MB)
- webmFull Size VP8 Video (15 MB)
- ogvFull Size Theora Video (30.7 MB)
In the example app below we have a list of products. We’d like to give the user the ability to search these products based on any of its fields and while we could create this functionality from scratch instead we’ll use a gem called Ransack. This is the successor to MetaSearch which was covered in episode 251 and it allows us to create complex search forms with ease.
Ransack is installed by adding it to the gemfile and running bundle
.
gem 'ransack'
Once Ransack has installed we can use it in the action we want to add searching to, in this case the ProductsController
’s index
action. We make a search object here by calling Product.search
and passing in the q
parameter (this contains a hash of the search parameters that the user passes in). To get the found products we call result
on this object.
class ProductsController < ApplicationController def index @search = Product.search(params[:q]) @products = @search.result end end
Next we need to make the form. Ransack provides a form builder for doing this called search_form_for
and we’ll use this to add the search form to the top of the page. This method takes a block that has a form builder passed to it. In the block we define the fields we want to search against and the names we pick for these fields are important. Naming the text field in the form name_cont
means that Ransack will search for products whose name contains the value entered in this text field.
<%= search_form_for @search do |f| %> <div class="field"> <%= f.label :name_cont, "Name contains" %> <%= f.text_field :name_cont %> </div> <div class="actions"><%= f.submit "Search" %></div> <% end %>
When we reload the page now it will have a search form on it and when we enter a value into that field and submit the form the products will be filtered.
This is pretty powerful and we can easily add more functionality to the search by adding more fields to the view, no other logic is needed elsewhere. For example to add the ability to filter by price we can add two more fields like this:
<%= search_form_for @search do |f| %> <div class="field"> <%= f.label :name_cont, "Name contains" %> <%= f.text_field :name_cont %> </div> <div class="field"> <%= f.label :price_gteq, "Price between" %> <%= f.text_field :price_gteq %> <%= f.label :price_lteq, "and" %> <%= f.text_field :price_lteq %> </div> <div class="actions"><%= f.submit "Search" %></div> <% end %>
We can now search for products within a given price range.
If we look at the Basic Searching section of the wiki we’ll find a list of the predicates that we can pass in to customize how the search is performed and the SQL that is executed for each one. Ransack also makes it easy to add links for sorting so we’ll make the table’s headers into links that sort the search results. In the view we can use a helper method called sort_link
so we’ll replace the static text in the tables’ header cells with links to sort the products.
<tr> <th><%= sort_link(@search, :name, "Product Name") %></th> <th><%= sort_link(@search, :released_on, "Release Date") %></th> <th><%= sort_link(@search, :price, "Price") %></th> </tr>
When we reload the page now we have links for sorting the results table by each of the displayed fields.
Creating a Dynamic Search Form
Next we’ll show you some of the more advanced feature of Ransack. There’s a lot more to the search form builder than meets the eye; we can create completely dynamic search forms with it so that the user has complete control over the columns and predicates that are used in the search. Instead of having concrete search fields on our page we’ll create something more dynamic. We can call a condition_fields
method on the form builder and this passes another form builder in for each of the search conditions. We then loop through each of the condition’s attribute_fields and display an attribute_select
for each one. We then display fields for the predicate and value.
<%= search_form_for @search do |f| %> <%= f.condition_fields do |c| %> <div class="field"> <%= c.attribute_fields do |a| %> <%= a.attribute_select %> <% end %> <%= c.predicate_select %> <%= c.value_fields do |v| %> <%= v.text_field :value %> <% end %> </div> <% end %> <div class="actions"><%= f.submit "Search" %></div> <% end %>
This code may seem rather verbose but it’s worth it for what it does. We’ll need to make a small change to the controller before this will work. Searches have no conditions by default so we’ll add a new empty one.
class ProductsController < ApplicationController def index @search = Product.search(params[:q]) @products = @search.result @search.build_condition end end
When we reload the page now we have three fields where we can select the field we want to filter, the predicate and the condition. When we submit the form the products are filtered and we have another set of fields so that we can add another search.
What’s good about this is that we can even add associations here. The attribute_select
can have an associations option and as a product belongs to a Category
we’ll add this in.
<%= a.attribute_select associations: [:category] %>
When we reload the page now we’ll be able to search by the product’s category fields too.
Adding and Removing Conditions Dynamically
It would be nice if we could add and remove conditions dynamically with JavaScript. This is a little complicated to implement but the pattern works similarly to nested forms so we’ll implement something similar to what we did in episode 196. The first thing we’ll do is move the condition fields into a partial.
<%= search_form_for @search do |f| %> <%= f.condition_fields do |c| %> <%= render "condition_fields", f: c%> <% end %> <div class="actions"><%= f.submit "Search" %></div> <% end %>
We’ll need to rename the form field variable in the new partial and add a link for removing the search.
<div class="field"> <%= f.attribute_fields do |a| %> <%= a.attribute_select associations: [:category] %> <% end %> <%= f.predicate_select %> <%= f.value_fields do |v| %> <%= v.text_field :value %> <% end %> <%= link_to "remove", '#', class: "remove_fields" %> </div>
In the ApplicationHelper
we’ll write a link_to_add_fields
method which works similarly to the one we wrote in episode 196.
module ApplicationHelper def link_to_add_fields(name, f, type) new_object = f.object.send "build_#{type}" id = "new_#{type}" fields = f.send("#{type}_fields", new_object, child_index: id) do |builder| render(type.to_s + "_fields", f: builder) end link_to(name, '#', class: "add_fields", data: {id: id, fields: fields.gsub("\n", "")}) end end
We’ll call this method in the index template to generate a link for creating conditions.
<%= search_form_for @search do |f| %> <%= f.condition_fields do |c| %> <%= render "condition_fields", f: c%> <% end %> <p><%= link_to_add_fields "Add Conditions", f, :condition %> <div class="actions"><%= f.submit "Search" %></div> <% end %>
The last step is the JavaScript to get this working.
jQuery -> $('form').on 'click', '.remove_fields', (event) -> $(this).closest('.field').remove() event.preventDefault() $('form').on 'click', '.add_fields', (event) -> time = new Date().getTime() regexp = new RegExp($(this).data('id'), 'g') $(this).before($(this).data('fields').replace(regexp, time)) event.preventDefault()
This is all we need to add and remove the fields dynamically. When we reload the page now we’ll see the links for adding and removing search conditions.
This works well but if the user adds too many conditions they might run into the limit of the data that can be sent over a GET request. One way around this problem is to use POST instead so we’ll implement this. First in the routes file we’ll add a block to the products resource and add a search
route that takes POST requests and which routes to the index
action.
Store::Application.routes.draw do resources :products do collection { post :search, to: 'products#index' } end root to: 'products#index' end
We’ll need to change the search form so that it points to this action.
<%= search_form_for @search, url: search_products_path, method: :post do |f| %> <%= f.condition_fields do |c| %> <%= render "condition_fields", f: c %> <% end %> <p><%= link_to_add_fields "Add Conditions", f, :condition %> <div class="actions"><%= f.submit "Search" %></div> <% end %>
Now when we perform a search on the products the data is sent through a POST request instead of through URL parameters. We still have a problem with the sort links, however, as these will send a GET request to the search
route and this doesn’t work. Instead of handling sorting through links we could move this functionality up into the form and Ransack can help with this as it has methods for generating the select methods for sorting by different fields.
<div class="field"> Sort: <%= f.sort_fields do |s| %> <%= s.sort_select %> <% end %> </div>
Since there’s no sorting specified by default in the controller we can build one we can build one like we did for the conditions. if no current sort exists.
class ProductsController < ApplicationController def index @search = Product.search(params[:q]) @products = @search.result @search.build_condition @search.build_sort if @search.sorts.empty? end end
Now our page will have sort fields where we can choose the sort field and direction.
There may be other cases where we have links that need to carry over the search conditions but which can’t do this through the form, such as pagination links. There are a couple of solutions for this problem. One is to use some JavaScript to submit the link as a POST request, another is to persist the search parameters in the database like we show in episode 111.
That’s it for our episode on Ransack. For an example of a complex search form take a look at the Ransack demo app. This includes a more-complete advanced search feature with multiple sort fields or even add condition groups.