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.
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"
).
- 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.