Starting With Phoenix Channels

I wanted to get some time with Phoenix Channels, so I figured I'd try them out by keeping the count of cart items in sync across multiple tabs. Full source code available here.

First up I had used the generator to create my channel.

mix phoenix.gen.channel Cart carts

Before I could use it I needed to wire it up by adding channel "carts:*", PhoenixCart.CartChannel to web/channels/user_socket.ex

I wanted to pass the ID of the users cart to the channel so I generated a unique token for their cart.

<%= tag :meta, name: "channel_token", content: Phoenix.Token.sign(@conn, "cart", @order.id) %>

In my app.js I grabbed that token and passed it when connecting to the socket.

socket.connect({token: $("meta[name=channel_token]").attr("content")})

Now to handle that connection

# web/channels/user_socket.ex
defmodule PhoenixCart.UserSocket do  
  use Phoenix.Socket
  alias PhoenixCart.OrderView

  channel "carts:*", PhoenixCart.CartChannel

  transport :websocket, Phoenix.Transports.WebSocket
  transport :longpoll, Phoenix.Transports.LongPoll

  def connect(%{"token" => token}, socket) do
    case Phoenix.Token.verify(socket, "cart", token) do
      {:ok, order_id} ->
        socket = assign(socket, :cart_count, OrderView.line_item_count(order_id))
        {:ok, socket}
      {:error, _} ->
        :error
    end
  end

  def id(_socket), do: nil

end  

In the connect function I first verify the token. It should give me the integer of the users cart id. I then can update our socket with that information.

Now back in the app.js

let chan = socket.channel("carts:lobby", {})  
chan.join().receive("ok", msg => {  
    console.log("Success!", msg)
})

I set the channel to join and then attempt to join that channel. This is how I handled that on the phoenix side.

# web/channels/cart_channel.ex
defmodule PhoenixCart.CartChannel do  
  use PhoenixCart.Web, :channel

  def join("carts:lobby", payload, socket) do
    if authorized?(payload) do
      {:ok, socket.assigns[:cart_count], socket}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

  def handle_out(event, payload, socket) do
    push socket, event, payload
    {:noreply, socket}
  end

  # Add authorization logic here as required.
  defp authorized?(_payload) do
    true
  end
end  

When a client joins the carts:lobby channel the :cart_count we assigned to the socket gets sent to them. This is great now they have the current count and its communicated through channels.

Now we are ready to handle the problem I wanted to solve. So if this same user opens up another tab. Adds something to their cart. Then goes back to their old tab, we want it to get the latest cart count.

# web/controllers/cart_controller.ex
def update(conn, %{"id" => id, "line_item" => line_item_params}) do  
    order = Repo.get!(Order, id)
    product = Repo.get!(Product, line_item_params["product_id"])

    changeset = LineItem.changeset(%LineItem{}, line_item_params)

    if changeset.valid? do
      Repo.insert!(changeset)

      PhoenixCart.Endpoint.broadcast!(
        "carts:lobby", "add_to_cart", %{count: PhoenixCart.OrderView.line_item_count(id)}
      )

      conn
      |> put_flash(:info, "Added to cart.")
      |> redirect(to: cart_path(conn, :index))
    else
      conn
      |> redirect(to: product_path(conn, :show, product))
    end
  end

To handle this I updated the update function on the cart controller. Anytime a users cart is updated I broadcast the add_to_cart event to the channel.

All that is left it to update the javascript to watch for that event.

chan.on("add_to_cart", msg => {  
    console.log(msg.count)
})

All set. We now have a working websocket that updates a users cart count accross tabs! We could take this a step further and update the users complete cart if they are on the cart page. Pretty awesome!