
ActiveAdmin gem is a popular tool for building admin interfaces in Ruby on Rails apps. In this tutorial, I will describe a couple of less obvious tips and performance optimization techniques.
Active Admin should probably never be used for client-facing parts of the interface because it’s a bit clunky. But it can hardly be matched for an internal admin user interface development speed and simplicity.
For a great intro about how to start using ActiveAdmin with modern Rails check out this article and the official docs.
Here comes the first tip:
Add query persistence to filters
Filters are one of my favorite features of Active Admin. They let you mix various search conditions with a simple UI. One issue with default implementation is that the search query is not persistent. Every time you click away to a different page, you need to do a new search from scratch.
Filters UX can be improved by adding the following files:
app/config/initializers/aa_filters_persistance.rb
module ActiveAdmin
module FiltersPersistance
extend ActiveSupport::Concern
CLEAR_FILTERS = "clear_filters"
FILTER = "Filter"
included do
before_action :resolve_filters
end
private
def resolve_filters
session_key = "#{controller_name}_q".to_sym
if params[:commit] == CLEAR_FILTERS
session.delete(session_key)
elsif (params[:q] || params[:commit] == FILTER) && action_name.inquiry.index?
session[session_key] = params[:q]
elsif session[session_key] && action_name.inquiry.index?
params[:q] = session[session_key]
end
end
end
end
Also, add the following code to app/assets/javascripts/active_admin.js
//= require active_admin/base
$(function() {
$('.clear_filters_btn').attr('href', '?commit=clear_filters');
})
Next just add this code at the bottom of config/initializers/active_admin.rb
ActiveAdmin::BaseController.send(:include, ActiveAdmin::FiltersPersistance)
With that in place, your filter queries will be persisted in session on a per page basis, making navigating the admin panel more pleasant.
Normalize blank attributes
Active Admin uses Formtastic under the hood. There is a known issue with blank values populating your models after submitting a form with empty fields.
If your model does not validate a presence of data, you might end up with several attributes set to an empty string ""
because that’s what is sent from an empty form field.
There is a simple way to define declarative API for keeping your attributes in a correct state. It uses Active Record callbacks under the hood, but the despite all the hate they get, I think normalizing model attributes can be a valid use case for them. I can recommend this blog post for an interesting write up on potential callback use cases.
To avoid blank ghost attributes you need to add the following module:
config/initializers/normalize_blank_values.rb
module NormalizeBlankValues
extend ActiveSupport::Concern
included do
before_save :normalize_blank_values
end
def normalize_blank_values
begin
self.class.const_get("NORMALIZABLE_ATTRIBUTES")
rescue NameError
[]
end.each do |column|
self[column].present? || self[column] = nil
end
end
end
and then include and setup it in your model:
app/models/user.rb
class User < ApplicationRecord
include NormalizeBlankValues
NORMALIZABLE_ATTRIBUTES = %i(email)
...
end
Unless you explicitly bypass callbacks, you should not see empty string instead of nil
again.
Watch out for slow filters
Talking about filters, their default implementation in Active Admin can slow your Rails app to a crawl. The problem is that Active Admin creates a select filter for all the has_many
relation on a model. So if a user has_many
posts, /admin/users
view will display select box, rendering data of ALL the posts present in the database.
This issue can easily be overlooked when starting to work on an app, and your dataset is still small. Only after a while, you might begin to notice delays, memory issues or even server timeouts.
A simple way to significantly reduce memory usage is to pluck the necessary attributes from the collection, to avoid instantiating the full-blown Active Record objects:
in app/admin/users.rb
ActiveAdmin.register User do
preserve_default_filters!
filter :posts, as: :select, collection: -> { Post.pluck(:title, :id) }
...
end
This tip should be applied to all the collection
values in your Active Admin Formtastic forms and views.
In case your collections have more than couple thousand objects you should consider adding autocomplete powered by JSON endpoint, but that’s outside the scope of this tutorial.
Use custom form views
Rendering custom form views is a powerful way to customize Active Admin interface. Declaring collection, member or batch actions, that render a custom view is quite simple but not mentioned in the official docs. I will cover a batch action example because its most complex.
You need to start by declaring a batch action that renders a view and another one that receives params submitted from the form:
app/admin/users.rb
batch_action :bulk_set_email do |ids|
render "admin/users/bulk_set_email", locals: {
form: Admin::Users::BulkSetEmailForm.new(ids)
}
end
collection_action :bulk_set_email, method: :put do
form = Admin::Users::BulkSetEmailForm.new(
params.fetch(:users).fetch(:users_ids)
)
if form.submit(params.fetch(:users))
redirect_to admin_users_path, notice: "Users emails updated"
else
flash[:notice] = "There was a problem updating users emails"
render "admin/users/bulk_set_email", locals: {
form: form
}
end
end
and adding the view file itself:
app/views/admin/users/bulk_set_email.html.erb
<%= semantic_form_for [:admin, form], html: { method: :put, :class => "form-horizontal" }, url: bulk_set_email_admin_users_path do |f| %>
<%= f.semantic_errors :base %>
<%= f.actions do %>
<%= f.action :submit, as: :button, label: "Update emails" %>
<br>
<br>
<% form.users.each do |user| %>
<label>
<input
type="text"
name="users[users_emails][]"
id="users_users_emails_user_email"
value="<%= user.email %>"
/>
<input
type="hidden"
name="users[users_ids][]"
id="users_users_emails_user_id"
value="<%= user.id %>"
/>
</label>
<% end %>
<br>
<br>
<%= f.action :submit, as: :button, label: "Update emails" %>
<% end %>
<% end %>
Finally, you need to add a form object that contains the code required to perform the action. Form objects are a good way to avoid cluttering controller DSL with business logic, check out this blog post for more in depth info about them. I’ve recently started following a convention where form object accepts arguments needed to render a form in an initializer. It also has a submit
method doing the actual work done and returning true or false based on whether action succeed.
app/forms/admin/users/bulk_set_email_form.rb
class Admin::Users::BulkSetEmailForm
include ActiveModel::Conversion
include ActiveModel::Validations
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
attr_reader :users
def initialize(users_ids = [])
@users = User.where(id: users_ids)
end
def submit(params)
@params = params
return false unless valid?
@params.fetch(:users_ids).zip(@params.fetch(:users_emails)).each do |pair|
User.find(pair.fetch(0)).update!(email: pair.fetch(1))
end
true
rescue => e
Rails.logger.error e
false
end
def valid?
emails_valid = @params.fetch(:users_emails).all? do |email|
email.match(VALID_EMAIL_REGEX)
end
errors.add :base, "All emails must be valid" unless emails_valid
emails_valid
end
def self.model_name
ActiveModel::Name.new(self, nil, "Users")
end
end
Check out this GIF to see a custom Active Admin form in action.
You could even embed a dynamic React component inside Active Admin using this technique, but that’s a story for another blogpost.
Summary
I’ve worked on a couple of commercial projects where using Active Admin made a lot of sense business-wise. I am aware that many serious developers would frown upon seeing it in their Gemfiles, but I have a feeling this library is not going away any time soon. You can check out this repository to see all the described tips applied to a barebones Rails app.