{"slug":"search-and-add-tagger","meta":{"title":"Search-and-Add Tagger (find a record or create it, in one field)","slug":"search-and-add-tagger","category":"Forms","summary":"One input that searches an existing table, shows matches as you type, lets you create a brand-new record inline when there is no match, and renders picks as removable badges — works both inside a new-record form and as an auto-saving sidebar widget.","tags":["stimulus","turbo","forms","search","tagging","many-to-many","quick-create","ux-default"],"status":"stable","visibility":"public","source_project":"crm.llamapress.ai — /conversations/new, \"People on this call\"","layers":["model","controller","view","stimulus_js"],"related":[{"title":"Cmd+K Global Search","url":"/cookbook/global-search-command-palette","summary":"The same fetch-partial-into-a-dropdown trick, scaled across many tables at once."},{"title":"Async Action Feedback","url":"/cookbook/async-action-feedback","summary":"How to show \"Saving…\" state on the background PATCH this pattern fires."}]},"body":"# Search-and-Add Tagger (find a record or create it, in one field)\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nAlmost every app has a form field that means \"attach some other records to this record\":\npeople on a call, tags on a post, ingredients in a recipe, staff on a job. The lazy build\nis a multi-select box, which forces the user to already know the record exists — and\ndead-ends them when it does not.\n\nThis pattern is **one text input that does both jobs**. The user types a couple of\nletters, matching records appear in a dropdown underneath, and the **first row of that\ndropdown is always `Add \"\u003cwhat they typed\u003e\" as a new record…`**. Picking either one drops\na removable badge above the input. There is no separate \"manage contacts\" trip, and there\nis no dead end.\n\nIt is a many-to-many join, a 6-line search action, one Stimulus controller, and two small\npartials.\n\n\u003e **When to use:** any `has_many :through` field where the user may not know whether the\n\u003e record already exists — people, tags, skills, products, categories.\n\u003e **When not to:** a short fixed list that never grows (use checkboxes), or single-select\n\u003e from a closed set (use a plain `select`).\n\n---\n\n## The 80/20 in one breath\n\n1. **Join model.** `Conversation has_many :participants, through: :conversation_participants, source: :contact`.\n2. **A `search` scope** on the searched model, and a `GET` collection route that renders a\n   **partial of result rows** (HTML, not JSON).\n3. **A Stimulus controller** holding an array of selected IDs. On input → `fetch` the\n   partial → dump it into a dropdown div. On click a row → push the ID, re-render badges.\n4. **Two save modes off the same controller.** Inside a new-record form there is nothing\n   to PATCH yet, so it writes the IDs into a **hidden field** the parent form submits.\n   On an already-saved record it **PATCHes immediately** and swaps the section back in\n   via Turbo Stream.\n5. **Quick-create** is a normal modal form that posts to its own action; on success the\n   server returns a Turbo Stream that **dispatches a `CustomEvent`**, and the tagger\n   listens for that event and auto-selects the record it just created.\n\n---\n\n## Layer 1 — Model \u0026 routes\n\n```ruby\n# app/models/conversation.rb\nclass Conversation \u003c ApplicationRecord\n  has_many :conversation_participants, dependent: :destroy\n  has_many :participants, through: :conversation_participants, source: :contact\nend\n\n# app/models/conversation_participant.rb   (the join table: conversation_id, contact_id)\nclass ConversationParticipant \u003c ApplicationRecord\n  belongs_to :conversation\n  belongs_to :contact\nend\n```\n\n```ruby\n# app/models/contact.rb\nclass Contact \u003c ApplicationRecord\n  belongs_to :organization, optional: true\n\n  # LEFT OUTER join, so contacts with no organization still match.\n  scope :search, -\u003e(query) {\n    if query.present?\n      left_outer_joins(:organization).where(\n        \"contacts.first_name ILIKE :q OR \" \\\n        \"contacts.last_name ILIKE :q OR \" \\\n        \"concat(contacts.first_name, ' ', contacts.last_name) ILIKE :q OR \" \\\n        \"contacts.title ILIKE :q OR \" \\\n        \"organizations.name ILIKE :q\",\n        q: \"%#{query}%\"\n      )\n    else\n      all\n    end\n  }\n\n  def full_name = \"#{first_name} #{last_name}\"\nend\n```\n\n```ruby\n# config/routes.rb\nresources :conversations do\n  collection do\n    get  :search_contacts     # the type-ahead\n    post :quick_add_contact   # create-inline\n  end\n  member do\n    patch :update_participants # auto-save on an existing record\n  end\nend\n```\n\n---\n\n## Layer 2 — Controller (three skinny actions)\n\n```ruby\n# app/controllers/conversations_controller.rb\n\n# GET /conversations/search_contacts?q=dar\n# Returns an HTML PARTIAL, not JSON — the Stimulus controller injects it verbatim.\ndef search_contacts\n  @contacts = params[:q].present? ? Contact.search(params[:q]).limit(20) : Contact.none\n  render partial: \"conversations/contact_search_results\", locals: { contacts: @contacts }\nend\n\n# PATCH /conversations/:id/update_participants   (auto-save mode only)\ndef update_participants\n  ids          = params[:participant_contact_ids].to_s.split(\",\").map(\u0026:to_i).reject(\u0026:zero?)\n  existing_ids = @conversation.conversation_participants.pluck(:contact_id)\n\n  (ids - existing_ids).each { |id| @conversation.conversation_participants.create!(contact_id: id) }\n  @conversation.conversation_participants.where(contact_id: existing_ids - ids).destroy_all\n\n  respond_to do |format|\n    format.turbo_stream                       # -\u003e update_participants.turbo_stream.erb\n    format.json { render json: { success: true } }\n  end\nend\n\n# POST /conversations/quick_add_contact   (create-inline from the modal)\ndef quick_add_contact\n  @contact = Contact.new(params.require(:contact).permit(:first_name, :last_name, :email, :phone, :organization_id))\n\n  if @contact.save\n    render turbo_stream: [\n      # 1. close the modal by emptying it\n      turbo_stream.replace(\"new_contact_modal\", %(\u003cdiv id=\"new_contact_modal\"\u003e\u003c/div\u003e).html_safe),\n      # 2. tell the tagger (any tagger) that a record now exists\n      turbo_stream.append(\"body\", helpers.tag.script(\n        \"document.dispatchEvent(new CustomEvent('contact-created', { detail: \" \\\n        \"{ contactId: #{@contact.id}, contactName: '#{j @contact.full_name}' } }))\".html_safe\n      ))\n    ]\n  else\n    # re-render the modal WITH modal-open, since showModal() state is lost on replace\n    render turbo_stream: turbo_stream.replace(\"new_contact_modal\",\n      partial: \"conversations/quick_contact_form\", locals: { contact: @contact })\n  end\nend\n```\n\nOn create (the new-record form path) the parent controller parses the same CSV out of the\nhidden field:\n\n```ruby\n# app/controllers/conversations_controller.rb  (private)\ndef save_participants\n  raw = params[:conversation][:participant_contact_ids]\n  return if raw.blank?\n\n  # Handle BOTH shapes: \"3,7,9\" from the hidden field, or an array from a plain form.\n  ids          = raw.is_a?(String) ? raw.split(\",\").map(\u0026:to_i).reject(\u0026:zero?) : raw.reject(\u0026:blank?).map(\u0026:to_i)\n  existing_ids = @conversation.conversation_participants.pluck(:contact_id)\n\n  (ids - existing_ids).each { |id| @conversation.conversation_participants.create!(contact_id: id) }\n  @conversation.conversation_participants.where(contact_id: existing_ids - ids).destroy_all\nend\n```\n\nCall it from `create` **after** `@conversation.save`, and remember to strip the key out of\nthe attributes hash — it is not a column:\n\n```ruby\n# app/controllers/conversations_controller.rb\n@conversation = Conversation.new(conversation_params.except(:participant_contact_ids))\n```\n\n---\n\n## Layer 3 — The tagger partial (one file, two modes)\n\nThe same partial serves the new-record form and the auto-saving sidebar. The **only**\ndifference is whether `data-inline-tagger-save-url-value` is present.\n\n```erb\n\u003c%# app/views/conversations/_participants_tagger.html.erb %\u003e\n\u003c% form_mode = local_assigns[:form_mode] || false %\u003e\n\n\u003cdiv class=\"my-5\"\n     data-controller=\"inline-tagger\"\n     data-inline-tagger-search-url-value=\"\u003c%= search_contacts_conversations_path %\u003e\"\n     \u003c%# auto-save mode ONLY — omit this in form mode so it writes the hidden field instead %\u003e\n     \u003c% unless form_mode %\u003edata-inline-tagger-save-url-value=\"\u003c%= update_participants_conversation_path(conversation) %\u003e\"\u003c% end %\u003e\n     data-inline-tagger-selected-value=\"\u003c%= conversation.participants.pluck(:id) %\u003e\"\n     data-inline-tagger-contact-names-value=\"\u003c%= conversation.participants.each_with_object({}) { |p, h| h[p.id] = p.full_name }.to_json %\u003e\"\u003e\n\n  \u003clabel class=\"block font-medium text-sm mb-2\"\u003ePeople on this call\u003c/label\u003e\n  \u003cp class=\"text-sm text-base-content/60 mb-2 italic\"\u003eStart typing a name to find someone, or add a new person.\u003c/p\u003e\n\n  \u003c%# Selected records, as removable badges %\u003e\n  \u003cdiv class=\"flex flex-wrap gap-1.5 mb-3\" data-inline-tagger-target=\"selected\"\u003e\n    \u003c% conversation.participants.each do |participant| %\u003e\n      \u003cspan class=\"badge badge-primary gap-1 py-3 px-3 cursor-pointer transition hover:opacity-70\"\n            data-contact-id=\"\u003c%= participant.id %\u003e\"\n            data-action=\"click-\u003einline-tagger#removeContact\"\u003e\n        \u003cspan data-name\u003e\u003c%= participant.full_name %\u003e\u003c/span\u003e\n        \u003ci class=\"fa-solid fa-xmark text-[10px]\"\u003e\u003c/i\u003e\n      \u003c/span\u003e\n    \u003c% end %\u003e\n  \u003c/div\u003e\n\n  \u003c%# The one input %\u003e\n  \u003cdiv class=\"relative\"\u003e\n    \u003ci class=\"fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40 text-xs\"\u003e\u003c/i\u003e\n    \u003cinput type=\"text\"\n      data-inline-tagger-target=\"input\"\n      data-action=\"input-\u003einline-tagger#search\"\n      placeholder=\"Search contacts to tag...\"\n      class=\"input input-sm input-bordered w-full pl-8 pr-3\"\n      autocomplete=\"off\"\u003e\n\n    \u003cdiv class=\"absolute z-50 mt-1 w-full bg-base-100 border border-base-300 rounded-lg shadow-xl hidden max-h-48 overflow-y-auto\"\n         data-inline-tagger-target=\"results\"\u003e\u003c/div\u003e\n  \u003c/div\u003e\n\n  \u003c%# FORM MODE ONLY: this is what actually gets submitted with the parent form %\u003e\n  \u003c% if form_mode %\u003e\n    \u003c%= hidden_field_tag \"conversation[participant_contact_ids]\",\n          conversation.participants.pluck(:id).join(\",\"),\n          data: { inline_tagger_target: \"hiddenField\" } %\u003e\n  \u003c% end %\u003e\n\u003c/div\u003e\n```\n\nRender it from the form, choosing the mode by whether the record exists yet:\n\n```erb\n\u003c%# app/views/conversations/_form.html.erb %\u003e\n\u003c%= render partial: \"conversations/participants_tagger\",\n           locals: { conversation: conversation, form_mode: !conversation.persisted? } %\u003e\n```\n\nIn auto-save mode, wrap the whole thing in a Turbo Frame so the PATCH response has\nsomething to replace:\n\n```erb\n\u003c%# app/views/conversations/show.html.erb — sidebar mode %\u003e\n\u003c%= turbo_frame_tag \"participants_section\" do %\u003e\n  \u003c%= render partial: \"conversations/participants_tagger\", locals: { conversation: @conversation } %\u003e\n\u003c% end %\u003e\n```\n\n```erb\n\u003c%# app/views/conversations/update_participants.turbo_stream.erb %\u003e\n\u003c%= turbo_stream.replace \"participants_section\" do %\u003e\n  \u003c%= render partial: \"conversations/participants_tagger\", locals: { conversation: @conversation } %\u003e\n\u003c% end %\u003e\n```\n\n### The dropdown rows — quick-add is ALWAYS row one\n\n```erb\n\u003c%# app/views/conversations/_contact_search_results.html.erb %\u003e\n\u003c%# Rendered even when there ARE matches — the user may still mean someone new. %\u003e\n\u003cbutton type=\"button\"\n  data-action=\"click-\u003einline-tagger#openQuickAdd\"\n  class=\"w-full text-left px-3 py-2 hover:bg-base-200 flex items-center gap-3 transition text-sm text-primary border-b border-base-200 mb-1 pb-2\"\u003e\n  \u003ci class=\"fa-solid fa-plus-circle text-xs\"\u003e\u003c/i\u003e\n  \u003cspan\u003eAdd \"\u003cspan data-quick-add-name\u003e\u003c%= params[:q] %\u003e\u003c/span\u003e\" as new contact...\u003c/span\u003e\n\u003c/button\u003e\n\n\u003c% contacts.each do |contact| %\u003e\n  \u003c%# TWO actions on one click, space-separated: cache the name, then select. %\u003e\n  \u003cbutton type=\"button\"\n    data-action=\"click-\u003einline-tagger#storeContactName click-\u003einline-tagger#addContact\"\n    data-contact-id=\"\u003c%= contact.id %\u003e\"\n    data-contact-name=\"\u003c%= contact.full_name %\u003e\"\n    class=\"w-full text-left px-3 py-2 hover:bg-base-200 flex items-center gap-3 transition text-sm\"\u003e\n    \u003ci class=\"fa-solid fa-user text-base-content/40 text-xs\"\u003e\u003c/i\u003e\n    \u003cdiv\u003e\n      \u003cspan class=\"font-medium\"\u003e\u003c%= contact.full_name %\u003e\u003c/span\u003e\n      \u003c% if contact.organization %\u003e\n        \u003cspan class=\"text-base-content/50 text-xs ml-1\"\u003e\u003c%= contact.organization.name %\u003e\u003c/span\u003e\n      \u003c% end %\u003e\n    \u003c/div\u003e\n  \u003c/button\u003e\n\u003c% end %\u003e\n```\n\n---\n\n## Layer 4 — The Stimulus controller\n\n```js\n// app/javascript/controllers/inline_tagger_controller.js\nimport { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"input\", \"results\", \"selected\", \"saveIndicator\", \"hiddenField\"]\n  static values = {\n    searchUrl:    String,\n    saveUrl:      String,                          // absent =\u003e form mode\n    selected:     Array,\n    contactNames: { type: Object, default: {} }\n  }\n\n  connect() {\n    this.selected      = this.selectedValue || []\n    this._contactNames = this.contactNamesValue || {}\n\n    // Names for any server-rendered badges, so a re-render doesn't print \"Contact #12\".\n    this.selectedTarget.querySelectorAll(\"[data-contact-id]\").forEach(badge =\u003e {\n      const nameEl = badge.querySelector(\"[data-name]\")\n      if (nameEl) this._contactNames[parseInt(badge.dataset.contactId)] = nameEl.textContent.trim()\n    })\n\n    this.renderSelected()\n\n    // Cross-component handshake with the quick-create modal.\n    this._boundHandleCreated = this._handleContactCreated.bind(this)\n    document.addEventListener(\"contact-created\", this._boundHandleCreated)\n  }\n\n  disconnect() {\n    document.removeEventListener(\"contact-created\", this._boundHandleCreated) // or you leak\n  }\n\n  search() {\n    const query = this.inputTarget.value.trim()\n    if (query.length \u003c 1) {\n      this.resultsTarget.innerHTML = \"\"\n      this.resultsTarget.classList.add(\"hidden\")\n      return\n    }\n\n    fetch(`${this.searchUrlValue}?q=${encodeURIComponent(query)}`, { headers: { Accept: \"text/html\" } })\n      .then(r =\u003e r.text())\n      .then(html =\u003e {\n        this.resultsTarget.innerHTML = html      // server-rendered rows, dropped in whole\n        this.resultsTarget.classList.remove(\"hidden\")\n      })\n  }\n\n  storeContactName(event) {\n    const b = event.currentTarget\n    this._contactNames[parseInt(b.dataset.contactId)] = b.dataset.contactName\n  }\n\n  addContact(event) {\n    const id = parseInt(event.currentTarget.dataset.contactId)\n    if (!this.selected.includes(id)) {\n      this.selected.push(id)\n      this.renderSelected()\n      this.save()\n    }\n    this._clearSearch()\n    this.inputTarget.focus()\n  }\n\n  removeContact(event) {\n    const id = parseInt(event.currentTarget.dataset.contactId)\n    this.selected = this.selected.filter(x =\u003e x !== id)\n    this.renderSelected()\n    this.save()\n  }\n\n  renderSelected() {\n    this.selectedTarget.innerHTML = \"\"\n    this.selected.forEach(id =\u003e {\n      const badge = document.createElement(\"span\")\n      badge.className     = \"badge badge-primary gap-1 py-3 px-3 cursor-pointer transition hover:opacity-70\"\n      badge.dataset.contactId = id\n      // Stimulus watches the DOM, so an action set on a created node still binds.\n      badge.dataset.action    = \"click-\u003einline-tagger#removeContact\"\n      badge.innerHTML = `\u003cspan data-name\u003e${this._contactNames[id] || `Contact #${id}`}\u003c/span\u003e\n                         \u003ci class=\"fa-solid fa-xmark text-[10px]\"\u003e\u003c/i\u003e`\n      this.selectedTarget.appendChild(badge)\n    })\n  }\n\n  // Prefill the modal with whatever they typed, then open it.\n  openQuickAdd() {\n    const name = this.inputTarget.value.trim()\n    if (!name) return\n    const nameInput = document.getElementById(\"quick_contact_name\")\n    if (nameInput) nameInput.value = name\n    document.getElementById(\"new_contact_modal\")?.showModal()\n  }\n\n  _handleContactCreated(event) {\n    const id = parseInt(event.detail.contactId)\n    this._contactNames[id] = event.detail.contactName\n    if (!this.selected.includes(id)) {\n      this.selected.push(id)\n      this.renderSelected()\n      this.save()\n    }\n    this._clearSearch()\n  }\n\n  _clearSearch() {\n    this.inputTarget.value    = \"\"\n    this.resultsTarget.innerHTML = \"\"\n    this.resultsTarget.classList.add(\"hidden\")\n  }\n\n  save() {\n    // FORM MODE — nothing to PATCH yet, so hand the IDs to the parent form.\n    if (!this.hasSaveUrlValue || !this.saveUrlValue) {\n      if (this.hasHiddenFieldTarget) this.hiddenFieldTarget.value = this.selected.join(\",\")\n      return\n    }\n\n    // AUTO-SAVE MODE — persist right now.\n    if (this.hasSaveIndicatorTarget) {\n      this.saveIndicatorTarget.classList.remove(\"hidden\")\n      this.saveIndicatorTarget.textContent = \"Saving...\"\n    }\n\n    const body = new URLSearchParams()\n    body.append(\"participant_contact_ids\", this.selected.join(\",\"))\n    const csrf = document.querySelector(\"[name='csrf-token']\")?.content\n    if (csrf) body.append(\"authenticity_token\", csrf)\n\n    fetch(this.saveUrlValue, {\n      method:  \"PATCH\",\n      headers: { Accept: \"text/vnd.turbo-stream.html\" },\n      body\n    })\n      .then(r =\u003e r.text())\n      .then(html =\u003e Turbo.renderStreamMessage(html))\n  }\n}\n```\n\n---\n\n## Layer 5 — The quick-create modal\n\nA plain DaisyUI `\u003cdialog\u003e` sitting next to the form, holding a normal `form_with` that\nposts to `quick_add_contact`. The tagger only prefills `#quick_contact_name` and calls\n`showModal()`.\n\n```erb\n\u003c%# app/views/conversations/_quick_contact_form.html.erb %\u003e\n\u003c%# NOTE the \"modal-open\" class — see the gotcha about re-rendering on validation error. %\u003e\n\u003cdiv id=\"new_contact_modal\" class=\"modal modal-open\"\u003e\n  \u003cdiv class=\"modal-box\"\u003e\n    \u003ch3 class=\"font-bold text-lg mb-4\"\u003eAdd New Contact\u003c/h3\u003e\n\n    \u003c% if contact.errors.any? %\u003e\n      \u003cdiv class=\"alert alert-warning mb-4 text-sm\"\u003e\n        \u003cul class=\"list-disc ml-4\"\u003e\n          \u003c% contact.errors.each { |e| %\u003e\u003cli\u003e\u003c%= e.full_message %\u003e\u003c/li\u003e\u003c% } %\u003e\n        \u003c/ul\u003e\n      \u003c/div\u003e\n    \u003c% end %\u003e\n\n    \u003c%= form_with model: contact, url: quick_add_contact_conversations_path,\n                  data: { turbo_stream: true }, class: \"space-y-4\" do |f| %\u003e\n      \u003c%# id must match what openQuickAdd() prefills %\u003e\n      \u003c%= f.text_field :first_name, id: \"quick_contact_name\", required: true,\n                       class: \"input input-bordered w-full\", placeholder: \"First name\" %\u003e\n      \u003c%= f.text_field :last_name,  class: \"input input-bordered w-full\", placeholder: \"Last name\" %\u003e\n      \u003c%= f.email_field :email,     class: \"input input-bordered w-full\", placeholder: \"email@example.com\" %\u003e\n      \u003cdiv class=\"flex justify-end gap-2\"\u003e\n        \u003cbutton type=\"button\" class=\"btn btn-ghost\"\n                onclick=\"document.getElementById('new_contact_modal').close()\"\u003eCancel\u003c/button\u003e\n        \u003c%= f.submit \"Add Contact\", class: \"btn btn-primary\" %\u003e\n      \u003c/div\u003e\n    \u003c% end %\u003e\n  \u003c/div\u003e\n  \u003cform method=\"dialog\" class=\"modal-backdrop\"\u003e\u003cbutton\u003eclose\u003c/button\u003e\u003c/form\u003e\n\u003c/div\u003e\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **A new record has no ID, so it cannot auto-save.** This is the whole reason for the two\n  modes. In form mode the controller writes `\"3,7,9\"` into a hidden field and the parent\n  form carries it; in auto-save mode it PATCHes. **Branch on `hasSaveUrlValue`, not on a\n  boolean you pass in** — then the ERB only has to omit one attribute.\n- **Parse both shapes on the server.** The hidden field sends a comma-joined **String**,\n  but a plain checkbox form (or a JSON API client) sends an **Array**. The `raw.is_a?(String)`\n  branch in `save_participants` is not defensive padding — both really happen.\n- **Strip the key before `Model.new`.** `participant_contact_ids` is not a column;\n  `Conversation.new(conversation_params.except(:participant_contact_ids))` or you get an\n  `UnknownAttributeError` on every create.\n- **Keep a client-side name cache or badges say \"Contact #12\".** The controller only tracks\n  IDs, so `renderSelected()` has nothing to print. Feed the cache from **all three**\n  sources: the `contactNames` value (server JSON, for records loaded with the page), a\n  scrape of server-rendered badges in `connect()`, and `storeContactName` on click. Miss\n  one and badges degrade the first time something re-renders.\n- **Two actions on one click is legal Stimulus:** `data-action=\"click-\u003einline-tagger#storeContactName click-\u003einline-tagger#addContact\"`.\n  They run left to right. Order matters — cache the name *before* the render that needs it.\n- **Always show the \"Add …\" row, even when there are matches.** Hiding it behind a\n  zero-results check recreates the dead end for near-misses (\"Jon\" vs \"Jonathan\"). It costs\n  nothing and it is the row users reach for most.\n- **The modal cannot talk to the tagger directly — use a `CustomEvent`.** They are separate\n  Turbo forms with no shared scope. The server's success response appends a `\u003cscript\u003e` that\n  dispatches `contact-created`, and every mounted tagger listens. **Turbo Stream–appended\n  `\u003cscript\u003e` tags DO execute** (unlike `innerHTML`-injected ones), which is the only reason\n  this works. Escape the name with `j` / `escape_javascript` or an apostrophe in\n  \"O'Brien\" breaks the page.\n- **Remove the document listener in `disconnect()`.** Turbo caches and restores pages; skip\n  this and old controllers keep listening, so one create adds the record to several dead\n  taggers and fires several PATCHes.\n- **Diff, don't nuke.** Compute `ids - existing_ids` and `existing_ids - ids` instead of\n  `destroy_all` then recreate. Nuking throws away join-row IDs, `created_at`, and any extra\n  columns on the join (role, speaking order, notes).\n- **Re-render the modal with `modal-open` on validation failure.** `showModal()` state lives\n  on the DOM node; a `turbo_stream.replace` swaps that node out, so without the explicit\n  class the modal silently vanishes and the user's typing is gone.\n- **The Turbo Stream target must actually be on the page.** `turbo_stream.replace\n  \"participants_section\"` is a **silent no-op** if that ID is missing — server logs a clean\n  200, browser does nothing. In sidebar mode the `turbo_frame_tag \"participants_section\"`\n  wrapper is what the stream aims at; drop it and auto-save \"works\" but never updates.\n- **Return `Model.none` for a blank query**, not `all`. `Contact.search(\"\")` with an `all`\n  fallback dumps the whole table into the dropdown on the first keystroke.\n- **`left_outer_joins`, not `joins`.** Searching the parent org's name is a genuinely good\n  feature (\"who do we know at Acme?\"), but an inner join hides every contact without an\n  organization.\n- **No debounce here.** At `limit(20)` and a 1-character minimum this is fine for a few\n  thousand rows. Past that, debounce ~200ms in `search()` and add a trigram index —\n  `ILIKE '%q%'` cannot use a btree index.\n- **The dropdown needs `z-50` and a positioned parent.** Its container is\n  `absolute` inside `relative`; without the z-index it renders behind following form fields,\n  which reads as \"the search is broken\".\n\n---\n\n## Files this pattern touches\n\n```\napp/models/conversation.rb                                  # has_many :through\napp/models/conversation_participant.rb                      # the join\napp/models/contact.rb                                       # scope :search\napp/controllers/conversations_controller.rb                 # search_contacts, quick_add_contact,\n                                                            # update_participants, save_participants\napp/javascript/controllers/inline_tagger_controller.js\napp/views/conversations/_participants_tagger.html.erb       # the widget (both modes)\napp/views/conversations/_contact_search_results.html.erb     # dropdown rows\napp/views/conversations/_quick_contact_form.html.erb        # create-inline modal\napp/views/conversations/update_participants.turbo_stream.erb\nconfig/routes.rb\n```\n\n## How to adapt to your schema\n\n1. **Rename the pair.** `Conversation` → your owner model, `Contact` → the model being\n   attached, `ConversationParticipant` → your join table. Keep the Stimulus identifier\n   generic (`inline-tagger`) so one controller serves every tagger in the app.\n2. **Rewrite `scope :search`** for your columns. Search the fields a human would type —\n   including the parent's name via `left_outer_joins`.\n3. **Rename the param** `participant_contact_ids` consistently in four places: the hidden\n   field, `save_participants`, `update_participants`, and the PATCH body.\n4. **Cut the quick-create half** if the attached table is closed (tags an admin curates).\n   Delete the first row of `_contact_search_results`, the modal partial, `openQuickAdd`,\n   `_handleContactCreated`, and the `quick_add_contact` route/action. The rest stands alone.\n5. **Cut the auto-save half** if the widget only ever lives on a form. Delete `saveUrl`,\n   `update_participants`, and the `.turbo_stream.erb` — `save()` then just writes the\n   hidden field.\n6. **Font Awesome is not guaranteed** on every box. Swap `\u003ci class=\"fa-solid fa-xmark\"\u003e`\n   for an inline SVG or the literal `×` if icons render as empty squares.\n7. **Extra columns on the join** (role, order, notes) work fine — carry them as extra\n   `data-` attributes on the dropdown row and pass a JSON array instead of a CSV string.\n"}