Hotwiring taggables

We found ourselves wanting a tagging engine for M O R T I M E R – your fastest way from job done to invoice sent BTW; and when evaluating the existing gems my hands trembled just slightly: the 'best in class' with 20+ mio downloads and shy of 200 contributors and yet well over 250 issues – nah, don't think so...
So then, surely someone else did this years ago, right? Well - José Farias shared hotwire_combobox about 2 years ago, that's how close I've been able to get. A programmer, Yair Even Or, did start sharing Tagify 9 years ago, and that project indeed has a lot of potential, but again - 70 issues - and it's a js-soup, but then again, it's been a valuable smorgåsbord of implementation details! We will not even reach it to it's socks – for a long, long time, if ever...
Ohh - and in case you wonder - I've got a Copilot eagerly chiming along the way which will have us go back and do 're-runs' on a number of occasions but that's 2025 for you right there I guess 😜
Use cases
I better get the use cases straight, first! This sketch done in one of my top favorite tools, Whimsical (yes I'm a customer, and no, I do not get credits for mentions) should give you a sense of the domain:

Whether the input comes verbatim through some AI speech-to-text or will be even further digested by other LLM's spitting out stuff like
{
time_material: {
time: "1.25",
task: "QA",
location: "Lab1",
user: "bio intern",
started_at: "6:45PM"
}
}
really is non-essentiel.
UI - framing use cases
With the title encompassing hotwire I bet you're on the edge of your stool waiting for the implementation details, but we need to set the expectations - so here is the "MVP" so to speak:

Looking like an ordinary input - but it has a trick or two up it's sleeves 😉
Like when you start typing the input will assist you with suggestions matching what you have typed so far

To mobile users what follows is of no interest.
From here you can go different ways. Input a comma or period to tell the input field that your tag is done and should be added as is. Use the arrow keys Up and Down to navigate the suggestions. Use Enter to select a particular suggestion. Use Escape to remove the dropdown

When selected you can use Backspace to go back and start editing the tag or start typing the next tag.
There is one more frame to consider. With very long tags (multi-word tags like non-stick polycarbonate based appliances) or a whole cloud of tags you will find yourself running out of chrome so to speak – so the input field should (automagically preferably) consider where to place the added tags, inline or below

UI - implementation
Finally – that was quite a, ehhh foreplay, don't you think? Let's get dirty, shall we?
Initial Frame
We use Phlex extensively in M O R T I M E R and because Brad Gessler did such a tremendous job on Superform, our 'forms' – at least the CRUD ones, mostly looks like this:
class Projects::Form < ApplicationForm
def view_template(&)
row field(:name).input().focus
row field(:description).input()
row field(:start_date).datetime(class: "mort-form-datetime")
row field(:end_date).datetime(class: "mort-form-datetime")
row field(:state)
.select(Project.project_states, class: "mort-form-select")
row field(:budget).input()
row field(:is_billable).boolean(class: "mort-form-bool")
row field(:is_separate_invoice).boolean(class: "mort-form-bool")
...8<..
row field(:actual_minutes).input()
end
end
If you'd like to read more about how M O R T I M E R utilises Phlex leave a comment. Anyways, we're not ready to build the 'shrink-wrapped' version just yet so we'll start with the first "frame" and write the necessary test – by the way: Phlex is sooo easy to test being just PORO (plain old ruby objects), but first let's make sure the basic input field is in place
require "application_system_test_case"
class TagTest < ApplicationSystemTestCase
# setup do
# @tenant = tenants(:one)
# @user = users(:superadmin)
# end
test "the tag to have an input field" do
output = render TagComponent.new
assert_match "<input", output.to_s
end
end
test/components/tag_test.rb
That obviously does turn out red – but is easily fixed with
class TagComponent < ApplicationComponent
def view_template
input(type: "text", value: "tag1, tag2")
end
end
views/components/tag_component.rb
Ok - we're moving, GREAT! The input element is BTW the element holding on to what every values are provided up front as well as added during an editor session (a lot more on this later).
Time for som Hotwiring - don't you think? Here we go: The <turbo-frame></turbo-frame>
will allow Rails and Turbo/Stimulus to update the DOM 'in flight':
test "the tag to have a turbo_frame " do
output = render TagComponent.new
resource: TimeMaterial.new,
field: :task_comment
assert_match "<turbo-frame id=\"tag", output.to_s
end
The component arguments is just us slowly preparing to move this into actually serving proper values for ActiveRecord objects. Making this test green requires a few changes, the important ones being
include Phlex::Rails::Helpers::TurboFrameTag
def view_template
turbo_frame_tag "#{Current.get_user.id}_tag" do
div(data: { controller: "tag" }) do
@editable ? editable_view : show_view
end
end
end
views/components/tag_component.rb
This is a naiive implementation (think: multiple forms in the view, multiple fields in a form, more contexts, and what not) but will restrict the turbo_stream to whatever the current_user is meddling with, at least.
We will skip adding any label to the field, and styling is also of lesser interest right now.
Lookup Frame
Building a list of autocomplete elements is what we will focus on. We will need a test for that (and add 2-3 tags to the fixtures/tags.yml
test "the tag to have a lookup container" do
@tags = Tag.all
m = "<div id=\"time_material-task_comment-lookup-container\""
output = render TagComponent.new
resource: TimeMaterial.new,
field: :task_comment,
resources: Tag.all,
search: "do"
assert_match m, output.to_s
assert_match tags(:one).name, output.to_s
end
We also make sure the tags are listed with the last assert_match. In order for this to turn green we will change the component to look like this:
class TagComponent < ApplicationComponent
def initialize(resource:, field:, resources: nil, search: "")
@resource = resource
@field = field
@resources = resources
@search = search
end
def view_template
turbo_frame_tag "#{Current.get_user.id}_tag" do
div(data: { controller: "tag" }) do
input(type: "text",
data: { tag_target: "input", action: "keyup->tag#keyup" },
name: resource_field("%s[%s]"),
id: resource_field,
class: @field_class,
value: @search)
render_tags_list
end
end
end
def render_tags_list
return if @resources.nil?
div(id: resource_field("%s-%s-lookup-container") do
@resources.each do |tag|
a(href: "#", data: { action: "click->tag#pick" }) { tag.name }
end
end
end
def resource_field(joined = "%s_%s")
joined % [ @resource.class.to_s.underscore, @field.to_s.underscore ]
end
end
views/components/tag_component.rb
We like the 🟢 so much! Now for this 'frame' to ever come to fruition, we'll have to add some stimuli or rather just one and we'll use this command to do it: rails g stimulus tag
that we then edit and make look like this
import { Controller } from "@hotwired/stimulus"
import { get } from "@rails/request.js";
// Connects to data-controller="tag"
export default class extends Controller {
static targets = [
"input",
"output",
"selectedTags",
"tagList"]
lastSearch = null;
connect() {
setTimeout(() => {
if (this.inputTarget.textContent == undefined)
this.inputTarget.textContent = "";
this.lastSearch = this.inputTarget.textContent;
},10)
this.inputTarget.focus();
}
// put the cursor at the end of the 'input'
focus(event) {
event.preventDefault();
let caret = document.createRange();
caret.selectNodeContents(this.inputTarget);
caret.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(caret);
}
keyup(event){
this.lookup(event);
}
keydown(event) {
}
lookup(event) {
if (event.target.value.length > 1) {
get(`/tags/tags?search=${event.target.value}`, {
responseKind: "turbo-stream"
})
}
}
}
javascript/controllers/tags_controller.js
We are not nearly there - and we are not doing any system tests yet, but that calls for a fresh cup of coffee I think! We're not home yet but gettin' there, and while you get that ☕ here is the controller handling the 'get' action
class TagsController < MortimerController
def tags
tags = Tag.by_tenant
.where("name LIKE ?", "%#{params[:search]}%")
.order("name ASC")
.limit(10)
respond_to do |format|
format.turbo_stream {
render partial: "tags/tags", locals: { tags: tags }
}
end
end
end
controllers/tags_controller.rb
it's not done - don't worry, but more like a WIP. I know – it's not RESTful as such but one extra route on the tags resource for this specialized purpose seems like a small price to pay.
The accompanying partial with the "tags/tags" reference above:
<%= turbo_stream.replace("#{Current.get_user.id}_tag") do %>
<%= render TagComponent.new(resource: Tag.new,
field: :name,
resources: tags,
search: params[:search],
show_label: true,
field_class: "mort-form-text",
label_class: "text-red-500",
value_class: "mr-5",
editable: true) %>
<% end %>
views/tags/_tags.turbostream.erb
That, mostly, finished the lookup frame in our first run (yes I reckon there will be at least a second run). Now on to the
Selected Frame
We'll press on with a test
test "the tag to have a selected container with a selected tag" do
@tags = Tag.all
m = "<div id=\"time_material-task_comment-selected-container\""
output = render TagComponent.new
resource: TimeMaterial.new,
field: :task_comment,
resources: @tags,
value: [ tags(:one).name ]
assert_match m, output.to_s
assert_match tags(:one).name, output.to_s
end
Hmmm 🔴 – not totally unexpected but; 😮 Second run so soon?! Who'd guess 😎
Here's the predicament we're in: As per our use case #1 (initial frame) we're good, but now we need to paint a box inside the input box! That's totally not going to fly. What to do?
First we have to go back and refactor the initial frame - yep, second run! While we're at it let's clean up the methods
class TagComponent < ApplicationComponent
def initialize(resource:, field:, resources: nil, search: "", value: "")
@resource = resource
@field = field
@resources = resources
@search = search
@value = value.class == String ?
value :
value.join(", ")
end
def view_template
turbo_frame_tag "#{Current.get_user.id}_tag" do
div(class: "mort-field", data: { controller: "tag" }) do
#
# making the input a hidden one!
input(type: "hidden",
name: resource_field("%s[%s]"),
id: resource_field,
value: @value,
data: { tag_target: "output" })
@editable ? editable_view : show_view
end
end
end
def editable_view
div(class: "flex flex-col", data: { action: "click->tag#focus" }) do
label_container
div(class: "inline-flex w-full \
rounded-md border-0 bg-white py-1.5 pl-1 \
text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 ") do
selected_container
end
div(class: "relativ") do
render_tags_list
end
end
end
def selected_container
div(id: resource_field("%s-%s-selected-container"),
data: { tag_target: "selectedTags" }, class: "flex flex-wrap") do
@value.each do |tag|
span(class: "flex items-center") do
a(href: "#",
data: { action: "click->tag#removeTag",
id: tag.id }, class: "ml-0.5 mb-0.5") do
span(class: "flex items-center bg-gray-200 \
text-gray-700 px-2 py-1 rounded-md text-sm") do
span { tag.name }
render Icons::Cancel.new(css: "ml-2 h-4 w-4 text-gray-400")
end
end
end
end
editor_field
end
end
def editor_field
span(contenteditable: true,
data: {
tag_target: "input",
action: "keydown->tag#keydown keyup->tag#keyup focus->tag#focus",
placeholder: I18n.t("components.tag.#{@field}")
},
# hmm CSS does not seem to bite!?
style: "border: none; outline: none;",
class: "grow ml-0.5 px-1",
name: resource_field("%s[%s]-input"),
id: resource_field("%s_%s-input")) { @search.html_safe }
end
end
(The backslash on the attributes means you should keep the strings together – only there for readability here!)
Much better - now we just need to sprinkle CSS all over the place (under the influence of Tagify, remember?)
.mort-form-tag {
@apply inline-flex w-full rounded-md border-0 bg-white py-1.5 pl-3 text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300;
}
[contenteditable="true"]:active,
[contenteditable="true"]:focus {
border: none;
outline: none;
}
.tag-input {
flex-grow: 1;
display: inline-block;
position: relative;
margin: 5px;
padding: var(--tag-pad);
line-height: normal;
white-space: pre-wrap;
box-sizing: inherit;
overflow: hidden;
&::before {
content: attr(data-placeholder);
width: 100%;
height: 100%;
margin: auto 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
pointer-events: none;
position: absolute;
opacity: 0;
transition: all 0.5s;
}
&::after {
content: attr(data-suggest);
display: inline-block;
vertical-align: middle;
position: absolute;
min-width: calc(100% - 1.5em);
text-overflow: ellipsis;
overflow: hidden;
white-space: pre;
opacity: .3;
pointer-events: none;
max-width: 100px;
transition: all 0.5s;
}
}
assets/tailwind/application.css
The .mort-form-tag
and 5 topmost lines of CSS are of my own 'googling', though. Turns out (at least some) browsers by default decorate a contenteditable element.
The tag_controller.js
even had to have a few "coats of paint" :
lookup(txt) {
if (txt.length > 1 && txt !== this.lastSearch) {
this.lastSearch = txt;
get(`/tags/tags?search=${txt}`, {
responseKind: "turbo-stream"
});
}
}
addTag(event) {
event.preventDefault();
let tags = this.outputTarget.value;
let data = this.inputTarget.textContent;
let url = encodeURI(`/tags/tags?add_tag=${data}&value=${tags}`);
get(url, {
responseKind: "turbo-stream",
});
}
removeTag(event) {
event.preventDefault();
const tag = event.currentTarget;
const selectedTags = this.selectedTagsTarget;
if (selectedTags) {
const tagItem = selectedTags
.querySelector(`[data-id="${tag.dataset.id}"]`);
if (tagItem) {
let tags = this.outputTarget.value.split(",");
tags = tags.filter(t => t !== tag.dataset.id).join(",");
let url = encodeURI(
`/tags/tags?` +
`search=${this.inputTarget.textContent}&` +
`value=${tags}`
);
get(url, {
responseKind: "turbo-stream",
});
}
}
}
pick(event) {
event.preventDefault();
if (this.outputTarget.value === undefined) {
this.outputTarget.value = "";
}
if (this.outputTarget.value != "") {
this.outputTarget.value = this.outputTarget.value + ",";
}
let url = encodeURI(
`/tags/tags?`+
`value=${this.outputTarget.value}${event.target.textContent}`
);
get(url, {
responseKind: "turbo-stream",
});
this.tagListTarget.classList.add('hidden');
}
Now we are getting the hang of it – so let's refactor somewhat, again.
First, we need to realize that keeping a tab on the tags is a challenge with words - so we will hold their ID's instead. value = [ ID, ID, .., ID ]
Let's have a test for that – in fact all we need to do is change the last test ever so much:
test "the tag to have a selected container with a selected tag" do
tags = []
m = "<div id=\"time_material-task_comment-selected-container\""
output = render TagComponent.new
resource: TimeMaterial.new,
field: :task_comment,
resources: tags,
value: [ tags(:one).id ]
assert_match m, output.to_s
assert_match "data-id=\"#{tags(:one).id}\"", output.to_s
assert_match tags(:one).name, output.to_s
end
Now, here's one for the "buffs" reading this: the second assert_match
probably does not sit well with you! "Don't test the implementation - test the consequence/result" I hear you yell, and you're right, alright?! (and I'm painfully aware that with this entire post I probably commit enough 'design/developer sins' to only ever be ridiculed and made the laughing stock – but such is life) 🤷♂️
The TagsController was long overdue for some serious refactor
def tags
if params[:add_tag].present?
tag = Tag.find_or_create_by(name: params[:add_tag],
tenant_id: Current.get_tenant.id,
created_by: Current.get_user)
end
unless params[:search].blank?
tags = Tag.by_tenant
.where("name LIKE ?", "%#{params[:search]}%").order("name ASC")
.limit(10)
search = params[:search]
else
tags = []
search = ""
end
if params[:value].blank?
value = tag.present? ? [ tag ] : []
else
ids = params[:value].to_s.split(",").map(&:strip)
ids.push tag.id if tag.present?
value = Tag.by_tenant.where(id: ids) rescue []
end
respond_to do |format|
format.turbo_stream {
render partial: "tags/tags", locals: { tags:, search:, value: }
}
end
end
controllers/tags_controller.rb
Four issues remain (+ the ones you might spot):
- doing the persistence of tags on any particular resource — like a TimeMaterial ActiveRecord
- Test how editing existing ActiveRecord objects fare
- Solve the layout automagically when tags get crowded in the input
- Adding more than one tag input field on a form
Leave a note - or perhaps your take on this. You are always welcome to visit with us at M O R T I M E R.
🙏