---
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.
---

# Search-and-Add Tagger (find a record or create it, in one field)

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) 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.

Almost every app has a form field that means "attach some other records to this record":
people on a call, tags on a post, ingredients in a recipe, staff on a job. The lazy build
is a multi-select box, which forces the user to already know the record exists — and
dead-ends them when it does not.

This pattern is **one text input that does both jobs**. The user types a couple of
letters, matching records appear in a dropdown underneath, and the **first row of that
dropdown is always `Add "<what they typed>" as a new record…`**. Picking either one drops
a removable badge above the input. There is no separate "manage contacts" trip, and there
is no dead end.

It is a many-to-many join, a 6-line search action, one Stimulus controller, and two small
partials.

> **When to use:** any `has_many :through` field where the user may not know whether the
> record already exists — people, tags, skills, products, categories.
> **When not to:** a short fixed list that never grows (use checkboxes), or single-select
> from a closed set (use a plain `select`).

---

## The 80/20 in one breath

1. **Join model.** `Conversation has_many :participants, through: :conversation_participants, source: :contact`.
2. **A `search` scope** on the searched model, and a `GET` collection route that renders a
   **partial of result rows** (HTML, not JSON).
3. **A Stimulus controller** holding an array of selected IDs. On input → `fetch` the
   partial → dump it into a dropdown div. On click a row → push the ID, re-render badges.
4. **Two save modes off the same controller.** Inside a new-record form there is nothing
   to PATCH yet, so it writes the IDs into a **hidden field** the parent form submits.
   On an already-saved record it **PATCHes immediately** and swaps the section back in
   via Turbo Stream.
5. **Quick-create** is a normal modal form that posts to its own action; on success the
   server returns a Turbo Stream that **dispatches a `CustomEvent`**, and the tagger
   listens for that event and auto-selects the record it just created.

---

## Layer 1 — Model & routes

```ruby
# app/models/conversation.rb
class Conversation < ApplicationRecord
  has_many :conversation_participants, dependent: :destroy
  has_many :participants, through: :conversation_participants, source: :contact
end

# app/models/conversation_participant.rb   (the join table: conversation_id, contact_id)
class ConversationParticipant < ApplicationRecord
  belongs_to :conversation
  belongs_to :contact
end
```

```ruby
# app/models/contact.rb
class Contact < ApplicationRecord
  belongs_to :organization, optional: true

  # LEFT OUTER join, so contacts with no organization still match.
  scope :search, ->(query) {
    if query.present?
      left_outer_joins(:organization).where(
        "contacts.first_name ILIKE :q OR " \
        "contacts.last_name ILIKE :q OR " \
        "concat(contacts.first_name, ' ', contacts.last_name) ILIKE :q OR " \
        "contacts.title ILIKE :q OR " \
        "organizations.name ILIKE :q",
        q: "%#{query}%"
      )
    else
      all
    end
  }

  def full_name = "#{first_name} #{last_name}"
end
```

```ruby
# config/routes.rb
resources :conversations do
  collection do
    get  :search_contacts     # the type-ahead
    post :quick_add_contact   # create-inline
  end
  member do
    patch :update_participants # auto-save on an existing record
  end
end
```

---

## Layer 2 — Controller (three skinny actions)

```ruby
# app/controllers/conversations_controller.rb

# GET /conversations/search_contacts?q=dar
# Returns an HTML PARTIAL, not JSON — the Stimulus controller injects it verbatim.
def search_contacts
  @contacts = params[:q].present? ? Contact.search(params[:q]).limit(20) : Contact.none
  render partial: "conversations/contact_search_results", locals: { contacts: @contacts }
end

# PATCH /conversations/:id/update_participants   (auto-save mode only)
def update_participants
  ids          = params[:participant_contact_ids].to_s.split(",").map(&:to_i).reject(&:zero?)
  existing_ids = @conversation.conversation_participants.pluck(:contact_id)

  (ids - existing_ids).each { |id| @conversation.conversation_participants.create!(contact_id: id) }
  @conversation.conversation_participants.where(contact_id: existing_ids - ids).destroy_all

  respond_to do |format|
    format.turbo_stream                       # -> update_participants.turbo_stream.erb
    format.json { render json: { success: true } }
  end
end

# POST /conversations/quick_add_contact   (create-inline from the modal)
def quick_add_contact
  @contact = Contact.new(params.require(:contact).permit(:first_name, :last_name, :email, :phone, :organization_id))

  if @contact.save
    render turbo_stream: [
      # 1. close the modal by emptying it
      turbo_stream.replace("new_contact_modal", %(<div id="new_contact_modal"></div>).html_safe),
      # 2. tell the tagger (any tagger) that a record now exists
      turbo_stream.append("body", helpers.tag.script(
        "document.dispatchEvent(new CustomEvent('contact-created', { detail: " \
        "{ contactId: #{@contact.id}, contactName: '#{j @contact.full_name}' } }))".html_safe
      ))
    ]
  else
    # re-render the modal WITH modal-open, since showModal() state is lost on replace
    render turbo_stream: turbo_stream.replace("new_contact_modal",
      partial: "conversations/quick_contact_form", locals: { contact: @contact })
  end
end
```

On create (the new-record form path) the parent controller parses the same CSV out of the
hidden field:

```ruby
# app/controllers/conversations_controller.rb  (private)
def save_participants
  raw = params[:conversation][:participant_contact_ids]
  return if raw.blank?

  # Handle BOTH shapes: "3,7,9" from the hidden field, or an array from a plain form.
  ids          = raw.is_a?(String) ? raw.split(",").map(&:to_i).reject(&:zero?) : raw.reject(&:blank?).map(&:to_i)
  existing_ids = @conversation.conversation_participants.pluck(:contact_id)

  (ids - existing_ids).each { |id| @conversation.conversation_participants.create!(contact_id: id) }
  @conversation.conversation_participants.where(contact_id: existing_ids - ids).destroy_all
end
```

Call it from `create` **after** `@conversation.save`, and remember to strip the key out of
the attributes hash — it is not a column:

```ruby
# app/controllers/conversations_controller.rb
@conversation = Conversation.new(conversation_params.except(:participant_contact_ids))
```

---

## Layer 3 — The tagger partial (one file, two modes)

The same partial serves the new-record form and the auto-saving sidebar. The **only**
difference is whether `data-inline-tagger-save-url-value` is present.

```erb
<%# app/views/conversations/_participants_tagger.html.erb %>
<% form_mode = local_assigns[:form_mode] || false %>

<div class="my-5"
     data-controller="inline-tagger"
     data-inline-tagger-search-url-value="<%= search_contacts_conversations_path %>"
     <%# auto-save mode ONLY — omit this in form mode so it writes the hidden field instead %>
     <% unless form_mode %>data-inline-tagger-save-url-value="<%= update_participants_conversation_path(conversation) %>"<% end %>
     data-inline-tagger-selected-value="<%= conversation.participants.pluck(:id) %>"
     data-inline-tagger-contact-names-value="<%= conversation.participants.each_with_object({}) { |p, h| h[p.id] = p.full_name }.to_json %>">

  <label class="block font-medium text-sm mb-2">People on this call</label>
  <p class="text-sm text-base-content/60 mb-2 italic">Start typing a name to find someone, or add a new person.</p>

  <%# Selected records, as removable badges %>
  <div class="flex flex-wrap gap-1.5 mb-3" data-inline-tagger-target="selected">
    <% conversation.participants.each do |participant| %>
      <span class="badge badge-primary gap-1 py-3 px-3 cursor-pointer transition hover:opacity-70"
            data-contact-id="<%= participant.id %>"
            data-action="click->inline-tagger#removeContact">
        <span data-name><%= participant.full_name %></span>
        <i class="fa-solid fa-xmark text-[10px]"></i>
      </span>
    <% end %>
  </div>

  <%# The one input %>
  <div class="relative">
    <i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40 text-xs"></i>
    <input type="text"
      data-inline-tagger-target="input"
      data-action="input->inline-tagger#search"
      placeholder="Search contacts to tag..."
      class="input input-sm input-bordered w-full pl-8 pr-3"
      autocomplete="off">

    <div 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"
         data-inline-tagger-target="results"></div>
  </div>

  <%# FORM MODE ONLY: this is what actually gets submitted with the parent form %>
  <% if form_mode %>
    <%= hidden_field_tag "conversation[participant_contact_ids]",
          conversation.participants.pluck(:id).join(","),
          data: { inline_tagger_target: "hiddenField" } %>
  <% end %>
</div>
```

Render it from the form, choosing the mode by whether the record exists yet:

```erb
<%# app/views/conversations/_form.html.erb %>
<%= render partial: "conversations/participants_tagger",
           locals: { conversation: conversation, form_mode: !conversation.persisted? } %>
```

In auto-save mode, wrap the whole thing in a Turbo Frame so the PATCH response has
something to replace:

```erb
<%# app/views/conversations/show.html.erb — sidebar mode %>
<%= turbo_frame_tag "participants_section" do %>
  <%= render partial: "conversations/participants_tagger", locals: { conversation: @conversation } %>
<% end %>
```

```erb
<%# app/views/conversations/update_participants.turbo_stream.erb %>
<%= turbo_stream.replace "participants_section" do %>
  <%= render partial: "conversations/participants_tagger", locals: { conversation: @conversation } %>
<% end %>
```

### The dropdown rows — quick-add is ALWAYS row one

```erb
<%# app/views/conversations/_contact_search_results.html.erb %>
<%# Rendered even when there ARE matches — the user may still mean someone new. %>
<button type="button"
  data-action="click->inline-tagger#openQuickAdd"
  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">
  <i class="fa-solid fa-plus-circle text-xs"></i>
  <span>Add "<span data-quick-add-name><%= params[:q] %></span>" as new contact...</span>
</button>

<% contacts.each do |contact| %>
  <%# TWO actions on one click, space-separated: cache the name, then select. %>
  <button type="button"
    data-action="click->inline-tagger#storeContactName click->inline-tagger#addContact"
    data-contact-id="<%= contact.id %>"
    data-contact-name="<%= contact.full_name %>"
    class="w-full text-left px-3 py-2 hover:bg-base-200 flex items-center gap-3 transition text-sm">
    <i class="fa-solid fa-user text-base-content/40 text-xs"></i>
    <div>
      <span class="font-medium"><%= contact.full_name %></span>
      <% if contact.organization %>
        <span class="text-base-content/50 text-xs ml-1"><%= contact.organization.name %></span>
      <% end %>
    </div>
  </button>
<% end %>
```

---

## Layer 4 — The Stimulus controller

```js
// app/javascript/controllers/inline_tagger_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "results", "selected", "saveIndicator", "hiddenField"]
  static values = {
    searchUrl:    String,
    saveUrl:      String,                          // absent => form mode
    selected:     Array,
    contactNames: { type: Object, default: {} }
  }

  connect() {
    this.selected      = this.selectedValue || []
    this._contactNames = this.contactNamesValue || {}

    // Names for any server-rendered badges, so a re-render doesn't print "Contact #12".
    this.selectedTarget.querySelectorAll("[data-contact-id]").forEach(badge => {
      const nameEl = badge.querySelector("[data-name]")
      if (nameEl) this._contactNames[parseInt(badge.dataset.contactId)] = nameEl.textContent.trim()
    })

    this.renderSelected()

    // Cross-component handshake with the quick-create modal.
    this._boundHandleCreated = this._handleContactCreated.bind(this)
    document.addEventListener("contact-created", this._boundHandleCreated)
  }

  disconnect() {
    document.removeEventListener("contact-created", this._boundHandleCreated) // or you leak
  }

  search() {
    const query = this.inputTarget.value.trim()
    if (query.length < 1) {
      this.resultsTarget.innerHTML = ""
      this.resultsTarget.classList.add("hidden")
      return
    }

    fetch(`${this.searchUrlValue}?q=${encodeURIComponent(query)}`, { headers: { Accept: "text/html" } })
      .then(r => r.text())
      .then(html => {
        this.resultsTarget.innerHTML = html      // server-rendered rows, dropped in whole
        this.resultsTarget.classList.remove("hidden")
      })
  }

  storeContactName(event) {
    const b = event.currentTarget
    this._contactNames[parseInt(b.dataset.contactId)] = b.dataset.contactName
  }

  addContact(event) {
    const id = parseInt(event.currentTarget.dataset.contactId)
    if (!this.selected.includes(id)) {
      this.selected.push(id)
      this.renderSelected()
      this.save()
    }
    this._clearSearch()
    this.inputTarget.focus()
  }

  removeContact(event) {
    const id = parseInt(event.currentTarget.dataset.contactId)
    this.selected = this.selected.filter(x => x !== id)
    this.renderSelected()
    this.save()
  }

  renderSelected() {
    this.selectedTarget.innerHTML = ""
    this.selected.forEach(id => {
      const badge = document.createElement("span")
      badge.className     = "badge badge-primary gap-1 py-3 px-3 cursor-pointer transition hover:opacity-70"
      badge.dataset.contactId = id
      // Stimulus watches the DOM, so an action set on a created node still binds.
      badge.dataset.action    = "click->inline-tagger#removeContact"
      badge.innerHTML = `<span data-name>${this._contactNames[id] || `Contact #${id}`}</span>
                         <i class="fa-solid fa-xmark text-[10px]"></i>`
      this.selectedTarget.appendChild(badge)
    })
  }

  // Prefill the modal with whatever they typed, then open it.
  openQuickAdd() {
    const name = this.inputTarget.value.trim()
    if (!name) return
    const nameInput = document.getElementById("quick_contact_name")
    if (nameInput) nameInput.value = name
    document.getElementById("new_contact_modal")?.showModal()
  }

  _handleContactCreated(event) {
    const id = parseInt(event.detail.contactId)
    this._contactNames[id] = event.detail.contactName
    if (!this.selected.includes(id)) {
      this.selected.push(id)
      this.renderSelected()
      this.save()
    }
    this._clearSearch()
  }

  _clearSearch() {
    this.inputTarget.value    = ""
    this.resultsTarget.innerHTML = ""
    this.resultsTarget.classList.add("hidden")
  }

  save() {
    // FORM MODE — nothing to PATCH yet, so hand the IDs to the parent form.
    if (!this.hasSaveUrlValue || !this.saveUrlValue) {
      if (this.hasHiddenFieldTarget) this.hiddenFieldTarget.value = this.selected.join(",")
      return
    }

    // AUTO-SAVE MODE — persist right now.
    if (this.hasSaveIndicatorTarget) {
      this.saveIndicatorTarget.classList.remove("hidden")
      this.saveIndicatorTarget.textContent = "Saving..."
    }

    const body = new URLSearchParams()
    body.append("participant_contact_ids", this.selected.join(","))
    const csrf = document.querySelector("[name='csrf-token']")?.content
    if (csrf) body.append("authenticity_token", csrf)

    fetch(this.saveUrlValue, {
      method:  "PATCH",
      headers: { Accept: "text/vnd.turbo-stream.html" },
      body
    })
      .then(r => r.text())
      .then(html => Turbo.renderStreamMessage(html))
  }
}
```

---

## Layer 5 — The quick-create modal

A plain DaisyUI `<dialog>` sitting next to the form, holding a normal `form_with` that
posts to `quick_add_contact`. The tagger only prefills `#quick_contact_name` and calls
`showModal()`.

```erb
<%# app/views/conversations/_quick_contact_form.html.erb %>
<%# NOTE the "modal-open" class — see the gotcha about re-rendering on validation error. %>
<div id="new_contact_modal" class="modal modal-open">
  <div class="modal-box">
    <h3 class="font-bold text-lg mb-4">Add New Contact</h3>

    <% if contact.errors.any? %>
      <div class="alert alert-warning mb-4 text-sm">
        <ul class="list-disc ml-4">
          <% contact.errors.each { |e| %><li><%= e.full_message %></li><% } %>
        </ul>
      </div>
    <% end %>

    <%= form_with model: contact, url: quick_add_contact_conversations_path,
                  data: { turbo_stream: true }, class: "space-y-4" do |f| %>
      <%# id must match what openQuickAdd() prefills %>
      <%= f.text_field :first_name, id: "quick_contact_name", required: true,
                       class: "input input-bordered w-full", placeholder: "First name" %>
      <%= f.text_field :last_name,  class: "input input-bordered w-full", placeholder: "Last name" %>
      <%= f.email_field :email,     class: "input input-bordered w-full", placeholder: "email@example.com" %>
      <div class="flex justify-end gap-2">
        <button type="button" class="btn btn-ghost"
                onclick="document.getElementById('new_contact_modal').close()">Cancel</button>
        <%= f.submit "Add Contact", class: "btn btn-primary" %>
      </div>
    <% end %>
  </div>
  <form method="dialog" class="modal-backdrop"><button>close</button></form>
</div>
```

---

## Gotchas (the hard-won stuff)

- **A new record has no ID, so it cannot auto-save.** This is the whole reason for the two
  modes. In form mode the controller writes `"3,7,9"` into a hidden field and the parent
  form carries it; in auto-save mode it PATCHes. **Branch on `hasSaveUrlValue`, not on a
  boolean you pass in** — then the ERB only has to omit one attribute.
- **Parse both shapes on the server.** The hidden field sends a comma-joined **String**,
  but a plain checkbox form (or a JSON API client) sends an **Array**. The `raw.is_a?(String)`
  branch in `save_participants` is not defensive padding — both really happen.
- **Strip the key before `Model.new`.** `participant_contact_ids` is not a column;
  `Conversation.new(conversation_params.except(:participant_contact_ids))` or you get an
  `UnknownAttributeError` on every create.
- **Keep a client-side name cache or badges say "Contact #12".** The controller only tracks
  IDs, so `renderSelected()` has nothing to print. Feed the cache from **all three**
  sources: the `contactNames` value (server JSON, for records loaded with the page), a
  scrape of server-rendered badges in `connect()`, and `storeContactName` on click. Miss
  one and badges degrade the first time something re-renders.
- **Two actions on one click is legal Stimulus:** `data-action="click->inline-tagger#storeContactName click->inline-tagger#addContact"`.
  They run left to right. Order matters — cache the name *before* the render that needs it.
- **Always show the "Add …" row, even when there are matches.** Hiding it behind a
  zero-results check recreates the dead end for near-misses ("Jon" vs "Jonathan"). It costs
  nothing and it is the row users reach for most.
- **The modal cannot talk to the tagger directly — use a `CustomEvent`.** They are separate
  Turbo forms with no shared scope. The server's success response appends a `<script>` that
  dispatches `contact-created`, and every mounted tagger listens. **Turbo Stream–appended
  `<script>` tags DO execute** (unlike `innerHTML`-injected ones), which is the only reason
  this works. Escape the name with `j` / `escape_javascript` or an apostrophe in
  "O'Brien" breaks the page.
- **Remove the document listener in `disconnect()`.** Turbo caches and restores pages; skip
  this and old controllers keep listening, so one create adds the record to several dead
  taggers and fires several PATCHes.
- **Diff, don't nuke.** Compute `ids - existing_ids` and `existing_ids - ids` instead of
  `destroy_all` then recreate. Nuking throws away join-row IDs, `created_at`, and any extra
  columns on the join (role, speaking order, notes).
- **Re-render the modal with `modal-open` on validation failure.** `showModal()` state lives
  on the DOM node; a `turbo_stream.replace` swaps that node out, so without the explicit
  class the modal silently vanishes and the user's typing is gone.
- **The Turbo Stream target must actually be on the page.** `turbo_stream.replace
  "participants_section"` is a **silent no-op** if that ID is missing — server logs a clean
  200, browser does nothing. In sidebar mode the `turbo_frame_tag "participants_section"`
  wrapper is what the stream aims at; drop it and auto-save "works" but never updates.
- **Return `Model.none` for a blank query**, not `all`. `Contact.search("")` with an `all`
  fallback dumps the whole table into the dropdown on the first keystroke.
- **`left_outer_joins`, not `joins`.** Searching the parent org's name is a genuinely good
  feature ("who do we know at Acme?"), but an inner join hides every contact without an
  organization.
- **No debounce here.** At `limit(20)` and a 1-character minimum this is fine for a few
  thousand rows. Past that, debounce ~200ms in `search()` and add a trigram index —
  `ILIKE '%q%'` cannot use a btree index.
- **The dropdown needs `z-50` and a positioned parent.** Its container is
  `absolute` inside `relative`; without the z-index it renders behind following form fields,
  which reads as "the search is broken".

---

## Files this pattern touches

```
app/models/conversation.rb                                  # has_many :through
app/models/conversation_participant.rb                      # the join
app/models/contact.rb                                       # scope :search
app/controllers/conversations_controller.rb                 # search_contacts, quick_add_contact,
                                                            # update_participants, save_participants
app/javascript/controllers/inline_tagger_controller.js
app/views/conversations/_participants_tagger.html.erb       # the widget (both modes)
app/views/conversations/_contact_search_results.html.erb     # dropdown rows
app/views/conversations/_quick_contact_form.html.erb        # create-inline modal
app/views/conversations/update_participants.turbo_stream.erb
config/routes.rb
```

## How to adapt to your schema

1. **Rename the pair.** `Conversation` → your owner model, `Contact` → the model being
   attached, `ConversationParticipant` → your join table. Keep the Stimulus identifier
   generic (`inline-tagger`) so one controller serves every tagger in the app.
2. **Rewrite `scope :search`** for your columns. Search the fields a human would type —
   including the parent's name via `left_outer_joins`.
3. **Rename the param** `participant_contact_ids` consistently in four places: the hidden
   field, `save_participants`, `update_participants`, and the PATCH body.
4. **Cut the quick-create half** if the attached table is closed (tags an admin curates).
   Delete the first row of `_contact_search_results`, the modal partial, `openQuickAdd`,
   `_handleContactCreated`, and the `quick_add_contact` route/action. The rest stands alone.
5. **Cut the auto-save half** if the widget only ever lives on a form. Delete `saveUrl`,
   `update_participants`, and the `.turbo_stream.erb` — `save()` then just writes the
   hidden field.
6. **Font Awesome is not guaranteed** on every box. Swap `<i class="fa-solid fa-xmark">`
   for an inline SVG or the literal `×` if icons render as empty squares.
7. **Extra columns on the join** (role, order, notes) work fine — carry them as extra
   `data-` attributes on the dropdown row and pass a JSON array instead of a CSV string.
