As the lead developer at HeerlijkZoeken.nl I wanted to try the new Rails ActionCable technology for a new feature: shopping lists. The idea is that you can walk in a store or on a market, mark an ingredient as checked when you add it to your (physical) basket and continue shopping. ActionCable can make the experience nicer because it, based on WebSockets, allows for real time notifying other viewers and editors of the same shopping list. No more shouting around in the supermarket: I’ve got the milk! Sure, nothing essential, but I needed an excuse ;)
(Note that we recently migrated from Rails 4, so not everything was in place in our app, just ignore the bits Rails already made for you; everything has been tested with Rails 5.0.0.1)
To start: You need a web server that can open multiple threads, so if you’re still using Webrick in development (which can’t receive or send anything while serving one browser), switch to Puma (simply add gem 'puma'
to your Gemfile
, and remove gem 'webrick'
if it is mentioned somewhere).
ActionCable lives by default in a new directory below /app
: channels
.
You need to define the ActionCable::Channel
and ActionCable::Connection
classes as follows:
# app/channels/application_cable/channel.rb
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end
Make sure you also properly configure config/cable.yml
. Even for local development I would recommended using Redis (it’s a light install), since it allows you to trigger broadcasts from your Rails-console.
local: &local
adapter: redis
url: redis://localhost:6379
development: *local
test:
adapter: async
production: *local
Make sure you’ve added the Redis gem to your Gemfile
when you’re going to use this:
gem ‘redis’
On the client side we need to make sure there the ActionCable library is also present there:
# app/assets/javascripts/cable.coffee
#= require action_cable
#= require_self
#= require_true ./channels
@App ||= {}
App.cable = ActionCable.createConsumer()
Messages need to be broadcasted over a channel to which subscribers can listen to. Hence we first need to create a CheckList channel. We let our subscribers listen to the stream of changes on the CheckList model when they decide to actively start listening to a stream.
# app/channels/check_list_channel.rb
class CheckListChannel < ApplicationCable::Channel
def subscribed
end
def start_listening stream_prefs
check_list = CheckList.find stream_prefs['check_list_id']
stream_for check_list
end
def stop_listening
stop_all_streams
end
end
A broadcast starts with sending, in our case checking off a recipe.
Note: My start is always to make things work without (client side) Javascript first. I assume you have a CheckList model, with many CheckListItems and hence have a form looking something like this:
<%= form_for [@check_list] do |form| %>
<%= form.simple_fields_for :check_list_items do |f| %>
<label>
<%=f.check_box :checked %>
<%=f.object.name %>
</label>
<% end %>
<%= f.submit %>
<% end %>
To start sending, simply add an after_save: broadcast_change
action to the checklist item (you might want to move this to a separate Job, but lets keep it simple here):
# app/models/check_list_item.rb
def broadcast_change
CheckListChannel.broadcast_to(recipe_collection, self)
end
If you’d save the form, old school style, it would trigger a broadcast of the CheckListItem on the CheckListChannel.
So we have a channel, which can serve our broadcast. But how can we hear the changes? We now turn to the JavaScript side of things (yep, this is a progressive enhancement), we let the earlier defined consumer subscribe to the RecipeCollectionChannel (now we’re just logging the response, we’ll do something in a sec):
# check_list_channel.coffee
App.checkListChannel = App.cable.subscriptions.create "CheckListChannel",
received: (data) ->
console.log(data)
In the show.erb.html I add the following line, which tells the recipeChannel to tune into the right stream.
<script type="text/javascript">
setTimeout(function() {
App.checkListChannel.perform("start_listening", {check_list_id: <%=@recipe_collection.id%>});
}, 1000)
</script>
There is room for improvement here, but for now this works.
The data returned is just a plain JavaScript object, with the values of the broadcasted model, in this case of a CheckListItem-instance. When we get an updated CheckListItem we also need to update the interface correspondently.
To find the checkbox associated with the CheckBoxItem we need to make sure we can easily find the checkbox in Javascript. Since Rails’ FormBuilder doesn’t generate a usable id by default, we will assign a predefined id to it.
<label>
<%= f.check_box :checked, id:"check_list_item_#{f.object.id}_checked_field" %>
<%= f.object.name%>
</label>
We can now locate the checkbox that corresponds to the returned data by get the element by its id and update the status of the checkbox with the status of the model:
# check_list_channel.coffee
App.checkListChannel = App.cable.subscriptions.create "CheckListChannel",
received: (data) ->
input_id = 'check_list_item_' + data['id'] + '_checked_input'
checked_status = data['checked']
input = document.getElementById(input_id)
input.checked = checked_status if input
If the input cannot be found, we could decide to render create new inputs on request now, although it might be nicer to alert the user that the list has changed and ask him or her to update the whole list.
Just updating the list when others make changes is fun, but it is even nicer if it doesn’t require hitting the submit button. So we register a new EventListener that gets triggered if it involves an item from our check list:
document.addEventListener('change', (e)->
form = document.querySelector('form.edit_check_list[method=post]')
form_input = form.querySelector('#'+e.target.id)
if form_input
message_id = parseInt(e.target.id.match(/check_list_item_(.*)_checked_input/)[1])
message = {id: message_id, checked: e.target.checked}
App.checkListChannel.perform("inform", message);
)
Yep, we’re calling an new method here on the CheckListChannel, so we must make sure it is understood at the Rails side of things:
We’re adding the following method to check_list_channel.rb
:
def inform data
rci = CheckListItem.find(data['id'])
rci.checked = data['checked']
rci.save
end
It will cause another message to be sent back to the client, which potentially could lead to firing another change-event, but since the truth about the state is always in the database potential conflicts will be resolved eventually :).
That’s it for now. Right now we’re, however, allowing anyone to follow what is going on and modify everything when they tune in, you might want to limit that to certain users. That’s up for the next article.
The image by ccPixs was CC-BY licensed.
Enjoyed this? Follow me on Mastodon or add the RSS, euh ATOM feed to your feed reader.
Dit artikel van murblog van Maarten Brouwers (murb) is in licentie gegeven volgens een Creative Commons Naamsvermelding 3.0 Nederland licentie .