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 :throughfield 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 plainselect).
The 80/20 in one breath
- Join model.
Conversation has_many :participants, through: :conversation_participants, source: :contact. - A
searchscope on the searched model, and aGETcollection route that renders a partial of result rows (HTML, not JSON). - A Stimulus controller holding an array of selected IDs. On input →
fetchthe partial → dump it into a dropdown div. On click a row → push the ID, re-render badges. - 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.
- 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 onhasSaveUrlValue, 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 insave_participantsis not defensive padding — both really happen. - Strip the key before
Model.new.participant_contact_idsis not a column;Conversation.new(conversation_params.except(:participant_contact_ids))or you get anUnknownAttributeErroron 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: thecontactNamesvalue (server JSON, for records loaded with the page), a scrape of server-rendered badges inconnect(), andstoreContactNameon 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 dispatchescontact-created, and every mounted tagger listens. Turbo Stream–appended<script>tags DO execute (unlikeinnerHTML-injected ones), which is the only reason this works. Escape the name withj/escape_javascriptor 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_idsandexisting_ids - idsinstead ofdestroy_allthen 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-openon validation failure.showModal()state lives on the DOM node; aturbo_stream.replaceswaps 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 theturbo_frame_tag "participants_section"wrapper is what the stream aims at; drop it and auto-save "works" but never updates. - Return
Model.nonefor a blank query, notall.Contact.search("")with anallfallback dumps the whole table into the dropdown on the first keystroke. left_outer_joins, notjoins. 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 insearch()and add a trigram index —ILIKE '%q%'cannot use a btree index. - The dropdown needs
z-50and a positioned parent. Its container isabsoluteinsiderelative; 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
- 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. - Rewrite
scope :searchfor your columns. Search the fields a human would type — including the parent's name vialeft_outer_joins. - Rename the param
participant_contact_idsconsistently in four places: the hidden field,save_participants,update_participants, and the PATCH body. - 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 thequick_add_contactroute/action. The rest stands alone. - 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. - 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. - 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.