giant robots smashing into other giant robots

We are thoughtbot. We make web & mobile apps.

Tagged:

Comments (View)

Recipe: Ajax searching and filtering

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

Side note: This feature is testing the non-Javascript path through the app. I’m not going to show any Javascript testing in this recipe. The thoughtbot team has been trying to settle on a Javascript integration strategy that we’re happy with. We’ve tried some things but aren’t in love with anything yet.

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"

The following #{factory_name.pluralize} exist: step definition comes for free with Factory Girl. Note the extra cleverness FG has with the name: #{project_name} field in the table. Real time-saver.

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, Fred, has already sliced the HTML and CSS.

Another side note: field_with_id comes from Webrat::Locators but Joe is starting to advocate for a nicer abstraction called NamedElements that wouldn’t require writing your own step definitions.

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" %>
<% end %>

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:

<div id="filter-list" style="display: none;">
  <form id="filters">
    <!-- a bunch of checkboxes -->
  </form>
</div>

The results:

<tbody class="striped" id="reports-tbody">
  <%= render :partial => "report", :collection => @reports %>
</tbody>

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 "should find reports assigned to any of a set of safety inspectors" do
  first  = Factory(:safety_inspector)
  second = Factory(:safety_inspector)
  third  = Factory(:safety_inspector)

  find_me = Factory(:report, :safety_inspector => first)
  me_too  = Factory(:report, :safety_inspector => second)
  not_me  = Factory(:report, :safety_inspector => third)

  reports = Report.search({ :safety_inspectors => "#{first.id},#{second.id}" })

  reports.should include(find_me)
  reports.should include(me_too)
  reports.should_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)
  return no_op if locations.blank?
  location_id_equals_any(locations.to_array_of_ints)
end

named_scope :distinct, :select => "distinct reports.*"
named_scope :no_op, {}

The no_op is our way of maintaining chainability in the common cases where most searching and filtering criteria is blank. We don’t even want the SQL to be built for blank options. Maybe we should patch this upstream to Searchlogic?

The array of ints is a custom String extension:

class String
  def to_array_of_ints
    self.split(',').collect { |integer| integer.to_i }.to_a
  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 LIKEing while avoiding SQL injection:

def self.full_text_search(query)
  return no_op if query.blank?
  text_search(query)
end

named_scope :text_search, lambda {|query|
  {
    :joins => "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)",
    :conditions => ["(reports.fieldnotes ILIKE :query) OR
                     (reports.name ILIKE :query) OR
                     (locations.name ILIKE :query) OR
                     (users.name ILIKE :query)", { :query => "%#{query}%" }]
  }
}

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!

Dessert: jQuery class

I’m still in the 101 classes of jQuery culinary school, which is why I’m excited about thoughtbot teaming up with Bocoup to provide our first jQuery class in June.

If you’re not familiar with Bocoup, they’ve been setting Boston afire recently with their torrid pace of about one Javascript event a week at their loft, where this training will also be held.

Hope to see you there!

Tagged:

Comments (View)

Integrating vim into your life

More tips from thoughtbot about using vim, but this time with an emphasis on fitting it into your life.

Copy and paste

To copy and paste from your PRIMARY (on OS X, your clipboard) you use the register. For example, to paste from something you’ve copied elsewhere use "p and to copy the current line into your system-wide buffer use "*yy .

For those of us with both a PRIMARY and a CLIPBOARD, the * register is the PRIMARY and the + register is the CLIPBOARD.

Reducing distractions

You can fullscreen MacVim to block out other distractions with :set fuoptions=maxvert,maxhorz and then :set fullscreen. You can get back with :set nofullscreen

File management

Converts from IDEs like Visual Studio like Nick might miss a file explorer for your project. Luckily, NERDTree comes to the rescue. Packed with plenty of good shortcuts, this helpful plugin saves Nick a lot of time when a simple :e or using the :R macros in rails.vim just won’t cut it.

Search results from the command line

While Jason blogged about integrating Ack into vim before, here’s a handy shell script to open a new vim with search results from the command line.

editor=${VISUAL:-vim}
if [ &quot;$#&quot; = &quot;1&quot; ]; then
  $editor -c &quot;/$1&quot; $(grep -l $1 **/*)
elif [ &quot;$#&quot; = &quot;2&quot; ]; then
  $editor -c &quot;/$1&quot; $(grep -l $1 $2) 
else
  $editor $(grep -l $@) 
fi

Save that as vg then, to open all files that mention current_user from the command line run: vg current_user

The full blame

You see that weird piece of code? Who checked that in?! Why is it even there?!

Add these to your .vimrc to get the quick blame for any highlighted lines (\b for svn, \g for git, and \h for Mercurial):

vmap <Leader>b :<C-U>!svn blame <C-R>=expand("%:p") <CR> \| sed -n <C-R>=line("'<") <CR>,<C-R>=line("'>") <CR>p <CR> 
vmap <Leader>g :<C-U>!git blame <C-R>=expand("%:p") <CR> \| sed -n <C-R>=line("'<") <CR>,<C-R>=line("'>") <CR>p <CR> 
vmap <Leader>h :<C-U>!hg blame -fu <C-R>=expand("%:p") <CR> \| sed -n <C-R>=line("'<") <CR>,<C-R>=line("'>") <CR>p <CR>

ctags

Exuberant ctags is a program that scans source files for keywords and supports many languages, including Ruby. Jumping to a defined tag is much faster and easier than searching for it in your project using Ack or Grep, and Vim integrates with ctags nicely.

You can create a tags file using the ctags command (run ctags --help for options), but if you’re using the excellent rails.vim plugin by Tim Pope, you can run the :Rtags command from Vim. Running this command only takes a moment, and will generate a tags file containing all the keywords and locations in your project. Note that you’ll have to regenerate your tags file using the same command for it to pick up new keywords.

Once you have a tags file, you can jump to a tag by using the :tag command:

:tag ensure_user_is_admin

Or by pressing Ctrl+] when the cursor is over a keyword. If there is more than one match for a tag, you can use :tn (next tag), :tp (previous tag), and :ts (select from a list) to navigate through matches. Again, this is much faster than searching.

Another benefit of using ctags is that you can use it for tab completion. I find that tab completion becomes unusably slow in a large project if you’re finding keywords from open buffers, but you can tell Vim to only use the current file and ctags when finding keywords:

:set complete=.,t

Completion results with this setting are instantaneous.

[Contributions by Jon Yurek, Nick Quaranto, and Joe Ferris.]