In the last part of the series, we implemented the initial page of the application, which lists all the available event types, letting the visitor select one and transition to the next page that we left empty. To do so, we took advantage of LiveView's features like live sessions and function components. In this part, we will start by implementing all the logic surrounding the empty CalendlexWeb.EventTypeLive
live view. We will render a monthly calendar, letting the visitor navigate through the months, and selecting a date. Let's get cracking!
Displaying the initial event type page
Let's start by modifying the CalendlexWeb.EventTypeLive
module to load the corresponding event type on its mount callback:
# ./lib/calendlex_web/live/event_type_live.ex
defmodule CalendlexWeb.EventTypeLive do
use CalendlexWeb, :live_view
alias CalendlexWeb.Components.EventType
def mount(%{"event_type_slug" => slug}, _session, socket) do
case Calendlex.get_event_type_by_slug(slug) do
{:ok, event_type} ->
socket =
socket
|> assign(event_type: event_type)
|> assign(page_title: event_type.name)
{:ok, socket}
{:error, :not_found} ->
{:ok, socket, layout: {CalendlexWeb.LayoutView, "not_found.html"}}
end
end
end
Taking the event type slug from the parameters received, it calls the Calendlex.get_event_type_by_slug/1
function. If the event type exists, it assigns it to the socket and sets the corresponding page title. On the contrary, it renders a regular error page. Let's first take care of the happy path and implement the Calendlex.get_event_type_by_slug/1
function:
# ./lib/calendlex.ex
defmodule Calendlex
# ...
defdelegate get_event_type_by_slug(slug),
to: Calendlex.EventType.Repo,
as: :get_by_slug
end
It delegates its call to the Calendlex.EventType.Repo.get_by_slug/1
function. Let's add it:
# ./lib/calendlex/event_type/repo.ex
defmodule Calendlex.EventType.Repo do
alias Calendlex.{EventType, Repo}
# ...
def get_by_slug(slug) do
case Repo.get_by(EventType, slug: slug) do
nil ->
{:error, :not_found}
event_type ->
{:ok, event_type}
end
end
end
If we start the Phoenix server and visit http://localhost:4000/15-minute-meeting, we should see the empty page correctly. Now let's take care of the error path, and create the not_found
layout template:
# ./lib/calendlex_web/templates/layout/not_found.html.heex
<main role="main" class="py-32 mx-auto">
<div class="w-2/5 mx-auto">
<div class="px-6 py-12 mb-2 text-center bg-white border border-gray-200 shadow-md rounded-md gap-x-2">
<header class="mb-8 text-lg font-bold text-gray-900">
<h2><%= @owner.name %></h2>
<p>This Calendlex URL is not valid.</p>
</header>
<p>If you are the owner of this account, you can log in to find out more.</p>
</div>
</div>
</main>
If we visit an invalid URL like http://localhost:4000/invalid-event-type, we should see the following:
Great! Let's edit the CalendlexWeb.EventTypeLive
template file and add some initial content:
# ./lib/calendlex_web/live/event_type_live.html.heex
<div class="w-3/5 mx-auto">
<div class="flex flex-auto p-6 mb-2 bg-white border border-gray-200 shadow-md rounded-md gap-x-2">
<div class="flex-1">
<div class="mb-4">
<%= live_redirect to: Routes.live_path(@socket, CalendlexWeb.PageLive) do %>
<div class="flex items-center justify-center inline-block text-xl text-blue-500 border rounded-full w-9 h-9">
<i class="fas fa-arrow-left"></i>
</div>
<% end %>
</div>
<h4 class="text-gray-500">Bigardone</h4>
<h1 class="my-3 text-xl text-black"><%= @event_type.name %></h1>
<div class="flex flex-row items-center mb-2 text-gray-500 gap-2">
<div class="text-gray-300">
<i class="far fa-clock"></i>
</div>
<%= @event_type.duration %> min
</div>
</div>
<div class="px-8 border-l border-gray-100">
<header class="mb-8">
<h3 class="text-lg font-semibold text-gray-900">Select a date & time</h3>
</header>
</div>
</div>
</div>
Once the browser refreshes the page, we should see the following:
Rendering the monthly calendar
With the initial layout ready, let's work on the monthly calendar. Since we will work with dates and times and formatting them, let's use Timex, which is the usual library I use for these cases. To install it, we need to add it to the dependencies in the mix file and run the corresponding mix deps.get
command:
# ./mix.exs
defmodule Calendlex.MixProject do
use Mix.Project
# ...
defp deps do
[
# ...
{:timex, "~> 3.7"}
]
end
# ...
end
Before continuing further, we have to consider the visitor's time zone to handle any date and time. Therefore, we need a way to assign it to the socket. Fortunately for us, this is very straightforward, thanks to JavaScript. Let's edit the main app.js
file:
// ./assets/js/app.js
// ...
const liveSocket = new LiveSocket('/live', Socket, {
params: {
_csrf_token: csrfToken,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
});
// ...
We add a new timezone
parameter to the live socket connection with the timeZone
value from Intl.DateTimeFormat.prototype.resolvedOptions(), which returns a new object with properties reflecting the current user's browser's locale and date and time. Remember the live public session we created in the previous part? Let's edit it to take into account the timezone
parameter:
# ./lib/calendlex_web/live/init_assigns.ex
defmodule CalendlexWeb.Live.InitAssigns do
import Phoenix.LiveView
def on_mount(:default, _params, _session, socket) do
owner = Application.get_env(:calendlex, :owner)
time_zone = get_connect_params(socket)["timezone"] || owner.time_zone
socket =
socket
|> assign(:owner, owner)
|> assign(:time_zone, time_zone)
{:cont, socket}
end
end
We use get_connect_params/1 to take the value from the socket's connection parameters and assign it to the socket. If timezone
does not exist, we set the owner's time zone as the default value. Let's jump back to the CalendlexWeb.EventTypeLive
and add the corresponding assigns that we need to build the calendar:
# ./lib/calendlex_web/live/event_type_live.ex
defmodule CalendlexWeb.EventTypeLive do
alias Calendlex.{EventType, Repo}
alias Timex.Duration
def mount(%{"event_type_slug" => slug}, _session, socket) do
case Calendlex.get_event_type_by_slug(slug) do
{:ok, event_type} ->
socket =
socket
# ...
|> assign_dates()
{:ok, socket}
# ...
end
end
defp assign_dates(socket) do
current = Timex.today(socket.assigns.time_zone)
beginning_of_month = Timex.beginning_of_month(current)
end_of_month = Timex.end_of_month(current)
previous_month =
beginning_of_month
|> Timex.add(Duration.from_days(-1))
|> date_to_month()
next_month =
end_of_month
|> Timex.add(Duration.from_days(1))
|> date_to_month()
socket
|> assign(current: current)
|> assign(beginning_of_month: beginning_of_month)
|> assign(end_of_month: end_of_month)
|> assign(previous_month: previous_month)
|> assign(next_month: next_month)
end
defp date_to_month(date_time) do
Timex.format!(date_time, "{YYYY}-{0M}")
end
end
In the mount/3
function's happy path, we call a new assign_dates
function that takes the socket
. This function calculates the current date, the beginning, and the end of the current month and assigns them to the socket. It also assigns the next and previous months with the "{YYYY}-{0M}"
format that we will use to navigate through the different months. Let's get back to the view's template and build up the calendar:
# ./lib/calendlex_web/live/event_type_live.html.heex
<div class="w-3/5 mx-auto">
<div class="flex flex-auto p-6 mb-2 bg-white border border-gray-200 shadow-md rounded-md gap-x-2">
<div class="flex-1">
# ...
<%= @event_type.duration %> min
</div>
</div>
<div class="px-8 border-l border-gray-100">
<header class="mb-8">
<h3 class="text-lg font-semibold text-gray-900">Select a date & time</h3>
</header>
<EventType.calendar
id="calendar"
current_path={Routes.live_path(@socket, CalendlexWeb.EventTypeLive, @event_type.slug)}
previous_month={@previous_month}
next_month={@next_month}
current={@current}
end_of_month={@end_of_month}
beginning_of_month={@beginning_of_month}
time_zone={@time_zone} />
</div>
</div>
</div>
We will use a new function component in the already existing CalendlexWeb.Components.EventType
module to render the calendar. Let's alias this module in the view and implement the new function:
# ./lib/calendlex/event_type/repo.ex
defmodule Calendlex.EventType.Repo do
# ...
alias CalendlexWeb.Components.EventType
#...
end
# ./lib/calendlex_web/live/components/event_type.ex
defmodule CalendlexWeb.Components.EventType do
use Phoenix.Component
# ...
def calendar(
%{
current_path: current_path,
previous_month: previous_month,
next_month: next_month
} = assigns
) do
previous_month_path = build_path(current_path, %{month: previous_month})
next_month_path = build_path(current_path, %{month: next_month})
assigns =
assigns
|> assign(previous_month_path: previous_month_path)
|> assign(next_month_path: next_month_path)
~H"""
<div>
<div class="flex items-center mb-8">
<div class="flex-1">
<%= Timex.format!(@current, "{Mshort} {YYYY}") %>
</div>
<div class="flex justify-end flex-1 text-right">
<%= live_patch to: @previous_month_path do %>
<button class="flex items-center justify-center w-10 h-10 text-blue-700 align-middle rounded-full hover:bg-blue-200">
<i class="fas fa-chevron-left"></i>
</button>
<% end %>
<%= live_patch to: @next_month_path do %>
<button class="flex items-center justify-center w-10 h-10 text-blue-700 align-middle rounded-full hover:bg-blue-200">
<i class="fas fa-chevron-right"></i>
</button>
<% end %>
</div>
</div>
<div class="mb-6 text-center uppercase calendar grid grid-cols-7 gap-y-2 gap-x-2">
<div class="text-xs">Mon</div>
<div class="text-xs">Tue</div>
<div class="text-xs">Wed</div>
<div class="text-xs">Thu</div>
<div class="text-xs">Fri</div>
<div class="text-xs">Sat</div>
<div class="text-xs">Sun</div>
</div>
<div class="flex items-center gap-x-1">
<i class="fas fa-globe-americas"></i>
<%= @time_zone %>
</div>
</div>
"""
end
defp build_path(current_path, params) do
current_path
|> URI.parse()
|> Map.put(:query, URI.encode_query(params))
|> URI.to_string()
end
end
calendar
takes the assigned previous_month
and nex_month
values to generate the month navigation paths, consisting of current_path
with a query string parameter named month
with the corresponding value. Why are we doing this? To render a different month in the calendar, we have to update the current
, beginning_of_month
, and end_of_month
assigns. There are two ways of achieving this:
- Add a click event on each month's navigation buttons and the corresponding
handle_event/3
callback function in the live view. - Use live_patch/2 against the same URL, add any query string parameter we need, and implement the corresponding handle_params/3 callback function in the live view module.
In this particular case, the second option has two winning advantages over the first one. Firstly, handle_params/3
is called right after mount/3
and before the initial render so we can reuse its logic for the initial mount. Secondly, and most important, it also updates the browser's URL, so if the user refreshes the page, all the parameters that we use to build the view's state will not get lost. Let's jump back to the browser and check that we see the following:
Looking good so far. However, if we click on either of the <
or >
buttons to display a different month, we can see the following error in the terminal:
[error] GenServer #PID<0.617.0> terminating
** (UndefinedFunctionError) function CalendlexWeb.EventTypeLive.handle_params/3 is undefined or private
(calendlex 0.1.0) CalendlexWeb.EventTypeLive.handle_params(%{"event_type_slug" => "15-minute-meeting", "month" => "2021-12"}, "http://localhost:4000/15-minute-meeting?month=2021-12", #Phoenix.LiveView.Socket<assigns: %{__changed__: %{}, beginning_of_month: ~D[2021-11-01], current: ~D[2021-11-26], end_of_month: ~D[2021-11-30], event_type: %Calendlex.EventType{__meta__: #Ecto.Schema.Metadata<:loaded, "event_types">, color: "blue", description: "Short meeting call.", duration: 15, id: "51bd2b10-783f-42f5-bc89-57e4253d0127", inserted_at: ~N[2021-11-23 07:17:59], name: "15 minute meeting", slug: "15-minute-meeting", updated_at: ~N[2021-11-23 07:17:59]}, flash: %{}, live_action: nil, next_month: "2021-12", owner: %{name: "Bigardone", time_zone: "Europe/Madrid"}, page_title: "15 minute meeting", previous_month: "2021-10", time_slots: [], time_zone: "Europe/Madrid"}, endpoint: CalendlexWeb.Endpoint, id: "phx-FrsEfVWph4A5eQCE", parent_pid: nil, root_pid: #PID<0.617.0>, router: CalendlexWeb.Router, transport_pid: #PID<0.611.0>, view: CalendlexWeb.EventTypeLive, ...>)
(phoenix_live_view 0.17.5) lib/phoenix_live_view/utils.ex:369: anonymous fn/5 in Phoenix.LiveView.Utils.call_handle_params!/5
(telemetry 1.0.0) /Users/ricardogarciavega/projects/elixir/calendlex/deps/telemetry/src/telemetry.erl:293: :telemetry.span/3
(phoenix_live_view 0.17.5) lib/phoenix_live_view/channel.ex:117: Phoenix.LiveView.Channel.handle_info/2
(stdlib 3.15) gen_server.erl:695: :gen_server.try_dispatch/4
(stdlib 3.15) gen_server.erl:771: :gen_server.handle_msg/6
(stdlib 3.15) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
To fix the error, we must implement the handle_params/3
callback in the CalendlexWeb.EventTypeLive
module:
# ./lib/calendlex_web/live/event_type_live.ex
defmodule CalendlexWeb.EventTypeLive do
use CalendlexWeb, :live_view
alias CalendlexWeb.Components.EventType
def mount(%{"event_type_slug" => slug}, _session, socket) do
case Calendlex.get_event_type_by_slug(slug) do
{:ok, event_type} ->
socket =
socket
|> assign(event_type: event_type)
|> assign(page_title: event_type.name)
# we remove this line
# |> assign_dates()
{:ok, socket}
{:error, :not_found} ->
{:ok, socket, layout: {CalendlexWeb.LayoutView, "not_found.html"}}
end
end
def handle_params(params, _uri, socket) do
# we call `assign_dates` passing `params` as well
socket = assign_dates(socket, params)
{:noreply, socket}
end
# this function now accepts an additional parameter `params`
defp assign_dates(socket, params) do
current = current_from_params(socket, params)
# ...
end
defp current_from_params(socket, %{"month" => month}) do
case Timex.parse("#{month}-01", "{YYYY}-{0M}-{D}") do
{:ok, current} ->
NaiveDateTime.to_date(current)
_ ->
Timex.today(socket.assigns.time_zone)
end
end
defp current_from_params(socket, _) do
Timex.today(socket.assigns.time_zone)
end
# ...
end
We have made four changes to the current module's logic:
- First of all, since
handle_params/3
gets called right aftermount/3
, we don't need to callassign_dates/2
frommount
, so we can remove the call. - We have implemented the
handle_params/3
callback function, which callsassign_dates/2
passing the socket and the parameters received in the request. - We have added a new parameter,
params
, to theassign_dates
function that we use to calculate the value ofcurrent
. - Last but not least, we have added a new
current_from_params/2
function which takes the socket and the parameters and buildscurrent
.
If we go back to the browser and click again on the previous and next month buttons, we should see how the URL in the browser updates, rendering the corresponding month. Moreover, if we now refresh the browser, it doesn't lose track of the month we were displaying. Yay!
Our calendar is almost ready. The only thing left is rendering the month's days. Let's jump back to the CalendlexWeb.Components.EventType.calendar/1
function component and implement the corresponding logic:
# ./lib/calendlex_web/live/components/event_type.ex
defmodule CalendlexWeb.Components.EventType do
use Phoenix.Component
# We will implement this module in a minute...
import CalendlexWeb.LiveViewHelpers
alias __MODULE__
# ...
def calendar(
%{
current_path: current_path,
previous_month: previous_month,
next_month: next_month
} = assigns
) do
# ...
~H"""
<div>
# ...
<div class="mb-6 text-center uppercase calendar grid grid-cols-7 gap-y-2 gap-x-2">
<div class="text-xs">Mon</div>
<div class="text-xs">Tue</div>
<div class="text-xs">Wed</div>
<div class="text-xs">Thu</div>
<div class="text-xs">Fri</div>
<div class="text-xs">Sat</div>
<div class="text-xs">Sun</div>
<%= for i <- 0..@end_of_month.day - 1 do %>
<EventType.day
index={i}
current_path={@current_path}
date={Timex.shift(@beginning_of_month, days: i)}
time_zone={@time_zone} />
<% end %>
</div>
# ...
</div>
"""
end
# ...
def day(%{index: index, current_path: current_path, date: date, time_zone time_zone} = assigns) do
date_path = build_path(current_path, %{date: date})
disabled = Timex.compare(date, Timex.today(time_zone)) == -1
weekday = Timex.weekday(date, :monday)
class =
class_list([
{"grid-column-#{weekday}", index == 0},
{"content-center w-10 h-10 rounded-full justify-center items-center flex", true},
{"bg-blue-50 text-blue-600 font-bold hover:bg-blue-200", not disabled},
{"text-gray-200 cursor-default pointer-events-none", disabled}
])
assigns =
assigns
|> assign(disabled: disabled)
|> assign(:text, Timex.format!(date, "{D}"))
|> assign(:date_path, date_path)
|> assign(:class, class)
~H"""
<%= live_patch to: @date_path, class: @class, disabled: @disabled do %>
<%= @text %>
<% end %>
"""
end
end
To display the days, we loop through the number of days in the current month, invoking a new function component called day/1
assigning it the following values:
-
index
: the current index in the loop. -
current_path
: the current LiveView's path. -
date
: the current date in the loop. -
time_zone
: the visitor's time zone.
The component consists of a link pointing to the current path, adding a new query string parameter called date
. If the date is before today, we disable the link to prevent scheduling events in the past. Since we want to style the link depending on different factors, we will use a new helper module to generate the class
attribute's value. Let's go ahead and create the helper module:
# ./lib/calendlex_web/live/live_view_helpers.ex
defmodule CalendlexWeb.LiveViewHelpers do
def class_list(items) do
items
|> Enum.reject(&(elem(&1, 1) == false))
|> Enum.map(&elem(&1, 0))
|> Enum.join(" ")
end
end
class_list/1
takes a list of two-element tuples, where the first element is a string containing some class names, and the second is a boolean value representing whether it should apply the classes or not. It goes through all the items in the list, rejecting the falsy ones and concatenating the remaining ones. For our day
component, we want to add certain styles only when it is the first day of the month, others when it is disabled or not, and we have some common styles as well. "grid-column-#{weekday}"
is a custom class that we use to position the first day of the month using CSS Grid Layout, so let's add it to the main CSS file:
/* ./assets/css/app.css */
/* ... */
.grid-column-1 {
grid-column: 1;
}
.grid-column-2 {
grid-column: 2;
}
.grid-column-3 {
grid-column: 3;
}
.grid-column-4 {
grid-column: 4;
}
.grid-column-5 {
grid-column: 5;
}
.grid-column-6 {
grid-column: 6;
}
.grid-column-7 {
grid-column: 7;
}
Jumping back to the browser, we should see our calendar in all its glory:
We should see how the calendar updates, rendering the new month's days if we navigate through the months. There's only one thing left to do: handle the new date
parameter on the live patch invoked when we click on a date:
# ./lib/calendlex_web/live/event_type_live.ex
defmodule CalendlexWeb.EventTypeLive do
use CalendlexWeb, :live_view
# ...
defp current_from_params(socket, %{"date" => date}) do
case Timex.parse(date, "{YYYY}-{0M}-{D}") do
{:ok, current} ->
NaiveDateTime.to_date(current)
_ ->
Timex.today(socket.assigns.time_zone)
end
end
# ...
end
Since we previously left everything ready when we implemented the month
parameter, we only have to add a new version of the current_from_params
function for the date
parameter and set it as the current date. If we now click on a date, we should see how the browser's URL updates adding the date
parameter, and if we refresh the browser's page, the calendar should render on the date we selected:
That's it for this part. In the next part, we will use the event type and date selected by the user to render all the available time slots in the day. We will also implement the schedule event live view, creating a new event once the visitor submits its form. In the meantime, you can check the end result in the live demo, or have a look at the source code.
Happy coding!
Top comments (2)
Good one, Ricardo.
The calendar layout using CSS is clever. I was thinking how I would do it using Elixir but I see you sidestepped the problem altogether, lol. Bravo.
I like the series very much so far! Keep it going!
Thanks
Thanks for your kind words, I'm glad you like the series!
I also tried to layout the calendar using Elixir, but then I realised I had to use the right tool for the job :)