Autocomplete using Typeahead and Searchkick in Rails 5

Libraries Used

  • Searchkick Gem for Search
  • ElasticSearch for Full Text Search
  • Typeahead Javascript Library for Autocomplete

Setup SearchKick

Create a new Rails 5 project and add searchkick gem to Gemfile.

gem 'searchkick'

Run bundle. Create an article resource.

rails g scaffold article title content:text

Add searchkick to the article model.

class Article < ApplicationRecord
  searchkick
end

Setup Database

Add sample data to seeds.rb.

Article.destroy_all
data = [{ title: 'Star Wars', content: 'Wonderful adventure in the space' }, 
        { title: 'Lord of the Rings', content: 'Lord that became a ring' },
        { title: 'Man of the Rings', content: 'Lord that became a ring' },
        { title: 'Woman of the Rings', content: 'Lord that became a ring' },
        { title: 'Dog of the Rings', content: 'Lord that became a ring' },
        { title: 'Daddy of the Rings', content: 'Lord that became a ring' },
        { title: 'Mommy of the Rings', content: 'Lord that became a ring' },
        { title: 'Duck of the Rings', content: 'Lord that became a ring' },
        { title: 'Drug Lord of the Rings', content: 'Lord that became a ring' },
        { title: 'Native of the Rings', content: 'Lord that became a ring' },
        { title: 'Naysayer of the Rings', content: 'Lord that became a ring' },
        { title: 'Tab Wars', content: 'Lord that became a ring' },
        { title: 'Drug Wars', content: 'Lord that became a ring' },
        { title: 'Cheese Wars', content: 'Lord that became a ring' },
        { title: 'Dog Wars', content: 'Lord that became a ring' },
        { title: 'Dummy Wars', content: 'Lord that became a ring' },
        { title: 'Dummy of the Rings', content: 'Lord that became a ring' }
        ]
Article.create(data)

Migrate and populate the database.

rails db:migrate
rails db:seed

Test Connectivity to ElasticSearch

Index the articles data in elasticsearch.

rake searchkick:reindex CLASS=Article

We can now play in the rails console to verify search functionality.

> results = Article.search('War')
  Article Search (11.7ms)  curl http://localhost:9200/articles_development/_search?pretty -d '{"query":{"dis_max":{"queries":[{"match":{"_all":{"query":"War","boost":10,"operator":"and","analyzer":"searchkick_search"}}},{"match":{"_all":{"query":"War","boost":10,"operator":"and","analyzer":"searchkick_search2"}}},{"match":{"_all":{"query":"War","boost":1,"operator":"and","analyzer":"searchkick_search","fuzziness":1,"prefix_length":0,"max_expansions":3,"fuzzy_transpositions":true}}},{"match":{"_all":{"query":"War","boost":1,"operator":"and","analyzer":"searchkick_search2","fuzziness":1,"prefix_length":0,"max_expansions":3,"fuzzy_transpositions":true}}}]}},"size":1000,"from":0,"fields":[]}'
 => #<Searchkick::Results:0x007fcf42475dd8 @klass=Article (call 'Article.connection' to establish a connection), @response={"took"=>9, "timed_out"=>false, "_shards"=>{"total"=>5, "successful"=>5, "failed"=>0}, "hits"=>{"total"=>6, "max_score"=>0.37037593, "hits"=>[{"_index"=>"articles_development_20160518103333170", "_type"=>"article", "_id"=>"16", "_score"=>0.37037593}, {"_index"=>"articles_development_20160518103333170", "_type"=>"article", "_id"=>"15", "_score"=>0.37037593}, {"_index"=>"articles_development_20160518103333170", "_type"=>"article", "_id"=>"12", "_score"=>0.3074455}, {"_index"=>"articles_development_20160518103333170", "_type"=>"article", "_id"=>"14", "_score"=>0.3074455}, {"_index"=>"articles_development_20160518103333170", "_type"=>"article", "_id"=>"1", "_score"=>0.21875}, {"_index"=>"articles_development_20160518103333170", "_type"=>"article", "_id"=>"13", "_score"=>0.21875}]}}, @options={:page=>1, :per_page=>1000, :padding=>0, :load=>true, :includes=>nil, :json=>false, :match_suffix=>"analyzed", :highlighted_fields=>[]}>

We are able to connect to the elasticsearch server using searchkick library and retrieve the search results.

> results.class
 => Searchkick::Results

The result is Searchkick::Results object. We have 6 records in the results.

> results.size
  Article Load (0.4ms)  SELECT "articles".* FROM "articles" WHERE "articles"."id" IN (16, 15, 12, 14, 1, 13)
 => 6

Integrate Typeahead with Rails 5 App

Add the search form to the articles index page.

<%= form_tag articles_path, method: :get do %>
  <%= text_field_tag :query, params[:query], class: 'form-control' %>
  <%= submit_tag 'Search' %>
<% end %>

You can now search in the articles index page without autocompletion. Let's implement the autocomplete feature. Download typeahead.js version 0.11.1 and move it to vendor/assets/javascripts directory. Include typeahead.js in the application.js.

//= require typeahead

Add the endpoint for the autocomplete suggestions in articles controller.

def autocomplete
  render json: Article.search(params[:query], autocomplete: true, limit: 10).map(&:title)
end

Declare the route for autocomplete.

Rails.application.routes.draw do
  resources :articles do
    get :autocomplete
  end
end

Add id and autocomplete attributes to the search text field in articles index page.

<%= text_field_tag :query, params[:query], class: 'form-control', id: "article_search" %>

Add the following javascript to articles.js.

var ready;
ready = function() {
    var engine = new Bloodhound({
        datumTokenizer: function(d) {
            console.log(d);
            return Bloodhound.tokenizers.whitespace(d.title);
        },
        queryTokenizer: Bloodhound.tokenizers.whitespace,
        remote: {
            url: '../articles/autocomplete?query=%QUERY',
            wildcard: '%QUERY'
        }
    });

    var promise = engine.initialize();

    promise
        .done(function() { console.log('success!'); })
        .fail(function() { console.log('err!'); });

    $('.typeahead').typeahead(null, {
        name: 'engine',
        displayKey: 'title',
        source: engine.ttAdapter()
    });
}

$(document).ready(ready);
$(document).on('page:load', ready);

If you don't provide the wildcard, you will get the error:

GET http://localhost:3000/search/autocomplete?query=%QUERY 400 (Bad Request)

in the browser inspect window and in the log file:

HTTP parse error, malformed request puma

Isolating Problems

You can use curl to isolate the problem to either front-end or back-end.

curl http://localhost:3000/articles?query='dog'

In the log file, we see:

Article Search (19.4ms)  curl http://localhost:9200/articles_development/_search?pretty -d '{"query":{"dis_max":{"queries":[{"match":{"_all":{"query":"dog","boost":10,"operator":"and","analyzer":"searchkick_search"}}},{"match":{"_all":{"query":"dog","boost":10,"operator":"and","analyzer":"searchkick_search2"}}},{"match":{"_all":{"query":"dog","boost":1,"operator":"and","analyzer":"searchkick_search","fuzziness":1,"prefix_length":0,"max_expansions":3,"fuzzy_transpositions":true}}},{"match":{"_all":{"query":"dog","boost":1,"operator":"and","analyzer":"searchkick_search2","fuzziness":1,"prefix_length":0,"max_expansions":3,"fuzzy_transpositions":true}}}]}},"size":1000,"from":0,"fields":[]}'
  Rendering articles/index.html.erb within layouts/application

In the terminal output:

<!DOCTYPE html>
<html>
  <head>
    <title>Autoc</title>
    <meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="O9rx6qf0ik6ae" />
    <link rel="stylesheet" media="all" href="/assets/articles.self-e3b04b855.css?body=1" data-turbolinks-track="reload" />
<link rel="stylesheet" media="all" href="/assets/scaffolds.self-c8daf17deb4.css?body=1" data-turbolinks-track="reload" />
<link rel="stylesheet" media="all" href="/assets/application.self-a9e16886.css?body=1" data-turbolinks-track="reload" />
    <script src="/assets/jquery.self-35bf4c.js?body=1" data-turbolinks-track="reload"></script>
<script src="/assets/jquery_ujs.self-e87806d0cf4489.js?body=1" data-turbolinks-track="reload"></script>
<script src="/assets/typeahead.self-7d0ec0be4d31a26122.js?body=1" data-turbolinks-track="reload"></script>
<script src="/assets/turbolinks.self-979a09514ef27c8.js?body=1" data-turbolinks-track="reload"></script>
<script src="/assets/articles.self-ca74ce155498e7f0.js?body=1" data-turbolinks-track="reload"></script>
<script src="/assets/action_cable.self-97a1acc11db.js?body=1" data-turbolinks-track="reload"></script>
<script src="/assets/cable.self-6e05142.js?body=1" data-turbolinks-track="reload"></script>
<script src="/assets/application.self-afe802b04eaf.js?body=1" data-turbolinks-track="reload"></script>
  </head>
  <body>
    <p id="notice"></p>
<form action="/articles" accept-charset="UTF-8" method="get"><input name="utf8" type="hidden" value="&#x2713;" />
  <input type="text" name="query" id="article_search" value="dog" class="form-control" />
  <input type="submit" name="commit" value="Search" data-disable-with="Search" />
</form>
<h1>Articles</h1>
<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Content</th>
      <th colspan="3"></th>
    </tr>
  </thead>
  <tbody>
      <tr>
        <td>Dog Wars</td>
        <td>Lord that became a ring</td>
        <td><a href="/articles/15">Show</a></td>
        <td><a href="/articles/15/edit">Edit</a></td>
        <td><a data-confirm="Are you sure?" rel="nofollow" data-method="delete" href="/articles/15">Destroy</a></td>
      </tr>
      <tr>
        <td>Dog of the Rings</td>
        <td>Lord that became a ring</td>
        <td><a href="/articles/5">Show</a></td>
        <td><a href="/articles/5/edit">Edit</a></td>
        <td><a data-confirm="Are you sure?" rel="nofollow" data-method="delete" href="/articles/5">Destroy</a></td>
      </tr>
  </tbody>
</table>
<a href="/articles/new">New Article</a>
  </body>
</html>

This is without autocomplete. If you search for something, you will get the error:

ActiveRecord::RecordNotFound (Couldn't find Article with 'id'=autocomplete):

This is because we did not define the routes within the collection block in routes.rb. Check the output of rake routes:

article_autocomplete GET    /articles/:article_id/autocomplete(.:format) articles#autocomplete

The route is not correct. Let's fix it in routes.rb.

Rails.application.routes.draw do
  resources :articles do
    collection do
      get :autocomplete
    end
  end
end

Implement Autocomplete

In article model configure autocomplete.

searchkick autocomplete: ['title']

Implement the autocomplete action in the articles controller.

def autocomplete
  render json: Article.search(params[:query], autocomplete: false, limit: 10).map do |book|
    { title: book.title, value: book.id }
  end
end

You need to add the typeahead class to the search form.

<%= text_field_tag :query, params[:query], class: 'form-control typeahead' %>

You will now be able to see the autocomplete in action as you type the search term.

Style Autocomplete Dropdown

Let's now style the dropdown box in the autocomplete list. Create typeahead.scss and add:

.tt-query {
  -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
     -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
          box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}

.tt-hint {
  color: #999
}

.tt-menu {    /* used to be tt-dropdown-menu in older versions */
  width: 422px;
  margin-top: 4px;
  padding: 4px 0;
  background-color: #fff;
  border: 1px solid #ccc;
  border: 1px solid rgba(0, 0, 0, 0.2);
  -webkit-border-radius: 4px;
     -moz-border-radius: 4px;
          border-radius: 4px;
  -webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2);
     -moz-box-shadow: 0 5px 10px rgba(0,0,0,.2);
          box-shadow: 0 5px 10px rgba(0,0,0,.2);
}

.tt-suggestion {
  padding: 3px 20px;
  line-height: 24px;
}

.tt-suggestion.tt-cursor,.tt-suggestion:hover {
  color: #fff;
  background-color: #0097cf;

}

.tt-suggestion p {
  margin: 0;
}

Handle the search in the articles index action:

def index
  @articles = if params[:query].present?
    Article.search(params[:query])
  else
    Article.all
  end
end

You will now see a nice looking drop-down for the autocomplete values. You can download the source code for this article from autocomplete. If you want to use Twitter Bootstrap style, checkout the second link in the references section of this article.

Things that Can Go Wrong

If you rename the headlines.coffee to headlines.js, remember to remove the comments generated by Rails. You will get error in the Chrome Inspect tool. The headlines javascript will display success! in the javascript console if javascript has no issues.

If you don't see any autocompletion results as you type, make sure you reindex the records using the rake task as shown in this article.

Searchkick Syntax Change

The latest version of searchckick version breaks the autocomplete functionality because autocomplete key has been removed. The error is unknown keywords autocomplete:. If you read the test for autocomplete: https://github.com/ankane/searchkick/blob/master/test/autocomplete_test.rb, you can see match: :text_start. The test helper: https://github.com/ankane/searchkick/blob/7b06dbe4cdec37ff1349ccf01bf8cb3295971007/test/test_helper.rb uses text_start: [:name]. To fix this issue, declaration for searchkick in the model should look like this:

class Movie < ActiveRecord::Base
  searchkick word_start: [:title, :director]
end

Reindex and search with:

Movie.search "jurassic pa", match: :word_start

These can be found in the readme of the gem under Autocomplete heading.

Summary

In this article, you learned how to use Typeahead javascript library to implement autocomplete for search feature using ElasticSearch and SearcKick in Rails 5 apps.

References


Related Articles

Watch this Article as Screencast

You can watch this as a screencast Autocomplete using Typeahead and Searchkick in Rails 5