How to use tomselect javascript library to create and edit a"belongs_to :many relation" with Ruby on Rails

Nick K.Nick K.
4 min read

Imagine that you have a classic has_many :through relation where you need to select several records out of thousands. Your form should be using an html select tag which would prove problematic if you had to select, say 5 records, out of 50.000 or more.

We are going to build on this great example https://web.archive.org/web/20230328193812/https://coolrequest.dev/2021/11/25/replace_select2.html where the author uses rails 7, https://tom-select.js.org/, stimulus to fetch the data with ajax and autocomplete.

Let's create a simple application with ruby on rails 7 and bootstrap:

rails new tomselect_example -j esbuild -c bootstrap

Now, create an item model with name and description:

rails g scaffold Item name:string description:text

Your table description should look like this:

class CreateItems < ActiveRecord::Migration[7.0]
  def change
    create_table :items do |t|
      t.string :name
      t.text :description

      t.timestamps
    end
  end
end

Your Item should have many, say Students:

rails g scaffold Student first_name:string, last_name:string, email_address:string

Your generated table should look like this:

class CreateStudents < ActiveRecord::Migration[7.0]
  def change
    create_table :students do |t|
      t.string :first_name
      t.string :last_name
      t.string :email_address

      t.timestamps
    end
  end
end

Create a joint table to hold your student selections:

rails g migration ItemStudents

Edit your migration file to look like this:

class CreateJoinTableItemStudents < ActiveRecord::Migration[7.0]
  def change
    create_table :item_students do |t|
      t.integer "item_id"
      t.integer "student_id"
    end
  end
end

In your models you should have:

class Item < ApplicationRecord
  validates_presence_of :name, :description
  has_many :item_students
  has_many :students, through: :item_students
end


class Student < ApplicationRecord
  belongs_to :item, optional: true 
end

On the Student model, we set optional: true because of the following reasons:

On our edit controller we need to make the selected items available to view as such:

# GET /items/1/edit
  def edit
    @selected = @item.student_ids 
    @selected_for_tomselect = @selected.collect { |x| {id: Student.find(x).id,  email_address: Student.find(x).email_address} }
  end

Your autocomplete method in items controller:

def autocomplete
    if params[:q].length > 2
      search_string = []
      columns.each do |term|
        search_string << "#{term} like :search"
      end
      list = Student.order(:email_address).where(search_string.join(' or '), search: "%#{params[:q]}%")
      render json: list.map { |u|
        { 
          id:            u.id,
          first_name:    u.first_name,
          last_name:     u.last_name,
          email_address: u.email_address
        }
      }
    end
  end

  def columns
    %w(email_address first_name last_name)
  end

Your edit view should look like this:

<%= form_with(model: item) do |form| %>

  <div class="mb-3">
  <h6>TomSelect Ajax reload with Search using form.select<h6>
    <%= form.select :student_ids, {}, {},
      { multiple: true,
        class: "form-select form-select-md mb-3",
        placeholder: 'Type min. 3 characters to start searching',
        data: {
          controller: 'search',
          search_url_value: autocomplete_items_path,
          search_selected_value: @selected_for_tomselect.to_json
        }
      }
    %>
  </div>

   Debug Data:
   <div class="mb-3">
     selected_options: <%= @selected_for_tomselect.to_json %>
   </div>
   <div class="mb-3">
     selected_items: <%= @selected %>
   </div>

And finally your stimulus search_controller:

import { Controller } from "@hotwired/stimulus"
import { get }        from '@rails/request.js'
import TomSelect      from "tom-select"

// Connects to data-controller="search"
export default class extends Controller {
  static values = { url: String, selected: String }

  connect() {
    this.element.setAttribute( "autocomplete", "off" );

    if (this.selectedValue == 'null') {
      var selected_json_data   = new Array()
      var selected_items_array = new Array();
    } else {
        var selected_json_data = JSON.parse(this.selectedValue)
        var selected_items_array = new Array()
        for(let i = 0; i < selected_json_data.length; i++) {
          selected_items_array.push(selected_json_data[i].id)
        }
    }

    var config = {
      plugins: ['clear_button', 'remove_button'],
      shouldLoad:function(q){
        return q.length > 2;
      },
      render: {
        option: this.render_option,
        //item: this.render_option
        item: function(data, escape) {
          return `<div>${escape(data.email_address)}</div>`
        }
      },
      loadThrottle: 300,
      // check this out in search items reload new data->
      // https://github.com/orchidjs/tom-select/issues/78
      // https://stackoverflow.com/questions/74622800/fetching-data-from-api-to-create-tom-select-options
      maxItems: 10,
      maxOptions: 10,
      valueField: 'id',
      labelField: 'email_address',
      searchField: ['email_address', 'first_name', 'last_name'],
      options: selected_json_data,
      items: selected_items_array,
      sortField: {
        field: "email_address",
        direction: "asc"
      },
      create: false,
      load: (q, callback) => this.search(q, callback),
     }

    let this_tom_select = new TomSelect(this.element, config)
    this_tom_select.clearCache()
  }


   async search(q, callback) {
    const response = await get(this.urlValue, {
      query: { q: q },
      responseKind: 'json'
    })

    if(response.ok) {
      callback(await response.json)
    } else {
        callback()
    }
  }

render_option(data, escape) {
    if(data.email_address)
      return `
      <div>
        ${escape(data.first_name)}&nbsp;${escape(data.last_name)} | ${escape(data.email_address)}</div>
      </div>`
    else
      return `<div>${escape(data.first_name)}</div>`
  }



}

One important point to note here is how we fill in the Tomselect parameters options and items before we instantiate tomselect

options: selected_json_data,
items: selected_items_array,

If we try to create a new record, we should be able to select from thousands of records:

And finally if we try to edit the record, we should be able to show the selected items as well:

You can find the source code here:

https://github.com/nkokkos/rails_7_modal_form

You need to do

rails db:seed

to fill the students table with thousands of records so that you can see how tomselect can handle the load.

Hope this helps you out.

0
Subscribe to my newsletter

Read articles from Nick K. directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nick K.
Nick K.