Multi-Select, Bulk Edit & Mass Reassignment | LlamaPress Cookbook
LlamaPress
Cookbook
Tables Stable

Multi-Select, Bulk Edit & Mass Reassignment

Multi-select rows and apply one change to all of them — with an explicit field list, a real "leave this alone" state, mixed-value display, a blast-radius preview, a batch record you can undo, and a guard that refuses to blank real data.

Proven on rsb.llamapress.ai view · stimulus_js · controller · model · sql
Live demo — try it Open full screen

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 Contact model with name/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

  1. 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.
  2. Three states, not two. Every column is set on all, cleared on all, or left alone per row. "Left alone" = absent from both lists.
  3. Mixed values show as "multiple values", never blank. A blank is a false statement about the selection.
  4. Preview before write. Same endpoint, dry_run: true, so the preview can't drift from the write.
  5. The batch is a record. A BulkEdit row 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.
  6. 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:

  • set is the instruction. Nothing is inferred from record state.
  • clear is separate from set. "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_run is 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:

  1. 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 Set of 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.
  2. 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.
  3. "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.
  4. 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.
  • nil and "" are two spellings of nothing, and they will eventually be compared. Normalize at the boundary (normalize above, or a before_validation that maps "" to nil) 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 NULL precondition only arose from a bulk import using insert_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) on set and & BULK_COLUMNS on clear, 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 results and 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 filter is 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 Set and 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

  1. Replace Contact/contacts with your model and set BULK_COLUMNS — start with the smallest useful set. Identity fields (name, email) almost never belong in it.
  2. Replace current_user.account.contacts with your tenant scope. The service takes the scope, never a class, so it cannot reach another tenant's rows.
  3. Swap the text inputs in the panel for the right control per column (a <select> for status, a searchable picker for owner_id). The applyToggle + MIXED placeholder contract stays the same whatever the control is.
  4. If you have no audit log yet, bulk_edits.results already holds every before-value — that alone gives you undo and a post-hoc investigation trail.
  5. 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.

Related