Using JavaScript in Ruby on Rails. Let's Build a To-Do App!

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.

Post last updated on May 13, 2023