High-Quality Inline-Editable Table
⚠️ 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.
A data table that feels like a spreadsheet: every cell is click-to-edit, you can
Tab/Shift+Tab/Enter between cells, edits autosave after you stop typing
(with a live "Saving…/Saved" indicator), every column is sortable, and there's
a fast debounced search. No full-page reloads for edits; sorting and search use
clean URL params so the view is shareable and back-button friendly.
When to use: admin data grids, CRM record lists, any "edit a list of rows in place" screen. When not to: if a row needs a rich multi-field form, use a drawer/modal instead — inline editing shines for flat, scalar columns.
Scope. This pattern edits one cell on one record. Bulk editing is a different operation with a different payload, not this pattern in a loop. If you need multi-select or "apply this to all selected rows", read Multi-Select, Bulk Edit & Mass Reassignment first — extending a single-cell form into a bulk one has erased real customer data.
The example below uses a
Contactmodel withname/email/company/statuscolumns. Swap in your own model and columns — see How to adapt at the bottom.
The 80/20 in one breath
- A normal Rails
indexthat renders a<table>of records. - Each editable
<td>carriescontenteditable+ data attributes: the record id, the column name, and the original value. - One Stimulus controller (
inline-table) handles focus/Tab navigation, a per-cell debounce, the autosavefetch, and the indicator. - One JSON action (
PATCH /contacts/:id) updates a single attribute and returns{ ok: true }. - Sorting + search are plain GET links/inputs that set
?sort=&dir=&q=— the server does the work, wrapped in a Turbo Frame so only the table swaps. No client state.
Everything else below is just making those five things robust.
Layer 1 — Model & SQL
app/models/contact.rb — the whitelists are the security boundary; never let a
client-supplied column name reach update/order without passing through one.
class Contact < ApplicationRecord
belongs_to :account
EDITABLE_COLUMNS = %w[name email phone company status].freeze
SORTABLE_COLUMNS = (EDITABLE_COLUMNS + %w[created_at updated_at]).freeze
scope :search, ->(q) {
return all if q.blank?
term = "%#{sanitize_sql_like(q.strip)}%"
where("name ILIKE :t OR email ILIKE :t OR company ILIKE :t", t: term)
}
end
Optional indexes once the table is large (db/migrate/XXXX_add_indexes_to_contacts.rb):
class AddIndexesToContacts < ActiveRecord::Migration[7.1]
def change
add_index :contacts, :name
add_index :contacts, :updated_at
# ILIKE '%term%' can't use a btree index (leading wildcard). A pg_trgm GIN
# index keeps search sub-100ms past ~50k rows. Drop this for small tables.
enable_extension "pg_trgm" unless extension_enabled?("pg_trgm")
add_index :contacts, :name, using: :gin, opclass: :gin_trgm_ops, name: "idx_contacts_name_trgm"
end
end
Layer 2 — Controller
app/controllers/contacts_controller.rb — index (sort + search + paginate) and
update (single-cell autosave).
class ContactsController < ApplicationController
before_action :authenticate_user!
def index
sort = Contact::SORTABLE_COLUMNS.include?(params[:sort]) ? params[:sort] : "created_at"
dir = params[:dir] == "asc" ? "asc" : "desc"
@contacts = current_user.account.contacts
.search(params[:q])
.order(Arel.sql("#{sort} #{dir}"))
.page(params[:page]).per(50)
@sort = sort
@dir = dir
end
# PATCH /contacts/:id — body: { column: "email", value: "new@x.com" }
def update
contact = current_user.account.contacts.find(params[:id]) # tenant-scoped
column = params[:column].to_s
unless Contact::EDITABLE_COLUMNS.include?(column)
return render json: { ok: false, error: "Column not editable" }, status: :unprocessable_entity
end
if contact.update(column => params[:value])
render json: { ok: true, value: contact.public_send(column), updated_at: contact.updated_at.iso8601 }
else
render json: { ok: false, errors: contact.errors.full_messages }, status: :unprocessable_entity
end
end
end
Why the request names the column.
params[:column]is the instruction. The client tells the server which field to change; the server never works it out by comparing the record's old and new state. Keep this property if you extend the pattern. A state comparison (changed,previous_changes) also picks up fields the browser submitted that the user never touched — HTML forms send every field, including empty ones — and those empty values will overwrite real data. See Multi-Select, Bulk Edit & Mass Reassignment for what this looks like when it goes wrong at scale.
Route — add to config/routes.rb:
resources :contacts, only: [:index, :update]
Header-sort helper — app/helpers/contacts_helper.rb:
module ContactsHelper
# Toggle sort direction while preserving other query params (search, page).
def sort_params(col)
dir = (params[:sort] == col && params[:dir] == "asc") ? "desc" : "asc"
contacts_path(request.query_parameters.merge(sort: col, dir: dir))
end
end
Layer 3 — The View
app/views/contacts/index.html.erb — the magic is the data-* attributes on each
cell and the sortable headers, all inside a turbo_frame_tag.
<div data-controller="inline-table" data-inline-table-url-value="/contacts">
<div class="flex items-center justify-between mb-3">
<%= form_with url: contacts_path, method: :get, data: { inline_table_target: "searchForm" } do |f| %>
<%= f.search_field :q, value: params[:q], placeholder: "Search…",
data: { action: "input->inline-table#search" },
class: "border rounded px-3 py-1.5 w-72" %>
<%= f.hidden_field :sort, value: @sort %>
<%= f.hidden_field :dir, value: @dir %>
<% end %>
<span data-inline-table-target="status" class="text-xs text-gray-400"></span>
</div>
<%= turbo_frame_tag "contacts-table" do %>
<table class="min-w-full text-sm">
<thead>
<tr>
<% { "name" => "Name", "email" => "Email", "company" => "Company", "status" => "Status" }.each do |col, label| %>
<th class="text-left px-3 py-2 select-none">
<%= link_to sort_params(col), data: { turbo_frame: "contacts-table" }, class: "inline-flex items-center gap-1 hover:text-blue-600" do %>
<%= label %>
<% if @sort == col %><span><%= @dir == "asc" ? "▲" : "▼" %></span><% end %>
<% end %>
</th>
<% end %>
</tr>
</thead>
<tbody>
<% @contacts.each do |c| %>
<tr data-id="<%= c.id %>">
<% %w[name email company status].each do |col| %>
<td
contenteditable="true"
tabindex="0"
data-inline-table-target="cell"
data-id="<%= c.id %>"
data-column="<%= col %>"
data-original="<%= c.public_send(col) %>"
data-action="focus->inline-table#cellFocus blur->inline-table#cellBlur input->inline-table#cellInput keydown->inline-table#cellKeydown"
class="px-3 py-2 border-t outline-none focus:bg-blue-50 focus:ring-2 focus:ring-inset focus:ring-blue-400"
><%= c.public_send(col) %></td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<div class="mt-4"><%= paginate @contacts if respond_to?(:paginate) %></div>
<% end %>
</div>
Why
turbo_frame_tag: sort-header clicks and search submits swap only the table, preserving scroll and the rest of the page — no custom JS for sorting. The Stimulus controller only owns editing.
Layer 4 — The Stimulus controller (the heart of it)
app/javascript/controllers/inline_table_controller.js — copy this verbatim; it's
generic and needs no per-model edits.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["cell", "status", "searchForm"]
static values = { url: String, debounce: { type: Number, default: 600 } }
connect() {
this._timers = new Map() // cell -> debounce timer
this._dirty = new Set() // cells with unsaved edits
this._csrf = document.querySelector('meta[name="csrf-token"]')?.content
window.addEventListener("beforeunload", this._warnIfDirty)
}
disconnect() {
this._timers.forEach(clearTimeout)
window.removeEventListener("beforeunload", this._warnIfDirty)
}
// --- Editing ---
cellFocus(e) {
const range = document.createRange() // select-all on focus → typing replaces
range.selectNodeContents(e.target)
const sel = window.getSelection()
sel.removeAllRanges(); sel.addRange(range)
}
cellInput(e) {
const cell = e.target
this._dirty.add(cell)
clearTimeout(this._timers.get(cell))
this._timers.set(cell, setTimeout(() => this.save(cell), this.debounceValue))
this.setStatus("editing")
}
cellBlur(e) { // flush pending save on blur
const cell = e.target
if (this._dirty.has(cell)) { clearTimeout(this._timers.get(cell)); this.save(cell) }
}
cellKeydown(e) {
const cell = e.target
if (e.key === "Enter") { // commit + move down
e.preventDefault(); cell.blur(); this.moveBy(cell, +1, "row")
} else if (e.key === "Escape") { // revert
e.preventDefault()
cell.textContent = cell.dataset.original
this._dirty.delete(cell)
clearTimeout(this._timers.get(cell))
cell.blur(); this.setStatus("idle")
} else if (e.key === "Tab") { // next / prev cell
e.preventDefault(); cell.blur(); this.moveBy(cell, e.shiftKey ? -1 : +1, "cell")
}
}
// --- Navigation ---
moveBy(cell, delta, unit) {
const cells = this.cellTargets
const idx = cells.indexOf(cell)
const next = unit === "cell"
? cells[idx + delta]
: cells[idx + delta * this._columnsPerRow()]
if (next) next.focus()
}
_columnsPerRow() {
const firstId = this.cellTargets[0]?.dataset.id
return this.cellTargets.filter(c => c.dataset.id === firstId).length
}
// --- Saving ---
async save(cell) {
const value = cell.textContent.trim()
if (value === cell.dataset.original) { this._dirty.delete(cell); this.setStatus("idle"); return }
this.setStatus("saving")
cell.classList.add("opacity-60")
try {
const res = await fetch(`${this.urlValue}/${cell.dataset.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": this._csrf },
body: JSON.stringify({ column: cell.dataset.column, value })
})
const data = await res.json()
if (!res.ok || !data.ok) throw new Error((data.errors || ["Save failed"]).join(", "))
cell.dataset.original = data.value ?? value
cell.textContent = data.value ?? value
this._dirty.delete(cell)
this.flash(cell, "ok"); this.setStatus("saved")
} catch (err) {
cell.textContent = cell.dataset.original // revert on failure
this._dirty.delete(cell)
this.flash(cell, "err"); this.setStatus("error", err.message)
} finally {
cell.classList.remove("opacity-60")
}
}
// --- Search ---
search() {
clearTimeout(this._searchTimer)
this._searchTimer = setTimeout(() => this.searchFormTarget.requestSubmit(), 300)
}
// --- Indicator ---
setStatus(state, msg) {
if (!this.hasStatusTarget) return
const map = { idle: "", editing: "✎ Editing…", saving: "⟳ Saving…", saved: "✓ Saved", error: `⚠ ${msg || "Save failed"}` }
this.statusTarget.textContent = map[state] || ""
this.statusTarget.className = "text-xs " + (state === "error" ? "text-red-500" : state === "saved" ? "text-green-600" : "text-gray-400")
if (state === "saved") {
clearTimeout(this._savedTimer)
this._savedTimer = setTimeout(() => { if (!this._dirty.size) this.setStatus("idle") }, 1500)
}
}
flash(cell, kind) {
const cls = kind === "ok" ? "bg-green-100" : "bg-red-100"
cell.classList.add(cls)
setTimeout(() => cell.classList.remove(cls), 700)
}
_warnIfDirty = (e) => { if (this._dirty?.size) { e.preventDefault(); e.returnValue = "" } }
}
Gotchas (the hard-won stuff)
contenteditablepastes rich HTML. A paste from Word/Excel injects styled<span>s. Readingcell.textContent(notinnerHTML) on save neutralizes it. For a cleaner caret, also strip on paste:e.preventDefault(); document.execCommand("insertText", false, e.clipboardData.getData("text/plain")).- Debounce and flush on blur. Debounce-only loses the save when a user edits then
immediately clicks away.
cellBlurflushes. - Tab default must be prevented. Native
Tabmoves focus by DOM order including links/buttons;e.preventDefault()+ explicitmoveBykeeps it inside the grid. - Revert on server rejection. Validation failures (bad email, etc.) must restore
data-originalso the cell never shows an unsaved-but-rejected value. - Mass-assignment safety is the whitelist. Because the column name is dynamic,
EDITABLE_COLUMNS.include?(column)— notparams.permit— is the boundary. Without it a caller could PATCHaccount_idoradmin. - Tenant-scope the lookup. Start from
current_user.account.contactssofind(params[:id])can't reach another tenant's row. - Let the server own sorting/search. Re-render via the Turbo Frame so new cells get
fresh
data-originalfrom the server — no stale client state to reconcile. ILIKE '%term%'can't use a btree index (leading wildcard). Thepg_trgmGIN index keeps search fast past ~50k rows. Skip for small tables.- Select-all on focus (
cellFocus) is what makes it feel like a spreadsheet. - The
fetchsave is what protects focus. This pattern never removes the element the user is standing on, so the caret stays put for free. The moment a save starts returningturbo_stream.replacefor the whole row — usually to refresh a computed total — focus falls todocument.bodyand every edit throws the user back to row 1. Fixing that needs a snapshot/restore pass: Focus Jumping in Inline-Editable Tables. - The single-cell payload is a safety property, not an implementation detail. Two reasonable-looking extensions — a whole-row form, plus multi-select — combined to erase ~1,400 real values in a production app. Neither is wrong alone; together they let the server guess intent from a state diff. If you're heading that way, read Multi-Select, Bulk Edit & Mass Reassignment first.
Files this pattern touches
app/models/<model>.rb # EDITABLE/SORTABLE_COLUMNS + search scope
app/controllers/<plural>_controller.rb # index (sort/search) + update (autosave)
app/helpers/<plural>_helper.rb # sort_params
app/views/<plural>/index.html.erb # table + turbo_frame + search
app/javascript/controllers/inline_table_controller.js # the Stimulus controller (copy verbatim)
config/routes.rb # resources only: [:index, :update]
db/migrate/XXXX_add_indexes.rb # optional, for scale
How to adapt to your schema
- Replace
Contact/contactswith your model, and the column lists inEDITABLE_COLUMNSand the view's%w[...]loops. - Keep the whitelist guard — it's the security boundary.
- Replace
current_user.account.contactswith your tenant scope. - Drop the
pg_trgmmigration if your table is small; droppaginateif you don't use Kaminari. - The
inline_table_controller.jsis generic — copy it verbatim, no edits needed. - For a
select-type column (e.g.status), swap thecontenteditable<td>for a<select data-action="change->inline-table#cellSelect">and add acellSelecthandler that callssave(e.target.closest('td')). The saved payload is unchanged — still one column, one record. - Do not add row checkboxes here. Multi-select needs its own endpoint and its own safety rules: Multi-Select, Bulk Edit & Mass Reassignment.