Introduction
Toggle buttons are useful for communicating and changing state. Here's what the user sees:
- Clicks on
- An ajax request is started and the button becomes
- When the request succeeds the button becomes
This pattern presents itself in every one of my applications, so I wanted to document how I implement it in Ruby on Rails, as it is a good introduction to ajax, unobtrusive javascript, and Rails handling javascript requests.
Demo
This webpage is not hosted by Rails, so the above UX is just a simulation.
How it Works
- A Rails helper method renders the button in its current state.
- This button has an
href
along withdata-method
,data-type=script
, anddata-remote=true
attributes which instructjquery_ujs.js
to perform an ajax request and evaluate the result as javascript. - When the user clicks either button, a jQuery listener switches to the loading icon while another listener switches it back on completion.
- In Rails, the main resource has a child which provides
#update
and#destroy
actions for toggling on and off respectively. - After saving the change, Rails responds with one line of javascript that replaces the button with its new state.
The Code
# models/post.rb
class Post < ApplicationRecord
def favorited?
favorited_at != nil
end
def favorite
self.favorited_at = Time.zone.now
end
def favorite!
favorite
save!
end
def unfavorite
self.favorited_at = nil
end
def unfavorite!
unfavorite
save!
end
end
# config/routes.rb
resources :posts do
resource :favorite, only: %w[update destroy]
end
# controllers/posts/favorites_controller.rb
class Posts::FavoritesController < ApplicationController
before_action :load_post
def update
@post.favorite!
end
def destroy
@post.unfavorite!
end
private
def load_post
@post = Post.find(params[:post_id])
end
end
# helpers/posts_helper.rb
module PostsHelper
def link_to_toggle_post_favorite(post)
url = post_favorite_path(post)
if post.favorited?
link_to_with_icon(
"icon-star",
"Favorite",
url,
method: "DELETE",
remote: true,
class: "favorite btn btn-primary",
)
else
link_to_with_icon(
"icon-star",
"Favorite",
url,
method: "PUT",
remote: true,
class: "favorite btn",
)
end
end
def link_to_with_icon(icon_css, title, url, options = {})
icon = content_tag(:i, nil, class: icon_css)
title_with_icon = icon << " ".html_safe << h(title)
link_to(title_with_icon, url, options)
end
end
# Gemfile
gem "jquery-rails"
// assets/javascript/application.js
//
// jquery_ujs allows us to use 'data-remote',
// 'data-type', and 'data-method' attributes
//
//= require jquery
//= require jquery_ujs
//= require_tree .
/* assets/javascripts/loading.js */
// This isn't necessarily specific to toggle buttons
$(function() {
// Change the link's icon while the request is performing
$(document).on('click', 'a[data-remote]', function(event, b, c) {
var icon = $(this).find('i');
icon.data('old-class', icon.attr('class'));
icon.attr('class', 'icon-refresh');
});
// Change the link's icon back after it's finished.
$(document).on('ajax:complete', function(e) {
var icon = $(e.target).find('i');
if (icon.data('old-class')) {
icon.attr('class', icon.data('old-class'));
icon.data('old-class', null);
}
})
// Don't fail silently
$(document).ajaxError(function( event, jqxhr, settings, exception ) {
if (jqxhr.status) {
alert("We're sorry, but something went wrong (" + jqxhr.status + ')');
}
});
})
<%# views/posts/show.html.erb %>
<%= div_for @post do %>
<%= link_to_toggle_post_favorite @post %>
<% end %>
/* views/posts/favorites/update.js.erb */
$('#post-<%= @post.id %> .favorite').replaceWith("<%=j link_to_toggle_post_favorite @post %>");
/* views/posts/favorites/destroy.js.erb */
$('#post-<%= @post.id %> .favorite').replaceWith("<%=j link_to_toggle_post_favorite @post %>");
Wrap-up
Unobtrusive javascript allows the application logic to stay in Ruby.
We follow "The Rails' Way" to #update
and #destroy
in the controller, which will help the application gracefully grow when we add more functionality to posts like additional toggles, fields, or a public RESTful API.
Let me know what else you think could be improved.