Building a nested, dynamic form in LiveView

Karoline Lende
multiverse-tech
Published in
13 min readJun 30, 2023

At Multiverse, we are building an outstanding alternative to university and corporate training in the form of professional apprenticeships. The Multiverse Platform, built primarily using Elixir, Phoenix and LiveView, helps candidates find the apprenticeship of their dreams.

Recently, I was involved with the development of a new interview scheduling feature, where hiring managers can input their availability to interview directly on the Multiverse Platform. Candidates then get invited to book their interview from the available slots.

This was a prime opportunity for our team to experiment and build with LiveView. We needed a form that would allow the user to:

  • Add multiple interview availabilities on different days in one go, by specifying a start and end time for each slot
  • Delete existing interview availabilities
  • Only amend interview availability slots in the future (adding a slot for the past makes little sense!)
  • Not be able to add overlapping interview availabilities for the same day
  • Not be able to add an availability that was shorter than the required interview duration

In other words, we needed a dynamic, nested form that was time aware, where possible start and end time selections were dependent on other selections. What at first sounded quite simple suddenly started to sound a little more complicated, and a lot more interesting!

View of the interview scheduling form where you can add and delete interview availability slots.
The lovely new interview availability form we built in LiveView.

When I started working on this I had very little experience with LiveView, so it was a steep learning curve for me, but I learned a few very useful tricks that made working with LiveView so much easier. In this article, I will go through a step-by-step approach to building the form, gradually adding more complexity. I’ve tried explaining the key bits of code required, but you can also access the full code in my GitHub repo.

Please note that this demo is written in LiveView v0.17.5. Since then, there’s been several great improvements to LiveView to make it easier to build dynamic, nested forms! Maybe one day I’ll get around to rewriting this app in the newest LiveView version 🤩

A note on the database structure…

In Phoenix LiveView, your frontend form is tightly coupled with your backend through Ecto and changesets. Therefore, it is useful to understand your database structure upfront, and keep this in mind when you build out the form.

Why do we have a nested form? Well.. For each open role, we have one or more interview stages. Each interview stage can have many interview availabilities. And each availability in turn can have many interview bookings.

Nested associations in our database.

With this structure in mind, let’s start building!

1. Building out the basic scaffolding

Let’s start by building a very simple form where you can toggle between view and edit mode.

In the mount function, assign the interview stage to the socket in order to display some basic information. We also want to be able to toggle between view and edit mode, so I’ll set edit mode to false by default. 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.ex 

def mount(%{"interview_stage_id" => interview_stage_id}, _session, socket) do
interview_stage = InterviewStages.get_by_id!(interview_stage_id)

socket =
socket
|> assign(interview_stage: interview_stage)
|> assign(edit_mode: false)

{:ok, socket}
end

(I really wish Medium had syntax highlighting for Elixir!)

We’ll use a components file to determine what should be rendered. In the heex file: 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.html.heex

<h1> Interview Availability Form </h1>
<h2> <%= @interview_stage.name %> </h2>
<%= if @edit_mode do %>
<FormComponents.edit_availability />
<% else %>
<FormComponents.show_availability />
<% end %>

Now our initial components will look like this: 🔗 View on GitHub

# lib/live_view_scheduler/interview_availability_live/form_components.ex

defmodule LiveViewSchedulerWeb.InterviewAvailabilityLive.FormComponents do
use LiveViewSchedulerWeb, :component

def show_availability(assigns) do
~H"""
<div> View mode </div>
<button phx-click="toggle-edit-mode"> Edit </button>
"""
end

def edit_availability(assigns) do
~H"""
<div> Edit mode </div>
<button phx-click="toggle-edit-mode"> Cancel </button>
"""
end
end

Finally, in our index file we want to handle toggling edit mode: 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.ex 

def handle_event("toggle-edit-mode", _, socket) do
{:noreply, assign(socket, edit_mode: !socket.assigns.edit_mode)}
end

Phew, now the basic scaffolding is in place. We can start building the form!

Looking beautiful.

2. Viewing existing availability for different weeks

Before we start creating a form for adding and deleting availability, we want to build out the show_availability component to display existing availability. We’ll do this week by week, and allow the user to toggle between weeks as well.

Firstly, we want to assign the currently selected week and the existing availability for that week in the mount function. We will load the availability as an association on the interview stage. 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.ex 

def mount(%{"interview_stage_id" => interview_stage_id}, _session, socket) do
selected_week_beginning = Timex.beginning_of_week(Date.utc_today())

interview_stage =
InterviewStages.get_weeks_availability_for_stage(
interview_stage_id,
selected_week_beginning
)

socket =
socket
|> assign(interview_stage: interview_stage)
|> assign(edit_mode: false)
|> assign(selected_week_beginning: selected_week_beginning)

{:ok, socket}
end

The query to load the selected week’s availability is shown below. Here, we are selecting availabilities with a start datetime after the start of the week and before the end of the week. Note we also have a deleted field for soft deleting availability, we’ll get to that later! We are also adding a virtual date field, which we can use to group the availabilities by day. 🔗 View on GitHub

# lib/live_view_scheduler/interview_stages.ex

def get_weeks_availability_for_stage(interview_stage_id, start_of_week) do
end_of_week = Timex.shift(start_of_week, weeks: 1)

Repo.one(
from is in InterviewStage,
left_join: ia in assoc(is, :interview_availabilities),
on:
not ia.deleted and
ia.start_datetime >= ^start_of_week and
ia.start_datetime < ^end_of_week,
where: is.id == ^interview_stage_id,
preload: [interview_availabilities: ia],
order_by: ia.start_datetime
)
|> Map.update!(:interview_availabilities, fn availabilities ->
availabilities |> Enum.map(&%{&1 | date: DateTime.to_date(&1.start_datetime)})
end)
end

Now we build the week select buttons: 🔗 View on GitHub

# lib/live_view_scheduler/interview_availability_live/form_components.ex

def show_availability(assigns) do
~H"""
<div> View mode </div>
<.week_select selected_week_beginning={@selected_week_beginning} />
<button phx-click="toggle-edit-mode"> Edit </button>
"""
end

defp week_select(assigns) do
~H"""
<div>
<h3> Week beginning: <%= @selected_week_beginning %> </h3>
<div>
<button phx-click="change_week" value="-1">
<span>← Previous week</span>
</button>
<button phx-click="change_week" value="1">
<span>Next week →</span>
</button>
</div>
</div>
"""
end

We now need to handle the week toggle event in our index file. It’s basically the exact same code as in the mount function, but changing which week to load our availabilities for. 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.ex 

def handle_event("change_week", %{"value" => value}, socket) do
selected_week_beginning =
Timex.shift(socket.assigns.selected_week_beginning, weeks: String.to_integer(value))

interview_stage =
InterviewStages.get_weeks_availability_for_stage(
socket.assigns.interview_stage.id,
selected_week_beginning
)

socket =
socket
|> assign(selected_week_beginning: selected_week_beginning)
|> assign(interview_stage: interview_stage)

{:noreply, socket}
end

Finally, we need to display the availabilities. As mentioned, we have added the virtual date field so that we can group availabilities by date. We use the helper function called find_availability_for_week to group the availabilities. 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.ex 

defp find_availability_for_week(start_of_week, availability) do
Enum.map(0..6, &Date.add(start_of_week, &1))
|> Enum.map(&{&1, availability |> filter_for_date(&1)})
end

defp filter_for_date(availability, date) do
availability
|> Enum.filter(fn
%{date: ^date} ->
true

_ ->
false
end)
end

Neat! Now we can display existing availability slots, grouped by date, in view mode. 🔗 View on GitHub

# lib/live_view_scheduler/interview_availability_live/form_components.ex

def day_availability(assigns) do
~H"""
<div class="flex">
<div class="m-8">
<span><%= @date %></span>
</div>
<%= if @slots == [] do %>
<div class="m-8">No availability added</div>
<% else %>
<div class="m-8">
<%= for %{start_datetime: start_time, end_datetime: end_time} <- @slots do %>
<div>
<%= format_time_window(start_time, end_time) %>
</div>
<% end %>
</div>
<% end %>
</div>
"""
end

Let’s see how things are looking in the UI..

Availabilities being displayed!

3. Adding new slots

Now we get to the meaty part. Everything has been static up until now, but we need to build the functionality to allow for editing slots. We are going to look at adding new slots first, and then deleting slots.

As we are creating our form, the first thing we need to think about is our changeset. The concept of Ecto changesets was difficult to wrap my head around as a beginner in Elixir and Phoenix LiveView. Remember we are dealing with nested data, so we need to create a changeset on the interview stage level that allows for editing several associated interview availabilities at once. So, let’s create our changeset first! 🔗 View on GitHub

# lib/live_view_scheduler/interview_stage.ex

def availability_changeset(
%__MODULE__{interview_availabilities: interview_availabilities} = interview_stage,
attrs
)
when is_list(interview_availabilities) do
interview_stage
|> cast(attrs, [])
|> cast_assoc(
:interview_availabilities,
with: fn interview_availability, attributes ->
%{interview_availability | interview_stage: interview_stage}
|> InterviewAvailability.create_changeset(attributes)
end
)
end

In this function, we are using the cast_assoc function to add multiple interview availabilities to the changeset. We delegate this to the interview availability changeset function: 🔗 View on GitHub

# lib/live_view_scheduler/interview_availability.ex  

def create_changeset(changeset \\ %__MODULE__{}, attrs) do
changeset
|> cast(attrs, [
:start_datetime,
:end_datetime,
:interview_stage_id,
:temp_id,
:date,
:deleted
])
|> validate_required(:start_datetime, message: "Start time must be selected")
|> validate_required(:end_datetime, message: "End time must be selected")
|> foreign_key_constraint(:interview_stage_id,
name: :interview_availability_interview_stage_id_fkey
)
|> validate_date_times()
|> validate_cannot_delete_past_availability()
end

You will see there are a bunch of validations, which I won’t go through in detail here. They include validating that:

  • The slot is at least as long as the duration of the interview stage
  • The end time is after the start time
  • The start and end time are on the same day

We also have a virtual field with a temporary id. This will be used for adding new slots.

Now that we have our changeset, we can start building out the form. For this we will use the Phoenix <.form /> component, with a change-time event and a save event. Similarly to our view mode, we are grouping our availabilities by day. First of all, we create the nested form: 🔗 View on GitHub

# lib/live_view_scheduler/interview_availability_live/form_components.ex

def edit_availability(assigns) do
~H"""
<div>Edit mode</div>
<h3>Week beginning: <%= @selected_week_beginning %></h3>
<.form let={f} for={@changeset} id="availability-form" phx-change="change-time" phx-submit="save">
<%= for {date, slot_forms} <- group_availability(f, @selected_week_beginning) do %>
<.edit_day_availability {assigns_to_attributes(assigns)} date={date} slot_forms={slot_forms} />
<% end %>
<button type="button" class="bg-gray" phx-click="toggle-edit-mode">Cancel</button>
<button type="submit">Save</button>
</.form>
"""
end

The group_availability function is very similar to the find_availability_for_week function we defined earlier. However, this time we are filtering on a Phoenix form builder rather than a list of interview availability structs. Therefore we are using the built-in inputs_for function. It is also important to note that the virtual date field can either be a DateTime struct or a string, depending on whether or not the slot has been persisted in the database, so we need to account for both of these cases in our filtering. 🔗 View on GitHub

# lib/live_view_scheduler/interview_availability_live/form_components.ex

defp group_availability(form, start_of_week) do
week_availabilities = inputs_for(form, :interview_availabilities)

Enum.map(0..6, &Date.add(start_of_week, &1))
|> Enum.map(&{&1, filter_for_date(&1, week_availabilities)})
end

defp filter_for_date(date, week_availabilities) do
week_availabilities
|> Enum.filter(fn availability_form ->
availability_date =
case input_value(availability_form, :date) do
%Date{} = x -> x
x when is_binary(x) -> Date.from_iso8601!(x)
end

availability_date == date
end)
end

The output of the group_availability function for a week with one availability slot will look something like the list below, with further data nested within the InterviewAvailability struct.

[
{~D[2023-05-22], []},
{~D[2023-05-23],
[
%Phoenix.HTML.Form{
source: #Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #LiveViewScheduler.InterviewAvailability<>, valid?: true>,
impl: Phoenix.HTML.FormData.Ecto.Changeset,
id: "availability-form_interview_availabilities_0",
name: "interview_stage[interview_availabilities][0]",
data: %LiveViewScheduler.InterviewAvailability{...},
errors: [],
index: 0,
action: nil
}
]},
{~D[2023-05-24], []},
{~D[2023-05-25], []},
{~D[2023-05-26], []},
{~D[2023-05-27], []},
{~D[2023-05-28], []}
]

Now we can take a look at our edit_day_availability component. For each day, we check if there are any slots, and if so loop through and display them. We have a few hidden inputs, including the temporary id, the date and the interview stage id. We will also have a plus button that allows for adding new slots with a new-slot event. 🔗 View on GitHub

# lib/live_view_scheduler/interview_availability_live/form_components.ex

defp edit_day_availability(assigns) do
~H"""
<div class="flex">
<div class="m-8">
<span><%= @date %></span>
</div>
<div class="flex">
<%= if @slot_forms == [] do %>
<span class="m-8">No availability added</span>
<% else %>
<div>
<%= for slot_form <- @slot_forms do %>
<fieldset>
<%= hidden_inputs_for(slot_form) %>
<%= hidden_input(slot_form, :temp_id) %>
<%= hidden_input(slot_form, :date) %>
<%= hidden_input(slot_form, :interview_stage_id) %>
<div>
<.future_slot slot_form={slot_form} date={@date} />
</div>
</fieldset>
<% end %>
</div>
<% end %>
<button type="button" phx-value-selected-day={@date} phx-click="new-slot" class="m-8">
+
</button>
</div>
</div>
"""
end

Each future slot has two selects to allow for updating the start and end time for the slot. You can check the full code in my GitHub repo. In this example, I haven’t implemented the logic for dynamically updating the time options in the dropdown based on other slots and selected start/end times (perhaps the topic for another blog post — this one is long enough as it is! 😅).

The last step is to add our new event handlers. We need event handlers for:

  • Adding a new slot
  • Changing the start or end time for a slot
  • Saving the form

Adding a new slot is the most interesting. In this case, we use build_assoc to create a new, empty availability with a temporary random id and a start and end time of nil. We then append this to our list of existing availabilities, and use put_assoc to update our changeset. 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.ex

def handle_event(
"new-slot",
%{"selected-day" => selected_day},
%{
assigns: %{
interview_stage: interview_stage,
changeset: changeset
}
} = socket
) do
date = Date.from_iso8601!(selected_day)

existing_availability =
Changeset.get_change(
changeset,
:interview_availabilities,
Changeset.get_field(changeset, :interview_availabilities)
)

new_availability =
Ecto.build_assoc(interview_stage, :interview_availabilities, %{
temp_id: :crypto.strong_rand_bytes(5) |> Base.url_encode64() |> binary_part(0, 5),
date: date,
start_datetime: nil,
end_datetime: nil
})

changeset =
Changeset.put_assoc(
changeset,
:interview_availabilities,
existing_availability ++ [new_availability]
)

{:noreply, assign(socket, changeset: changeset)}
end

The change-time event is much simpler, we simply take in the form data and pass it to the changeset function. 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.ex

def handle_event("change-time", %{"interview_stage" => interview_stage}, socket) do
changeset =
InterviewStage.availability_changeset(socket.assigns.interview_stage, interview_stage)

{:noreply, assign(socket, changeset: changeset)}
end

When saving our availabilities, we call Repo.update with our new changeset. If the update is successful, we switch back to view mode with our new availabilities and an empty changeset. If the update is unsuccessful, we reassign the changeset to allow for displaying form errors. 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.ex

def handle_event("save", %{"interview_stage" => interview_stage}, socket) do
socket =
InterviewStage.availability_changeset(socket.assigns.interview_stage, interview_stage)
|> Repo.update()
|> case do
{:ok, _} ->
updated_interview_stage =
InterviewStages.get_weeks_availability_for_stage(
socket.assigns.interview_stage.id,
socket.assigns.selected_week_beginning
)

socket
|> assign(edit_mode: false)
|> assign(:interview_stage, updated_interview_stage)
|> assign(
:changeset,
InterviewStage.availability_changeset(updated_interview_stage, %{})
)
|> clear_flash(:error)
|> put_flash(:info, "Availability saved")

{:error, changeset} ->
socket
|> put_flash(:error, "Error saving availability")
|> assign(changeset: changeset)
end

{:noreply, socket}
end

This is starting to look like a proper form now (if you ignore the terrible styling!!).

Look, we can edit things now!

4. Deleting slots

The final aspect I want to cover is how to delete slots. Let’s first add our delete-slot event. If the slot hasn’t been persisted in the database yet, we can simply delete it from our changeset. If the slot is currently saved in the database, we soft delete it by marking the column deleted as true. Similarly to our new-slot event, we can then use put_assoc to update our changeset. 🔗 View on GitHub

# lib/live_view_scheduler_web/interview_availability_live/index.ex

def handle_event(
"delete-slot",
%{"index" => index},
%{assigns: %{changeset: changeset}} = socket
) do
index = String.to_integer(index)

existing_availability =
Changeset.get_change(
changeset,
:interview_availabilities,
Changeset.get_field(changeset, :interview_availabilities)
)

slot_to_delete = Enum.at(existing_availability, index)

updated_availability =
if availability_is_already_persisted?(slot_to_delete) do
existing_availability
|> List.update_at(index, &Changeset.change(&1, %{deleted: true}))
else
List.delete_at(existing_availability, index)
end

changeset = Changeset.put_assoc(changeset, :interview_availabilities, updated_availability)

{:noreply, assign(socket, changeset: changeset)}
end

Finally, we need to add in a few extra bits to the form.

Firstly, we add hidden fields for deleted slots, so that we can keep track of the deleted field in our changeset. Secondly, we need to handle the case where we delete all slots for a day. For this day we will still have a list of slots, but with the field deleted set to true. So by filtering these out we can determine when we need to go back to displaying our “No availability added” copy. Finally, we have the delete button that is displayed within the <.future_slot /> component. 🔗 View on GitHub

# lib/live_view_scheduler/interview_availability_live/form_components.ex

defp edit_day_availability(assigns) do
~H"""
(...)
<div>
<%= case @slot_forms do %>
<% [] -> %>
<span>No availability added</span>
<% slot_forms -> %>
<%= if @all_deleted do %>
<span>No availability added</span>
<% end %>
<%= Enum.sort_by(slot_forms, &(input_value(&1, :deleted) != true)) |> Enum.map(fn slot_form -> %>
<%= if input_value(slot_form, :deleted) == true do %>
<%= hidden_inputs_for(slot_form) %>
<%= hidden_input(slot_form, :deleted) %>
<% else %>
<fieldset>
<%= hidden_inputs_for(slot_form) %>
<%= hidden_input(slot_form, :temp_id) %>
<%= hidden_input(slot_form, :date) %>
<%= hidden_input(slot_form, :interview_stage_id) %>
<div>
<.future_slot slot_form={slot_form} date={@date} />
</div>
</fieldset>
<% end %>
<% end) %>
<% end %>
</div>
<button type="button" phx-value-selected-day={@date} phx-click="new-slot" class="m-8">
+
</button>
</div>
</div>
"""
end

defp future_slot(assigns) do
~H"""
<div class="flex">
<.time_select id={@id} slot_form={@slot_form} field={:start_datetime} date={@date} />
<span class="m-8">-</span>
<.time_select id={@id} slot_form={@slot_form} field={:end_datetime} date={@date} />
<button type="button" phx-value-index={@slot_form.index} phx-click="delete-slot" class="m-8">
🗑
</button>
</div>
"""
end

Phewf, that’s quite the form!

And voilà!

Closing remarks

In this blog post I’ve covered how to build a nested form for interview scheduling in LiveView. I would love to hear if you’ve found it useful!

However, there were many things I didn’t cover:

  • Only allow for amending interview availability slots in the future (adding a slot for the past makes little sense!)
  • Restricting the user from adding overlapping interview availabilities for the same day
  • Dynamically updating available time options in the select dropdowns, so that the UI would restrict you from adding invalid slots
  • Displaying interview bookings for each interview availability
  • Timezones 😅

If you enjoyed this blog post and want to hear more about my lessons learned when working on this feature as an Elixir and LiveView newbie, check out my ElixirConf EU talk.

As Chris McCord demonstrated at ElixirConf EU in April, building nested forms in LiveView is going to get much simpler with the most recent Ecto and LiveView changes. Those changes have now been released, and I’ll be interested to see how much simpler it would be to build this form — perhaps I’ll even have another go!

--

--