Skip to content

About

Drag-and-drop ordering for Active Record: fractional ranks, signed keys and one endpoint

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Unmagic::Sortable

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 %>

Installation

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 }
end

The model

include 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 any sortable_columns given, in one write. It raises ArgumentError for 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.

The endpoint

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: 404 for a forged, expired or out-of-scope key.
  • Asks authorize: 403 if it says no.
  • Refuses a stale move. Every move rewrites a rank, so a rank that no longer matches original means someone else moved the record first. That's a 409, 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.

Keys in views

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.

Development

bundle install
bundle exec rspec                                                      # SQLite in memory
DATABASE_URL=postgres:///unmagic_sortable_test bundle exec rspec       # Postgres
bundle exec rubocop

License

MIT.

About

Drag-and-drop ordering for Active Record: fractional ranks, signed keys and one endpoint

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages