Custom Sorting in Administrate
Eli Fatsi, Former Development Director
Article Categories:
Posted on
Implementing a custom and re-usable Administrate class to enable sorting records with Ruby.
In one of our Administrate dashboards, we display a User's invitation_status
. This is a read-only field that we display on the index and show views, and the value is a string derived from the status of related Invitation records (eg: Invitation Sent 10-13-2021
, Accepted
).
The goal was to sort all User records by this invitation_status
. I'm sure it's possible to pull this off in SQL land, but I was curious to see what it'd take to run the sorting logic in Ruby. Administrate lets you easily override the default functionality with impressive percission, so here's the solution.
First, we update the generated UsersController to get our own order
functionality in where we want it:
# in app/controllers/admin/users_controller.rb
module Admin
class UsersController < Admin::AdministratorsController
def order
if sorting_attribute == "invitation_status"
@order ||= CustomSortOrder.new(sorting_attribute, sorting_direction)
else
super
end
end
# ...
end
end
This code will only apply our custom sort logic when the admin is sorting on the invitation_status
field. All other sort attempts will pass through to Administrate's default implementation.
Next, we define the CustomSortOrder
class that we're referencing above:
# in app/lib/custom_sort_order.rb
class CustomSortOrder < Administrate::Order
def apply(relation)
# sort records by attribute
sorted = relation.sort_by(&attribute.to_sym)
# assemble array of ids
ids = sorted.map(&:id)
if direction.to_sym == :desc
ids = ids.reverse
end
# Arel / SQL magic to order records by the ids array
id_condition = Arel::Nodes::Case.new(relation.arel_table[:id])
ids.each_with_index do |id, index|
id_condition.when(id).then(index)
end
# Finally, the ActiveRecord call
relation.where(id: ids).order(id_condition)
end
end
That relation.sort_by(&attribute.to_sym)
is a little cryptic, that will use Ruby's Enumerable#sort_by
method and sort on the value returned when invitation_status
(which is the string that attribute
holds) is called on each of the records individually.
The next few lines assemble the ids
array in the order we want it, nothing fancy here.
Then comes the meat of the ORM logic. This is the first time I've ever cracked into Arel::Nodes::Case
before, as per usual props to great and almighty StackOverflow for the support - https://stackoverflow.com/questions/1309624/simulating-mysqls-order-by-field-in-postgresql/66517571#66517571.
For demonstration, let's pretend like our ids
array ended up looking like [3, 1, 5]
. The resulting SQL query would be:
SELECT "users".*
FROM "users"
WHERE "users"."id" IN (3, 1, 5)
ORDER BY CASE "users"."id"
WHEN 3 THEN 0
WHEN 1 THEN 1
WHEN 5 THEN 2
END
Neat, huh?
A couple of caveats:
First, this is not an efficient way to sort things, and will not scale well once you get to hundreds or thousands of records. Assuming the method you're sorting on triggers a database query or two, you're looking at a classic N+1 situation. If you can figure out how to sort using a more efficient SQL query, I'd recommend that. If that's tough and your data set is small enough though, this will do the trick.
Second, Administrate doesn't play too well when you start defining attribute types that aren't backed by database attributes. For example, searching in Administrate assembles a database query that checks against all searchable
attributes included in the index view. Since our invitation_status
field was initially set up as a Field::String
type, any search attempt would query the database for roughly invitation_status LIKE '%search_term%'
and the database would go "NEVER HEARD OF THAT 💥💥💥". The solution for this is one more custom Administrate subclass:
# in app/fields/non_searchable_string_field.rb
require "administrate/field/base"
class NonSearchableStringField < Administrate::Field::String
def self.searchable?
false
end
end
And in the UserDashboard
, we use our new custom field
# in app/dashboards/user_dashboard.rb
class UserDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
id: Field::Number,
# ... other attributes
invitation_status: NonSearchableStringField
}
# ...
end
The last required step is to copy+paste the view files from Administrate's String
field into a new app/views/fields/non_searchable_string_field
directory. Check the docs for generating those initial view files. Our new NonSearchableStringField
behaves exactly like a regular Administrate String
field, but it's no longer searchable -> problem solved!
Recap / Ode to Administrate #
I love the flexibility that Administrate provides when your use cases push you outside the standard lanes. While solutions like this can feel a little hacky at times (and cause N+1 performance issues), they're generally quick to pull off and result in precise solutions with minimal side effects.
If you've customized Administrate in a cool way, or have suggestions for a better way to pull off custom sorting (maybe I should have leaned into COLLECTION_FILTERS
for this use case...) let me know in the comments below!