Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DO NOT MERGE] Channels #12

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,42 @@
# Pointing Party
Welcome to Elixir School's workshop on Real-Time Phoenix with Channels, PubSub, Presence and LiveView! This application exists to guide workshop participants through each of these topics by allowing them to build out a set of features to support a fantastically fun game of "pointing poker".
Welcome to Elixir School's workshop on Real-Time Phoenix with Channels, PubSub, Presence and LiveView! This application exists to guide workshop participants through each of these topics by allowing them to build out a set of features to support a fantastically fun game of "pointing party".

Collaborators can sign in with a username and click a button to start the "pointing party". Tickets are displayed for estimation and each user can cast their store point estimate vote. Once all the votes are cast, a winner is declared and the participants can move on to estimate the next ticket.

The master branch of this app represents the starting state of the code for this workshop. Clone down and make sure you're on the master branch in order to follow along.
The master branch of this app represents the starting state of the code for this workshop. Clone down and make sure you're on the master branch in order to follow along.

## Resources

### Phoenix Channels

* [Official guide](https://hexdocs.pm/phoenix/channels.html)
* [API documentation](https://hexdocs.pm/phoenix/Phoenix.Channel.html#content)

### Phoenix PubSub

* [API documentation](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html)

### Phoenix LiveView

* [LiveView announcement](https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript) by Chris McCord
* [API documentation](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html)
* ["Walk-Through of Phoenix LiveView"](https://elixirschool.com/blog/phoenix-live-view/) by Sophie DeBenedetto
* ["Building Real-Time Features with Phoenix Live View and PubSub"](https://elixirschool.com/blog/live-view-with-pub-sub/) by Sophie DeBenedetto
* ["Using Channels with LiveView for Better UX"](https://elixirschool.com/blog/live-view-with-channels/) by Sophie DeBenedetto
* ["Tracking Users in a Chat App with LiveView, PubSub Presence"](https://elixirschool.com/blog/live-view-with-presence/) by Sophie DeBenedetto

### Property-based Testing and StreamData

* [StreamData on GitHub](https://github.com/whatyouhide/stream_data)
* [StreamData documentation](https://hexdocs.pm/stream_data/StreamData.html)
* [Elixir School article on StreamData](https://elixirschool.com/en/lessons/libraries/stream-data/)
* [_Property-Based Testing with PropEr, Erlang, and Elixir_ and _PropEr Testing_](https://propertesting.com/) by Fred Hebert
* ["An introduction to property-based testing"](https://fsharpforfunandprofit.com/posts/property-based-testing/) by Scott Wlaschin
* ["Choosing properties for property-based testing"](https://fsharpforfunandprofit.com/posts/property-based-testing-2/) by Scott Wlaschin

### Estimation

* [_Agile Estimating and Planning_](https://www.mountaingoatsoftware.com/books/agile-estimating-and-planning) by Mike Cohn of Mountain Goat Software
* [planningpoker.com](https://www.planningpoker.com/) is a full-featured estimation tool that may work well for your team.

Thanks to James Grenning and Mike Cohn for their inspiration and their work in software estimation!
32 changes: 23 additions & 9 deletions assets/js/socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,50 @@ import updateUsers from './users'
const socket = new Socket('/socket', {params: {username: window.pointingParty.username}})
socket.connect()

const channel = socket.channel('room:lobby', {})
const presence = new Presence(channel)

presence.onSync(() => updateUsers(presence))
let driving = false;

// connect to Presence here
// set up your syncDiff function using updateUsers as a callback
if (window.pointingParty.username) {
channel.join()
.receive('ok', resp => { console.log('Joined successfully', resp) })
.receive('error', resp => { console.log('Unable to join', resp) })
}

const startButton = document.querySelector('.start-button')
startButton.addEventListener('click', event => {
driving = true;
// send 'start_pointing' message to the channel here
channel.push('start_pointing', {})
})

document
.querySelectorAll('.next-card')
.forEach(elem => {
elem.addEventListener('click', event => {
// send 'finalized_points' message to the channel here
channel.push('next_card', {points: event.target.value})
})
})

document
.querySelector('.calculate-points')
.addEventListener('click', event => {
const storyPoints = document.querySelector('.story-points')
// send 'user_estimated' to the channel here
channel.push('user_estimated', {points: storyPoints.value})
})

// call the relevant function defined below when you receive the following events from the channel:
// 'next_card'
// 'winner'
// 'tie'
channel.on('new_card', state => {
showCard(state)
})

channel.on('winner', state => {
showWinner(state)
})

channel.on('tie', state => {
showTie(state)
})

const showCard = state => {
document
Expand Down
38 changes: 21 additions & 17 deletions assets/js/users.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,43 @@
import { forEach, isNil, map, none } from 'ramda'

const usersElem = document.querySelector('.users')
import {every} from 'lodash'

const updateUsers = presence => {
const usersElem = document.querySelector('.users')
usersElem.innerHTML = ''
const users = presence.list(listBy)
users.forEach(addUser(usersElem))

// let users = list presences with the help of a listBy function
users.forEach(user => addUser(user))

// implement a feature that
// 1. checks if all fo the users in the present list have voted, i.e. have points values that are not nil
// 2. displays the user's vote next to their name if so
if (allHaveEstimated(users)) {
users.forEach(showPoints(usersElem))
}
}

const listBy = (username, {metas: [{points}, ..._rest]}) => {
// build out the listBy function so that it returns a list of users
// where each user looks like this:
// {username: username, points: points}
const listBy = (username, metas) => {
return {userId: username, points: metas.points}
}

const showPoints = ({userId, points}) => {
// const listBy = (userId, , ..._rest]}) => ({ userId, points })

const showPoints = usersElem => ({userId, points}) => {
const userElem = document.querySelector(`.${userId}.user-estimate`)
userElem.innerHTML = points
}

const addUser = user => {
const addUser = usersElem => ({userId, points}) => {
const userElem = document.createElement('dt')
userElem.appendChild(document.createTextNode(user.username))
userElem.appendChild(document.createTextNode(userId))
userElem.setAttribute('class', 'col-8')

const estimateElem = document.createElement('dd')
estimateElem.setAttribute('class', `${user.username} user-estimate col-4`)
estimateElem.setAttribute('class', `${userId} user-estimate col-4`)

usersElem.appendChild(userElem)
usersElem.appendChild(estimateElem)
}

const allHaveEstimated = users => {
const pointsCollection = users.map(({points}) => points)

return every(pointsCollection)
}

export default updateUsers
8 changes: 1 addition & 7 deletions assets/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
"watch": "webpack --mode development --watch"
},
"dependencies": {
"lodash": "^4.17.15",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"ramda": "^0.26.1"
"phoenix_html": "file:../deps/phoenix_html"
},
"devDependencies": {
"@babel/core": "^7.0.0",
Expand Down
5 changes: 3 additions & 2 deletions lib/pointing_party/vote_calculator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ defmodule PointingParty.VoteCalculator do

defp handle_tie(%{majority: nil, calculated_votes: calculated_votes}) do
calculated_votes
|> Enum.sort_by(&elem(&1, 1))
|> Enum.take(2)
|> Enum.sort_by(&elem(&1, 1), &>=/2)
|> Enum.map(&elem(&1, 0))
|> Enum.sort()
|> Enum.take(2)
end

defp handle_tie(%{majority: majority}), do: majority
Expand Down
54 changes: 42 additions & 12 deletions lib/pointing_party_web/channels/room_channel.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
defmodule PointingPartyWeb.RoomChannel do
use PointingPartyWeb, :channel

alias PointingParty.Card
alias PointingParty.{Card, VoteCalculator}
alias PointingPartyWeb.Presence

def join("room:lobby", _payload, socket) do
send(self(), :after_join)
Expand All @@ -10,31 +11,60 @@ defmodule PointingPartyWeb.RoomChannel do
end

def handle_info(:after_join, socket) do
# handle Presence listing and tracking here
push(socket, "presence_state", Presence.list(socket))
{:ok, _} = Presence.track(socket, socket.assigns.username, %{})
{:noreply, socket}
end

def handle_in("user_estimated", %{"points" => points}, socket) do
Presence.update(socket, socket.assigns.username, &(Map.put(&1, :points, points)))

if everyone_voted?(socket) do
calculate_story_points(socket)
end

{:noreply, socket}
end

def handle_in("next_card", %{"points" => points}, socket) do
updated_socket = save_vote_next_card(points, socket)
broadcast!(updated_socket, "new_card", %{card: current_card(updated_socket)})
{:reply, :ok, updated_socket}
end

def handle_in("start_pointing", _params, socket) do
updated_socket = initialize_state(socket)
# broadcast the "new_card" message with a payload of %{card: current_card}

broadcast!(updated_socket, "new_card", %{card: current_card(updated_socket)})
{:reply, :ok, updated_socket}
end

def handle_in("user_estimated", %{"points" => points}, socket) do
# update votes for user presence
# if everyone voted, calculate story point estimate with the help of the VoteCalculator
# broadcast the 'winner'/'tie' event with a payload of %{points: points}
intercept ["new_card"]

def handle_out("new_card", payload, socket) do
Presence.update(socket, socket.assigns.username, &(Map.put(&1, :points, nil)))
push(socket, "new_card", payload)
{:noreply, socket}
end

def handle_in("next_card", %{"points" => points}, socket) do
save_vote_next_card(points, socket)
# broadcast the "new_card" message with a payload of %{card: new_current_card}
defp current_card(socket) do
socket.assigns
|> Map.get(:current)
|> Map.from_struct()
|> Map.drop([:__meta__])
end

defp everyone_voted?(socket) do
socket
|> Presence.list()
|> Enum.map(fn {_username, %{metas: [metas]}} -> Map.get(metas, :points) end)
|> Enum.all?(&(not is_nil(&1)))
end

defp calculate_story_points(socket) do
current_users = Presence.list(socket)

{:reply, :ok, socket}
{event, points} = VoteCalculator.calculate_votes(current_users)
broadcast!(socket, event, %{points: points})
end

defp initialize_state(%{assigns: %{cards: _cards}} = socket), do: socket
Expand Down
6 changes: 3 additions & 3 deletions lib/pointing_party_web/channels/user_socket.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ defmodule PointingPartyWeb.UserSocket do
#
# See `Phoenix.Token` documentation for examples in
# performing token verification on connect.
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
def connect(%{"username" => username}, socket, _connect_info) do
{:ok, assign(socket, :username, username)}
end

# Socket id's are topics that allow you to identify all sockets for a given user:
#
Expand Down
Loading