Using JavaScript in Ruby on Rails. Let's Build a To-Do App!
In Ruby on Rails August 27, 2021
Updated on May 13, 2023
Rails has a lot of built-in helpers that simplify the process of adding Ajax/JavaScript functionality to our applications. Rails uses an unobtrusive scripting adapter (UJS) that provides helpers to assist in writing JavaScript code. We can make use of these helpers by adding data- attributes to our HTML elements. This, combined with Ruby helpers which assist in generating HTML markup, makes it easy to create dynamic pages.
Forms or links that are marked with a data-remote
attribute will be submitted as an AJAX request.
You can add JavaScript functionality even without writing code using custom data-
attributes. For
example, Rails uses the data-method
attribute to make requests that are not GET requests. Links with such
an attribute will be marked with a “post
”, “put
” or “delete
” method. Another
useful attribute is data-confirm
. When a user clicks on a link with a data-confirm
attribute, a dialog box will open, asking for confirmation.
Also, you can trigger AJAX calls to elements that are not referring to any URL. This behavior can be accomplished
using the data-url
attribute.
Rails uses the unobtrusive JavaScript driver to fire custom events on AJAX requests. There are seven custom events:
ajax:before
, ajax:beforeSend
, ajax:send
, ajax:stopped
,
ajax:success
, ajax:error
, and ajax:complete
. We can write handlers to respond
when these events fire.
Rails can also render JavaScript in views, using .js.erb
files. After the action is complete, the
JavaScript code in such files will be sent and executed on the client-side.
In this article, we will deal with using JavaScript with Ruby on Rails. We will build a simple to-do app, and we will
make use of remote forms, remote links, custom data-
attributes, and custom events.
We’ll use Rails 6.1.4.1 and Ruby 3.0.2.
Set Up the Application
Let’s get started! First, we create our application. We don’t need the Turbolinks gem, so we’ll run the “rails new” command with the –skip-turbolinks option:
rails new todo --skip-turbolinks
After the app is created, we can go to that folder:
cd todo
Next, in the app/views/layouts/application.html.erb
file, we’ll move the
javascript_pack_tag
from the <head>
of the document
right before the closing </body>
tag.
Then we create a subfolder called js/
in the app/javascript/
folder. In the newly created
subfolder, we create an index.js
file. Here we’ll put our custom JavaScript code. In order for this code
to be compiled, we need to import this file into our pack application
.
In Rails, we put these packs that are automatically compiled by Webpack in the
app/javascript/packs/
folder. The pack application
we use
is generated at the creation of the app. All we need to do is to add this line after the other import statements:
import "js/index"
And we’ll have our code compiled.
We will also use some icons in our application. That’s why I want to install the Bootstrap Icons library.
From your terminal type:
yarn add bootstrap-icons
In order to use them, we need to add to our application the custom CSS file with the classes needed to generate these
icons. We import this file in the application.js
file in the app/javascript/packs/
folder.
import "bootstrap-icons/font/bootstrap-icons.css"
Finally, we add some basic styles in the app/assets/stylesheets/application.css
file:
ul, li {
list-style-type: none;
padding: 0;
}
a:link, a:visited {
text-decoration: none;
color: rgb(51, 51, 51)
}
input[type="checkbox"] {
cursor: pointer;
}
Create the Task Model
Next, we’ll generate the Task model. Let's start by adding two attributes to our model: name and description. To do this, let's generate a migration:
rails g model Task name:string description:text
And after that, we’ll run the migration that was generated for us:
rails db:migrate
The task must have a name before it can be sent to the database, so we validate that by adding this to the
app/models/task.rb
file:
validates :name, presence: true
The task has two statuses: incomplete and completed. To achieve that, we’ll use enums. In Rails, an enum is an attribute that maps to integers in the database but can be queried by name.
We’ll generate a migration in order to add a status column in the database.
rails g migration AddStatusToTasks status:integer
We edit the migration file within the db/migrate/
folder in order to set a default:
class AddStatusToTasks < ActiveRecord::Migration[6.1]
def change
add_column :tasks, :status, :integer, default: 0
end
end
Then we run:
rails db:migrate
When the user adds a new task, it will be incomplete by default. Then, when the task will be complete, the user will
mark it as completed. Of course, later we’ll provide a mechanism in the browser for the user to do that. But first,
let’s declare these statuses in our app/models/task.rb
file, by adding this line:
enum status: {incomplete: 0, completed: 1}
Create the Tasks Controller and the Home Page
We also need a Tasks controller. Let’s generate one:
rails g controller Tasks
This command will generate the tasks_controller.rb
file in the app/controllers/
folder. This
controller is empty, so we need to add some methods to it. First, we’ll add the index
method:
def index
@tasks = Task.all
end
And then we create the corresponding index.html.erb
file in the views/tasks/
folder that was
created when we generated the controller.
The tasks index
page will be the home page for our application. In the config/routes.rb
file, add this line at the top:
root 'tasks#index'
In the app/views/tasks/
directory we’ll create a _task.html.erb
partial to call on the home
page. In this partial, we’ll add for now only the task name:
<li><%= task.name %></li>
In the Tasks controller, we make a @tasks_incomplete
instance variable available inside
the index
method by querying the tasks that have an “incomplete” status.
@tasks_incomplete = @tasks.incomplete
And then in the index.html.erb
we put a list of incomplete tasks:
<main id = 'tasks'>
<h1>Tasks</h1>
<ul id='tasks_incomplete'>
<%= render @tasks_incomplete %>
</ul>
</main>
I wrapped all the content in a <main>
element with a “tasks” id. This will be useful later when
we’ll add JavaScript to the page.
Create the Task
We now need to create some tasks. First, we’ll declare the list of permitted parameters in the private method
task_params
at the bottom of our controller:
private
def task_params
params.require(:task).permit(:name, :description)
end
Next, we’ll call the new
method inside the index method.
def index
@tasks = Task.all
@tasks_incomplete = @tasks.incomplete
@task = Task.new
end
And then we add a create
method to the controller:
def create
@task = Task.new(task_params)
@task.save
end
Also, we need a route for the create
action. In the routes.rb
file within the
config/
folder, we add this line:
resources :tasks, only: [:create], defaults: {format: 'js'}
We only need the JavaScript format. After the task is created, it will be displayed automatically on the home page using JavaScript view rendering.
Now, we need to create that view. Going to app/views/tasks/
folder, we create the
create.js.erb
file and add this code to it:
<% if @task.errors.empty? %>
var tasks = document.querySelector("#tasks_incomplete")
tasks.insertAdjacentHTML("beforeend", "<%= j render(@task) %>")
<% end %>
document.querySelector('#task_name').value =''
We cannot use ES6 syntax in views, since there is no JavaScript preprocessing here.
We query to get all the incomplete tasks and then append the newly created task to the list. Of course, only if there is no error on the creation.
Now let’s add in the home page the form for creating a new task. We put it at the bottom:
<%= form_with model: @task, id: 'task-form', local: false do |form| %>
<p>
<%= form.text_field :name, placeholder: 'Add task', required: true %>
</p>
<p>
<%= form.submit %>
</p>
<% end %>
We use the form_with
helper and add a local option. When this is set to “false” a
data-remote attribute will be added to the form and its value will be “true”. This way, the form will be submitted by
AJAX. After the form is submitted, we clean the form input value.
Show the Task Details
We want to show the task and its details when the user clicks on it. We’ll create a modal that is hidden and opens when the user clicks on the task name.
In the _task.html.erb
partial, we replace the actual HTML with this:
<li>
<%= link_to task.name, "#show-#{task.id}", remote:true, class:"show-buttons", id: "task-#{task.id}" %>
<div class ='modal show-task' id='show-<%=task.id %>'>
<div class = 'modal-content'>
<h2 class='modal-title'> Task Details: </h2>
<h3 id ='task-name-<%=task.id %>' ><%= task.name %></h3>
<h3> Description:</h3>
<p id ='task-description-<%=task.id %>'>
<%= task.description %>
</p>
<button class='close' data-remote='true'>X</button>
</div>
</div>
</li>
We add ids and classes to the HTML elements so we can manipulate them via JavaScript. We need to style this modal, so
let’s open the app/assets/stylesheets/tasks.scss
file and add the following code:
.modal {
display: none;
z-index: 1;
position: fixed;
padding-top: 100px;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background-color: #fefefe;
margin: auto;
padding: 20px;
border: 1px solid #666;
width: 50%;
}
.close {
cursor: pointer;
position: absolute;
top: 1rem;
right: 1rem;
}
.modal-title {
text-align: center;
font-weight: normal;
}
Please note the remote: true
option added to the link. We can now bind AJAX events to these links.
We want to trigger the modal when a user clicks on the task. And when the user clicks the close button in the modal,
we hide that modal.
Since the user can dynamically add items to their tasks list, we cannot add an event listener to these items, because it won’t work. Instead, we’ll use a technique called event delegation.
We’ll query select a parent of these list items and attach the event listener to it. In our case, this will be the
<main>
element of the document.
Let’s open the app/javascript/js/index.js
file and add this code to it:
document.querySelector('#tasks').addEventListener("ajax:success", (e) => {
if (e.target && e.target.className === 'show-buttons') {
const listItem = e.target.closest('li')
const modal = listItem.querySelector('.show-task')
modal.style.display = 'block'
}
if (e.target && e.target.className === 'close') {
const listItem = e.target.closest('li')
const modals = listItem.querySelectorAll('.modal')
modals.forEach((modal) => {
if (modal.style.display == 'block') {
modal.style.display = 'none'
}
})
}
})
We attach an event listener to the custom UJS event, ajax-success
. This listener will apply to all
children of the element with the “tasks” id that have a class name “show-buttons”.
We find the task that corresponds to the button the user clicked on, then we find the modal that shows that task, and then we set the value of the display property to “block” for that modal. So, the user will see the task details.
Similarly, we apply an event listener to all children of the element with the “tasks” id that have a class name “close”. We then find all the modals corresponding to that task. I did that because soon we’ll have another modal that will open a form to edit that task. And then we set the value of the display property to “none” for that modal.
Edit the Task
We need to be able to edit the task. So let’s start by adding the private set_task
method to our
controller. Add this method before the task_params
method:
def set_task
@task = Task.find(params[:id])
end
And call it on the before_action
method at the top of the file, right before the class declaration:
before_action :set_task, except: %i[index create]
Now, we add the update
method to the Tasks controller:
def update
@task.update(task_params)
end
Next, we set the route for this action. In the routes.rb
in the config/
folder, we add the
update
action in the array of actions to be routed for the task
resource:
resources :tasks, only: [:create, :update], defaults: {format: 'js'}
In the _task.html.erb
partial, after the modal that shows the task's details, we add
another modal with the task
editing form:
<div class ='modal edit-task' id='edit-<%=task.id %>'>
<div class = 'modal-content'>
<h2 class="modal-title"> Editing Task: </h2>
<%= form_with model: task, class: 'task-edit', local: false do |form| %>
<p>
<%= form.text_field :name, id: "task_edit-name-#{task.id}", required: true, placeholder: 'Task name' %>
</p>
<p>
<%= form.text_area :description, id: "task_edit-task-description-#{task.id}", placeholder: 'Task description' %>
</p>
<p>
<%= form.submit %>
</p>
<% end %>
<button class='close' data-remote='true'>X</button>
</div>
</div>
And we add a link to it after the link with the class “show-buttons”.
<%= link_to "#edit-#{task.id}", remote:true, class:'edit-buttons' do %>
<i class='bi bi-pencil-fill'></i>
<% end %>
We show the Bootstrap “edit” icon to the user. We want to display the edit
form when the user clicks on
that icon, and hide the form if the user clicks on the close button inside the modal.
But first let’s add some styles in the app/assets/stylesheets/tasks.scss
:
li {
display: flex;
gap: .5rem;
padding-bottom: .5rem;
}
.show-buttons {
width: 20rem;
}
To show the edit
form when the user clicks on the “edit” icon, we’ll attach a listener to the UJS custom
event ajax:success
and we’ll apply it to the parent element <main>
. And then we add
another method, so the modal will close automatically after the user clicks the “Update Task” button.
In our index.js
file in the app/javascript/js/
folder, we add the following functions right
after the two others we already wrote:
if (e.target && e.target.className === 'edit-buttons') {
const listItem = e.target.closest('li')
const modal = listItem.querySelector('.edit-task')
modal.style.display = 'block'
}
if (e.target && e.target.className === 'task-edit') {
const listItem = e.target.closest('li')
const modal = listItem.querySelector('.edit-task')
modal.style.display = 'none'
}
Of course, we need to show the updated task to the user on the home page, immediately, without refreshing the page.
For that, we’ll use the JavaScript view rendering.
In the folder app/views/tasks/
, we create the update.js.erb
file and add the following code:
<% if @task.errors.empty? %>
var taskLink = document.querySelector('#task-<%= @task.id %>')
taskLink.innerHTML = '<%= j link_to @task.name, "#show-#{@task.id}", remote:true, class:"show-buttons" %>'
var taskName = document.querySelector('#task-name-<%= @task.id %>')
taskName.textContent = '<%= @task.name %>'
var taskDescription = document.querySelector('#task-description-<%=@task.id %>')
taskDescription.textContent = '<%= j @task.description %>'
<% end %>
We select the <a>
element that creates the link to the task we just edited and change it to reflect
the new name. Also, we select the modal with the task details and change its content to reflect the new name and new
description.
Delete the Task
First, we need to add the destroy
method to our controller:
def destroy
@task.destroy
end
Then, in config/routes.rb
file, we add the destroy
action in the array of actions to be
routed for the task
resource:
Rails.application.routes.draw do
root 'tasks#index'
resources :tasks, only: [:create, :update, :destroy], defaults: {format: 'js'}
end
In the _task.html.erb
partial in the app/views/tasks/
folder, we link to the
destroy
action after the “edit” link:
<%= link_to task, method: :delete, remote:true, class:'delete-task', data: { confirm: 'Are you sure?' } do %>
<i class="bi bi-trash-fill"></i>
<% end %>
Next, we need to delete that task in the home page after the user confirms they want to delete it. In the
app/views/tasks/
directory, we create the destroy.js.erb
file and put the following code in
it:
<% if @task.errors.empty? %>
var item = document.querySelector('#task-name-<%= @task.id %>')
var listItem = item.closest('li')
listItem.remove()
<% end %>
When the user deletes the task, the function searches for the task entry on the home page and removes it.
Mark Tasks as Completed or Incomplete
When the user adds a task, it is incomplete. But later the user will want to mark it as completed or to toggle between statuses. Next, we will build a mechanism to do that.
First, we’ll add a toggle_status
method in the controller:
def toggle_status
if @task.incomplete?
@task.completed!
else
@task.incomplete!
end
end
The method checks the status of the task and if it is incomplete, changes it to completed. And vice versa, if the task is completed, it marks it as incomplete.
Of course, we need to set the route for the toggle_status
action in the config/routes.rb
:
Rails.application.routes.draw do
root 'tasks#index'
resources :tasks, only: [:create, :update, :destroy], defaults: {format: 'js'} do
member do
get :toggle_status
end
end
end
Finally, we’ll need to add a checkbox for every task. The checkbox will be unchecked if the task is incomplete and
checked if the task is complete.
In the _task.html.erb
partial after the <li>
tag, we add this:
<% if task.incomplete? %>
<%= check_box_tag "checkbox-#{task.id}", "incomplete", false, data: {remote:"true", url: toggle_status_task_path(task), method: "get" } %>
<% else %>
<%= check_box_tag "checkbox-#{task.id}", "completed", true, data: {remote:"true", url: toggle_status_task_path(task), method: "get" } %>
<% end %>
The <checkbox>
element has the data-remote
attribute set to true, so it will trigger
an AJAX call even though it’s not a link.
After the list of incomplete tasks, we want to add a list of completed tasks. We want the tasks that were marked as completed to be moved automatically to this new list.
In the Tasks
controller, we make a @tasks_completed
instance variable available inside the
index
method by querying the tasks that have a “completed” status.
@tasks_completed = @tasks.completed
In app/views/tasks/index.html.erb
, after the list of incomplete tasks, we add this code that will display
the completed tasks:
<h2> Completed </h2>
<ul id="tasks_completed">
<%= render @tasks_completed %>
</ul>
Now all we need to do is add some JavaScript code in order to display the task in the correct list automatically when
the user checks or unchecks it, without refreshing. We need to create the toggle_status.js.erb
file in
the app/views/tasks/
folder and add this code to it:
var item = document.querySelector('#checkbox-<%= @task.id %>')
var listItem = item.closest('li')
var completedTasks = document.querySelector('#tasks_completed')
var incompleteTasks = document.querySelector('#tasks_incomplete')
if (item.checked) {
completedTasks.appendChild(listItem)
}
else {
incompleteTasks.appendChild(listItem)
}
The function checks if the checkbox is checked or unchecked and puts the corresponding task in the appropriate list.
Conclusion
We built an application that allows us to dynamically add, remove and edit tasks, mark them as completed or incomplete, relatively easy, using the Rails Ajax helpers.