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>