The dev team at gap intelligence is committed to following Agile Scrum best practices. Once a month we have Dev Days where we get to spend an entire day working on passion projects. These can range from developing internal tools and organizing our Gulls and Padres season ticket to learning new techniques and increasing efficiencies in our current products. For our last Dev Day I decided to work on a simplified version of a planning poker tool.
Planning poker helps us to estimate future stories to work on. First, developers submit their estimates. Only after all the estimates are submitted, do we get to see everybody else's estimate. The benefit of doing this is that we can estimate stories without influencing one another.
I decided to use Action Cable, which is a new feature introduced as part of Rails 5. Action Cable integrates WebSocket communication with Rails applications by providing an easy-to-use, out-of-the-box experience. This is unique in that HTTP connections usually get closed as soon as data is sent. With WebSockets, the connection between the server and client is kept alive. This way, you can communicate in "real time".
A good example of WebSockets in use would be a chat client. In this app, messages must be updated in real time so that communication between users is able to flow.
With the help of Nithin Bekal's Action Cable tutorial, I was able to build a working version of our own planning poker tool, which we dubbed SWAG (Scientific Wild-Ass Guess).
Not wanting to rewrite Nithin's post, I'm going to highlight what was implemented to get this to work.
Back-End Code
First thing to note is that we didn't use a database with this application, or at least not for this first version. We're using Redis to store session usernames and estimates.
This is what the Session class looks like:
class Session def add_username(username) usernames.add(username) self end def delete_username(username) Estimate.new(username).delete usernames.delete(username) self end def usernames @usernames ||= Redis::Set.new('session-usernames') end end
Pretty straightforward. We store usernames in a Redis Set and we have the ability to add and delete usernames from said set.
We then have the Estimate class:
class Estimate attr_reader :username def initialize(username) @username = username end def delete redis_value.delete self end def size redis_value.value end def size=(value) redis_value.value = value end private def redis_value @redis_value ||= Redis::Value.new("estimate-#{username}") end end
Estimates get stored as simple Redis Values and each username can only have a single value. This ensures that each person only has a single value when estimating a story. So if a user changes their mind, the previous value gets overwritten.
But the real "complex" part of the code lives in the SessionStatus class:
class SessionStatus attr_reader :session delegate :usernames, to: :session def initialize(session) @session = session end def complete? estimates.all? { |estimate| estimate.size.present? } end def consensus label = '-' with_sizes = estimates.reject { |estimate| estimate.size == '-' } totals = with_sizes.each_with_object(Hash.new(0)) { |e, hash| hash[e.size.to_i] += 1 } label = totals.keys.first if complete? && totals.keys.one? label end def reset! estimates.each do |estimate| estimate.size = nil end end def results usernames.collect do |username| h = { username: username, size: '-', selected: username_has_estimate?(username) } h[:size] = Estimate.new(username).size if complete? h end end private def username_has_estimate?(username) estimates.any? do |estimate| estimate.username == username && estimate.size.present? && estimate.size != '-' end end def estimates @estimates ||= usernames.collect { |username| Estimate.new(username) } end end
We use this class for a few things:
- Determine if everybody has submitted their vote
- Figure out if we have a consensus across all votes
- Reset all votes to estimate a new story
- Get a hash to display all users with their votes on screen
Focusing on how we get results to display on screen:
def results usernames.collect do |username| h = { username: username, size: '-', selected: username_has_estimate?(username) } h[:size] = Estimate.new(username).size if complete? h end end
Here we're building a hash with all usernames and defaulting their size to a dash ('-'). This is the default value everybody gets before submitting their vote.
We then check to see if all users have voted. If that's the case, then we assign the correct value to each username in the hash.
Setting up Our Channel
Having all the information we need to display to each user, we can now go ahead and create an Action Cable Channel. This class will include the basic features needed for our planning poker tool.
class SwagChannel < ApplicationCable::Channel def subscribed ... end def unsubscribed ... end def size(data) ... end def reset(data) ... end private def render_summary ... end end
The default actions are subscribe and unsubscribe. These actions get called whenever a user signs on or off. Here we just add or remove the username from our session and broadcast an update to all connected users.
def subscribed if current_user.present? session = Session.new session.add_username(current_user) ActionCable.server.broadcast( 'estimates', summary: render_summary ) stream_from 'estimates' end end def unsubscribed if current_user.present? session = Session.new session.delete_username(current_user) ActionCable.server.broadcast( 'estimates', summary: render_summary ) end end
Whenever a user casts a vote, the size method gets called. Two things happen here: first we store each user's vote, and we broadcast an update to all users. If not all users have voted, we just make a mark displaying which users have voted and are waiting. If all users have already voted, then we display everybody's vote for all to see.
def size(data) estimate = Estimate.new(current_user) estimate.size = data['size'] ActionCable.server.broadcast( 'estimates', summary: render_summary ) end
Client-Side Code
The only thing that's missing to tie all this together is our client side code. This is where we turn on our cable connection and handle all our different interactions.
App.swag = App.cable.subscriptions.create "SwagChannel", connected: -> # Called when the subscription is ready for use on the server disconnected: -> # Called when the subscription has been terminated by the server received: (data) -> $('#estimates').html(data.summary) if data.reset $('.btn-group button').removeClass('active') reset: () -> @perform 'reset' size: (val) -> @perform 'size', size: val $(document).on 'change', 'input:radio[class="size-btn"]', (event) -> App.swag.size($(this).val()) $(document).on 'click', 'button.btn__reset', (event) -> App.swag.reset()
Here you can see that every time a user casts a vote, the size method gets called. This will then call the size method in our WagChannel class.
What I think is the most important part of this code is the received method. This is what gets called every time an update gets broadcasted to all users.
received: (data) -> $('#estimates').html(data.summary) if data.reset $('.btn-group button').removeClass('active')
Here we can see that whenever we receive an update, the estimates div contents get replaced.
Conclusion
What I think is the greatest benefit of using Action Cable is that everything just works. All the connections between client-side and server-side code are built for you. You just have to tie the pieces together.
Like I mentioned earlier, Nithin Bekal's Action Cable tutorial was of great help to me. There are many more resources out there on the subject, so it should be pretty straightforward to get started.
What's Next?
As you can see from the code presented here, this first version is pretty basic. The most immediate next step is to test this out with our team during one of our backlog grooming sessions. This will help us work out any kinks that may (definitely) exist. Then there's always the possibility of just opening this up as a free service to anybody that wishes to use it. For this to happen we would need to tweak the code to handle many sessions at a time, give users the ability to set custom size values, write specs and a lot more testing.
In the meantime, we're always looking for ways to improve our own process. Sometimes that means building our own tools; sometimes that means using an existing service. But always making sure that we're happy with what we choose and are getting value from it.
See you next time.