Search-and-Add Tagger in One Field | LlamaPress Cookbook
LlamaPress
Cookbook
Forms Stable

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

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.

Proven on crm.llamapress.ai — /conversations/new, "People on this call" model · controller · view · stimulus_js
Live demo — try it Open full screen

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

# 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
# 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
# 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)

# 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:

# 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:

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

<%# 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:

<%# 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:

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

<%# 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

// 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().

<%# 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.erbsave() 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.

Related