Hotwiring taggables

We found ourselves wanting a tagging engine for M O R T I M E Ryour 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:

initial frame

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

lookup frame

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

selected frame

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

selected large frame

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

  1. doing the persistence of tags on any particular resource — like a TimeMaterial ActiveRecord
  2. Test how editing existing ActiveRecord objects fare
  3. Solve the layout automagically when tags get crowded in the input
  4. 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.

🙏