{"slug":"inline-editable-table","meta":{"title":"High-Quality Inline-Editable Table","slug":"inline-editable-table","category":"Tables","summary":"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.","tags":["stimulus","hotwire","autosave","sorting","search","tables","turbo"],"status":"stable","visibility":"public","source_project":"rsb.llamapress.ai","layers":["view","stimulus_js","controller","model","sql"]},"body":"# High-Quality Inline-Editable Table\n\n\u003e ⚠️ **Cookbook example — not live code.** Every code block below is an **example\n\u003e snippet**, **not part of the llamapress.ai codebase**, and **not running on this\n\u003e server**. This is a reference recipe for a **Leo instance (an AI coding agent) to\n\u003e implement in its own app** — read it to understand the pattern, then recreate it there.\n\nA data table that feels like a spreadsheet: every cell is click-to-edit, you can\n`Tab`/`Shift+Tab`/`Enter` between cells, edits **autosave** after you stop typing\n(with a live \"Saving…/Saved\" indicator), every column is **sortable**, and there's\na fast **debounced search**. No full-page reloads for edits; sorting and search use\nclean URL params so the view is shareable and back-button friendly.\n\n\u003e **When to use:** admin data grids, CRM record lists, any \"edit a list of rows in\n\u003e place\" screen. **When not to:** if a row needs a rich multi-field form, use a\n\u003e drawer/modal instead — inline editing shines for flat, scalar columns.\n\n\u003e The example below uses a `Contact` model with `name/email/company/status` columns.\n\u003e Swap in your own model and columns — see **How to adapt** at the bottom.\n\n---\n\n## The 80/20 in one breath\n\n1. A normal Rails `index` that renders a `\u003ctable\u003e` of records.\n2. Each editable `\u003ctd\u003e` carries `contenteditable` + data attributes: the record id,\n   the column name, and the original value.\n3. One Stimulus controller (`inline-table`) handles focus/Tab navigation, a per-cell\n   debounce, the autosave `fetch`, and the indicator.\n4. One JSON action (`PATCH /contacts/:id`) updates a single attribute and returns\n   `{ ok: true }`.\n5. Sorting + search are plain GET links/inputs that set `?sort=\u0026dir=\u0026q=` — the server\n   does the work, wrapped in a Turbo Frame so only the table swaps. No client state.\n\nEverything else below is just making those five things robust.\n\n---\n\n## Layer 1 — Model \u0026 SQL\n\n`app/models/contact.rb` — the whitelists are the security boundary; never let a\nclient-supplied column name reach `update`/`order` without passing through one.\n\n```ruby\nclass Contact \u003c ApplicationRecord\n  belongs_to :account\n\n  EDITABLE_COLUMNS = %w[name email phone company status].freeze\n  SORTABLE_COLUMNS = (EDITABLE_COLUMNS + %w[created_at updated_at]).freeze\n\n  scope :search, -\u003e(q) {\n    return all if q.blank?\n    term = \"%#{sanitize_sql_like(q.strip)}%\"\n    where(\"name ILIKE :t OR email ILIKE :t OR company ILIKE :t\", t: term)\n  }\nend\n```\n\nOptional indexes once the table is large (`db/migrate/XXXX_add_indexes_to_contacts.rb`):\n\n```ruby\nclass AddIndexesToContacts \u003c ActiveRecord::Migration[7.1]\n  def change\n    add_index :contacts, :name\n    add_index :contacts, :updated_at\n\n    # ILIKE '%term%' can't use a btree index (leading wildcard). A pg_trgm GIN\n    # index keeps search sub-100ms past ~50k rows. Drop this for small tables.\n    enable_extension \"pg_trgm\" unless extension_enabled?(\"pg_trgm\")\n    add_index :contacts, :name, using: :gin, opclass: :gin_trgm_ops, name: \"idx_contacts_name_trgm\"\n  end\nend\n```\n\n---\n\n## Layer 2 — Controller\n\n`app/controllers/contacts_controller.rb` — `index` (sort + search + paginate) and\n`update` (single-cell autosave).\n\n```ruby\nclass ContactsController \u003c ApplicationController\n  before_action :authenticate_user!\n\n  def index\n    sort = Contact::SORTABLE_COLUMNS.include?(params[:sort]) ? params[:sort] : \"created_at\"\n    dir  = params[:dir] == \"asc\" ? \"asc\" : \"desc\"\n\n    @contacts = current_user.account.contacts\n                            .search(params[:q])\n                            .order(Arel.sql(\"#{sort} #{dir}\"))\n                            .page(params[:page]).per(50)\n\n    @sort = sort\n    @dir  = dir\n  end\n\n  # PATCH /contacts/:id  — body: { column: \"email\", value: \"new@x.com\" }\n  def update\n    contact = current_user.account.contacts.find(params[:id]) # tenant-scoped\n    column  = params[:column].to_s\n\n    unless Contact::EDITABLE_COLUMNS.include?(column)\n      return render json: { ok: false, error: \"Column not editable\" }, status: :unprocessable_entity\n    end\n\n    if contact.update(column =\u003e params[:value])\n      render json: { ok: true, value: contact.public_send(column), updated_at: contact.updated_at.iso8601 }\n    else\n      render json: { ok: false, errors: contact.errors.full_messages }, status: :unprocessable_entity\n    end\n  end\nend\n```\n\nRoute — add to `config/routes.rb`:\n\n```ruby\nresources :contacts, only: [:index, :update]\n```\n\nHeader-sort helper — `app/helpers/contacts_helper.rb`:\n\n```ruby\nmodule ContactsHelper\n  # Toggle sort direction while preserving other query params (search, page).\n  def sort_params(col)\n    dir = (params[:sort] == col \u0026\u0026 params[:dir] == \"asc\") ? \"desc\" : \"asc\"\n    contacts_path(request.query_parameters.merge(sort: col, dir: dir))\n  end\nend\n```\n\n---\n\n## Layer 3 — The View\n\n`app/views/contacts/index.html.erb` — the magic is the `data-*` attributes on each\ncell and the sortable headers, all inside a `turbo_frame_tag`.\n\n```erb\n\u003cdiv data-controller=\"inline-table\" data-inline-table-url-value=\"/contacts\"\u003e\n\n  \u003cdiv class=\"flex items-center justify-between mb-3\"\u003e\n    \u003c%= form_with url: contacts_path, method: :get, data: { inline_table_target: \"searchForm\" } do |f| %\u003e\n      \u003c%= f.search_field :q, value: params[:q], placeholder: \"Search…\",\n            data: { action: \"input-\u003einline-table#search\" },\n            class: \"border rounded px-3 py-1.5 w-72\" %\u003e\n      \u003c%= f.hidden_field :sort, value: @sort %\u003e\n      \u003c%= f.hidden_field :dir,  value: @dir %\u003e\n    \u003c% end %\u003e\n    \u003cspan data-inline-table-target=\"status\" class=\"text-xs text-gray-400\"\u003e\u003c/span\u003e\n  \u003c/div\u003e\n\n  \u003c%= turbo_frame_tag \"contacts-table\" do %\u003e\n    \u003ctable class=\"min-w-full text-sm\"\u003e\n      \u003cthead\u003e\n        \u003ctr\u003e\n          \u003c% { \"name\" =\u003e \"Name\", \"email\" =\u003e \"Email\", \"company\" =\u003e \"Company\", \"status\" =\u003e \"Status\" }.each do |col, label| %\u003e\n            \u003cth class=\"text-left px-3 py-2 select-none\"\u003e\n              \u003c%= link_to sort_params(col), data: { turbo_frame: \"contacts-table\" }, class: \"inline-flex items-center gap-1 hover:text-blue-600\" do %\u003e\n                \u003c%= label %\u003e\n                \u003c% if @sort == col %\u003e\u003cspan\u003e\u003c%= @dir == \"asc\" ? \"▲\" : \"▼\" %\u003e\u003c/span\u003e\u003c% end %\u003e\n              \u003c% end %\u003e\n            \u003c/th\u003e\n          \u003c% end %\u003e\n        \u003c/tr\u003e\n      \u003c/thead\u003e\n      \u003ctbody\u003e\n        \u003c% @contacts.each do |c| %\u003e\n          \u003ctr data-id=\"\u003c%= c.id %\u003e\"\u003e\n            \u003c% %w[name email company status].each do |col| %\u003e\n              \u003ctd\n                contenteditable=\"true\"\n                tabindex=\"0\"\n                data-inline-table-target=\"cell\"\n                data-id=\"\u003c%= c.id %\u003e\"\n                data-column=\"\u003c%= col %\u003e\"\n                data-original=\"\u003c%= c.public_send(col) %\u003e\"\n                data-action=\"focus-\u003einline-table#cellFocus blur-\u003einline-table#cellBlur input-\u003einline-table#cellInput keydown-\u003einline-table#cellKeydown\"\n                class=\"px-3 py-2 border-t outline-none focus:bg-blue-50 focus:ring-2 focus:ring-inset focus:ring-blue-400\"\n              \u003e\u003c%= c.public_send(col) %\u003e\u003c/td\u003e\n            \u003c% end %\u003e\n          \u003c/tr\u003e\n        \u003c% end %\u003e\n      \u003c/tbody\u003e\n    \u003c/table\u003e\n    \u003cdiv class=\"mt-4\"\u003e\u003c%= paginate @contacts if respond_to?(:paginate) %\u003e\u003c/div\u003e\n  \u003c% end %\u003e\n\u003c/div\u003e\n```\n\n\u003e **Why `turbo_frame_tag`:** sort-header clicks and search submits swap **only the\n\u003e table**, preserving scroll and the rest of the page — no custom JS for sorting. The\n\u003e Stimulus controller only owns editing.\n\n---\n\n## Layer 4 — The Stimulus controller (the heart of it)\n\n`app/javascript/controllers/inline_table_controller.js` — copy this verbatim; it's\ngeneric and needs no per-model edits.\n\n```javascript\nimport { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"cell\", \"status\", \"searchForm\"]\n  static values  = { url: String, debounce: { type: Number, default: 600 } }\n\n  connect() {\n    this._timers = new Map()   // cell -\u003e debounce timer\n    this._dirty  = new Set()   // cells with unsaved edits\n    this._csrf   = document.querySelector('meta[name=\"csrf-token\"]')?.content\n    window.addEventListener(\"beforeunload\", this._warnIfDirty)\n  }\n\n  disconnect() {\n    this._timers.forEach(clearTimeout)\n    window.removeEventListener(\"beforeunload\", this._warnIfDirty)\n  }\n\n  // --- Editing ---\n  cellFocus(e) {\n    const range = document.createRange()   // select-all on focus → typing replaces\n    range.selectNodeContents(e.target)\n    const sel = window.getSelection()\n    sel.removeAllRanges(); sel.addRange(range)\n  }\n\n  cellInput(e) {\n    const cell = e.target\n    this._dirty.add(cell)\n    clearTimeout(this._timers.get(cell))\n    this._timers.set(cell, setTimeout(() =\u003e this.save(cell), this.debounceValue))\n    this.setStatus(\"editing\")\n  }\n\n  cellBlur(e) {                            // flush pending save on blur\n    const cell = e.target\n    if (this._dirty.has(cell)) { clearTimeout(this._timers.get(cell)); this.save(cell) }\n  }\n\n  cellKeydown(e) {\n    const cell = e.target\n    if (e.key === \"Enter\") {               // commit + move down\n      e.preventDefault(); cell.blur(); this.moveBy(cell, +1, \"row\")\n    } else if (e.key === \"Escape\") {       // revert\n      e.preventDefault()\n      cell.textContent = cell.dataset.original\n      this._dirty.delete(cell)\n      clearTimeout(this._timers.get(cell))\n      cell.blur(); this.setStatus(\"idle\")\n    } else if (e.key === \"Tab\") {          // next / prev cell\n      e.preventDefault(); cell.blur(); this.moveBy(cell, e.shiftKey ? -1 : +1, \"cell\")\n    }\n  }\n\n  // --- Navigation ---\n  moveBy(cell, delta, unit) {\n    const cells = this.cellTargets\n    const idx = cells.indexOf(cell)\n    const next = unit === \"cell\"\n      ? cells[idx + delta]\n      : cells[idx + delta * this._columnsPerRow()]\n    if (next) next.focus()\n  }\n\n  _columnsPerRow() {\n    const firstId = this.cellTargets[0]?.dataset.id\n    return this.cellTargets.filter(c =\u003e c.dataset.id === firstId).length\n  }\n\n  // --- Saving ---\n  async save(cell) {\n    const value = cell.textContent.trim()\n    if (value === cell.dataset.original) { this._dirty.delete(cell); this.setStatus(\"idle\"); return }\n\n    this.setStatus(\"saving\")\n    cell.classList.add(\"opacity-60\")\n    try {\n      const res = await fetch(`${this.urlValue}/${cell.dataset.id}`, {\n        method: \"PATCH\",\n        headers: { \"Content-Type\": \"application/json\", \"Accept\": \"application/json\", \"X-CSRF-Token\": this._csrf },\n        body: JSON.stringify({ column: cell.dataset.column, value })\n      })\n      const data = await res.json()\n      if (!res.ok || !data.ok) throw new Error((data.errors || [\"Save failed\"]).join(\", \"))\n\n      cell.dataset.original = data.value ?? value\n      cell.textContent = data.value ?? value\n      this._dirty.delete(cell)\n      this.flash(cell, \"ok\"); this.setStatus(\"saved\")\n    } catch (err) {\n      cell.textContent = cell.dataset.original   // revert on failure\n      this._dirty.delete(cell)\n      this.flash(cell, \"err\"); this.setStatus(\"error\", err.message)\n    } finally {\n      cell.classList.remove(\"opacity-60\")\n    }\n  }\n\n  // --- Search ---\n  search() {\n    clearTimeout(this._searchTimer)\n    this._searchTimer = setTimeout(() =\u003e this.searchFormTarget.requestSubmit(), 300)\n  }\n\n  // --- Indicator ---\n  setStatus(state, msg) {\n    if (!this.hasStatusTarget) return\n    const map = { idle: \"\", editing: \"✎ Editing…\", saving: \"⟳ Saving…\", saved: \"✓ Saved\", error: `⚠ ${msg || \"Save failed\"}` }\n    this.statusTarget.textContent = map[state] || \"\"\n    this.statusTarget.className = \"text-xs \" + (state === \"error\" ? \"text-red-500\" : state === \"saved\" ? \"text-green-600\" : \"text-gray-400\")\n    if (state === \"saved\") {\n      clearTimeout(this._savedTimer)\n      this._savedTimer = setTimeout(() =\u003e { if (!this._dirty.size) this.setStatus(\"idle\") }, 1500)\n    }\n  }\n\n  flash(cell, kind) {\n    const cls = kind === \"ok\" ? \"bg-green-100\" : \"bg-red-100\"\n    cell.classList.add(cls)\n    setTimeout(() =\u003e cell.classList.remove(cls), 700)\n  }\n\n  _warnIfDirty = (e) =\u003e { if (this._dirty?.size) { e.preventDefault(); e.returnValue = \"\" } }\n}\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **`contenteditable` pastes rich HTML.** A paste from Word/Excel injects styled\n  `\u003cspan\u003e`s. Reading `cell.textContent` (not `innerHTML`) on save neutralizes it. For\n  a cleaner caret, also strip on paste:\n  `e.preventDefault(); document.execCommand(\"insertText\", false, e.clipboardData.getData(\"text/plain\"))`.\n- **Debounce *and* flush on blur.** Debounce-only loses the save when a user edits then\n  immediately clicks away. `cellBlur` flushes.\n- **Tab default must be prevented.** Native `Tab` moves focus by DOM order including\n  links/buttons; `e.preventDefault()` + explicit `moveBy` keeps it inside the grid.\n- **Revert on server rejection.** Validation failures (bad email, etc.) must restore\n  `data-original` so the cell never shows an unsaved-but-rejected value.\n- **Mass-assignment safety is the whitelist.** Because the column name is dynamic,\n  `EDITABLE_COLUMNS.include?(column)` — not `params.permit` — is the boundary. Without\n  it a caller could PATCH `account_id` or `admin`.\n- **Tenant-scope the lookup.** Start from `current_user.account.contacts` so\n  `find(params[:id])` can't reach another tenant's row.\n- **Let the server own sorting/search.** Re-render via the Turbo Frame so new cells get\n  fresh `data-original` from the server — no stale client state to reconcile.\n- **`ILIKE '%term%'` can't use a btree index** (leading wildcard). The `pg_trgm` GIN\n  index keeps search fast past ~50k rows. Skip for small tables.\n- **Select-all on focus** (`cellFocus`) is what makes it feel like a spreadsheet.\n\n---\n\n## Files this pattern touches\n\n```\napp/models/\u003cmodel\u003e.rb                                  # EDITABLE/SORTABLE_COLUMNS + search scope\napp/controllers/\u003cplural\u003e_controller.rb                 # index (sort/search) + update (autosave)\napp/helpers/\u003cplural\u003e_helper.rb                         # sort_params\napp/views/\u003cplural\u003e/index.html.erb                      # table + turbo_frame + search\napp/javascript/controllers/inline_table_controller.js  # the Stimulus controller (copy verbatim)\nconfig/routes.rb                                        # resources only: [:index, :update]\ndb/migrate/XXXX_add_indexes.rb                          # optional, for scale\n```\n\n## How to adapt to your schema\n\n1. Replace `Contact`/`contacts` with your model, and the column lists in\n   `EDITABLE_COLUMNS` and the view's `%w[...]` loops.\n2. Keep the **whitelist** guard — it's the security boundary.\n3. Replace `current_user.account.contacts` with your tenant scope.\n4. Drop the `pg_trgm` migration if your table is small; drop `paginate` if you don't\n   use Kaminari.\n5. The `inline_table_controller.js` is generic — copy it verbatim, no edits needed.\n6. For a `select`-type column (e.g. `status`), swap the `contenteditable` `\u003ctd\u003e` for a\n   `\u003cselect data-action=\"change-\u003einline-table#cellSelect\"\u003e` and add a `cellSelect`\n   handler that calls `save(e.target.closest('td'))` — see the\n   [multi-select patterns](/cookbook/multi-select-patterns) guide.\n"}