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: false keeps every row. By default in_order_of filters the result to only the values you listed, emitting a WHERE status IN (...). That means any status you forget to slot into BOARD_STAGES silently disappears from the queue. Passing filter: false drops the WHERE and instead parks unlisted statuses at the end via an ELSE branch, 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 after in_order_of, which puts its CASE first in the ORDER BY; created_at: :desc, id: :desc then orders rows within each band. The id is 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_of first and .order(...) second. If you reverse them — .order(created_at: :desc).in_order_of(...)created_at becomes 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.