7 GUIs: Implementing a Flight Booker in LiveView
This is the third post handling the 7 GUI tasks in LiveView. In the previous two, I wrote about implementing a counter and a temperature converter.
This post will cover some highlights of implementing a flight booker, the third of the 7 GUI tasks.
The focus of Flight Booker lies on modelling constraints between widgets on the one hand and modelling constraints within a widget on the other hand. Such constraints are very common in everyday interactions with GUI applications.
The Flight Booker
These are the task’s requirements:
- Create a select box with two options: “one-way flight” and “return flight”.
- Create two text fields for the departure and return dates. The return date text field should only be enabled for return flights.
- The return date must be strictly after the departure date.
- Invalid dates should be highlighted in red.
- The submit button should be disabled whenever something is invalid.
- Upon submission, the user should get a confirmation message with the booking dates.
Render
For rendering, I used a regular form_for
with a select
input for the flight types, two text inputs for the departure and return dates,
and a submit button. The form takes in a @changeset
which we’ll explore more
later.
def render(assigns) do
~L"""
<h1>Book Flight</h1>
<%= f = form_for @changeset, "#", id: "flight-booker", phx_submit: "book", phx_change: "validate" %>
<%= select f, :flight_type, @flight_types, id: "flight-type" %>
<%= text_input f, :departure, id: "departure-date", class: date_class(@changeset, :departure) %>
<%= error_tag f, :departure %>
<%= text_input f, :return, id: "return-date", class: date_class(@changeset, :return), disabled: one_way_flight?(@changeset) %>
<%= error_tag f, :return %>
<%= error_tag f, :date_mismatch %>
<%= submit "Book", id: "book-flight", disabled: !@changeset.valid? %>
</form>
"""
end
defp date_class(changeset, field) do
if changeset.errors[field] do
"invalid"
end
end
defp one_way_flight?(changeset) do
FlightBooker.one_way?(changeset.changes)
end
The form has two phx-
attributes. On change, we “validate”. And on submit, we
“book” the flights.
Since the return date field and the submit button have to be disabled under
certain circumstances, both have a disabled
attribute. We disable the return
date if the customer is booking a one-way flight, and we disable the submit
button if anything in the booking is invalid.
Though not part of the requirements, I also wanted to show an error
message when the return date isn’t strictly after the departure date (the
instructions only ask to disable the submit button). But I didn’t want the
background of the text fields to be red, which happens when there are errors on
:departure
or :return
. So, I chose to render an extra error called
:date_mismatch
.
Mount
Our mount function is pretty basic. We set up the :changeset
and
:flight_types
assigns. The data comes from the FlightBooker
module.
def mount(_, _, socket) do
changeset = FlightBooker.new_booking_changes()
flight_types = FlightBooker.flight_types()
{:ok, assign(socket, changeset: changeset, flight_types: flight_types)}
end
Handling events
As mentioned in the render
section, we have two events. The “validate” event
is triggered every time our form changes, and the “book” event is triggered when
the form is submitted.
As with the rest of the FlightBookerLive
code, we try to delegate most of the
logic of booking a trip to FlightBooker
.
Here are the two handle_event/3
functions in full:
def handle_event("validate", %{"booking" => params}, socket) do
changeset = FlightBooker.change_booking(socket.assigns.changeset, params)
socket
|> assign(:changeset, changeset)
|> noreply()
end
def handle_event("book", %{"booking" => params}, socket) do
{:ok, message} =
socket.assigns.changeset
|> FlightBooker.change_booking(params)
|> FlightBooker.book_trip()
socket
|> put_flash(:info, message)
|> noreply()
end
The Flight Booker and Booking modules
The FlightBooker
and Booking
modules are where the core of our work happens.
The FlightBooker
module is responsible for knowing how to book a trip. The
Booking
module contains the %Booking{}
struct and is responsible for
validations through changesets.
If you’ve never dealt with changesets, they’re a wonderful abstraction that comes from Ecto. A changeset represents a set of changes to a data structure, and it encapsulates whether those changes are valid or not.
Flight Booker
The most pertinent functions in FlightBooker
are new_booking_changes/0
and
change_booking/2
. Together, they allow us to create a new booking changeset
and to submit changes to the booking:
def new_booking_changes do
today = Date.utc_today()
booking = %Booking{departure: today, return: today}
Booking.one_way_changeset(booking)
end
def change_booking(changeset, params) do
changeset.data
|> Booking.changeset(params)
|> Map.put(:action, :insert)
end
They are both straightforward functions, and they delegate much of the work to
the Booking
module.
One thing worth highlighting is how we explicitly set the changeset’s :action
in change_booking/2
. Functions in Ecto.Repo
(such as Repo.insert
) usually
do this for us. Since we’re not persisting the data, we need to set the
:action
so that Phoenix knows to display any changeset errors on the form.
We also have a function to book the trip. But seeing as we’ve already validated the booking, the function serves mostly to choose the correct message:
def book_trip(booking) do
booking = Ecto.Changeset.apply_changes(booking)
{:ok, booking_message(booking)}
end
defp booking_message(booking) do
case booking.flight_type do
"one-way flight" ->
"You have booked a one-way flight on #{booking.departure}"
"return flight" ->
"You have booked a return flight departing #{booking.departure} and returning #{
booking.return
}"
end
end
Of course, if this were a real application and not just an exercise, I would perform validations when submitting the booking. That would ensure our booking is genuinely valid before booking flights. Since the aim of the task is more about the UI, I chose to leave the naive implementation.
The rest of the FlightBooker
functions are helper functions to get all the
flight types and check if a given booking is a one-way flight. If you’re
interested in those, take a look at the complete FlightBooker module.
Booking
If you’re familiar with Ecto, the Booking
module should be no surprise. We
start with an Ecto embedded schema for our Booking
:
embedded_schema do
field :flight_type, :string, default: "one-way flight"
field :departure, :date
field :return, :date
end
Then, we define two changesets that vary based on the flight type:
one_way_changeset/2
and two_way_changeset/2
.
def one_way_changeset(booking, changes \\ %{}) do
booking
|> cast(changes, [:flight_type, :departure])
|> validate_required([:flight_type, :departure])
|> validate_inclusion(:flight_type, ["one-way flight"])
end
def two_way_changeset(booking, changes \\ %{}) do
booking
|> cast(changes, [:flight_type, :departure, :return])
|> validate_required([:flight_type, :departure, :return])
|> validate_inclusion(:flight_type, ["return flight"])
|> validate_return_and_departure()
end
The only function that doesn’t come directly from Ecto is
validate_return_and_departure/1
. That’s the function where we make sure the
return date is strictly after the departure date:
defp validate_return_and_departure(changeset) do
departure = get_field(changeset, :departure)
return = get_field(changeset, :return)
if departure && return && Date.compare(departure, return) != :lt do
add_date_mismatch_if_last_error(changeset)
else
changeset
end
end
defp add_date_mismatch_if_last_error(changeset) do
if Enum.empty?(changeset.errors) do
add_error(changeset, :date_mismatch, "must be after departure date")
else
changeset
end
end
Note that we only add the :date_mismatch
error if there are no other errors. I
decided on that because I wanted to make sure we didn’t show the date mismatch
error if dates were invalid.
While refactoring some of the code, I also created a third changeset/2
function that chooses which of the other two changeset functions to use.
def changeset(booking, changes) do
case changes["flight_type"] do
"one-way flight" -> one_way_changeset(booking, changes)
"return flight" -> two_way_changeset(booking, changes)
end
end
That code initially lived in FlightBooker
, but I liked encapsulating that
knowledge in Booking
so that FlightBooker
could simply call
Booking.changeset/2
.
If you’re interested in seeing it all together, take a look at the entire Booking module.
Testing
The task required me to write some interesting tests because there were three things that I don’t usually run into:
- Testing CSS properties (background color being red),
- Testing disabled states, and
- Testing conditional fields on forms.
Though I typically don’t test CSS properties and disabled states, I was very pleased to know that we can test those things in LiveView if they’re essential to our application’s domain. In this case, since the 7 GUIs task explicitly states those as requirements, I decided to test them.
Testing CSS
I tested that invalid dates were highlighted in red by proxy — the test checks that the “.invalid” CSS class is present, and we assume that the red background color will be set. That could be an incorrect assumption if someone changes the CSS for “.invalid”, but I think it’s good enough for our purposes.
So, we have tests like this one, where we assert that we can find an element for the departure date with the “.invalid” class:
test "departure date field is red if date is invalid", %{conn: conn} do
date = Date.utc_today() |> to_string()
invalid_date = date <> "x"
{:ok, view, _html} = live(conn, "/flight_booker")
view
|> form("#flight-booker", booking: %{flight_type: "one-way flight", departure: invalid_date})
|> render_change()
assert has_element?(view, "#departure-date.invalid")
end
Testing disabled states
Because the task is very explicit about disabling the return field and submit
button under certain circumstances, I decided to write tests to ensure that. To
accomplish that, I used the handy :disabled
CSS pseudo-class in
the assertions.
For example, here we test that the return date is disabled for a one-way flight (the default when rendering):
test "return date is disabled if one-way flight is chosen", %{conn: conn} do
{:ok, view, _html} = live(conn, "/flight_booker")
assert has_element?(view, "#flight-type", "one-way flight")
assert has_element?(view, "#return-date:disabled")
end
Testing conditional fields on forms
Because the return field is only enabled when a user selects a “return flight”, it made for some more complex LiveView tests.
To submit a return flight, I had to first select the “return flight” and trigger a change event. Only then could I correctly fill out the complete form.
That meant I had to write tests like this:
test "user can book a return (two-way) flight", %{conn: conn} do
today = Date.utc_today() |> to_string()
tomorrow = Date.utc_today() |> Date.add(1) |> to_string()
{:ok, view, _html} = live(conn, "/flight_booker")
flight_type = "return flight"
# set flight type to "return flight" and trigger change
view
|> form("#flight-booker", booking: %{flight_type: flight_type})
|> render_change()
# now we can submit the form with a return date
html =
view
|> form("#flight-booker",
booking: %{
flight_type: flight_type,
departure: today,
return: tomorrow
}
)
|> render_submit()
assert html =~ "You have booked a return flight departing #{today} and returning #{tomorrow}"
end
I’m not a big fan of that test. I prefer my tests clearly separated into setup, action, and assertion. In that test, the change event obscures the real action. So, I went ahead and extracted some helpers so that my main action could remain clear:
test "user can book a return (two-way) flight", %{conn: conn} do
today = Date.utc_today() |> to_string()
tomorrow = Date.utc_today() |> Date.add(1) |> to_string()
{:ok, view, _html} = live(conn, "/flight_booker")
html =
view
|> set_return_flight(%{departure: today, return: tomorrow})
|> render_submit()
assert html =~ "You have booked a return flight departing #{today} and returning #{tomorrow}"
end
defp set_return_flight(view, dates) do
flight_data = Map.merge(%{flight_type: "return flight"}, dates)
view
|> change_flight_type("return flight")
|> form("#flight-booker", booking: flight_data)
end
defp change_flight_type(view, flight_type) do
view
|> form("#flight-booker", booking: %{flight_type: flight_type})
|> render_change()
view
end
If you’re interested in seeing all the tests, take a look at the test file.
Resources
These are links to the repo with all my examples and the commit for the Flight Booker:
You can also find my posts for the previous two tasks:
For a full description of all tasks, take a look at the 7 GUIs website.