7 GUIs: Implementing a Temperature Converter in LiveView

In a previous post, I wrote about implementing a counter in LiveView. That was the first of seven typical challenges in GUI programming described on the 7 GUIs website.

In this post, I’ll cover some highlights of the second task: a temperature converter.

Temperature Converter increases the complexity of Counter by having bidirectional data flow between the Celsius and Fahrenheit inputs and the need to check the user input for validity.

The Temperature Converter

These are the task’s requirements:

  • Create two text inputs: one for Celsius and one for Fahrenheit.
  • Changing one should change the other.
  • The user should not be allowed to enter invalid values.

A temperature converter in LiveView. Setting temperature in Celsius changes the temperature in Fahrenheit and vice versa.

Render

Since we need users to provide text inputs, we use two text_input/3 helpers. I opted to render them inside two separate forms on the page because Phoenix.LiveViewTest.form/3 makes testing them a lot easier. And I like when tests are easy to write.

These are the two forms:

  <form action="#" id="celsius" phx-change="to_fahrenheit">
    <label for="celsius">Celsius</label>
    <%= text_input :temp, :celsius, value: @celsius %>
  </form>

  <form action="#" id="fahrenheit" phx-change="to_celsius">
    <label for="fahrenheit">Fahrenheit</label>
    <%= text_input :temp, :fahrenheit, value: @fahrenheit %>
  </form>

Note that each form only has a phx-change attribute attached to it. Thus, even though we’re using <form> elements, we never actually submit the forms. Instead, everything happens through change events — whenever the text inputs change, we send the corresponding event to the LiveView.

And we control the inputs’ values by passing the @celsius and @fahrenheit assigns as the value attributes. That makes LiveView the source of truth for the state of the Celsius and Fahrenheit values, similar to how React’s controlled components work.

Mount

When we mount the LiveView, we set the initial temperatures: 0°C and 32°F.

  def mount(_, _, socket) do
    {:ok, assign_temperatures(socket, C: 0, F: 32)}
  end

To always set both temperatures at the same time, we create an assign_temperatures/2 helper function:

  defp assign_temperatures(socket, temps) do
    socket
    |> assign(:celsius, Keyword.fetch!(temps, :C))
    |> assign(:fahrenheit, Keyword.fetch!(temps, :F))
  end

That function prevents us from accidentally updating only one value in the UI, displaying an incorrect temperature conversion — e.g., 0°C and 212°F.

Handling events

Since we have two forms, we have two handle_event/3 callbacks. Here, we’ll only consider the callback that handles the conversion from Celsius to Fahrenheit. The other callback is the mirror image, and you can find it in the commit.

Our LiveView process receives the "to_fahrenheit" event, and we extract the temperature passed in the parameters:

  def handle_event("to_fahrenheit", %{"temp" => %{"celsius" => celsius}}, socket) do

We convert the temperature from a string to an integer and pass it to our Temperature module which is responsible for transforming one temperature into the other.

We then assign both temperatures, and LiveView re-renders the page. Note that we use our assign_temperatures/2 helper here too:

  fahrenheit = Temperature.to_fahrenheit(celsius)

  socket
  |> assign_temperatures(C: celsius, F: fahrenheit)
  |> noreply()

On my first pass, I used String.to_integer/1 to turn the celsius parameter value into an integer. But the task also specifies that non-numerical values should not update the opposite temperature.

So, I changed the implementation to use Integer.parse/1 instead. If the parsing fails, Integer.parse/1 returns an :error, and we can then put an error message on the page.

With that, the handle event callback looks like this:

  def handle_event("to_fahrenheit", %{"temp" => %{"celsius" => celsius}}, socket) do
    case Integer.parse(celsius) do
      {celsius, ""} ->
        fahrenheit = Temperature.to_fahrenheit(celsius)

        socket
        |> assign_temperatures(C: celsius, F: fahrenheit)
        |> noreply()

      :error ->
        socket
        |> put_flash(:error, "Celsius must be a number")
        |> noreply()
    end
  end

There was one surprising modification I had to make to the code above. When a user inputs an invalid value, we show a flash message. If then they change the input to a valid value, the error message does not disappear.

Thus, I had to make sure to remove the error message when we successfully parse the value into an integer. That means I had to add a put_flash/3 call in the success path:

   {celsius, ""} ->
     fahrenheit = Temperature.to_fahrenheit(celsius)

     socket
     |> assign_temperatures(C: celsius, F: fahrenheit)
+    |> put_flash(:error, nil)
     |> noreply()

I didn’t like having to leave that code there because it is unintuitive — when I think of a colleague looking at the code for the first time, I imagine it will be confusing to see us setting the error flash to nil there.

Thinking back on it, I could’ve done two things to clarify the resetting of the flash:

1) I could have put it in a helper function called reset_flash/1 to indicate the intent behind setting the error flash to nil:

defp reset_flash(socket), do: put_flash(socket, :error, nil)

2) And, I could have moved it inside the assign_temperatures/2 helper to make it clear that assigning temperatures successfully and resetting the flash should be synchronized:

  defp assign_temperatures(socket, temps) do
    socket
    |> assign(:celsius, Keyword.fetch!(temps, :C))
    |> assign(:fahrenheit, Keyword.fetch!(temps, :F))
    |> reset_flash()
  end

I didn’t do those two things at the time (so you won’t see them in the commit), but I think they would be nice improvements.

The Temperature module

The formula for converting a temperature C in Celsius into a temperature F in Fahrenheit is C = (F - 32) * (5/9) and the dual direction is F = C * (9/5) + 32.

I extracted the functions to convert one temperature into the other into a Temperature module. I like to extract non-LiveView logic out of the LiveView module as much as possible so that LiveView is only concerned with rendering and updating state.

The Temperature module is simple:

defmodule Gui.Temperature do
  def to_fahrenheit(celsius) do
    celsius * (9 / 5) + 32
  end

  def to_celsius(fahrenheit) do
    (fahrenheit - 32) * (5 / 9)
  end
end

We could have added guards to validate that the values passed are integers, but it didn’t seem necessary for our use case since we already validated that in the LiveView.

Failure recovery

One of the great things about LiveView is that it has failure recovery by default.

If you look closely at the code, there’s one scenario (that I know of) that will cause the process to crash — when the user inputs an invalid string that starts with an integer but then has other values (e.g. "01a").

Inputing "01a" into Celsius causes the page to hang. LiveView refreshes to a clean state.

  • The LiveView process crashes.
  • The screen hangs for a second.
  • Then the form resets.

Here’s the error in the logs:

[error] GenServer #PID<0.551.0> terminating
** (CaseClauseError) no case clause matching: {1, "a"}
    (gui 0.1.0) lib/gui_web/live/temperature_live.ex:28:
    GuiWeb.TemperatureLive.handle_event/3

As you might have guessed, we don’t handle that type of string in the handle_event/3 callback. We only considered two cases of Integer.parse/1, but there is a third:

iex> Integer.parse("23")
{23, ""} # success path

iex> Integer.parse("asdf")
:error # error path

iex> Integer.parse("23asdf")
{23, "asdf"} # unhandled path

We could easily have accounted for that in our handle_event/3 callback by changing the second pattern match in the case statement from :error to the catch all _:

-  :error ->
+  _ ->
     socket
     |> put_flash(:error, "Celsius must be a number")
     |> noreply()

But even without that change, our Temperature Converter restarts to a good state without a problem. That’s the beauty of Erlang’s (and Elixir’s) fault tolerance.

Yes, the process crashes. But another one is started in its place, and LiveView’s client gracefully reconnects to the server. So we can continue converting temperatures without a problem.

Testing

There are many tests in the temperature_live_test.exs. Here are two for some of the most important functionality:

1) Testing converting Celsius to Fahrenheit:

# Converting Celsius to Fahrenheit:

  test "user converts temp from Celsius to Fahrenheit", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/temperature")

    view
    |> form("#celsius", temp: %{celsius: 5})
    |> render_change()

    assert view |> fahrenheit_field(41.0) |> has_element?()
  end

Note we created a fahrenheit_field/2 helper to abstract the CSS selector needed to find the input’s value, and that helper uses the wonderful Phoenix.LiveViewTest.element/3:

  defp fahrenheit_field(view, value) do
    element(view, "#temp_fahrenheit[value='#{value}']")
  end

2) Testing validations of non-integer inputs:

# Ensuring we're doing validation against invalid inputs:

  test "validates against invalid celsius input", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/temperature")

    html =
      view
      |> form("#celsius", temp: %{celsius: "hello"})
      |> render_change()

    assert html =~ "Celsius must be a number"
  end

You can see how nice it is to test with the form/3 and render_change/2 helpers in both of those tests.

Resources

You can find the repo with all my examples, and the commit for the Temperature Converter.

For a full description of all tasks, take a look at the 7 GUIs website.