Build a Workflow-Priority Ticket Queue with Rails' in_order_of Array Grouping
Note: The array-grouping behavior shown here is an upcoming feature, expected in Rails 8.2. It’s merged to main (PR #52871) but not in a released gem yet — the latest release is 8.1.3 — and an unreleased feature can still shift before it ships. The SQL examples below are reproduced from that PR’s implementation.
Nothing in this post runs on a released Rails; the array-grouping path only exists on main. To try it, point your Gemfile at the branch:
gem "rails", github: "rails/rails", branch: "main"
Be aware this pulls in all of Rails main’s unreleased changes — not just this one feature — and may surface dependency conflicts. Don’t do it on an app you can’t afford to break.
I wanted a support agent’s “My Queue” to behave the way a human triages: the tickets that need action sit at the top, the ones parked waiting on someone else sit in the middle, and the finished ones sink to the bottom. Inside each of those bands, newest first.
The tricky part is the top band. “Needs action” isn’t one status, it’s two: a brand new triage ticket and an in_progress one are equally urgent, so they should interleave by recency rather than form two separate clumps. That used to mean a hand-written CASE statement or a Ruby-side sort. In Rails 8.2, in_order_of can group several values into a single sort position, so the whole thing is one scope.
What We’re Building
A Ticket model with a status enum and a board_order scope. Calling Ticket.board_order returns every ticket ordered by workflow stage — active work, then blocked, then done — with the newest ticket first inside each stage. One query, no raw SQL.
The Rails Feature That Makes This Clean
in_order_of(column, values) has been around for a while: it orders rows to match the sequence of values you pass. What’s new in Rails 8.2 is that an entry in values can itself be an array, and every value in that nested array shares the same sort position.
Post.in_order_of(:state, [[:published, :canceled], :archived])
# SELECT "posts".* FROM "posts" WHERE "posts"."state" IN (1, 2, 3)
# ORDER BY CASE
# WHEN "posts"."state" IN (1, 2) THEN 1
# WHEN "posts"."state" = 3 THEN 2
# END ASC
published and canceled both map to position 1; archived gets position 2. That “several values, one bucket” mapping is exactly what a workflow board needs.
Building It
The Migration
class CreateTickets < ActiveRecord::Migration[8.2]
def change
create_table :tickets do |t|
t.string :subject, null: false
t.integer :status, null: false, default: 0
t.timestamps
end
add_index :tickets, :status
end
end
The index on status helps filtering, but note it can’t serve the ordering: the band sort is a computed CASE expression, so the database evaluates and sorts it in memory rather than reading it from an index. That’s true of every in_order_of call by design and is a non-issue for a small enum like this; just don’t expect the index to make the sort itself faster.
The Model
The status enum defines the raw stages. The BOARD_STAGES constant defines how those stages collapse into priority bands — nested arrays are bands that share a position.
class Ticket < ApplicationRecord
enum :status, {
triage: 0, # just arrived, nobody owns it yet
in_progress: 1, # an agent is actively working it
waiting: 2, # blocked on the customer or a third party
resolved: 3, # fixed, pending confirmation
closed: 4 # done
}
# Each top-level entry is one band, ordered top to bottom.
# A nested array means "these statuses share a band."
BOARD_STAGES = [
[:triage, :in_progress], # band 1: needs action
:waiting, # band 2: parked
[:resolved, :closed] # band 3: done
].freeze
scope :board_order, -> {
in_order_of(:status, BOARD_STAGES, filter: false)
.order(created_at: :desc, id: :desc)
}
end
That’s the whole feature. Two details worth calling out:
filter: falsekeeps every row. By defaultin_order_offilters the result to only the values you listed, emitting aWHERE status IN (...). That means any status you forget to slot intoBOARD_STAGESsilently disappears from the queue. Passingfilter: falsedrops theWHEREand instead parks unlisted statuses at the end via anELSEbranch, so a new status can never make tickets vanish. (See “Taking It Further” for when you’d actually want the filtering default.)order(...)is the in-band tie-breaker. It’s appended afterin_order_of, which puts itsCASEfirst in theORDER BY;created_at: :desc, id: :descthen orders rows within each band. Theidis there so tickets created in the same millisecond still come back in a stable order — without it, the worked example below isn’t actually guaranteed.
Order matters: call
in_order_offirst and.order(...)second. If you reverse them —.order(created_at: :desc).in_order_of(...)—created_atbecomes the primary sort and the priority bands collapse. It’s a wrong-but-plausible result that’s easy to ship by accident.
The Controller
class TicketsController < ApplicationController
def index
@tickets = Ticket.board_order
end
end
The View
<%# app/views/tickets/index.html.erb %>
<ol class="queue">
<% @tickets.each do |ticket| %>
<li class="ticket ticket--<%= ticket.status %>">
<span class="ticket__status"><%= ticket.status.humanize %></span>
<span class="ticket__subject"><%= ticket.subject %></span>
<time><%= ticket.created_at.to_fs(:short) %></time>
</li>
<% end %>
</ol>
Seeing It Work
Seed a few tickets out of order and watch the scope sort them:
Ticket.create!(subject: "Login button missing", status: :closed)
Ticket.create!(subject: "Refund not received", status: :triage)
Ticket.create!(subject: "API returning 500s", status: :in_progress)
Ticket.create!(subject: "Waiting on customer logs", status: :waiting)
Ticket.create!(subject: "Typo on pricing page", status: :triage)
Ticket.board_order.pluck(:subject, :status)
# => [["Typo on pricing page", "triage"], # band 1, newest
# ["API returning 500s", "in_progress"], # band 1
# ["Refund not received", "triage"], # band 1, oldest
# ["Waiting on customer logs", "waiting"], # band 2
# ["Login button missing", "closed"]] # band 3
(That output assumes the rows were inserted in the order above, so their created_at/id ascend together — the id: :desc tie-breaker is what makes it deterministic.)
The two triage tickets and the in_progress ticket interleave by recency in band 1 — they aren’t grouped by status, they’re grouped by priority band. Because we passed filter: false, the query has no WHERE clause; every row is returned and ordered by the CASE:
SELECT "tickets".* FROM "tickets"
ORDER BY CASE
WHEN "tickets"."status" IN (0, 1) THEN 1
WHEN "tickets"."status" = 2 THEN 2
WHEN "tickets"."status" IN (3, 4) THEN 3
ELSE 4
END ASC, "tickets"."created_at" DESC, "tickets"."id" DESC
Taking It Further
Narrow the queue to just the listed statuses. We used filter: false to keep every row. If you instead want the queue restricted to the statuses you named — say a focused view that hides everything finished — drop the option (or pass filter: true) and list only the bands you care about. That emits a WHERE status IN (...), so any status not in the list is excluded entirely:
scope :active_queue, -> {
# only triage / in_progress / waiting; resolved and closed are filtered out
in_order_of(:status, [[:triage, :in_progress], :waiting])
.order(created_at: :desc, id: :desc)
}
Just remember the trade-off that pushed us to filter: false for the main board: with filtering on, a status you forget to list silently disappears.
Surface fresh arrivals first. nil is a valid entry and can live inside a band — [nil, :triage] would bucket unassigned tickets alongside new ones at the top. Note this does nothing on the schema above: status is null: false, so it can never be nil. The tip only applies if you drop that constraint. (A bare IN (...) never matches NULL; in_order_of special-cases it for you so the nil band works.)
Render it as columns. The same scope feeds a Kanban view — @tickets.group_by(&:status) in the controller gives you one ordered array per status column, each already sorted by recency.
Wrapping Up
We built a support queue that triages itself: active work on top, parked tickets in the middle, finished work at the bottom, newest-first within each band — in one scope and one query. The thing that made it clean is in_order_of learning to map several values to the same sort position.
For the full implementation and discussion, see PR #52871. If you reach for enums a lot, you might also like type-safe JSON attributes with has_json, another recent Rails addition.