Active Admin Gem Tips and Performance Tuning for Rails Apps

 
Active Admin Ruby on Rails optimizations and tips represented by a wrench Photo by Pixabay from Pexels

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

"aa" prefix is required for initializer file containing new module definition to be loaded before "active_admin.rb" file.
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');
})
By default Active Admin uses "js.coffee" extension but since CoffeeScript is no longer the thing you can just rename it to "js".

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.



Back to index