Toqua
Collection of controller utilities for rails applications. Created with the intention of bringing back most of the nice things about inherited resources, but in a more simple and explicit way.
Installation
Add this line to your application's Gemfile:
gem 'toqua'
And then execute:
$ bundle
Or install it yourself as:
$ gem install toqua
Usage
This library contain different tools that can be used independently, described below.
Transform params
Use this to change the value of a params
key, for example:
class MyController < ApplicationController
include Toqua::TransformParams
transform_params(:q) { |v| JSON.parse(v) }
end
This would transform the value of params[:q]
from a raw string into its json representation. The new value is whatever returns your block. Also works on nested keys:
transform_params(user: :q) {|v| DataManipulation.change(v) }
Or more levels of nesting:
transform_params(user: {data: :q}) {|v| DataManipulation.change(v) }
Scoping
This allows you to further refine an ActiveRecord::Relation
, optionally depending on conditionals. For example:
class MyController < ApplicationController
include Toqua::Scoping
scope {|s| s.where(parent_id: params[:parent_id])}
def index
@elements = apply_scopes(Element)
end
end
The scope
definitions are lambdas that receive an ActiveRecord::Relation
as parameter and must return another
AR::Relation
. They are chained in the order they're defined. To use them, you explicitly call apply_scopes
with the initial
argument.
You can use :if
or :unless
to conditionally choose if execute the scope or not. Examples:
scope(if: :show_all?) { |s| s.includes(:a, :b) }
This will call the method show_all?
defined in the controller and their return value (truthy or falsey) will indicate if the scope
applies or not. You can also use anything that responds to :call
directly, ex:
scope(if: -> { false }) { |s| s.all }
Finally, you can also condition the scope execution based on the action on the controller:
scope(only: :index) { |s| s.includes(:a, :b) }
This is the foundation used to build searching, sorting and pagination over an AR::Relation
.
Used as an independent tool, it provides a way to define scopes used by multiple actions in the same place (authorization, eager loading, etc.).
Pagination
This tool can be used to paginate collections using Kaminari, providing some additional useful things. Example of basic usage:
class MyController < ApplicationController
include Toqua::Pagination
paginate
def index
@elements = apply_scopes(Element)
end
end
As the paginate
method uses scoping, you can pass the options of :if, :unless and :action
that will get forwarded to the scope
method, allowing
you to conditionally decide when to paginate. Ex:
paginate(only: :index)
Or, to paginate only on html but not xlsx format:
paginate(unless: :xlsx?)
The names of the url parameters used to identify the current page and the number of results per page are page
and per_page
by default, but can be changed using:
paginate(page_key: "page", per_page_key: "per_page")
The number of results in each page can be controlled with the :per
option:
paginate(per: 100)
The last option available is :headers
. If used, 3 additional headers will be attached into the response allowing you to know info about the pagination of the collection. This is useful for API clients. Ex:
paginate(headers: true)
The response will include the following headers:
- 'X-Pagination-Total': Total number of elements in the collection without pagination
- 'X-Pagination-Per-Page': Number of elements per page
- 'X-Pagination-Page': Number of the current page
Finally, the method paginated?
available in both the controller and the views will tell you if the collection has been paginated or not.
Search
Small utility to help in the implementation of searching, using Doure as a way to filter an AR model. Given that you have a model with filters defined, ex:
class Post < ApplicationRecord
extend Doure::Filterable
filter_class PostFilter
end
class PostFilter
cont_filter(:title)
cont_filter(:slug)
present_filter(:scheduled_at)
eq_filter(:id)
filter(:category_id_eq) { |s, value| s.joins(:post_categories).where(post_categories: {category_id: value}) }
end
You can setup searching in the controller using:
class PostsController < ApplicationController
include Toqua::Search
searchable
def index
@elements = apply_scopes(Post)
end
end
The parameter used to represent the search criteria is :q
.
The method search_params
will give you a hash representing the contents of :q
, which is the current search criteria, for example:
{title_cont: "Air", category_id_eq: "12", slug_cont: "", scheduled_at_present: "", id_eq: ""}
The method active_search_params
will give you only the search parameters containing some value:
{title_cont: "Air", category_id_eq: "12"}
The method search_object
, available in the view, gives an ActiveModel
like object stuffed with the current search_params
, so you can use that as the object
of the search form to automatically pre-fill all the search inputs with their current value. Ex:
= form_for search_object do |f|
= f.input :title_cont
= f.input :category_id_eq, collection: ...
The method searching?
will tell you if there's a current search or not.
Finally, you can define a default_search_params
method in the controller to setup default search criteria:
def default_search_params
{ visible_by_role: "editor" }
end
As a final note, remember to take care of properly sanitize the input of your search criteria to avoid unintended usage, using TransformParams as seen before or by any other means. Toqua doesn't apply any sanitization by default, since the values that may come from the view can vary between use cases (strings, arrays, hashes, etc.).
Sorting
The sorting utility allows you to sort the collection, using the parameter s
in the url with a format like title+asc
or title+desc
. Usage example:
class PostsController < ApplicationController
include Toqua::Sorting
sorting
def index
@elements = apply_scopes(Post)
end
end
A helper to create sorting links easily is not directly provided by the gem, but can be something like this:
def sort_link(name, label = nil, opts = {})
label ||= name.to_s.humanize
current_attr_name, current_direction = params[:s].present? && params[:s].split("+").map(&:strip)
next_direction = opts.fetch(:default_order, current_direction == "asc" ? "desc" : "asc")
parameters = request.query_parameters
parameters.merge!(opts[:link_params]) if opts[:link_params]
dest_url = url_for(parameters.merge(s: "#{name}+#{next_direction}"))
direction_icon = current_direction == "asc" ? "↑" : "↓"
anchor = current_attr_name == name.to_s ? "#{label} #{direction_icon}" : label
link_opts = opts.fetch(:link_opts, {})
link_to(anchor, dest_url, link_opts)
end
Then used as:
= sort_link :title, "Title"
Keyset pagination
The keyset pagination is similar to the pagination utility, but working with OrderQuery to provide pagination that works with no offsets. Example usage:
class PostsController < ApplicationController
include Toqua::KeysetPagination
keyset_paginate :score_order
def index
@elements = apply_scopes(Post)
end
end
It takes care of applying the correct scoping based on the id of the current element, as identified by the :idx
parameter as default. With the optional :headers
parameter some headers are also added into the response:
keyset_paginate :score_order, headers: true
Will generate those headers:
-
'X-KeysetPagination-Index'
: Theid
of the current element index. -
'X-KeysetPagination-Next-Index'
: Theid
of the element to use as the next page. -
'X-KeysetPagination-Prev-Index'
: Theid
of the element to use as the previous page.
The next and prev indexes are also available via the instance vars @keyset_pagination_prev_index
and @keyset_pagination_next_index
.
If the value of @keyset_pagination_prev_index
(or via header) is -1
it means the previous page is the initial one. If it's nil
, there's no previous page.
To generate pagination links, you can use something like this:
def keyset_pagination_next_link(index_key = :idx)
if @keyset_pagination_next_index
url_for(request.GET.merge(index_key => @keyset_pagination_next_index))
end
end
def keyset_pagination_prev_link(index_key = :idx)
if @keyset_pagination_prev_index
if @keyset_pagination_prev_index == -1
url_for(request.GET.merge(index_key => nil))
else
url_for(request.GET.merge(index_key => @keyset_pagination_prev_index))
end
end
end
Final notes
If you use multiple scope declarations either mixed with the other utilities shown here or not, be aware of the order. For example, pagination must always go last.
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake test
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/toqua.