High-Quality Inline-Editable Table | LlamaPress Cookbook
LlamaPress
Cookbook
Tables Stable

High-Quality Inline-Editable Table

A spreadsheet-grade data table — click-to-edit cells, Tab/Enter navigation, debounced autosave with a saving/saved indicator, sortable columns, and debounced server-side search.

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

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 Contact model with name/email/company/status columns. Swap in your own model and columns — see How to adapt at the bottom.


The 80/20 in one breath

  1. A normal Rails index that renders a <table> of records.
  2. Each editable <td> carries contenteditable + data attributes: the record id, the column name, and the original value.
  3. One Stimulus controller (inline-table) handles focus/Tab navigation, a per-cell debounce, the autosave fetch, and the indicator.
  4. One JSON action (PATCH /contacts/:id) updates a single attribute and returns { ok: true }.
  5. 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.rbindex (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)

  • contenteditable pastes rich HTML. A paste from Word/Excel injects styled <span>s. Reading cell.textContent (not innerHTML) 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. cellBlur flushes.
  • Tab default must be prevented. Native Tab moves focus by DOM order including links/buttons; e.preventDefault() + explicit moveBy keeps it inside the grid.
  • Revert on server rejection. Validation failures (bad email, etc.) must restore data-original so 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) — not params.permit — is the boundary. Without it a caller could PATCH account_id or admin.
  • Tenant-scope the lookup. Start from current_user.account.contacts so find(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-original from the server — no stale client state to reconcile.
  • ILIKE '%term%' can't use a btree index (leading wildcard). The pg_trgm GIN 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 fetch save 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 returning turbo_stream.replace for the whole row — usually to refresh a computed total — focus falls to document.body and 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

  1. Replace Contact/contacts with your model, and the column lists in EDITABLE_COLUMNS and the view's %w[...] loops.
  2. Keep the whitelist guard — it's the security boundary.
  3. Replace current_user.account.contacts with your tenant scope.
  4. Drop the pg_trgm migration if your table is small; drop paginate if you don't use Kaminari.
  5. The inline_table_controller.js is generic — copy it verbatim, no edits needed.
  6. For a select-type column (e.g. status), swap the contenteditable <td> for a <select data-action="change->inline-table#cellSelect"> and add a cellSelect handler that calls save(e.target.closest('td')). The saved payload is unchanged — still one column, one record.
  7. Do not add row checkboxes here. Multi-select needs its own endpoint and its own safety rules: Multi-Select, Bulk Edit & Mass Reassignment.

Related