Recently, we were building a CMS system for a client, and had the requirement that admins should be able to set the order in which various records (photos, job adverts, team member pages) appeared on the frontend. Here’s how we did it.
We’re big fans of ActiveAdmin here at CookiesHQ – it allows to build admin backends quickly and simply, giving us more time to focus on customer-facing features.
ActiveAdmin does have the ability to reorder records by using the has_many
form helper (using the jQueryUI Sortable widget), but not collections of records in the index
view. Fortunately, ActiveAmin is very extensible, and we were able to add the functionality easily.
The following code is from a Rails 4.2 app, and assumes that your model has an integer column called order
.
Backend
First up, we define a custom action for our client-side code to submit to:
collection_action :reorder, method: :patch do
reorder_params = params.require(:items).map {|item| item.permit(:id, :order) }
reorder_ids = reorder_params.map {|item| item[:id] }
reorder_attributes = reorder_params.map {|item| item.slice(:order) }
resource_class.update(reorder_ids, reorder_attributes)
render json: { status: "success" }
end
Some wrangling of the params is needed in order to transform it into a format suitable for update
. We make the naive assumption in this case that the reordering worked, since we’re dealing with multiple records, but more robust code could wrap the update in a transaction and ensure that all the updates succeed, or otherwise rollback.
We also added some extra configuration to make reordering easier:
# ensures that the default ordering reflects our updates
config.sort_order = "order"
# reordering doesn't work across multiple pages,
# so try to put everything on one page
config.per_page = 100
# this filter isn't really needed
remove_filter :order
Markup
Rather than make the entire table row draggable (which proved to be problematic as it has several links within it, clicking on which was sometimes interpreted as a drag), we set up a special column as a “drag handle” for the row (using the font-awesome-rails gem).
We opted to define this in the controller, rather than a helper module, since helper reloading in development is currently an issue in ActiveAdmin.
controller do
private
def reorderable_column(dsl)
# Don't allow reordering if filter(s) present
# or records aren't sorted by `order`
return if params[:q].present? || params[:order] != "order"
dsl.column(sortable: false) do
dsl.fa_icon :arrows, class: "js-reorder-handle"
end
end
helper_method :reorderable_column
end
Since ActiveAdmin’s filtering can exclude records from the index table (and since our reordering affects the entire table at once), we disable reordering if a filter is present. Similarly, we also disable reordering if the table is ordered by a column other than order
.
The reorderable_column
method is then used like so:
index do
reorderable_column(self)
selectable_column
column :title
# etc...
end
Passing self
is a bit ugly, but necessary in order to access the column
method without monkey-patching ActiveAdmin.
Client-side
Since ActiveAdmin already comes with jQueryUI, using the Sortable widget seemed like an obvious choice. We thus put together a little CoffeeScript class that initialises the jQueryUI Sortable widget, and sets up an AJAX request to fire when the ordering is updated:
# admin/reorderable_table.js.coffee
class Admin.ReorderableTable
constructor: (selector) ->
$(selector).find("tbody").sortable
items: "tr"
handle: ".js-reorder-handle" # from `reorderable_column` above
update: @_sendPositions
_calculatePositions: (sortable) ->
# Sortable uses ids by default for serialisation
for itemId, index in $(sortable).sortable("toArray")
# ActiveAdmin sets the id to the form "underscored_classname_id"
id: itemId.split("_").pop()
order: index + 1
_sendPositions: (event) =>
positions = items: @_calculatePositions(event.target)
# `url` assumes that we're on the index page, with no extra params
$.ajax
url: "#{window.location.pathname}/reorder"
method: "PATCH"
dataType: "json"
contentType: "application/json"
data: JSON.stringify(positions)
# Initialise on page load
$ ->
new Admin.ReorderableTable(".index_table")
This file then needs to be referenced from active_admin.js.coffee
in your app/assets/javascript
folder:
#= require active_admin/base
#= require ./admin/reorderable_table
Extract to a module
Now we have some functionality that can be wrapped up in a module and reused. Unfortunately, we can’t use concerns, since the object that you manipulate inside of ActiveAdmin.register
isn’t a class, but an instance of ActiveAdmin::ResourceDSL
. Thus, including a module would include our behaviour in all ActiveAdmin resources (which may or may not be what you want).
We instead settled on a convention for “concerns”, which consisted of modules with an .apply_to
method, into which was passed the instance of ActiveAdmin::ResourceDSL
. We then use instance_exec
in order to call methods as if we were inside the ActiveAdmin.register
block:
module ReorderableByAdmin
def self.apply_to(dsl)
dsl.instance_exec do
# set config, define controller methods/actions etc.
end
end
end
Conclusion
The finished product:
We’ve since extended this approach to handle a grid layout for index
pages, by subclassing the CoffeeScript class and customising the initialisation of the jQueryUI Sortable widget, but with the same backend.
Image from Wikimedia Commons