How to use tomselect javascript library to create and edit a"belongs_to :many relation" with Ruby on Rails
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)} ${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.
Subscribe to my newsletter
Read articles from Nick K. directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by