#182: Phoenix LiveView Tutorial Part 6

Published January 27, 2024 10m 0s

We’ve built the guess grid and a keyboard below. But when we try to select different letters from our keyboard, our grid isn’t updated above. In this episode let’s update our application so that when a user selects a letter, our grid above is updated.

In our last episode, we created a Guesses embedded schema module to manage the state of our game. We’ll be using that to update a user’s guess when they click a letter. Alright, let’s get started by opening our keycap_component.ex and below in the render callback where we’re rendering a letter keycap. Let’s update it to use LiveViews bindings to send an event to our LiveView when a user selects a letter. Let’s add the phx-click event to our keycap with the event of “letter”. Then we’ll add the phx-value- attribute with the name of key and then for the value, we’ll use the @keycap_value assign. Now when a user clicks a letter this will send an event called “letter” to our LiveView.

Module path: lib/werdle_web/components/game_board/keycap_component.ex

...

def render(assigns) do
  ~H"""
    <kbd id={@id} phx-click="letter" phx-value-key={@keycap_value} ...>

  ...
end

...

Now let’s go ahead and handle this in our parent WordLive.Index LiveView and we’ll add a handle_event LiveView callback. In the docs, we can see how we need to implement the callback to handle the event and the params. Let’s go ahead and pattern-match on the “letter” event and we’ll pattern-match on the “key” map to get the keycap value and we need to accept the socket as well.

Then handle_event needs to return {:noreply, socket} so let’s add that. This won’t do anything now, but we can ensure that our letter event is wired up correctly and that our LiveView is set up to handle it.

lib/werdle_web/live/world_live/index.exdefmodule WerdleWeb.WordLive.Index do
  ...
  
  @impl true
  def handle_event("letter", %{"key" => key}, socket) do
    {:noreply, socket}
  end

end

Let’s start the server.

$ mix phx.server
...

And if we go back to our game and try to select different letters, our UI isn’t updating, but if we go to the logs. We see that we’ve got some logs for our “letter” event and we can see the parameters that are being sent to the callback match what we added.

[debug] HANDLE EVENT "letter" in WerdleWeb.WordLive.Index
  Parameters: %{"key" => "e"}
[debug] Replied in 156µs
[debug] HANDLE EVENT "letter" in WerdleWeb.WordLive.Index
  Parameters: %{"key" => "l"}
[debug] Replied in 152µs
[debug] HANDLE EVENT "letter" in WerdleWeb.WordLive.Index
  Parameters: %{"key" => "i"}
[debug] Replied in 142µs
[debug] HANDLE EVENT "letter" in WerdleWeb.WordLive.Index
  Parameters: %{"key" => "x"}
[debug] Replied in 153µs

Great, now we need to update our LiveView to take the letter from the guess and update our Guesses changeset with it. Now each new game should start with the initial guess row, so let’s update our mount callback to assign a current_guess of 0. Then in our handle_event callback let’s grab the Guesses changeset from the assigns. Then let’s get the current_guess.

Now we have our Guesses changeset, which will track a player’s guesses - and the row of the current guess. Let’s use these - along with the letter of the word guess - to update the state of the game. We’ll create a function to handle this in our Game module.

Let’s add a new public function called update_guesses that will take the changeset, the new_character, and the guess_row. Now we want this function to take our changeset, update the current guess row with the new letter, and then return an updated changeset. Our guess_row is an integer. We need to use that to return the appropriate field for our changeset. To do that Let’s create another function, guess_field . Then inside the function, we’ll call String.to_exsiting_atom giving it the string of “guess_” followed by our row. This matches the format we used for the fields in our Guesses embedded schema. But we need to be careful whenever dynamically creating atoms in Elixir since they are not garbage collected. Because we’ll be controlling the number of rows this should be ok.

Then once we have our guess_field we can use that with Changeset.get_change and our guesses changeset to get the current guess. If there’s a current guess for that guess field, we’ll update it by appending the new character - or letter - to the guess. Then we’ll take the changes for the changeset, pipe them into Map.merge with the updated guess for the current field, and then pipe that into our change_guesses function to return the updated changeset. If this is the first guess for a row, we’ll return a changeset for the row with a list that contains a single letter.

Let’s also add an alias for Ecto.Changeset so we can call it without the prefix.

lib/werdle/game.exdefmodule Werdle.Game do
  alias Werdle.Game.Guesses
  alias Ecto.Changeset

  ...

  def update_guesses(changeset, new_character, guess_row) do
    guess_field = guess_field(guess_row)
    if current_guess = Changeset.get_change(changeset, guess_field) do
      updated_guess = current_guess ++ [new_character]
      changeset.changes
      |> Map.merge(%{guess_field => updated_guess})
      |> change_guesses()
    else
      change_guesses(%{guess_field => [new_character]})
    end
  end

  defp guess_field(row) do
    String.to_existing_atom("guess_#{row}")
  end

end

Now that we have a function we can call to update our Guesses changeset let’s go back to our WordLive.Index LiveView. In our handle_event callback, let’s change the current_guess variable to guess_row since that’s what we’re calling it in the Game module.

Now we just need to update our socket with the new changeset. So let’s assign an updated changeset called Game.update_guesses passing in the current changeset, the key, which is our new letter, and the guess_row. Then let’s return an updated socket for it to be used in the :noreply tuple. With the changeset now updated in the socket that will make the updated changeset available to all components that we’ve passed it to via their assigns.

lib/werdle_web/live/world_live/index.exdefmodule WerdleWeb.WordLive.Index do
  ...
  @impl true
  def mount(_params, _session, socket) do
    ...
    |> assign(:current_guess, 0)
    ...
  end


  @impl true
  def handle_event("letter", %{"key" => key}, socket) do
    changeset = socket.assigns.changeset
    guess_row = socket.assigns.current_guess
    socket = assign(socket, :changeset, Game.update_guesses(changeset, key, guess_row))

    {:noreply, socket}
  end

  ...
end

Now if we go back to the game and select some letters - still nothing happens. When we select a letter, our changeset is updated in the socket assigns. But we aren’t using the values from the changeset in our component. We now just need to update the cell component to retrieve the correct character from the changeset and display it.

Since we know we’ll need a function we can call to retrieve a character from the grid. Let’s open our Game module and add a new public function called get_char_from_grid func that will take the changeset, row, and the column. We’ll need these to get the correct letter for each grid location. Then let’s use our guess_field function to get the guesses field. We can then get the guess list with Changeset.get_field passing in the changeset, and the guess_field.

Now that we have the full guess row, we just need to get the letter in that column of the row. To do that let’s call Enum.at passing in the guess list and the column.

lib/werdle/game.exdefmodule Werdle.Game do
...

def get_char_from_grid(changeset, row, column) do
  guess_field = guess_field(row)
  guess = Changeset.get_field(changeset, guess_field)
  Enum.at(guess, column)
end

...
end

Great, now we can open the cell_component.ex module. We need to call our function. But to do that, we need to get the changeset as well as the row and the column of the component. To do that let’s pattern match on the assigns to get the id and then the changeset.

Now the id gets passed into the CellComponent from the RowComponent so let’s open that up. We can see the format is “input-cell” followed by the row index and the column index. Great, we can use this string to get the row and column. Let’s go back to the CellComponent and let’s create a function to return the row and column from the id. We’ll call our new private function get_row_and_column and it will take the id. Then we’ll split our id on dashes and we know that the row and column will be the last two in the returned list, so let’s pattern match on those. For these to work with our function we need them to be integers, so let’s convert them here.

Now let’s call this from our render function above and pattern match on the returned row and column. Then we can call Game.get_char_from_grid to get the input_value. And let’s update our assigns with the input_value so we can use it as the value in our HTML below. To ensure our letter is always displayed in upper case we’ll add the uppercase class.

Oh, and we don’t want the quotes around our @input_value so I’ll remove those. Then let’s add an alias for our Werdle.Game module so we can call it without the prefix.

Template path: lib/werdle_web/components/game_board/cell_component.ex

defmodule WerdleWeb.GameBoard.CellComponent do
  ...

  alias Werdle.Game

  ...

  @impl true
  def render(%{id: id, changeset: changeset} = assigns) do
    [row, column] = get_row_and_column(id)
    input_value = Game.get_char_from_grid(changeset, row, column)
    assigns = assign(assigns, :input_value, input_value)

    ~H"""
    <div class="relative sm:w-16 sm:h-16 h-14 w-14 col-span-1 pointer-events-none select-none">
      <input
        id={@id}
        type="text"
        value={@input_value}
        class={"uppercase ..."}
        maxlength="1" />
    </div>
    """
  end

  defp get_row_and_column(id) do
    [_, _, row, column] = String.split(id, "-")
    [String.to_integer(row), String.to_integer(column)]
  end

end

Let’s test out our changes in the browser and now when we select letters from our keyboard - perfect - they are updated in the grid above! But a guess should only be 5 letters long what if we were to keep selecting letters? We can see in our logs that the handle_event continues to be called for each letter. We need to update our LiveView so that our guess is only updated up to 5 letters. Anything beyond that we don’t want to update.

Let’s go back to our WordLive.Index LiveView and in our handle_event callback let’s add an if/else conditional to it. With a function we’ll need to create called Game.five_char_guess? that will take the changeset and the guess_row. Now if the guess doesn’t have 5 characters, we’ll want to use our existing code to call Game.update_guesses to return an updated changeset for us to use. However, if we do have 5 characters in our guess we don’t want to update the changeset. So let’s just return a {:noreply, socket} as-is.

lib/werdle_web/live/word_live/index.ex...

@impl true
def handle_event("letter", %{"key" => key}, socket) do
  changeset = socket.assigns.changeset
  guess_row = socket.assigns.current_guess

  if Game.five_char_guess?(changeset, guess_row) do
    {:noreply, socket}
  else
    socket =
      socket
      |> assign(:changeset, Game.update_guesses(changeset, key, guess_row))

    {:noreply, socket}
  end
end

...

Now we need to implement the five_char_guess? function. Let’s go to the Game module and define the function. We’ll use the guess_row to get the current guess field.

To get the guesses character count, we’ll take the changeset and pipe that into Changeset.get_change passing in the guess_field and then an empty list for the default. Then let’s pipe that into Enum.count. With our character count, we can check if it is equal to 5.

lib/werdle/game.ex...

def five_char_guess?(changeset, guess_row) do
  guess_field = guess_field(guess_row)
  char_count =
    changeset
    |> Changeset.get_change(guess_field, [])
    |> Enum.count()

  char_count == 5

end

...

With implemented that let’s add an IO.inspect to our handle_event callback, so we can see the state of our Guesses changeset. Now when we click on extra letters, we can see our handle_event callback is still being invoked, but our guess stays at 5 characters.

Ready to Learn More?

Subscribe to get access to all episodes and exclusive content.

Subscribe Now