Check out my consultancy offering at RailsReviews.com!

Filterable

Decouple your filtering and faceted search using concerns

How

  • Use a Filterable concern to mediate between your controllers/concerns and the filtering logic
  • In separate Filter classes, define how and which rules apply, and how to update filter params

Caveats

The Book and Restaurant classes in this example mimick an ActiveRecord model. The array refinements simply serve as stand-ins for model scopes or AR queries.

app/reflexes/filter_reflex.rb

class FilterReflex < ApplicationReflex
  include Filterable
  def filter
    resource, param = element.dataset.to_h.fetch_values(:resource, :param)
    value = if element["type"] == "checkbox"
      element.checked
    else 
      element.dataset.value || element.value
    end
    set_filter_for!(resource, param, value)
  end  
end

app/controllers/concerns/filterable.rb

module Filterable
  extend ActiveSupport::Concern
  included do
    if respond_to?(:helper_method)
      helper_method :filter_active_for?
      helper_method :filter_for
    end
  end
  def filter_active_for?(resource, attribute, value=true)
    filter = filter_for(resource)
    filter.active_for?(attribute, value)
  end
  private
  def filter_for(resource)
    "#{resource}Filter".constantize.new(session)
  end
  def set_filter_for!(resource, param, value)
    filter_for(resource).merge!(param, value)
  end
end

app/controllers/books_controller.rb

class BooksController < ApplicationController
  include Filterable
  def index
    @books = Book.all
    @books = filter_for("Book").apply!(@books)
  end
end

app/filters/base_filter.rb

class BaseFilter
  include ActiveModel::Model
  include ActiveModel::Attributes
  def initialize(session)
    @_session = session
    super(@_session.fetch(:filters, {})[filter_resource_class])
  end
  def apply!(_chain)
    raise NotImplementedError
  end
  def merge!(_attribute, _value)
    @_session[:filters] ||= {}
    @_session[:filters][filter_resource_class] ||= {}
  end
  def active_for?(attribute, value=true)
    filter_attribute = send(attribute)
    return filter_attribute.include?(value) if filter_attribute.is_a?(Enumerable)
    filter_attribute == value
  end
  def filter_resource_class
    @filter_resource_class || self.class.name.match(/\A(?<resource>.*)Filter\Z/)[:resource]
  end
end

app/filters/book_filter.rb

class BookFilter < BaseFilter
  attribute :query, :string, default: ""
  def apply!(chain)
    chain = chain.search(query) if query.present?
    chain
  end
  def merge!(attribute, value)
    super
    send(:"#{attribute}=", value)
    @_session[:filters]["Book"].merge!(attribute => send(attribute))
  end
end

app/models/book.rb

class Book < ApplicationRecord
  scope :search, -> { where ... }
end

app/views/books/index.html.erb

<h2 class="mt-4">Books</h2>
<input type="text" class="form-control" id="book_query" placeholder="Search for author or title" data-reflex="input->Filter#filter" data-resource="Book" data-param="query" data-reflex-root="#books-table"/>
<table class="table" id="books-table">
  <thead>
    <tr>
      <th scope="col">Author</th>
      <th scope="col">Title</th>
    </tr>
  </thead>
  <tbody>
    <% @books.each do |book| %>
      <tr>
        <td><%= book.author %></td>
        <td><%= book.title %></td>
      </tr>
    <% end %>
  </tbody>
</table>