Multi-Select, Bulk Edit & Mass Reassignment
⚠️ Cookbook example — not live code. Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
Multi-select a set of rows, set one field, hit Apply. This guide covers the two halves of that: the selection model (how the user picks rows and knows what they picked) and the bulk write (the payload, the endpoint, and the safety mechanisms that stop it destroying data nobody looked at).
When to use: any table where a user needs to change the same thing on many rows — mass-reassigning an owner, marking a batch shipped/approved/archived, fixing an imported column on hundreds of rows. When not to: editing one cell on one record — that's the inline-editable-table pattern, and it is safe precisely because it does not do any of what's below.
Adding multi-select to an existing inline-editable table is the single most dangerous edit you can make to it. It is not a checkbox column plus a loop. Read "Why this is its own guide" before you add the first
<input type="checkbox">.
The examples use a
Contactmodel withname/company/status/owner_id/notes. Swap in your own model and columns — see How to adapt at the bottom.
Why this is its own guide
Multi-select looks like a UI feature. It is not — it is a change to what a single user action means. Bulk editing turns one instruction into many writes. Your error rate stays the same. Your blast radius does not.
Worse, the user looks at one row while writing to many, so the feedback loop that normally catches mistakes is switched off by the design itself. Assume errors will be silent and found weeks later by a customer.
A real incident. A production app extended the single-cell pattern two ways: it replaced per-cell saves with a whole-row form that submits all 26 columns, and it added multi-select so an edit on one row applied to the rest. The server then worked out what to apply by asking the database what had changed:
# ❌ THE BUG. Never do this.
changed_fields = item.previous_changes.keys & SYNCABLE_FIELDS
The edited row held NULL in a delivery-number column. HTML forms submit every
field, so the browser sent "". Rails recorded nil → "" as a change. That empty
value was then copied onto the other 25 rows and erased their real delivery numbers.
The same mechanism on a different column wiped 35 assignee names, and a downstream
callback then removed 35 QC checkmarks because those stages no longer had an assignee.
Across six weeks and 9 projects, roughly 1,400 real values were replaced with blanks.
Nobody noticed until a customer did.
The root cause, stated as a principle:
The server inferred user intent from a state difference. "What changed in the database" and "what did the user ask for" are different questions. A state difference also contains fields the browser sent empty, fields holding stale values because the page loaded ten minutes ago, and fields a callback wrote on its own.
Everything below exists to make that inference impossible.
The 80/20 in one breath
- The request names the fields. The payload carries an explicit list of columns to
set and columns to clear. The server never asks
changed,previous_changes, or diffs old against new. - Three states, not two. Every column is set on all, cleared on all, or left alone per row. "Left alone" = absent from both lists.
- Mixed values show as "multiple values", never blank. A blank is a false statement about the selection.
- Preview before write. Same endpoint,
dry_run: true, so the preview can't drift from the write. - The batch is a record. A
BulkEditrow with an id, the user, the field list, the target ids, and the before-value of every row it touched — so you can audit and undo it. - A guard refuses to blank a real value unless the user explicitly asked to clear that field. This is the one rule that catches the failure nobody predicted.
Layer 1 — The payload contract
This is params[:column] from the single-cell pattern, grown up. It is not a record.
// POST /contacts/bulk_update
{
"selected_ids": [12, 15, 19, 23],
"set": { "status": "delivered", "owner_id": 4 }, // apply to every selected row
"clear": ["notes"], // blank on every selected row
"dry_run": false
// Any column in NEITHER list keeps each row's own value. That is the third state.
}
Three properties make this safe:
setis the instruction. Nothing is inferred from record state.clearis separate fromset. "Make this empty" is a deliberate, distinguishable act — not an empty string that looks like an untouched form field.- Absence means "leave alone". A single-cell form has no way to express this, which is exactly why extending a row form into a bulk form destroys data.
⚠️ If your bulk form cannot express "leave this field alone", it will destroy data. That is not a risk, it's a certainty on a long enough timeline.
Layer 2 — Model, batch record & SQL
app/models/contact.rb — reuse the same whitelist as single-cell editing. It is the
security boundary for both paths, applied to every column in the list.
class Contact < ApplicationRecord
belongs_to :account
belongs_to :owner, class_name: "User", optional: true
EDITABLE_COLUMNS = %w[name company status owner_id notes].freeze
# Columns a bulk edit may touch. Usually a SUBSET of EDITABLE_COLUMNS —
# per-row identity fields (name, email) rarely make sense to mass-assign.
BULK_COLUMNS = %w[company status owner_id notes].freeze
end
app/models/bulk_edit.rb — the batch's identity. Without this you cannot undo a batch,
audit it as a unit, or alert on it.
class BulkEdit < ApplicationRecord
belongs_to :user
belongs_to :account
# target_type : "Contact"
# target_ids : jsonb array of ids
# set_values : jsonb hash { "status" => "delivered" }
# cleared_columns: jsonb array [ "notes" ]
# results : jsonb array [ { "id" => 12, "status" => "ok", "before" => {...} } ]
def columns_touched = set_values.keys + cleared_columns
def undo!(actor:)
transaction do
results.select { _1["status"] == "ok" }.each do |row|
account.contacts.where(id: row["id"]).update_all(row["before"].symbolize_keys)
end
update!(undone_at: Time.current, undone_by_id: actor.id)
end
end
end
# db/migrate/XXXX_create_bulk_edits.rb
class CreateBulkEdits < ActiveRecord::Migration[7.1]
def change
create_table :bulk_edits do |t|
t.references :user, null: false, foreign_key: true
t.references :account, null: false, foreign_key: true
t.string :target_type, null: false
t.jsonb :target_ids, null: false, default: []
t.jsonb :set_values, null: false, default: {}
t.jsonb :cleared_columns, null: false, default: []
t.jsonb :results, null: false, default: []
t.integer :undone_by_id
t.datetime :undone_at
t.timestamps
end
add_index :bulk_edits, [:account_id, :created_at]
end
end
If you keep an audit/version log, stamp bulk_edit_id on every record it writes. In
the incident above, investigators had to reconstruct the batch by finding audit rows
written within 0.2 seconds of each other, because nothing recorded that a bulk edit had
happened at all.
Layer 3 — The apply service (and the guard)
app/services/bulk_edits/apply.rb — one class, used by both the preview and the write.
module BulkEdits
class Apply
class Unsafe < StandardError; end
Preview = Struct.new(:target_count, :per_column, :destructive_count, keyword_init: true)
def initialize(scope:, ids:, set: {}, clear: [], user:, dry_run: false)
@scope = scope # already tenant-scoped
@ids = Array(ids).map(&:to_i).uniq
@set = set.to_h.stringify_keys.slice(*Contact::BULK_COLUMNS)
@clear = Array(clear).map(&:to_s) & Contact::BULK_COLUMNS
@user = user
@dry_run = dry_run
end
def call
raise Unsafe, "No rows selected" if @ids.empty?
raise Unsafe, "No fields to apply" if @set.empty? && @clear.empty?
raise Unsafe, "A field cannot be both set and cleared" if (@set.keys & @clear).any?
# ── RULE 6: the guard. ────────────────────────────────────────────────
# A bulk apply never replaces a real value with an empty one unless the
# user explicitly asked to clear that field. This fires no matter which
# column, user, or form is involved — including the ones nobody predicted.
blank_sets = @set.select { |_, v| blank_value?(v) }.keys
raise Unsafe, "Refusing to blank #{blank_sets.join(', ')} — use `clear` to do that on purpose" if blank_sets.any?
records = @scope.where(id: @ids).to_a
return preview(records) if @dry_run
write!(records)
end
private
def blank_value?(v) = v.nil? || (v.is_a?(String) && v.strip.empty?)
def assignments = @set.merge(@clear.index_with(nil))
def preview(records)
per_column = assignments.keys.index_with do |col|
target = assignments[col]
{
changing: records.count { |r| normalize(r[col]) != normalize(target) },
losing: records.count { |r| !blank_value?(r[col]) && blank_value?(target) }
}
end
Preview.new(
target_count: records.size,
per_column: per_column,
destructive_count: per_column.values.sum { _1[:losing] }
)
end
def write!(records)
bulk = nil
ActiveRecord::Base.transaction do
bulk = BulkEdit.create!(
user: @user, account: @user.account, target_type: @scope.klass.name,
target_ids: records.map(&:id), set_values: @set, cleared_columns: @clear
)
results = records.map do |record|
before = assignments.keys.index_with { |col| record[col] }
record.assign_attributes(assignments)
if record.save
{ id: record.id, status: "ok", before: before }
else
{ id: record.id, status: "failed", errors: record.errors.full_messages }
end
end
bulk.update!(results: results)
end
bulk
end
# One canonical representation of "no value" — see Gotchas.
def normalize(v) = blank_value?(v) ? nil : v
end
end
Read the guard twice. Rules 1–5 require the author to have correctly predicted the failure mode. The guard does not. It is the backstop that catches the class, not the instance — and it is what would have stopped the incident in one line.
Layer 4 — Controller
app/controllers/contacts_controller.rb — one action, two modes.
# POST /contacts/bulk_update
def bulk_update
service = BulkEdits::Apply.new(
scope: current_user.account.contacts, # tenant scope, always
ids: params[:selected_ids],
set: params.fetch(:set, {}).permit(*Contact::BULK_COLUMNS).to_h,
clear: params[:clear],
user: current_user,
dry_run: ActiveModel::Type::Boolean.new.cast(params[:dry_run])
).call
if service.is_a?(BulkEdits::Apply::Preview)
render json: { ok: true, preview: service.to_h }
else
render json: { ok: true, bulk_edit_id: service.id,
applied: service.results.count { _1["status"] == "ok" },
failed: service.results.count { _1["status"] == "failed" } }
end
rescue BulkEdits::Apply::Unsafe => e
render json: { ok: false, error: e.message }, status: :unprocessable_entity
end
# POST /contacts/bulk_edits/:id/undo
def undo_bulk_edit
bulk = current_user.account.bulk_edits.find(params[:id])
bulk.undo!(actor: current_user)
render json: { ok: true, reverted: bulk.results.count { _1["status"] == "ok" } }
end
# config/routes.rb
resources :contacts, only: [:index, :update] do
collection { post :bulk_update }
end
post "bulk_edits/:id/undo", to: "contacts#undo_bulk_edit", as: :undo_bulk_edit
Preview and write are the same code path.
dry_runis the only difference. A preview computed by separate code will eventually disagree with the write, and the user trusted the preview.
Layer 5 — Multi-select in the view
Two pieces on top of the inline-editable table: a checkbox column (the selection), and a bulk panel that only appears when something is selected (the instruction).
The selection model, before any markup
Four rules that decide whether multi-select is safe, independent of how it looks:
- The selection is one list of ids, held in one place. Not "whatever is checked in
the DOM right now" — the DOM is re-rendered by sort, search, and Turbo, and a
checkbox that scrolled out of the filtered set is still selected. Keep a
Setof ids and re-derive the checkboxes from it, never the other way round. A real incident (2026-08-05) hit 1,565 rows because deselected rows were still in the submitted set. - The count is the primary safety signal, so show it constantly. "26 rows selected" belongs next to the Apply button, in the panel header, at all times — not in a confirm dialog that appears after the user has decided.
- "Select all" must say which "all" it means. On a paginated table, the 50 rows on screen and the 2,431 rows matching the filter are wildly different blast radii. Offer both explicitly ("Select 50 on this page" / "Select all 2,431 matching") and never let a header checkbox silently mean the larger one.
- A selection must survive a re-render, or be cleared by it. Pick one and be obvious. Silently keeping a stale selection across a filter change is how a user applies an edit to rows they can no longer see.
The markup
<%# app/views/contacts/index.html.erb — inside the existing table %>
<td class="px-3 py-2 border-t">
<input type="checkbox" value="<%= c.id %>"
data-bulk-edit-target="rowCheckbox"
data-action="change->bulk-edit#selectionChanged"
data-values="<%= Contact::BULK_COLUMNS.index_with { |k| c[k] }.to_json %>">
</td>
<%# app/views/contacts/_bulk_panel.html.erb %>
<div data-controller="bulk-edit" data-bulk-edit-url-value="<%= bulk_update_contacts_path %>">
<div data-bulk-edit-target="panel" hidden
class="fixed bottom-0 inset-x-0 border-t bg-white shadow-lg p-4">
<p class="text-sm font-medium mb-3">
<span data-bulk-edit-target="count">0</span> rows selected
</p>
<% %w[company status owner_id notes].each do |col| %>
<label class="flex items-center gap-2 mb-2 text-sm">
<%# The "apply" checkbox IS the third state. Unticked = leave each row alone. %>
<input type="checkbox" data-bulk-edit-target="applyToggle" data-column="<%= col %>"
data-action="change->bulk-edit#toggleField">
<span class="w-28 text-gray-600"><%= col.humanize %></span>
<input type="text" data-bulk-edit-target="fieldInput" data-column="<%= col %>" disabled
class="border rounded px-2 py-1 flex-1 disabled:bg-gray-50 disabled:text-gray-400">
<button type="button" data-action="bulk-edit#clearField" data-column="<%= col %>"
class="text-xs text-red-600 hover:underline">Clear on all</button>
</label>
<% end %>
<p data-bulk-edit-target="blastRadius" class="text-sm my-3 text-amber-700"></p>
<button data-action="bulk-edit#preview" class="px-3 py-1.5 border rounded">Preview</button>
<button data-action="bulk-edit#apply" data-bulk-edit-target="applyButton" disabled
class="px-3 py-1.5 bg-blue-600 text-white rounded disabled:opacity-40">Apply</button>
</div>
</div>
Two rules the markup encodes:
- The "apply" checkbox is the third state made visible. Unticked means "leave each row's own value alone", and the input is disabled so it cannot be typed into by accident.
- "Clear on all" is a separate, red, deliberate control. It is the only way to write an empty value, and it never happens by leaving a box empty.
Layer 6 — The Stimulus controller
app/javascript/controllers/bulk_edit_controller.js
import { Controller } from "@hotwired/stimulus"
const MIXED = "— multiple values —"
export default class extends Controller {
static targets = ["rowCheckbox", "panel", "count", "applyToggle", "fieldInput",
"blastRadius", "applyButton"]
static values = { url: String }
connect() { this._cleared = new Set(); this._csrf = document.querySelector('meta[name="csrf-token"]')?.content }
get selected() { return this.rowCheckboxTargets.filter(c => c.checked) }
get ids() { return this.selected.map(c => c.value) }
selectionChanged() {
const n = this.selected.length
this.panelTarget.hidden = n === 0
this.countTarget.textContent = n
this.showMixedState()
this.invalidatePreview()
}
// RULE 3 — show the SELECTION's state, not one row's state.
// A blank here would be a false statement about the selection.
showMixedState() {
const rows = this.selected.map(c => JSON.parse(c.dataset.values))
this.fieldInputTargets.forEach(input => {
const col = input.dataset.column
const values = [...new Set(rows.map(r => (r[col] ?? "") + ""))]
input.placeholder = values.length > 1 ? MIXED : (values[0] || "(empty on all)")
})
}
toggleField(e) {
const col = e.target.dataset.column
const input = this.fieldInputTargets.find(i => i.dataset.column === col)
input.disabled = !e.target.checked
if (!e.target.checked) input.value = ""
this._cleared.delete(col)
this.invalidatePreview()
}
clearField(e) {
const col = e.target.dataset.column
if (!confirm(`Clear ${col} on all ${this.ids.length} selected rows?`)) return
this._cleared.add(col)
this.invalidatePreview()
this.preview()
}
payload(dryRun) {
const set = {}
this.applyToggleTargets.filter(t => t.checked).forEach(t => {
const col = t.dataset.column
if (this._cleared.has(col)) return
set[col] = this.fieldInputTargets.find(i => i.dataset.column === col).value
})
// Columns in NEITHER `set` nor `clear` are left alone, per row. That is the point.
return { selected_ids: this.ids, set, clear: [...this._cleared], dry_run: dryRun }
}
// RULE 4 — the user's cost is one click; the consequence can be hundreds of rows.
async preview() {
const data = await this.post(this.payload(true))
if (!data.ok) return this.fail(data.error)
const p = data.preview
const parts = Object.entries(p.per_column)
.map(([col, s]) => `${s.changing} rows change ${col}`)
let msg = `This updates ${p.target_count} rows. ${parts.join(", ")}.`
if (p.destructive_count > 0) msg += ` ⚠ ${p.destructive_count} rows will LOSE an existing value.`
this.blastRadiusTarget.textContent = msg
this.applyButtonTarget.disabled = false // Apply unlocks only after a preview
}
async apply() {
const data = await this.post(this.payload(false))
if (!data.ok) return this.fail(data.error)
this.blastRadiusTarget.innerHTML =
`✓ Updated ${data.applied} rows. <a href="/bulk_edits/${data.bulk_edit_id}/undo"
data-turbo-method="post" class="underline">Undo this batch</a>`
setTimeout(() => location.reload(), 2500)
}
invalidatePreview() {
this.applyButtonTarget.disabled = true
this.blastRadiusTarget.textContent = ""
}
async post(body) {
const res = await fetch(this.urlValue, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json",
"X-CSRF-Token": this._csrf },
body: JSON.stringify(body)
})
return res.json()
}
fail(msg) { this.blastRadiusTarget.textContent = `⚠ ${msg}`; this.applyButtonTarget.disabled = true }
}
Note invalidatePreview(): Apply is disabled until a preview has run for the current
payload. Change anything and the preview goes stale, so the button locks again. The
preview is a gate, not a decoration.
Detection: an audit log is not detection
The incident above was fully recorded in the audit log as it happened, and nobody noticed for six weeks. Logging tells you what happened after someone asks. Alerting tells you it happened.
Run this hourly and alert on a spike (PaperTrail-style versions.object_changes jsonb —
adapt to your audit table):
-- Real values replaced with blanks, by column, in the last hour.
SELECT v.item_type,
c.key AS column_name,
count(*) AS blanked_rows,
count(DISTINCT v.whodunnit) AS users
FROM versions v
CROSS JOIN LATERAL jsonb_each(v.object_changes) AS c(key, val)
WHERE v.event = 'update'
AND v.created_at > now() - interval '1 hour'
AND coalesce(val->>0, '') <> '' -- had a real value
AND coalesce(val->>1, '') = '' -- now blank
GROUP BY 1, 2
HAVING count(*) >= 10
ORDER BY blanked_rows DESC;
The audit log cannot distinguish a deliberate clear from an accidental blanking —
which is itself part of the lesson. Once clear is an explicit payload field, it can:
join to bulk_edits.cleared_columns and everything left over is suspect.
Gotchas (the hard-won stuff)
- Never derive the field list from
previous_changes,changed,saved_changes, or any old-vs-new comparison. This is the entire bug. A state difference is not a user instruction. If you catch yourself writing& SYNCABLE_FIELDS, stop. - HTML forms submit every field, including untouched ones. A whole-row form has no way to tell "the user left this empty" from "the user emptied this". Do not build a bulk editor on top of one.
niland""are two spellings of nothing, and they will eventually be compared. Normalize at the boundary (normalizeabove, or abefore_validationthat maps""tonil) and pick one canonical form. Comparing across the two returns the wrong answer.- Import paths and form paths produce different data shapes. In the incident, the
NULLprecondition only arose from a bulk import usinginsert_all!, which skips callbacks, validations, and column defaults. Rows created through the form held""and were immune — which made the bug nearly impossible to reproduce by hand, because test data made through the UI never has the precondition. Seed your test data through the import path too, or normalize inside the import. - Watch for callbacks that cascade. Blanking an assignee removed 35 QC checkmarks via
an
after_save. Multiply every callback by the batch size before you ship. - Apply the whitelist to every column in the list, not just the first.
slice(*BULK_COLUMNS)onsetand& BULK_COLUMNSonclear, as above. A dynamic column name is the mass-assignment boundary here, exactly as in the single-cell pattern. - Wrap the write in one transaction so a mid-batch failure doesn't leave half a
reassignment. If partial success is genuinely wanted, record it per row in
resultsand say so in the UI — don't leave it ambiguous. - "Edit the primary row and apply to the rest" is a weak model. It cannot show mixed state, and it invites the user to treat one row's values as the selection's values. Prefer a dedicated panel that describes the selection.
- Select-all means the filtered set, not the page. If your table paginates, "select
all" must be explicit about which it means —
2,431 rows match this filteris a very different blast radius from the 50 on screen. - Never read the selection out of the DOM at submit time. Sorting, searching, paging
and any Turbo Frame swap rebuild the rows; a checkbox that is no longer rendered is
not unchecked, it is gone. Hold the ids in a
Setand render checkboxes from it. The 2026-08-05 incident was exactly this: the user deselected rows, the UI still treated the original set as selected, and one edit hit 1,565 rows. - Deselect must be as easy as select. If clearing a selection takes more clicks than making one, users will apply an edit rather than start over. A visible "Clear selection" and an Escape binding cost nothing.
- Shift-click range select is worth the 10 lines, and it is also a blast-radius multiplier — a mis-aimed shift-click grabs 200 rows as easily as 5. It is another reason the count and the preview must be impossible to miss.
- Cap the batch size (a few hundred) and run bigger ones as a background job with a progress indicator. A 30-second synchronous request that times out mid-write is the worst possible outcome.
Files this pattern touches
app/models/<model>.rb # BULK_COLUMNS whitelist
app/models/bulk_edit.rb # batch identity + undo!
app/services/bulk_edits/apply.rb # one path for preview and write + the guard
app/controllers/<plural>_controller.rb # bulk_update (dry_run) + undo_bulk_edit
app/views/<plural>/index.html.erb # multi-select checkbox column + data-values
app/views/<plural>/_bulk_panel.html.erb # selection panel (count, fields, preview)
app/javascript/controllers/bulk_edit_controller.js # selection Set, mixed state, preview, apply
config/routes.rb # collection post :bulk_update + undo
db/migrate/XXXX_create_bulk_edits.rb # the batch table
How to adapt to your schema
- Replace
Contact/contactswith your model and setBULK_COLUMNS— start with the smallest useful set. Identity fields (name, email) almost never belong in it. - Replace
current_user.account.contactswith your tenant scope. The service takes the scope, never a class, so it cannot reach another tenant's rows. - Swap the text inputs in the panel for the right control per column (a
<select>forstatus, a searchable picker forowner_id). TheapplyToggle+MIXEDplaceholder contract stays the same whatever the control is. - If you have no audit log yet,
bulk_edits.resultsalready holds every before-value — that alone gives you undo and a post-hoc investigation trail. - Do not skip the guard to save time. It is nine lines, and it is the only mechanism here that catches a failure mode you did not think of.