DEV Community

Lubien
Lubien

Posted on • Edited on

The Lazy Programmer's Intro to LiveView: Chapter 7

Conceding points to the winner

Sometimes you will lose and that's okay. Just don't forget to give your opponent 3 points for their victory. In this chapter, we will explore how one user could give 3 points to another if they lose to them. In case you haven't already, create a second account on your app.

Dynamic interactions with LiveView

So far we haven't created any sort of user interaction besides boring links from one page to another. Let's make our user LiveView more powerful. What we need to do is create an 'I lost to this person' button that when pressed gives them 3 points. Let's start with the render function. Update your lib/champions_web/live/user_live/show.ex render function to:

@impl true
def render(assigns) do
  ~H"""
  <.header>
    User <%= @user.id %>
    <:subtitle>This is a player on this app.</:subtitle>
  </.header>

  <.list>
    <:item title="Email"><%= @user.email %></:item>
    <:item title="Points"><%= @user.points %></:item>
  </.list>

+ <div class="my-4">
+   <.button type="button" phx-click="concede_loss">I lost to this person</.button>
+ </div>

  <.back navigate={~p"/users"}>Back to users</.back>
  """
end
Enter fullscreen mode Exit fullscreen mode

We just added a Phoenix <.button> component and it contains an attribute called phx-click. Clicking this button will trigger an event on our LiveView but there's a catch: we are not ready for this event yet. Press that button right now and your terminal will show the following error:

[error] GenServer #PID<0.3550.0> terminating
** (UndefinedFunctionError) function ChampionsWeb.UserLive.Show.handle_event/3 is undefined or private
    ChampionsWeb.UserLive.Show.handle_event("concede_loss", %{"value" => ""}, #Phoenix.LiveView.Socket<id: "phx-F2mNT8Nhl-Qz2gxH", endpoint: ChampionsWeb.Endpoint, view: ChampionsWeb.UserLive.Show, parent_pid: nil, root_pid: #PID<0.3550.0>, router: ChampionsWeb.Router, assigns: %{__changed__: %{}, current_user: #Champions.Accounts.User<__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1, email: "lubien@example.com", confirmed_at: ~N[2023-06-17 13:55:41], points: 0, inserted_at: ~N[2023-06-17 13:53:56], updated_at: ~N[2023-06-17 13:55:41], ...>, flash: %{}, live_action: :show, page_title: "Showing user enemy@example.com", user: #Champions.Accounts.User<__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 2, email: "enemy@example.com", confirmed_at: nil, points: 0, inserted_at: ~N[2023-06-18 10:28:49], updated_at: ~N[2023-06-18 10:28:49], ...>}, transport_pid: #PID<0.3544.0>, ...>)
    (phoenix_live_view 0.19.2) lib/phoenix_live_view/channel.ex:401: anonymous fn/3 in Phoenix.LiveView.Channel.view_handle_event/3
    (telemetry 1.2.1) /Users/lubien/workspace/champions/deps/telemetry/src/telemetry.erl:321: :telemetry.span/3
    (phoenix_live_view 0.19.2) lib/phoenix_live_view/channel.ex:221: Phoenix.LiveView.Channel.handle_info/2
    (stdlib 3.17.2) gen_server.erl:695: :gen_server.try_dispatch/4
    (stdlib 3.17.2) gen_server.erl:771: :gen_server.handle_msg/6
    (stdlib 3.17.2) proc_lib.erl:236: :proc_lib.wake_up/3
Last message: %Phoenix.Socket.Message{topic: "lv:phx-F2mNT8Nhl-Qz2gxH", event: "event", payload: %{"event" => "concede_loss", "type" => "click", "value" => %{"value" => ""}}, ref: "12", join_ref: "4"}
State: %{components: {%{}, %{}, 1}, join_ref: "4", serializer: Phoenix.Socket.V2.JSONSerializer, socket: #Phoenix.LiveView.Socket<id: "phx-F2mNT8Nhl-Qz2gxH", endpoint: ChampionsWeb.Endpoint, view: ChampionsWeb.UserLive.Show, parent_pid: nil, root_pid: #PID<0.3550.0>, router: ChampionsWeb.Router, assigns: %{__changed__: %{}, current_user: #Champions.Accounts.User<__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1, email: "lubien@example.com", confirmed_at: ~N[2023-06-17 13:55:41], points: 0, inserted_at: ~N[2023-06-17 13:53:56], updated_at: ~N[2023-06-17 13:55:41], ...>, flash: %{}, live_action: :show, page_title: "Showing user enemy@example.com", user: #Champions.Accounts.User<__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 2, email: "enemy@example.com", confirmed_at: nil, points: 0, inserted_at: ~N[2023-06-18 10:28:49], updated_at: ~N[2023-06-18 10:28:49], ...>}, transport_pid: #PID<0.3544.0>, ...>, topic: "lv:phx-F2mNT8Nhl-Qz2gxH", upload_names: %{}, upload_pids: %{}}
Enter fullscreen mode Exit fullscreen mode

That's a lot to unpack but bear with me. I like Phoenix errors because they tell you a lot once you understand how to read them. I'd like to teach you that right now so let's break down it bit by bit:

Learning how to interpret a LiveView event error

First of all, the first line tells you something very important: [error] GenServer #PID<0.3550.0> terminating. That means this error was bad, our LiveView page crashed and had to be restarted. That's not a great experience so you definitely want to fix this.

On line 2 you can already see a good hint at the issue: UndefinedFunctionError. That name says all: LiveView expected a certain function to be defined but it wasn't. Keep reading that line and we can see that function ChampionsWeb.UserLive.Show.handle_event/3 is undefined or private tells us that we forgot to create a handle_event function that takes 3 arguments or we mistakenly created it as a private function using defp. Well you didn't forget it since you're learning yet, I just wanted you to experience this cool error message though.

But say you've never heard of handle_event/3 , what do I need to do with that function? Line 3 gives you exactly what Phoenix tried passing to that function so you can pretty much get started with that: ChampionsWeb.UserLive.Show.handle_event("concede_loss", %{"value" => ""}, #Phoenix.LiveView.Socket<id: "phx-F2mNT8Nhl-Qz2gxH"...>). Do note that Phoenix.LiveView.Socket contains a lot of information so it can be threatening to read the whole piece but in time you will learn to ignore it and focus on the other arguments, I even cut it for you already in this paragraph.

Creating our handle_event/3

handle_event/3 is a new LiveView callback you should learn about. It is triggered when one of your LiveView events from the render function is called such as phx-click. In this case, we created a phx-click="concede_loss" so our event name will be concede_loss. The second argument with contains values, we will ignore that for now, and last but not least there's the socket as the third argument. Did you notice that all callbacks have the socket as the last argument? That's because all callbacks are just functions that manage what is inside the socket so it makes sense for it to be available.

@impl true
def handle_event("concede_loss", _value, socket) do
  {:noreply, socket}
end
Enter fullscreen mode Exit fullscreen mode

The code above is the minimal event handler needed to receive our custom event and do nothing. handle_event/3 must return either {:noreply, socket} or {:reply, map, socket}. For now, we will focus on the :noreply case. Now that we have an event handler we need to modify it to get the user being shown on the page and add 3 points to them. Fortunately socket contains a property called assigns that has all assigns. Since this page has a user assigns, socket.assigns.user is readily available to us.

@impl true
def handle_event("concede_loss", _value, socket) do
  user = socket.assigns.user
  {:ok, updated_user} = Accounts.update_user_points(user, user.points + 3)
  {:noreply, assign(socket, :user, updated_user)}
end
Enter fullscreen mode Exit fullscreen mode

That's it! All we had to do was get the user assign and run that function we created back in the early chapters of this project. Don't forget to reassign the user so the LiveView updates immediately without a page refresh needed to show the new points.

Conceding a draw

I've also told you when this series started that draws would give both players 1 point. You can probably guess 90% of what needs to be done by copying and pasting what you just learned so let's start with that. We will create a concede_draw event that's a carbon copy of concede_loss.

  @impl true
  def handle_event("concede_loss", _value, socket) do
    user = socket.assigns.user
    {:ok, updated_user} = Accounts.update_user_points(user, user.points + 3)
    {:noreply, assign(socket, :user, updated_user)}
  end
+
+ def handle_event("concede_draw", _value, socket) do
+   user = socket.assigns.user
+   {:ok, updated_user} = Accounts.update_user_points(user, user.points + 1)
+   {:noreply, assign(socket, :user, updated_user)}
+ end

  @impl true
  def render(assigns) do
    ~H"""
    <.header>
      User <%= @user.id %>
      <:subtitle>This is a player on this app.</:subtitle>
    </.header>

    <.list>
      <:item title="Email"><%= @user.email %></:item>
      <:item title="Points"><%= @user.points %></:item>
    </.list>

    <div class="my-4">
      <.button type="button" phx-click="concede_loss">I lost to this person</.button>
+     <.button type="button" phx-click="concede_draw">Declare draw match</.button>
    </div>

    <.back navigate={~p"/users"}>Back to users</.back>
    """
  end
Enter fullscreen mode Exit fullscreen mode

Do note we didn't add @impl true on the second handle_event/3 clause because Elixir will already infer that from the first one. How can I know which user am I on my LiveView so I can add 1 point to myself? Back when we generated our auth code Phoenix added something called LiveSession, which we will explore later, but the important bit is that it adds an assign called current_user on your socket, so you can use it on callbacks and on your render functions, we even used it to show your points on the navbar.

def handle_event("concede_draw", _value, socket) do
+ my_user = socket.assigns.current_user
  user = socket.assigns.user
  {:ok, updated_user} = Accounts.update_user_points(user, user.points + 1)
+ {:ok, updated_my_user} = Accounts.update_user_points(my_user, my_user.points + 1)
- {:noreply, assign(socket, :user, updated_user)}
+ {:noreply,
+   socket
+   |> assign(:user, updated_user)
+   |> assign(:current_user, updated_my_user)
+ }
end
Enter fullscreen mode Exit fullscreen mode

Amazing, we already have two main features of our app up and running. But if you pay attention to your navbar you will notice that your points aren't increasing but if you refresh the page your new points will show up. What is happening with our layout?

Difference between root.html.heex and app.html.heex layout

The root.html.heex layout is meant to be rendered only once and never update again with one single exception: updating page titles. That means that in order for us to see live updates to our navbar we must move our navbar logic to app.html.heex which is LiveView-aware. That's quite simple actually. Remove this entire ul from your root.html.heex:

<!DOCTYPE html>
<html lang="en" class="[scrollbar-gutter:stable]">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <.live_title suffix=" · Phoenix Framework">
      <%= assigns[:page_title] || "Champions" %>
    </.live_title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
    </script>
  </head>
  <body class="bg-white antialiased">
-   <ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
-     <%= if @current_user do %>
-       <li class="text-[0.8125rem] leading-6 text-zinc-900">
-         <%= @current_user.email %>
-         (<%= @current_user.points %> points)
-       </li>
-       <li>
-         <.link
-           href={~p"/users/settings"}
-           class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
-         >
-           Settings
-         </.link>
-       </li>
-       <li>
-         <.link
-           href={~p"/users/log_out"}
-           method="delete"
-           class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
-         >
-           Log out
-         </.link>
-       </li>
-     <% else %>
-       <li>
-         <.link
-           href={~p"/users/register"}
-           class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
-         >
-           Register
-         </.link>
-       </li>
-       <li>
-         <.link
-           href={~p"/users/log_in"}
-           class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
-         >
-           Log in
-         </.link>
-       </li>
-     <% end %>
-   </ul>
    <%= @inner_content %>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Then add it to the top of your app.html.heex:

+<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
+<%= if @current_user do %>
+  <li class="text-[0.8125rem] leading-6 text-zinc-900">
+    <%= @current_user.email %>
+    (<%= @current_user.points %> points)
+  </li>
+  <li>
+    <.link
+      href={~p"/users/settings"}
+      class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
+    >
+      Settings
+    </.link>
+  </li>
+  <li>
+    <.link
+      href={~p"/users/log_out"}
+      method="delete"
+      class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
+    >
+      Log out
+    </.link>
+  </li>
+<% else %>
+  <li>
+    <.link
+      href={~p"/users/register"}
+      class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
+    >
+      Register
+    </.link>
+  </li>
+  <li>
+    <.link
+      href={~p"/users/log_in"}
+      class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
+    >
+      Log in
+    </.link>
+  </li>
+<% end %>
+</ul>
<header class="px-4 sm:px-6 lg:px-8">
... rest of the things here
Enter fullscreen mode Exit fullscreen mode

Now your points should be updated each time you click on the draw button! But here's a catch: we just broke our tests! If you run mix test you will spot two errors. They're both related to the fact that the login and register tests send you to the root path / and expect your navbar to be there but the homepage explicitly says it doesn't want to use app.html.heex on lib/champions_web/controllers/page_controller.ex by render(conn, :home, layout: false).

That's okay and since we won't be caring for that page in the future the solution is quite simple: let's change those tests to send users to the /users page. Locate and change this on test/champions_web/controllers/user_session_controller_test.exs

# Now do a logged in request and assert on the menu
-conn = get(conn, ~p"/")
+conn = get(conn, ~p"/users")
response = html_response(conn, 200)
assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log_out"
Enter fullscreen mode Exit fullscreen mode

Do the same thing on test/champions_web/live/user_registration_live_test.exs:

# Now do a logged in request and assert on the menu
-conn = get(conn, "/")
+conn = get(conn, "/users")
response = html_response(conn, 200)
assert response =~ email
assert response =~ "Settings"
assert response =~ "Log out"
Enter fullscreen mode Exit fullscreen mode

Your mix test should be running just fine now!

Preventing unauthorized users from using these buttons

Right now if you open an anonymous tab and hit those buttons you will have pleasant surprises. Hitting the concede loss button will work just fine even though I'm not a player. Hitting the concede draw button will crash our LiveView because there's no current_user assign. Let's fix that.

@impl true
def handle_event("concede_loss", _value, socket) do
  socket =
    if socket.assigns.current_user do
      user = socket.assigns.user
      {:ok, updated_user} = Accounts.update_user_points(user, user.points + 3)
      assign(socket, :user, updated_user)
    else
      put_flash(socket, :error, "You must be a player to do that!")
    end

  {:noreply, socket}
end

def handle_event("concede_draw", _value, socket) do
  socket =
    if my_user = socket.assigns.current_user do
      user = socket.assigns.user
      {:ok, updated_user} = Accounts.update_user_points(user, user.points + 1)
      {:ok, updated_my_user} = Accounts.update_user_points(my_user, my_user.points + 1)
      socket
      |> assign(:user, updated_user)
      |> assign(:current_user, updated_my_user)
    else
      put_flash(socket, :error, "You must be a player to do that!")
    end

  {:noreply, socket}
end
Enter fullscreen mode Exit fullscreen mode

This time a simple if expression could solve our problems. Even if our user shouldn't be able to do some events we still need to return {:noreply, socket} but in case they're unauthorized we will use put_flash/3 to show a pretty error message. Now let's hide these buttons from those who shouldn't see them:

@impl true
def render(assigns) do
  ~H"""
  <.header>
    User <%= @user.id %>
    <:subtitle>This is a player on this app.</:subtitle>
  </.header>

  <.list>
    <:item title="Email"><%= @user.email %></:item>
    <:item title="Points"><%= @user.points %></:item>
  </.list>

- <div class="my-4">
+ <div :if={@current_user} class="my-4">
    <.button type="button" phx-click="concede_loss">I lost to this person</.button>
    <.button type="button" phx-click="concede_draw">Declare draw match</.button>
  </div>

  <.back navigate={~p"/users"}>Back to users</.back>
  """
end
Enter fullscreen mode Exit fullscreen mode

HEEx nodes can use :if={condition} to show elements, pretty handy. At this point, you could be asking yourself 'Why bother validating in the events if we are not going to show these buttons?' And that's a really good question. The thing about LiveView events is that there's no real magic on the HTML side, a <.button phx-click="concede_loss"> is just a regular HTML button like <button phx-click="concede_loss>. If your users are clever to figure that out they could 👻 haxx your system! Don't believe me? Paste the HTML below on an unauthenticated tab on /users/:id using your browser dev tools:

<button type="button" phx-click="concede_loss">
  Hacking to the Gate 🔥
</button>
Enter fullscreen mode Exit fullscreen mode

How to prevent a user from giving points to himself infinitely

Right now you can also go to your own page and concede losses/draws to yourself and you get all the points. That's not right so let's fix this!

@impl true
def handle_event("concede_loss", _value, socket) do
  socket =
    case {socket.assigns.current_user, socket.assigns.user} do
      {nil, _user} ->
        put_flash(socket, :error, "You must be a player to do that!")

      {%{id: my_id}, %{id: other_id}} when my_id == other_id ->
        put_flash(socket, :error, "You can't give points to yourself")

      {_my_user, user} ->
        {:ok, updated_user} = Accounts.update_user_points(user, user.points + 3)
        assign(socket, :user, updated_user)
    end

  {:noreply, socket}
end

def handle_event("concede_draw", _value, socket) do
  socket =
    case {socket.assigns.current_user, socket.assigns.user} do
      {nil, _user} ->
        put_flash(socket, :error, "You must be a player to do that!")

      {%{id: my_id}, %{id: other_id}} when my_id == other_id ->
        put_flash(socket, :error, "You can't give points to yourself")

      {my_user, user} ->
        {:ok, updated_user} = Accounts.update_user_points(user, user.points + 1)
        {:ok, updated_my_user} = Accounts.update_user_points(my_user, my_user.points + 1)
        socket
        |> assign(:user, updated_user)
        |> assign(:current_user, updated_my_user)
    end

  {:noreply, socket}
end
Enter fullscreen mode Exit fullscreen mode

This time we changed our if to a pattern match to compare {my_user, user} so we can prevent both my_user being nil and my_user and user being the same person. As for the HEEx change:

-<div :if={@current_user} class="my-4">
+<div :if={@current_user && @current_user.id != @user.id} class="my-4">
Enter fullscreen mode Exit fullscreen mode

That should cover all cases.

Don't forget the tests

How do we test a LiveView with a button click? How do we test whether being authenticated or not and whether I'm the same user on that page or not? In all tests that required a user so far we did this:

describe "Show" do
  setup [:create_user]

  test "displays user", %{conn: conn, user: user} do
    {:ok, _show_live, html} = live(conn, ~p"/users/#{user}")

    assert html =~ "This is a player on this app"
    assert html =~ user.email
  end
end
Enter fullscreen mode Exit fullscreen mode

The setup [:create_user] will create a user for us and pass it under our context at line 4 so we can immediately go to its page. :create_user does not authenticates us. If we change that to setup [:register_and_log_in_user] it will authenticate us and our context user will be ourselves so we can use this to go to our own page. Let's add a test for that. Go to test/champions_web/live/user_live_test.exs and add this suite:

describe "Authenticated Show" do
  setup [:register_and_log_in_user]

  test "displays my own user but no action buttons", %{conn: conn, user: user} do
    {:ok, _show_live, html} = live(conn, ~p"/users/#{user}")

    assert html =~ "This is a player on this app"
    assert html =~ user.email
    refute html =~ "I lost to this person"
    refute html =~ "Declare draw match"
  end
end
Enter fullscreen mode Exit fullscreen mode

Now we should also be able to go to another user's page and see those buttons. Inside that describe block add this test:

test "displays another user with action buttons", %{conn: conn, user: _user} do
  other_user = user_fixture()
  {:ok, _show_live, html} = live(conn, ~p"/users/#{other_user}")

  assert html =~ "This is a player on this app"
  assert html =~ other_user.email
  assert html =~ "I lost to this person"
  assert html =~ "Declare draw match"
end
Enter fullscreen mode Exit fullscreen mode

Now we should also verify that unauthenticated users can't see those buttons. Luckly we already have a describe for that! Here's how the full suite for this page ended up as:

describe "Authenticated Show" do
  setup [:register_and_log_in_user]

  test "displays my own user but no action buttons", %{conn: conn, user: user} do
    {:ok, _show_live, html} = live(conn, ~p"/users/#{user}")

    assert html =~ "This is a player on this app"
    assert html =~ user.email
    refute html =~ "I lost to this person"
    refute html =~ "Declare draw match"
  end

  test "displays another user with action buttons", %{conn: conn, user: _user} do
    other_user = user_fixture()
    {:ok, _show_live, html} = live(conn, ~p"/users/#{other_user}")

    assert html =~ "This is a player on this app"
    assert html =~ other_user.email
    assert html =~ "I lost to this person"
    assert html =~ "Declare draw match"
  end
end

describe "Show" do
  setup [:create_user]

  test "displays user", %{conn: conn, user: user} do
    {:ok, _show_live, html} = live(conn, ~p"/users/#{user}")

    assert html =~ "This is a player on this app"
    assert html =~ user.email
+   refute html =~ "I lost to this person"
+   refute html =~ "Declare draw match"
  end
end
Enter fullscreen mode Exit fullscreen mode

But we forgot to test our actions! Before we do that we are going to do two small HTML tweaks that will make our testing live easier. We are going to add tags to help us find the number of points users have inside our HTML. Inside show.ex change this line:

-<:item title="Points"><%= @user.points %></:item>
+<:item title="Points"><span data-points><%= @user.points %></span></:item>
Enter fullscreen mode Exit fullscreen mode

And inside app.html.heex change this line:

<li class="text-[0.8125rem] leading-6 text-zinc-900">
  <%= @current_user.email %>
- (<%= @current_user.points %> points)
+ <span data-my-points>(<%= @current_user.points %> points)</span>
</li>
Enter fullscreen mode Exit fullscreen mode

First, add an alias to Champions.Accounts on the top of our user_live_test.exs file. We are going to be using it to check the state of things in the database.

defmodule ChampionsWeb.UserLiveTest do
  use ChampionsWeb.ConnCase

+ alias Champions.Accounts
  import Phoenix.LiveViewTest
  import Champions.AccountsFixtures
...
Enter fullscreen mode Exit fullscreen mode

From now on we will add tests to "Authenticated Show". Here's how you can trigger a click action from LiveView:

test "concede 3 points when I lose to another player", %{conn: conn, user: _user} do
  other_user = user_fixture()
  {:ok, show_live, _html} = live(conn, ~p"/users/#{other_user}")

  assert other_user.points == 0
  assert show_live |> element("button", "I lost to this person") |> render_click()
  assert element(show_live, "span[data-points]") |> render() =~ "3"
  assert Accounts.get_user!(other_user.id).points == 3
end
Enter fullscreen mode Exit fullscreen mode

This time we use new helpers from LiveViewTest. First, we need to locate the button so we use element/3 to locate a <button> with a certain text. After that, we use render_click/2 to trigger our event. After that, we use element/3 again on our LiveView to find the span[data-points] we just added to see if the value 3 appears there, this ensures our LiveView is updating the UI. The last test line gets the latest state of the other user and asserts it should be 3. Time to add the draw match test:

test "concede 1 point to each user when there's a draw match", %{conn: conn, user: user} do
  other_user = user_fixture()
  {:ok, show_live, _html} = live(conn, ~p"/users/#{other_user}")

  assert user.points == 0
  assert other_user.points == 0
  assert show_live |> element("button", "Declare draw match") |> render_click()
  assert element(show_live, "span[data-my-points]") |> render() =~ "1"
  assert element(show_live, "span[data-points]") |> render() =~ "1"
  assert Accounts.get_user!(user.id).points == 1
  assert Accounts.get_user!(other_user.id).points == 1
end
Enter fullscreen mode Exit fullscreen mode

Very similar to the previous test but we add checks for the logged-in user too. At this point, we should be very confident our tests cover the important bits of our user interactions. Now let's spend a bit of our time on optimization.

Refactoring because we can

Now that we have unit tests that ensure our system work we have room for some refactoring without having the trouble of manual testing every step of the way. Whenever we change something just run mix test and you should be good to go. I've saved some cool things just for now.

The first thing we can notice is that both handle_event/3 have the same check for users being logged in and users being different. We can use pattern matching to make that simpler! Let's start with one:

@impl true
+def handle_event(_event_name, _value, %{assigns: %{current_user: nil}} = socket) do
+ {:noreply, put_flash(socket, :error, "You must be a player to do that!")}
+end
+
def handle_event("concede_loss", _value, socket) do
  socket =
    case {socket.assigns.current_user, socket.assigns.user} do
-     {nil, _user} ->
-       put_flash(socket, :error, "You must be a player to do that!")
-
      {%{id: my_id}, %{id: other_id}} when my_id == other_id ->
        put_flash(socket, :error, "You can't give points to yourself")

      {_my_user, user} ->
        {:ok, updated_user} = Accounts.update_user_points(user, user.points + 3)
        assign(socket, :user, updated_user)
    end

  {:noreply, socket}
end

def handle_event("concede_draw", _value, socket) do
  socket =
    case {socket.assigns.current_user, socket.assigns.user} do
-     {nil, _user} ->
-       put_flash(socket, :error, "You must be a player to do that!")
-
      {%{id: my_id}, %{id: other_id}} when my_id == other_id ->
        put_flash(socket, :error, "You can't give points to yourself")

      {my_user, user} ->
        {:ok, updated_user} = Accounts.update_user_points(user, user.points + 1)
        {:ok, updated_my_user} = Accounts.update_user_points(my_user, my_user.points + 1)
        socket
        |> assign(:user, updated_user)
        |> assign(:current_user, updated_my_user)
    end

  {:noreply, socket}
end
Enter fullscreen mode Exit fullscreen mode

We added a clause for any handle_event/3 on the top that ignores the _event_name and matches if current_user is nil. If it is, stop everything and put_flash that error message. With that simple addition, we already traded 4 duplicated lines with 3 lines that should be easier to read. It's not really about the line count, it's all about being able to easily see that this first clause has an important meaning for all the events below. Let's do the same for the other error condition. Here's the final product:

@impl true
def handle_event(_event_name, _value, %{assigns: %{current_user: nil}} = socket) do
  {:noreply, put_flash(socket, :error, "You must be a player to do that!")}
end

def handle_event(_event_name, _value, %{assigns: %{current_user: current_user, user: user}} = socket)
 when current_user.id == user.id do
 {:noreply, put_flash(socket, :error, "You can't give points to yourself")}
end

def handle_event("concede_loss", _value, %{assigns: %{user: user}} = socket) do
  {:ok, updated_user} = Accounts.update_user_points(user, user.points + 3)
  {:noreply, assign(socket, :user, updated_user)}
end

def handle_event("concede_draw", _value, %{assigns: %{current_user: current_user, user: user}} = socket) do
  {:ok, updated_user} = Accounts.update_user_points(user, user.points + 1)
  {:ok, updated_my_user} = Accounts.update_user_points(current_user, current_user.points + 1)
  {:noreply,
    socket
    |> assign(:user, updated_user)
    |> assign(:current_user, updated_my_user)
  }
end
Enter fullscreen mode Exit fullscreen mode

Summary

  • phx-click works for sending events from HTML to your LiveView.
  • LiveView events are caught by handle_event/3 which takes 3 arguments: event name, value, and the socket.
  • Being able to read LiveView errors will help you creating your applications.
  • You can access assigns from socket.assigns including current_user to get data about yourself if you're logged in.
  • root.html.heex never updates based on assigns except for page_title. app.html.heex will be updated if they use assigns.
  • Dead Views and LiveViews can opt out of using app.html.heex if they need.
  • To verify that your user is logged in, check for socket.assigns.current_user.
  • You can add :if={condition} to HTML tags to make HEEx render or not them.
  • You should not trust hiding buttons from unauthorized users directly on HEEx, you should validate your events callbacks.
  • You can use put_flash/3 to send messages to your user.
  • LiveView comes with useful helper functions for tests ranging from getting elements on the screen to clicking buttons.
  • You can create handle_event/3 clauses to handle errors for all events below them b simply ignoring the event name.
  • You can keep your mind at easy refactoring your code if you have a good battery of tests. mix test can be your best friend.

Top comments (0)