Admino
A minimal, object-oriented solution to generate Rails administrative index views. Through query objects and presenters, it features a customizable table generator and search forms with filtering/sorting.
The philosophy behind it
The Rails ecosystem has many full-fledged solutions to generate administrative interfaces.
Although these tools are very handy to bootstrap a project quickly, they all obey the 80%-20% rule and tend to be very invasive, often mixing up different concerns on a single responsibility level, thus making tests unbelievably difficult to setup and write.
A time comes when these all-encompassing tools get in the way. And that will be the moment where all the cumulated saved time will be wasted to solve a single, trivial problem with ugly workarounds and epic facepalms.
So yes, if you're starting a small, short-lived project, go ahead with them, it will be fine! If you're building something that's more valuable or is meant to last longer, there are better alternatives.
A modular approach to the problem
The great thing is that you don't need to write a lot of code to get a more maintainable and modular administrative area. Gems like Inherited Resources and Simple Form, combined with Rails 3.1+ template-inheritance already give you ~90% of the time-saving features and the same super-DRY, declarative code that administrative interfaces offer, but with a far more relaxed contract.
If a particular controller or view needs something different from the standard CRUD/REST treatment, you can just avoid using those gems in that specific context, and fall back to standard Rails code. No workarounds, no facepalms. It seems easy, right? It is.
So what about Admino? Well, it complements the above-mentioned gems, giving you the the missing ~10%: a fast way to generate administrative index views.
Installation
Add this line to your application's Gemfile:
gem 'admino'
And then execute:
$ bundle
Admino::Query::Base
Admino::Query::Base implements the Query object pattern, that is, an object responsible for returning a result set (ie. an ActiveRecord::Relation) based on business rules.
Given a Task model, we can generate a TasksQuery query object subclassing Admino::Query::Base:
class TasksQuery < Admino::Query::Base
end
Each query object gets initialized with a hash of params, and features a #scope method that returns the filtered/sorted result set. As you may have guessed, query objects can be great companions to index controller actions:
class TasksController < ApplicationController
def index
@query = TasksQuery.new(params)
@tasks = @query.scope
end
end
Building the query itself
You can specify how a TaskQuery must build a result set through a simple DSL.
starting_scope
The starting_scope method is in charge of defining the scope that will start the filtering/ordering chain:
class TasksQuery < Admino::Query::Base
starting_scope { Task.all }
end
Task.create(title: 'Low priority task')
TaskQuery.new.scope.count # => 1
search_field
Once you define the following field:
class TasksQuery < Admino::Query::Base
# ...
search_field :title_matches
end
The #scope method will check the presence of the params[:query][:title_matches] key. If it finds it, it will augment the query with a
named scope called :title_matches, expected to be found within the Task model, that needs to accept an argument.
class Task < ActiveRecord::Base
scope :title_matches, ->(text) {
where('title ILIKE ?', "%#{text}%")
}
end
Task.create(title: 'Low priority task')
Task.create(title: 'Fix me ASAP!!1!')
TaskQuery.new.scope.count # => 2
TaskQuery.new(query: { title_matches: 'ASAP' }).scope.count # => 1
filter_by
class TasksQuery < Admino::Query::Base
# ...
filter_by :status, [:completed, :pending]
end
Just like a search field, with a declared filter group the #scope method will check the presence of a params[:query][:status] key. If it finds it (and its value corresponds to one of the declared scopes) it will augment the query the scope itself:
class Task < ActiveRecord::Base
scope :completed, -> { where(completed: true) }
scope :pending, -> { where(completed: false) }
end
Task.create(title: 'First task', completed: true)
Task.create(title: 'Second task', completed: true)
Task.create(title: 'Third task', completed: false)
TaskQuery.new.scope.count # => 3
TaskQuery.new(query: { status: 'completed' }).scope.count # => 2
TaskQuery.new(query: { status: 'pending' }).scope.count # => 1
TaskQuery.new(query: { status: 'foobar' }).scope.count # => 3
sorting
class TasksQuery < Admino::Query::Base
# ...
sorting :by_due_date, :by_title
end
Once you declare some sorting scopes, the query object looks for a params[:sorting] key. If it exists (and corresponds to one of the declared scopes), it will augment the query with the scope itself. The model named scope will be called passing an argument that represents the direction of sorting (:asc or :desc).
The direction passed to the scope will depend on the value of params[:sort_order], and will default to :asc:
class Task < ActiveRecord::Base
scope :by_due_date, ->(direction) { order(due_date: direction) }
scope :by_title, ->(direction) { order(title: direction) }
end
expired_task = Task.create(due_date: 1.year.ago)
future_task = Task.create(due_date: 1.week.since)
TaskQuery.new(sorting: 'by_due_date', sort_order: 'desc').scope # => [ future_task, expired_task ]
TaskQuery.new(sorting: 'by_due_date', sort_order: 'asc').scope # => [ expired_task, future_task ]
TaskQuery.new(sorting: 'by_due_date').scope # => [ expired_task, future_task ]
ending_scope
It's very common ie. to paginate a result set. The block declared in the ending_scope block will be always appended to the end of the chain:
class TasksQuery < Admino::Query::Base
ending_scope { |q| page(q.params[:page]) }
end
Inspecting the query state
A query object supports various methods to inspect the available search fields, filters and sortings, and their state:
query = TaskQuery.new
query.search_fields # => [ #<Admino::Query::SearchField>, ... ]
query.filter_groups # => [ #<Admino::Query::FilterGroup>, ... ]
search_field = query.search_field_by_name(:title_matches)
search_field.name # => :title_matches
search_field.present? # => true
search_field.value # => 'ASAP'
filter_group = query.filter_group_by_name(:status)
filter_group.name # => :status
filter_group.scopes # => [ :completed, :pending ]
filter_group.active_scope # => :completed
filter_group.is_scope_active?(:pending) # => false
sorting = query.sorting # => #<Admino::Query::Sorting>
sorting.scopes # => [ :by_title, :by_due_date ]
sorting.active_scope # => :by_due_date
sorting.is_scope_active?(:by_title) # => false
sorting.ascending? # => true
Presenting search form and filters to the user
Admino also offers a Showcase presenter that makes it really easy to generate search forms and filtering links:
<%# instanciate the the query object presenter %>
<% query = present(@query) %>
<%# generate the search form %>
<%= query.form do |q| %>
<p>
<%= q.label :title_matches %>
<%= q.text_field :title_matches %>
</p>
<p>
<%= q.submit %>
</p>
<% end %>
<%# generate the filtering links %>
<% query.filter_groups.each do |filter_group| %>
<h6><%= filter_group.name %></h6>
<ul>
<% filter_group.scopes.each do |scope| %>
<li>
<%= filter_group.scope_link(scope) %>
<li>
<% end %>
</ul>
<% end %>
<%# generate the sorting links %>
<h6>Sort by</h6>
<ul>
<% query.sorting.scopes.each do |scope| %>
<li>
<%= query.sorting.scope_link(scope) %>
</li>
<% end %>
</ul>
The great thing is that:
- the search form gets automatically filled in with the last input the user submitted
- a
is-activeCSS class gets added to the currently active filter scopes - if a particular filter link has been clicked and is now active, it is possible to deactivate it by clicking on the link again
- a
is-asc/is-descCSS class gets added to the currently active sorting scope - if a particular sorting scope link has been clicked and is now in ascending order, it is possible to make it descending by clicking on the link again
Simple Form support
The presenter also offers a #simple_form method to make it work with Simple Form out of the box.
I18n
To localize the search form labels, as well as the group filter names and scope links, please refer to the following YAML file:
en:
query:
attributes:
tasks_query:
title_matches: 'Title contains'
filter_groups:
tasks_query:
status:
name: 'Filter by status'
scopes:
completed: 'Completed'
pending: 'Pending'
sorting_scopes:
task_query:
by_due_date: 'By due date'
by_title: 'By title'
Output customization
The presenter supports a number of optional arguments that allow a great amount of flexibility regarding customization of CSS classes, labels and HTML attributes. Please refer to the tests for the details.
Overwriting the starting scope
Suppose you have to filter the tasks based on the @current_user work group. You can easily provide an alternative starting scope from the controller passing it as an argument to the #scope method:
def index
@query = TasksQuery.new(params)
@project_tasks = @query.scope(@current_user.team.tasks)
end
Coertions
Admino can perform automatic coertions from a param string input to the type needed by the model named scope:
class TasksQuery < Admino::Query::Base
# ...
field :due_date_from, coerce: :to_date
field :due_date_to, coerce: :to_date
end
The following coertions are available:
:to_boolean:to_constant:to_date:to_datetime:to_decimal:to_float:to_integer:to_symbol:to_time
If a specific coercion cannot be performed with the provided input, the scope won't be chained.
Please see the Coercible::Coercer::String class for details.
Default sorting
If you need to setup a default sorting, you can pass some optional arguments to a scoping declaration:
class TasksQuery < Admino::Query::Base
# ...
sorting :by_due_date, :by_title,
default_scope: :by_due_date,
default_direction: :desc
end
Admino::Table::Presenter
Admino offers a Showcase collection presenter that makes it really easy to generate HTML tables from a set of records:
<%= Admino::Table::Presenter.new(@tasks, Task, self).to_html do |row, record| %>
<%= row.column :title %>
<%= row.column :completed do %>
<%= record.completed ? '✓' : '✗' %>
<% end %>
<%= row.column :due_date %>
<% end %>
<table>
<thead>
<tr>
<th role='title'>Title</th>
<th role='completed'>Completed</th>
<th role='due_date'>Due date</th>
</tr>
<thead>
<tbody>
<tr id='task_1' class='is-even'>
<td role='title'>Call mum ASAP</td>
<td role='completed'>✓</td>
<td role='due_date'>2013-02-04</td>
</tr>
<tr id='task_2' class='is-odd'>
<!-- ... -->
</tr>
<tbody>
</table>
Record actions
Often table rows needs to offer some kind of action associated with the record. The presenter implements the following DSL to support that:
<%= Admino::Table::Presenter.new(@tasks, Task, self).to_html do |row, record| %>
<%# ... %>
<%= row.actions do %>
<%= row.action :show, admin_task_path(record) %>
<%= row.action :edit, edit_admin_task_path(record) %>
<%= row.action :destroy, admin_task_path(record), method: :delete %>
<% end %>
<% end %>
<table>
<thead>
<tr>
<!-- ... -->
<th role='actions'>Actions</th>
</tr>
<thead>
<tbody>
<tr id='task_1' class='is-even'>
<!-- ... -->
<td role='actions'>
<a href='/admin/tasks/1' role='show'>Show</a>
<a href='/admin/tasks/1/edit' role='edit'>Edit</a>
<a href='/admin/tasks/1' role='destroy' data-method='delete'>Destroy</a>
</td>
</tr>
<tbody>
</table>
Sortable columns
Once a query object is passed to the presenter, columns can be associated to specific sorting scopes of the query object using the sorting option:
<% query = present(@query) %>
<%= Admino::Table::Presenter.new(@tasks, Task, query, self).to_html do |row, record| %>
<%= row.column :title, sorting: :by_title %>
<%= row.column :due_date, sorting: :by_due_date %>
<% end %>
This generates links that allow the visitor to sort the result set in ascending and descending direction:
<table>
<thead>
<tr>
<th role='title'>
<a href="/admin/tasks?sorting=by_title&sort_order=desc" class='is-asc'>Title</a>
</th>
<th role='due_date'>
<a href="/admin/tasks?sorting=by_due_date&sort_order=asc" class='is-asc'>Due date</a>
</th>
</tr>
<thead>
<!-- ... -->
</table>
Customizing the output
The #column and #action methods are very flexible, allowing youto change almost every aspect of the generated table cells:
<%= Admino::Table::Presenter.new(@tasks, Task, self).to_html(class: 'table-class') do |row, record| %>
<%= row.column :title, 'Custom title',
class: 'custom-class', role: 'custom-role', data: { custom: 'true' },
sorting: :by_title, sorting_html_options: { desc_class: 'down' }
%>
<%= row.action :show, admin_task_path(record), 'Custom label',
class: 'custom-class', role: 'custom-role', data: { custom: 'true' }
%>
<% end %>
If you need more power, you can also decide to subclass Admino::Table::Presenter. For each HTML element, there's a set of methods you can override to customize it's appeareance.
Table cells are generated through two collaborator classes: Admino::Table::HeadRow and Admino::Table::ResourceRow. You can easily replace them with a subclass if you want. To grasp the idea here's an example:
class CustomTablePresenter < Admino::Table::Presenter
private
def
{ class: 'table-class' }
end
def (resource_index)
{ class: 'tr-class' }
end
def zebra_css_classes
%w(one two three)
end
def resource_row(resource, view_context)
ResourceRow.new(resource, view_context)
end
def head_row(collection_klass, query, view_context)
HeadRow.new(collection_klass, query, view_context)
end
class ResourceRow < Admino::Table::ResourceRow
private
def (action_name)
{ class: 'action-class' }
end
def
{ class: 'show-action-class' }
end
def (attribute_name)
{ class: 'column-class' }
end
end
class HeadRow < Admino::Table::ResourceRow
def (attribute_name)
{ class: 'column-class' }
end
end
end
Please refer to the tests for all the details.
Inherited resources
If the action URLs can be programmatically generated, it becomes even easier to specify the table actions:
<%= CustomTablePresenter.new(@tasks, Task, self).to_html do |row, record| %>
<%# ... %>
<%= row.actions :show, :edit, :destroy %>
<% end %>
For instance, using Inherited Resources to generate controller actions, you can use its helper methods to build a custom subclass of Admino::Table::Presenter:
class CustomTablePresenter < Admino::Table::Presenter
private
def resource_row(resource, view_context)
ResourceRow.new(resource, view_context)
end
class ResourceRow < Admino::Table::ResourceRow
def show_action_url
h.resource_url(resource)
end
def edit_action_url
h.edit_resource_url(resource)
end
def destroy_action_url
h.resource_url(resource)
end
def
{ method: :delete }
end
end
end
I18n
Column titles are generated using the model #human_attribute_name method, so if you already translated the model attribute names, you're good to go. To translate actions, please refer to the following YAML file:
en:
activerecord:
attributes:
task:
title: 'Title'
due_date: 'Due date'
completed: 'Completed?'
table:
actions:
task:
title: 'Actions'
show: 'Details'
edit: 'Edit task'
destroy: 'Delete'



