Adding a Users Search to a Rails Facebook Clone.

5 minute read

Photo by Markus Winkler on Unsplash

Introduction

I recently completed the core functionality of my Rails Facebook clone project, Gembook. I recently decided to add a rudimentary search form to the Users#index page, allowing users to search for other users by name. I knew this would involve playing around with routes.rb, and that I would need a form to submit a get request to the route created in routes.rb.

What does this post cover?

We will cover the steps necessary to implement a basic search feature. This will make it easier for users to find their friends, but the approach used here can be applied to any application where a user might want to search a collection.

This post will cover the following:

  1. Adding a route to handle search requests
  2. Creating the User.search method
  3. Handling the search request in the UsersController
  4. Submitting the Search Form and Displaying Results
  5. Testing this behavior with RSpec

Step 1 | Adding a Route to Handle Search Requests

I am using resourceful routes for this application. Since we are searching for users it makes sense to nest the search route within resources :users. For an excellent guide to routing in Rails, see the official guide.

Let’s break down get 'search', to: 'users#index', on: :collection. The snippet below allows get requests to /users/search and dispatches those requests to the index action of the UsersController. It will also create the search_users_url and search_users_path route helpers, which we will use later.

  # config/routes.rb
  resources :users, only: [:index, :show] do
    get 'search', to: 'users#index', on: :collection
    resource :profile
  end

Before submitting search requests, we need a way for requests to be processed. We are searching for users, so it makes sense that we involve the User model, as that is the model that knows everything about a given user, including their name, which is how a user will search for other users.

Step 2 | Creating the Search method

My User model includes both a first_name and last_name column. The User.search method will perform an Active Record query using the query argument. Unnecessary database calls are prevented by the guard clause return unless query. The query argument is assigned to name, stripped away of any whitespace, converted to lower-case, and split into an array. Users can then search by first or last name and the method will return matches for either or both. The lower function is an SQL function that converts a string to lower-case.

  # app/models/user.rb
  def self.search(query)
    return unless query

    name = query.strip.downcase.split
    where('lower(first_name) = ? OR lower(last_name) = ?', name.first, name.last)
  end

Step 3 | Handling the search request in the UsersController

Because Rails’ controllers handle all incoming requests from the router, when a get request is submitted to /users/search, the request will be dispatched to the users#index action as shown above. The controller also can create an instance variable, in this case @results, that is accessible to the View.

Below, the controller sends a message to the User class (:search) with an argument from the params hash, params[:query] and assigns the results to the @results instance variable.

  # app/controllers/users_controller.rb
  def index
    # other instance variables etc...
    @results = User.search(params[:query])
  end

Step 4 | Submitting the Search Form and Displaying Results

Submitting the Search Form

Here is where all the above setup pays off. Using the form_with helper, we can submit a get request to the search_users_path, triggering the index action of the UsersController (remember our route: get 'search', to: 'users#index', on: :collection). As seen in Step 3, the index action includes a call to the User.search method.

  <%= form_with(url: search_users_path, method: :get) do |f| %>
    <%= f.text_field :query, placeholder: 'Search for users by name.', required: true %>
    <%= f.submit 'Search' %>
  <% end %>

Upon submission of the form, the UsersController will have access to the params hash, specifically params[:query]. For example, consider a user named John Hancock. His friend, Thomas Jefferson, wants to search for him using Gembook. Mr. Jefferson enters ‘John’ into the text field. The query of ‘John’ gets passed to the UsersController as {"query"=>"John"} and an Active Record query is performed in the User model.

Displaying Results

To display the search results, we can create a partial: app/views/users/_result.html.erb. This allows utilization of the :collection option. For more on this, see the official guide.

<!-- app/views/users/index.html.erb -->
<% if params[:query].present? %>
  <h2>Search Results</h2>
  <ul>
    <%= render partial: 'result', collection: @results %>
  </ul>
  <%= content_tag(:em, "No users found") if @results.none? %>
<% end %>

What you display in your result partial will depend on your application.

Mine looks like this:

 <!-- app/views/users/_result.html.erb -->
  <% if current_user.friends.include?(result) %>
    <%= render partial: 'friend', locals: { friend: result } %>
  <% else %>
    <%= render partial: 'user', locals: { user: result } %>
  <% end %>

The result object is sent to a different partial depending on whether or not the current user is friends with the search result. If they are friends, an “unfriend” button will appear. If they are not friends, an “Add Friend” button will appear.

Step 5 | Testing with RSpec

Unit testing User.search

Before writing a system spec, let’s make sure this method behaves as expected with a unit test in user_spec.rb. A variable called expected results holds an Active Record relation returned from the test database. A variable called search_results holds the ActiveRecord relation returned by the User.search method. We then assert that user, who is a member of expected_results, is included in search_results.

 # spec/models/user_spec.rb
  RSpec.describe User, type: :model do
    before do
      Rails.application.load_seed
    end

    let!(:user) { User.first }

    # several other specs etc...

    describe '.search' do
      it 'accepts a query string and returns user results' do
        expected_results = User.where(first_name: user.first_name, last_name: user.last_name)
        search_results = User.search(user.full_name)
        user = expected_results.first
        expect(search_results).to include(user)
      end
    end
  end

Writing a System Spec

For the system spec, we want to simulate user interaction with the application. To achieve this, we can create two test users, Thomas and John, and have Thomas search for John. We will then assert our expectation that a valid search returns a matching user and that a search for a non-existent user comes up empty.

# spec/system/search_users_spec.rb

require 'rails_helper'

RSpec.describe "SearchUsers", type: :system do
  before do
    driven_by(:rack_test)
  end

  let!(:user) { User.create(first_name: 'Thomas', last_name: 'Jefferson', email: 'thomas@jefferson.com', password: 'foobar') }
  let!(:other_user) { User.create(first_name: 'John', last_name: 'Hancock', email: 'john@hancock.com', password: 'foobar') }

  describe 'searching for a user' do
    context 'a user is logged in at users#index' do
      before do
        login_as(user, scope: :user)
        visit users_path
      end

      it 'allows them to enter a query and shows them results' do
        query = other_user.first_name
        fill_in 'query', with: query
        click_on 'Search'
        expect(page).to have_content('Search Results')
        expect(page).to have_content(other_user.full_name)
      end

      it "Shows 'No users found' if there are no matches" do
        query = 'Joe'
        fill_in 'query', with: query
        click_on 'Search'
        expect(page).to have_content('Search Results')
        expect(page).to have_content('No users found')
      end
    end
  end
end

Conclusion

In this post, I attempted to show the process of adding a user search feature. In retrospect, I think a feature like this would be an excellent candidate for TDD. If I were to use TDD to implement this feature, I would probably start with the unit test and then write the method until the test passed, refactoring where appropriate. Next, I would write the system specs, and finally, write the form and view code to make them pass.

Writing your own search method is a good way to grapple with core Rails functionality. This feature utilizes the Rails router, Model, View, and Controller, all of which are working hard to display search results to your users.

I hope you enjoyed this post. If there is anything I did incorrectly or that could be improved, please let me know in the comments! I love to see different and better approaches to the same problem. Thanks and happy coding!