Drag-and-drop ordering for Active Record. Records keep a user-chosen order with fractional ranks, so a move writes one row and never renumbers a list. A move between groups — a card dragged to another column — changes that column in the same write. One mountable endpoint takes every drop.
It is the server half of unmagic-components' sortable lists and boards, and fills in their settings when both are installed.
class Card < ApplicationRecord
include Unmagic::Sortable
sortable_within :board_id # the group a card never leaves
sortable_column :column_id # what a drag into another column may change
end<%= sortable_list namespace: "cards", params: { column_id: column.id } do |list| %>
<% column.cards.ordered.each do |card| %>
<%= list.item(card) { render card } %>
<% end %>
<% end %>gem "unmagic-sortable"Give each sortable table a rank column without a fixed precision, indexed with its group:
add_column :cards, :sortable_rank, :decimal
add_index :cards, [ :board_id, :column_id, :sortable_rank ]On Postgres, numeric sorts numerically and keeps thousands of digits, so ranks
effectively never run out of room between two neighbours. SQLite and MySQL
decimals have far less precision; they work, but a list reordered in the same
spot thousands of times will eventually run out.
Mount the endpoint:
# config/routes.rb
mount Unmagic::Sortable::Engine, at: "/sortable"And say who may move what. Until you do, every move is refused: a signed key only proves the server showed that record to someone, not that this person may reorder it.
# config/initializers/unmagic_sortable.rb
Unmagic::Sortable.configure do |config|
# The controller the endpoint inherits from, so it runs your authentication.
config.parent_controller = "ApplicationController"
# What keys are scoped to. A key minted on one account's page won't move a
# record under another's. Called with the controller; nil for no scope.
config.scope = ->(controller) { Current.account&.id }
# Whether this request may move this record.
config.authorize = ->(controller, record) { Current.user.can?(:update, record) }
# After a move is saved: an audit trail, a broadcast.
config.moved = ->(controller, record) { record.broadcast_refresh_later }
endinclude Unmagic::Sortable gives a model:
ordered, a scope ordering by rank.- A rank on create, after the last record in its group, unless one was given.
move_between(lower, upper, **columns), which puts the record between two neighbours' ranks (either may be nil, for the top or bottom of a list) and sets anysortable_columns given, in one write. It raisesArgumentErrorfor a column the model didn't declare.sortable_siblings, the records in the same list.
sortable_within names the columns that bound a group; without it the whole
table is one list. sortable_column names the columns a move between lists may
change; only those are ever taken from a request.
Unmagic::Sortable::Rank.between(lower, upper) is the arithmetic on its own. It
jitters within the gap, so two moves into the same gap at the same moment don't
collide.
Every drop is a PATCH to the engine's ordering_path with:
| Field | |
|---|---|
moved |
The record's signed key |
original |
The rank the page showed it at |
prev |
The rank of the item now above it; blank at the top |
next |
The rank of the item now below it; blank at the bottom |
| a column | The destination list's value, for a move between lists |
The endpoint:
- Finds the record by its key, in the configured scope:
404for a forged, expired or out-of-scope key. - Asks
authorize:403if it says no. - Refuses a stale move. Every move rewrites a rank, so a rank that no longer
matches
originalmeans someone else moved the record first. That's a409, with a flash explaining it. - Refuses nonsense ranks, or neighbours in the wrong order:
422. - Otherwise writes one row and calls
moved.
A Turbo request gets a page refresh in every case, which morphs the page to the order that was actually saved. Anything else gets the bare status.
The stale-move flash is unmagic.sortable.stale.
The engine adds two view helpers:
sortable_key(record), the signed key in the configured scope.sortable_item_attributes(record), which gives{ key:, rank: }.
With unmagic-components installed, the engine sets its sortable_item and
sortable_url settings to these, so list.item(record) needs nothing more. It
does this before your own initializers run, so you can still override either.
Without unmagic-components, render the attributes yourself and post the fields above.
bundle install
bundle exec rspec # SQLite in memory
DATABASE_URL=postgres:///unmagic_sortable_test bundle exec rspec # Postgres
bundle exec rubocopMIT.