Building a nested, dynamic form in LiveView
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!
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.
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!
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..
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!!).
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!
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!