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

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.