Database

Autocomplete Using ActiveAdmin and Rails 3

I love how easy ActiveAdmin is to use out of the box, but it can force you into using a panoply of workarounds for forms with any significant level of complexity.


Filed under:

I love how easy ActiveAdmin is to use out of the box, but it can force you into using a panoply of workarounds for forms with any significant level of complexity. The challenge I faced was making this:

Form with autocompletion for inputs nested within a has many association, one of which is also polymorphic.
ActiveAdmin form with autocompletion for inputs nested within a has many association, one of which is also polymorphic.

The Setup

In my app, the line item new/edit form was where I needed autocompletion. A line item has many fees. In turn, a fee belongs to a line item, belongs to a track, and belongs to either a User or a Vendor via a payable_type and payable_id (that's the polymorphic part). I tackled this challenge in three steps: autocompletion on the has many association, autocompletion on a polymorphic association, and inclusion of objects from multiple models in the options for the polymorphic association.

I got a great start on adding basic autocomplete functionality thanks to Tyler Hunt's gist, but I needed to customize it to work for a nested form with a has_many association.

To start, some basic setup was required to install jQuery UI autocomplete. I already had the active_model_serializers gem, so all I needed was the jquery-ui-rails gem.

# Gemfile
gem jquery-ui-rails

Then, I added the autocomplete css to ActiveAdmin.

// app/assets/stylesheets/active_admin.css.scss
@import "jquery.ui.autocomplete";

Next up was adding the autocomplete javascript to ActiveAdmin.

// app/assets/javascripts/active_admin.js
//= require jquery.ui.autocomplete
//= require_tree ./active_admin

(Require_tree is important so the custom javascript in the next section will be included.)

Part 1: Has Many

For the ActiveAdmin form, I used the built-in has_many functionality so the user could dynamically add or delete as many fees as they wanted. I assigned each fee's track input to the autocomplete class, without a name (so it wouldn't be processed), and I also passed a url from which to get the JSON through the data attribute. Then I added a hidden track_id input that would be populated when the user made a selection and saved to the database when they submitted the form.

# app/admin/line_item_order.rb
f.has_many :fees do |fee|
  fee.input :track, as: :string, input_html: {
    class: 'autocomplete',
    name: '',
    value: fee.object.track.try(:title),
    data: {
      url: autocomplete_manage_tracks_path
    }
  }
  fee.input :track_id, as: :hidden
end

How does one get the JSON that will populate the autocomplete? I had to create the autocomplete_manage_tracks_path by creating a batch action. The search term entered by the user will be passed via the term parameter (which is built into JQuery UI's autocomplete).

# app/admin/track.rb
collection_action :autocomplete, method: :get do
  tracks = Track.where('LOWER(title) ILIKE ?', "#{params[:term]}%")
  render json: tracks, each_serializer: TrackAutocompleteSerializer, root: false
end

This was where Active Model Serializers came into play. I created a serializer for Track specifically for this autocomplete functionality. This passed along only the ID and the label to be rendered as JSON and received and processed into the matching autocompleted options.

# app/serializers/track_autocomplete_serializer.rb
class TrackAutocompleteSerializer < ActiveModel::Serializer
  attribute :id
  attribute :label
 
private
  def label
    # Because some tracks have the same title, I wanted to display the options with their IDs tagged on at the end
    object.title + " - " + object.id.to_s
  end
end

Then I created a new javascript file to add custom autocomplete functionality.

// app/assets/javascripts/active_admin/autocomplete.js
$(document).ready(function() {
  var autocomplete = function() {
    // Loop through elements that have the autocomplete class
    $('.autocomplete').each(function(index, input) {
      var $hiddenInput, $input;
      $input = $(input);
      // Find the dynamically generated hidden form element
      $hiddenInput = $('#' + $input.attr('id') + '_input').find(':hidden');
      return $input.autocomplete({
        minLength: 3,
        delay: 600,
        // Do an ajax call to a url to get back the JSON that will populate the autocomplete options
        source: function(request, response) {
          return $.ajax({
            url: $input.data('url'),
            dataType: 'json',
            data: {
              // Pass the search term
              term: request.term
            },
            success: function(data) {
              return response(data);
            }
          });
        },
        // Upon select, designate what to do with the selected item. In this case, display the label of their selection
        // and populate the hidden track field with the ID
        select: function(event, ui) {
          $input.val(ui.item.label);
          $hiddenInput.val(ui.item.id);
          return false;
        }
      // Populate the autocomplete options in a drop down unordered list
      }).data('ui-autocomplete')._renderItem = function(ul, item) {
        return $('<li></li>').data('item.autocomplete', item).append('<a>' + item.label + '</a>').appendTo(ul);
      };
    });
  };
 
  autocomplete();
 
  // Make sure that inputs generated on the fly (via the has many association) also have autocomplete functionality
  $('.has_many .button').click(function() {
    autocomplete();
  });
});

Voila! A user could add as many fees as they wanted and the form field for track would successfully autocomplete.

Part 2: Polymorphism with a Single Resource Option

Next up was adding autocomplete to the payable field on Fee, which is polymorphic. I had a bit of an unusual case here; I wanted the user to be able to enter either a Vendor, or the specific User associated with the line item's order. I tackled adding autocomplete as I did before with all Vendors as options, leaving the User for later.

# app/admin/line_item_order.rb
fee.input :payable, as: :string, input_html: {
  class: 'autocomplete',
  name: '',
  value: fee.object.payable.try(:name),
  data: {
    url: autocomplete_fee_manage_vendors_path
  }
}
fee.input :payable_identifier, as: :hidden

Next I needed to build the route...

# app/admin/vendor.rb
collection_action :autocomplete_fee, method: :get do
  vendors = Vendor.where('LOWER(name) ILIKE ?', "#{params[:term]}%")
  render json: vendors, each_serializer: VendorUserAutocompleteSerializer, root: false
end

...and the serializer. (I got ahead of myself on the naming, anticipating that a user would also be involved soon.)
As before, I structured the JSON with an ID and a label. But this time, I couldn't just pass the Vendor's ID, because a Fee needs both the payable_id and the payable_type. Here I built the ID attribute to match the format of the getter and setter I'd previously created for payable_identifier on the fee model. This was based on the ActiveAdmin wiki article on nested forms with polymorphic associations.

# app/serializers/vendor_user_autocomplete_serializer.rb
class VendorUserAutocompleteSerializer < ActiveModel::Serializer
  attribute :id
  attribute :label
 
private
  def label
    object.name + " - " + object.id.to_s
  end
 
  def id
    object.class.to_s + "-" + object.id.to_s
  end
end
 
# app/models/fee.rb
class Fee < ActiveRecord::Base
  belongs_to :payable, polymorphic: true
  belongs_to :line_item
  belongs_to :track
  attr_accessible :amount, :line_item_id, :payable_type, :payable_id, :payable_identifier, :track_id
  validates_numericality_of :amount
  validates_presence_of :payable_id
 
  def payable_identifier
    "#{payable_type.to_s}-#{payable_id.to_s}"
  end
 
  def payable_identifier=(payable_data)
    if payable_data.present?
      payable_data = payable_data.split('-')
      self.payable_type = payable_data[0]
      self.payable_id = payable_data[1]
    end
  end
end

Then I had to figure out how to find the appropriate inputs and hidden inputs on the page with the ActiveAdmin/Formtastic-assigned IDs for the fees that were dynamically generated when a user clicked "Add a fee." I was working with a slightly different attribute than the previous patterns for Track (payable_identifier rather than payable_id). I wanted this javascript to work for all autocomplete inputs, so I checked whether this was the payable_id input, and if it was, I set $hiddenInput to grab the hidden element with the ID of #payable_identifier_input rather than the standard #payable_id_input.

// app/assets/javascripts/active_admin/autocomplete.js
var $hiddenInput, $input;
$input = $(input);
if($input.attr('id').indexOf('payable_id') == -1) {
  $hiddenInput = $('#' + $input.attr('id') + '_input').find(':hidden');
} else {
  $hiddenInput = $('#' + $input.attr('id') + 'entifier_input').find(':hidden');
}

Ok! I was good to go for autocompletion on a fee's vendor.

Part 3: Polymorphism with Multiple Resources as Options

Only a few tweaks were necessary to always include the option of the specific User. Because the user was two steps away from the line item in the schema, I thought it would be easiest to include the user_id in a hidden field in the form.

# app/admin/line_item_order.rb
f.input :user, as: :hidden, value: Order.find(params[:order_id]).user.id, input_html: { name: ''}

Then I included that in the ajax request.

// app/assets/javascripts/active_admin/autocomplete.js
data: {
  term: request.term,
  user_id: $('input#line_item_user').val()
},

That's how params[:user_id] became accessible to the collection action.

# app/admin/vendor.rb
collection_action :autocomplete_fee, method: :get do
  user = User.find(params[:user_id])
  vendors = Vendor.where('LOWER(name) ILIKE ?', "#{params[:term]}%")
  render json: vendors.unshift(user), each_serializer: VendorUserAutocompleteSerializer, root: false
end

The serializer also needed a slight adjustment because User didn't have a name attribute.

# app/serializers/vendor_user_autocomplete_serializer.rb
def label
  if object.is_a?(User)
    "Charge to the client"
  else
    object.name + " - " + object.id.to_s
  end
end

I also had to throw in a conditional on the form itself because of the same problem.

# app/admin/line_item_order.rb
if fee.object.payable.is_a?(Vendor)
  fee.input :payable, as: :string, input_html: {
    class: 'autocomplete',
    name: '',
    value: fee.object.payable.try(:name),
    data: {
      url: autocomplete_fee_manage_vendors_path
    }
  }
elsif fee.object.payable.is_a?(User)
  fee.input :payable, as: :string, input_html: {
    class: 'autocomplete',
    name: '',
    value: fee.object.payable.try(:email),
    data: {
      url: autocomplete_fee_manage_vendors_path
    }
  }
else
  fee.input :payable, as: :string, input_html: {
    class: 'autocomplete',
    name: '',
    data: {
      url: autocomplete_fee_manage_vendors_path
    }
  }
end
fee.input :payable_identifier, as: :hidden

That's it! While it was fun for me to figure out, I think the client was even happier to have this added functionality. Do you have any ideas on how I could do this better? I'd love to hear them in the comments.

Learn more about JavaScript in our JavaScript Blog Archive.

Similar posts

Get notified on new marketing insights

Be the first to know about new B2B SaaS Marketing insights to build or refine your marketing function with the tools and knowledge of today’s industry.