Building a Planning Poker Application with Ruby on Rails + Hotwire

Nicolás Galdámez
Unagi
Published in
12 min readNov 1, 2023

In this article, we’re going to keep digging into the cool stuff that Turbo and Hotwire have to offer. This time we’re going to build a Planning Poker web application, enabling users to participate in estimation sessions remotely.

Everything shown here contains zero lines of Javascript .

DISCLAIMER: To gain a better comprehension of the content described in this article, it’s recommended to have a basic understanding of the concepts related to Turbo Frames and Turbo Streams. If you’re not familiar with these concepts, you can refer to this article, where I explain how to synchronize a slideshow of images across multiple windows and introduce some fundamental concepts.

Now, I invite you to watch the following video to get a better idea of what we’re going to build.

Our database will have rooms and members. For this, we’ll create two very simple tables:

create_table "rooms" do |t|
t.string "token"
end

create_table "members" do |t|
t.integer "room_id"
t.string "name"
t.integer "estimation"
end

The application is not intended to handle tickets or user stories, but simply to manage task estimation. We assumed that a moderator/scrum master reads a user story out loud and the team members choose an estimation using our app.

All users estimates will be visible to all the participants once the Reveal button is pressed.

Participating in a session

The first thing we’re going to do is set up the screen so that a user can participate in the estimation session.

Users do not need to register as such, they simply need to enter their name and this will be persisted in the database in the members table.

Displaying a modal

To display the modal simply include a link pointing to the “New participant” form in a turbo frame. We will use some style rules offered by Tailwind CSS to make the window appear floating and with an overlay in the background.

The link will point to the members/new route and we will pass the room where the user wants to participate as a parameter:

<%= turbo_frame_tag :new_member do %>
<%= link_to 'Participate', new_member_path(room_id: @room.id) %>
<% end %>

The response from new_member_path should be contained within a turbo frame with the same identifier that we used for the link frame, so that the response is rendered on the same turbo frame.

Something like this:

<%= turbo_frame_tag :new_member do %>
<!-- FORM -->
<% end %>

The complete response of new_member_path would look something like this. Take the time you need to understand what we've put together.

<!-- app/views/members/new.html.erb -->

<%= turbo_frame_tag :new_member do %>

<div class="fixed inset-0 z-50 overflow-y-auto bg-black/80">
<div class="h-screen w-full relative flex items-center justify-center">
<div class="relative max-w-md bg-white m-1 px-6 py-4 origin-bottom mx-auto min-w-[400px] rounded-lg">

<%= link_to room_path(@member.room.slug), target: :_top, class: 'absolute top-3 right-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center' do %>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Close modal</span>
<% end %>

<h3 class="mb-4 text-xl font-medium text-gray-900">Participate</h3>

<%= form_with model: @member, data: { turbo_frame: '_top' }, class: 'space-y-6' do |f| %>
<%= f.hidden_field :room_id %>

<div>
<%= f.label :name, class: 'block mb-2 text-sm font-medium text-gray-900' %>
<%= f.text_field :name, placeholder: 'John Doe', required: true, autocomplete: false, autofocus: true, class: 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5'%>
</div>

<button type="submit" class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
Start
</button>
<% end %>
</div>
</div>
</div>
<% end %>

Thanks to the CSS classes, the form appears floating on a dark background. Specifically, the “magic” is accomplished using the fixed, inset-0, and bg-black/80 classes. Let’s take a look at a simplified version of this code to better understand the process behind it:

<!-- OVERLAY -->
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/80">
<div class="h-screen w-full relative flex items-center justify-center">
<!-- WHITE FLOATING BOX -->
<div class="relative max-w-md bg-white m-1 px-6 py-4 origin-bottom mx-auto min-w-[400px] rounded-lg">
<!-- FORM -->
</div>
</div>
</div>

Another thing to highlight is that the form has data: { turbo_frame: '_top' }, which will cause the whole page to reload instead of rendering the response in the turbo frame. We reload the entire page rather than updating individual sections because it’s a simpler approach.

The form points to POST /members, where we create the member and load it into the session:

# app/controllers/members_controller.rb
class MembersController < ApplicationController
def new
@member = Member.new(room_id: params[:room_id])
end

def create
@member = Member.create(member_params)
session[:member_id] = @member.id
redirect_to @member.room
end

private

def member_params
params.require(:member).permit(:name, :room_id)
end
end

So far, we have the following 💪

Managing the empty state

The main screen of a room displays the list of members in small cards.

When there are no members, a message is displayed saying that someone is expected to join the room.

We can do this in a very simple way:

<%= render partial: 'empty_state' %>
<%= render @room.members %>

First, we render the members collection using render @room.members. And second, the rendering of a partial (empty_state) that contains the code associated with the following screen:

As you can see in the introduction video, when a member is added, this message is hidden and the collection of members is displayed. When the last user logs out, the message is shown again.

We can do this with an if statement to render the partial if there are no members:

<% if @room.members.empty? %>
<%= render partial: 'empty_state' %>
<% end %>

<%= render @room.members %>

But there is a simpler way to do it: with CSS.

<div class="hidden only:block">
<h1>Waiting for members to join</h1>
<p>
Waiting for members to participate in this incredible experience
</p>
</div>

As you can see, the <div> element is assigned the classes hidden and only:block. By default, the <div> is hidden unless it is the sole child element within its container. In simpler terms, if the <div> is the only nested element inside its container, it becomes visible ( block class ). Otherwise, it remains hidden.

This approach ensures that when there are no members, the ‘only child’ condition is met, and the message is displayed. However, when a member is added, the message is hidden because it now has ‘siblings’.”

Estimating

In order to estimate a task, the user is presented with a series of buttons. When a button is clicked, we should update the estimation attribute of the members table.

Let’s extract the buttons in a partial:

<!-- app/views/rooms/show.html.erb -->
<%= render partial: 'estimation'%>

The partial contains the code to display the buttons with a blue background:

<!-- app/views/rooms/_estimation.html.erb -->
<% [0, 1, 2, 3, 5, 8].each do |estimation| %>
<%= button_to estimation, estimations_path(estimation: estimation),
class: 'bg-blue-500 hover:bg-blue-700'
) %>
<% end %>

To make the button with the selected estimate display in green color, we can make use of the class_names property, which allows us to define CSS classes as long as a condition is met.

This way, we can change the partial to assign the bg-green-500 class if the estimate assigned by the user matches the one on the button:

<!-- app/views/rooms/_estimation.html.erb -->

<% [0, 1, 2, 3, 5, 8].each do |estimation| %>
<%= button_to estimation, estimations_path(estimation: estimation),
class: class_names(
'bg-blue-500 hover:bg-blue-700',
'hover:bg-green-500 bg-green-500': current_member.estimation == estimation,
) %>
<% end %>

The buttons, when clicked, execute the create action of the estimations_controller. The idea is that in this action, the estimate is saved and the buttons are updated, highlighting the button with the chosen estimation.

For this, we can wrap the code in a turbo frame and render the same partial from the estimations_controller once the action is performed. This way, the button panel will be updated, painting the corresponding button:

<!-- app/views/rooms/_estimation.html.erb -->

<%= turbo_frame_tag :estimation do %>
<!-- BUTTONS -->
<% end %>
# app/controllers/estimations_controller.rb
def create
current_member.update(estimation: params[:estimation])

render partial: 'rooms/estimation'
end

In this way, we achieve the following:

Showing the estimate

Once the user selects a value, we need to show the check indicating that the user has already selected their estimate.

To do this, let’s add a condition in the partial _member.html.erb to show the icon with the pencil or check depending on whether the user has decided an estimate:

<!-- app/views/members/_member.html.erb -->

<% if member.estimation.present? %>
<svg class="w-6 h-6 text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 12">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5.917 5.724 10.5 15 1.5"/>
</svg>
<% else %>
<svg class="w-6 h-6 text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 21">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.418 17.861 1 20l2.139-6.418m4.279 4.279 10.7-10.7a3.027 3.027 0 0 0-2.14-5.165c-.802 0-1.571.319-2.139.886l-10.7 10.7m4.279 4.279-4.279-4.279m2.139 2.14 7.844-7.844m-1.426-2.853 4.279 4.279"/>
</svg>
<% end %>

In this same partial, we will have the estimate selected by the user:

<h1 class="font-semibold">
<%= member.estimation %>
</h1>

<% if member.estimation.present? %>
<!-- CHECK ICON -->
<% else %>
<!-- PENCIL ICON -->
<% end %>

The estimated value should only be displayed if any of the members clicked on the Reveal button. But be patient, we will see how to handle this later.

With the turbo frame that contains the buttons, we managed to update the button panel with the corresponding color, but the member’s card does not change, neither for the user who selected the value nor for the rest of the members in the room.

To update the cards, we can perform a broadcast using turbo streams.

Broadcasting the estimation selection

The first thing we are going to do is define a channel through which we are going to transmit those streams.

<!-- app/views/rooms/show.html.erb -->

<%= turbo_stream_from @room %>

<%= render partial: 'empty_state' %>
<%= render @room.members %>

Instead of giving the channel a name, we use @room which will generate the name automatically, allowing us to have a different channel for each room.

Then in our controller, we broadcast over that channel, replacing the card of the member who made the estimation:

# app/controllers/estimations_controller.rb
def create
current_member.update(estimation: params[:estimation])

Turbo::StreamsChannel.broadcast_replace_to(
current_member.room, # channel
target: current_member,
partial: 'members/member',
locals: { member: current_member }
)

render partial: 'rooms/estimation'
end

Now, when selecting an estimate, the corresponding card will be updated for all users participating in the same room.

Revealing the estimate

As I mentioned earlier, I was going to tell you how to handle the logic of revealing the values selected by the members of the room. This means showing the check icon until a user clicks the Reveal button, at which point the values are shown.

To do this, we are going to use a technique provided by Tailwind CSS to apply styles based on the state of a parent element in the DOM hierarchy. In particular, in this case, we are going to make the estimate only appear if the div that contains the cards has the data attribute data-revealed. Otherwise, the check icon will be displayed.

<div> 
<div> <!-- FIRST MEMBER -->
<h1> <!-- It will be visible when data-revealed is set on parent -->
<!-- USER ESTIMATE -->
</h1>
<div> <!-- It will be hidden when data-revealed is set on parent-->
<!-- CHECK ICON -->
</div>
</div>

<div> <!-- SECOND MEMBER -->
<h1> <!-- It will be visible when data-revealed is set on parent -->
<!-- USER ESTIMATE -->
</h1>
<div> <!-- It will be hidden when data-revealed is set on parent-->
<!-- CHECK ICON -->
</div>
</div>
...
</div>

To achieve this, we need to mark the parent element with the class group and use modifiers group-data-[revealed]:hidden and group-data-[revealed]:block as appropriate.

It would look like this:

<div class="group">
<div>
<h1 class="hidden group-data-[revealed]:block ...">
<!-- VALUE -->
</h1>
<div class="block group-data-[revealed]:hidden">
<!-- CHECK ICON -->
</div>
</div>

<!-- OTHER MEMBER -->
...
</div>

The estimate will be hidden (hidden class) unless the element defined as group has the value data-revealed, in which case it will take the block class. Something similar, but with the opposite behavior, happens with the div with the icon.

Assigning the data-revealed attribute

To assign the data attribute data-revealed, we could do it through turbo streams combined with a Stimulus controller. But it seemed simpler to use the TurboPower gem that allows us to make changes to the DOM in a simpler way.

The steps to install and configure the gem are in the official documentation of Turbo Power.

The first thing we need to do is assign an id and the group css class to the parent element:

<div id="planning-poker" class="group">
<%= turbo_stream_from @room %>

<%= render partial: 'empty_state' %>
<%= render @room.members %>

...
</div>

Next, let’s add the button Reveal, which should only be displayed if the parent does not have the data-attribute data-revealed.

<div id="planning-poker" class="group">
<%= turbo_stream_from @room %>

<%= render partial: 'empty_state' %>
<%= render @room.members %>

<%= button_to 'Reveal', reveal_room_path(@room),
class: 'block group-data-[revealed]:hidden' %>
...
</div>

The button points to reveal action in rooms_controller. Using set_dataset_attributemethod from Turbo Power, when reveal is clicked, the data-revealed attribute will be set on the element with the id planning-poker.

def reveal
render turbo_stream: turbo_stream.set_dataset_attribute('#planning-poker', 'revealed', 'true')
end

This works as expected, buy it has a problem. It doesn’t display the estimates for all users, since the stream is only transmitted to the user who clicked on theReveal button. To make all users see the change, we should broadcast the stream.

The gem’s documentation doesn’t explain how to do it, but I figured out a way to make it work. Any suggestions are welcome :).

def reveal
render turbo_stream: Turbo::StreamsChannel.broadcast_set_dataset_attribute_to(
@room, # stream channel
targets: '#planning-poker',
attribute: 'revealed',
value: 'true'
)

head :no_content
end

Now, we achieve that the check is hidden and the estimates are shown for all participants in the room 👏.

More features

We have seen how to create a Planning Poker web application using Hotwire and Turbo. There are some features that were not included in this article, such as logging out or resetting the estimate. However, I leave you the repository with the complete implementation for you to download, analyze, suggest improvements, and play with.

Thank you for reading this far and I would appreciate it if you leave me a clap or comment and share the article.

If you liked this article, you may be interested in one of these:

Unagi offers software development services in Ruby and React. You can learn more about us on any of our channels.

--

--

Nicolás Galdámez
Unagi
Editor for

Co-fundador de @unagi. Me gusta el cine, la lectura, y la ensalada de frutas.