In the last part of the series, we generated the available time slots for a given data and rendered them as clickable items in the calendar's live view. We also implemented the booking form, which schedules a new event for the selected event type, date, and time slot. In this part, we are going to start implementing the private side of our application, in which we will be able to manage the existing event types and create new ones. Let's get cracking!
The private admin scope
Since this section of the application will expose sensitive data, we don't want to make it accessible to anyone not authorized. Therefore, we must use an authentication mechanism to protect all the admin routes. In this particular case, we will use basic HTTP authentication for the sake of simplicity. But how can we protect a bunch of live routes using this approach? Let's jump to the router file and find out:
# ./lib/calendlex_web/router.ex
defmodule CalendlexWeb.Router do
use CalendlexWeb, :router
import Plug.BasicAuth
# ...
pipeline :auth do
plug :basic_auth, Application.compile_env(:calendlex, :basic_auth)
end
live_session :private, on_mount: {CalendlexWeb.Live.InitAssigns, :private} do
scope "/admin", CalendlexWeb.Admin do
pipe_through :browser
pipe_through :auth
live "/", EventTypesLive
end
end
# ...
end
We are importing Plug's BasicAuth plug, which will cover all the HTTP authentication. Next, we create a new pipeline, :auth
, which contains the :basic_auth
plug and its configuration, which we take from the application. Finally, we create a new :private
live session holding the /admin
scope, which pipes through :auth
and contains the private live routes. Our project is not compiling anymore, so let's fix the errors. First of all, let's add the corresponding authentication configuration:
# ./config/config.exs
import Config
config :calendlex,
# ...
basic_auth: [username: "admin", password: "admin"]
The configuration is straightforward, setting admin
as the username and password. Now let's implement the new :private
live session in the InitAssigns
module:
# ./lib/calendlex_web/live/init_assigns.ex
defmodule CalendlexWeb.Live.InitAssigns do
import Phoenix.LiveView
# ...
def on_mount(:private, _params, _session, socket) do
owner = Application.get_env(:calendlex, :owner)
{:cont, assign(socket, :owner, owner)}
end
end
The on_mount/4
function takes the owner from the configuration and assigns it to the socket, making it accessible within all the /admin/*
routes. Now we are ready to implement the new admin EventTypesLive
live view, let's go ahead and create the initial module and template:
# ../lib/calendlex_web/live/admin/event_types_live.ex
defmodule CalendlexWeb.Admin.EventTypesLive do
use CalendlexWeb, :live_view
@impl LiveView
def mount(_params, _session, socket) do
{:ok, socket, temporary_assigns: [event_types: []]}
end
end
# ../lib/calendlex_web/live/admin/event_types_live.html.heex
<h1>Event types</h1>
Our project should compile again, and if we visit http://localhost:4000/admin in the browser, we should see the Basic HTTP authentication plug in action:
Entering admin/admin
should let us see the initial template we just created a minute ago:
The admin layout
Before continuing any further, let's stop for a second and think about what we will build in this and the following parts. We will be focusing on the private admin side of the application, which will have its navigation menu only visible for authenticated users. Since we are already using the current existing application layout, located in lib/calendlex_web/templates/layout/app.html.heex
, for the public part, it makes sense to create a totally different new layout for all the new admin screens, right? Let's go ahead and add the corresponding template file:
# ./lib/calendlex_web/templates/layout/admin.html.heex
<main role="main" class="flex flex-col flex-1">
<div class="bg-white">
<header class="container w-3/5 pt-12 mx-auto">
<h1 class="mb-3 text-2xl font-medium text-gray-900">My Calendlex</h1>
<nav class="flex gap-x-6">
<%= live_redirect to: Routes.live_path(@socket, CalendlexWeb.Admin.EventTypesLive), class: admin_nav_link_classes(@section == "event_types") do %>
Event types
<% end %>
</nav>
</header>
</div>
<section class="container flex-1 w-3/5 py-12 mx-auto">
<%= @inner_content %>
</section>
</main>
The template consists of the main navigation menu shared by all the admin pages and the inner content corresponding to the current one. To style the active navigation item, we use the admin_nav_link_classes/1
function that we have to add to the CalendlexWeb.LayoutView
module:
# ./lib/calendlex_web/views/layout_view.ex
defmodule CalendlexWeb.LayoutView do
use CalendlexWeb, :view
alias CalendlexWeb.LiveViewHelpers
# ...
def admin_nav_link_classes(is_current) do
LiveViewHelpers.class_list([
{"py-6 font-medium text-gray-400 border-b-2 border-white hover:border-gray-400 hover:text-gray-600",
true},
{"text-gray-600 border-blue-500 hover:text-gray-600 hover:border-blue-500", is_current}
])
end
end
To apply the new admin layout to all the admin pages, let's add a new macro to the CalendlexWeb
module:
# ./lib/calendlex_web.ex
defmodule CalendlexWeb do
# ...
def admin_live_view do
quote do
use Phoenix.LiveView,
layout: {CalendlexWeb.LayoutView, "admin.html"}
import CalendlexWeb.LiveViewHelpers
alias Phoenix.LiveView
unquote(view_helpers())
end
end
# ...
end
Now we can update the initial CalendlexWeb.Admin.EventTypesLive
to make use of the new admin macro and assign to the socket all the necessary data that we need:
# ./lib/calendlex_web/live/admin/event_types_live.ex
defmodule CalendlexWeb.Admin.EventTypesLive do
# using the new macro with the admin layout
use CalendlexWeb, :admin_live_view
@impl LiveView
def mount(_params, _session, socket) do
{:ok, socket, temporary_assigns: [event_types: []]}
end
@impl LiveView
def handle_params(_, _, socket) do
socket =
socket
|> assign(section: "event_types")
|> assign(page_title: "Event types")
{:noreply, socket}
end
end
If we now jump back to the browser, we should see the following:
Much better, moving on!
Listing event types
With the admin layout ready, let's list all the available event types:
# ./lib/calendlex_web/live/admin/event_types_live.ex
defmodule CalendlexWeb.Admin.EventTypesLive do
# ...
@impl LiveView
def handle_params(_, _, socket) do
event_types = Calendlex.available_event_types()
socket =
socket
|> assign(section: "event_types")
|> assign(page_title: "Event types")
|> assign(event_types: event_types)
|> assign(event_types_count: length(event_types))
{:noreply, socket}
end
end
Using the same Calendlex.available_event_types()
function we created in a previous part of the series, we get all the available event types from the database and assign them to the socket. We also assign the length of the list in the event_types_count
key. But why don't we use the lenght/1
directly in the template instead of assigning it to the socket? There is a reason related to LiveView's temporary assigns, but we will get to it later once we implement deleting event types. Now let's edit the template to render the event types assigned to the socket:
# ../lib/calendlex_web/live/admin/event_types_live.html.heex
<div class="flex mt-4 align-middle gap-x-6">
<div class="flex-1"></div>
<div class="flex-1 text-right">
<div class="inline-block px-4 py-1 text-blue-500 border border-blue-500 rounded-full cursor-pointer hover:bg-blue-100" do %>
<i class="fas fa-plus"></i> New event type
</div>
</div>
</div>
<%= if @event_types_count > 0 do %>
<div class="mt-4 grid grid-cols-3 gap-6">
<%= for event_type <- @event_types do %>
<.live_component module={CalendlexWeb.Admin.Components.EventType} id={"event_type_" <> event_type.id} event_type={event_type} />
<% end %>
</div>
<% else %>
<div class="mt-4">
<h3 class="mb-2 text-xl">You don't have any event types yet.</h3>
<p class="">You'll want to add an event type to allow people to schedule with you.</p>
</div>
<% end %>
In the template, we display a fake button to add new event types that we will implement later in the series. Depending on the event types, we render them using a new live component or display an info message when no items are available.
The event type live component
Live components, also known as stateful components, are the other component type within LiveView. It is a way of grouping state, markup, and events, and they even have a different life-cycle. Since we will implement multiple actions on each event type, like deleting and cloning, it makes sense to have all this related functionality in the component rather than in the main live view. Let's go ahead and write the initial implementation of the component:
# ./lib/calendlex_web/live/admin/components/event_type.ex
defmodule CalendlexWeb.Admin.Components.EventType do
use CalendlexWeb, :live_component
def mount(socket) do
{:ok, socket}
end
end
A live component's life-cycle looks like the following:
mount(socket) -> update(assigns, socket) -> render(assigns)
mount/1
is called once when the component is added to the page. update/2
is invoked immediately after mount/1
and every update from the live view. render/1
works the same as in a regular live view. If update/2
does not exist, mount/1
merges all the assigns automatically into the socket. In our case, since we are passing the event type in the event_type
assign, we can use it in its render template:
# ./lib/calendlex_web/live/admin/components/event_type.html.heex
<div class={"relative flex flex-col p-4 mb-2 border-gray-900 text-gray-400 bg-white cursor-pointer rounded-md shadow-sm hover:shadow-md border-t-4 #{@event_type.color}-border"}>
<header class="mb-4">
<h3 class="mb-1 text-xl text-gray-800"><%= @event_type.name %></h3>
<div class="mb-2 text-sm"><%= @event_type.duration %> mins</div>
<div><%= @event_type.description %></div>
</header>
<div class="flex-1">
<%= live_redirect to: Routes.live_path(@socket, CalendlexWeb.EventTypeLive, @event_type.slug), class: "text-blue-500 hover:underline" do %>View booking page<% end %>
</div>
<footer class="flex items-center h-16 px-4 mt-4 -m-4 text-sm border-t border-gray-200">
<button class="text-blue-500">
<i class="far fa-clone"></i> Copy link
</button>
</footer>
</div>
The template's content is pretty straightforward, displaying the event type's name, duration, and description. We are also adding a link to the public scheduling page we implemented in the previous parts of the series. Finally, we are also adding a convenient copy link, which will copy the public link to the user's clipboard on click. Jumping back to the browser, we should see something like the following:
Looking great so far! However, how can we implement the clipboard functionality?
LiveView's JavaScript client hooks
LiveView makes interoperability between its rendered HTML and external JavaScript very easy, thanks to client hooks, which are nothing but a JS object with predefined life-cycle callbacks like mounted
, beforeUpdate
, updated
, etc. You can attach a hook to any node by adding the phx-hook
attribute. Let's see them in action by creating a hook to copy the event type's link to the clipboard:
// ./assets/js/hooks.js
const Hooks = {};
Hooks.Clipboard = {
mounted() {
const originalInnerHTML = this.el.innerHTML;
const { content } = this.el.dataset;
this.el.addEventListener('click', () => {
navigator.clipboard.writeText(content);
this.el.innerHTML = '<i class="fas fa-check"></i> Copied';
setTimeout(() => {
this.el.innerHTML = originalInnerHTML;
}, 2000);
});
},
};
export default Hooks;
We have created a ./assets/js/hooks.js
file, that exposes a Hooks
object with the Clipboard
hooks. Clipboard
implements the mounted
callback which stores in a variable the current inner HTML from the this.el
, in our case it will correspond to the copy-link button (the node with the phx-hook="Clipboard"
attribute). It also takes the content to copy from el
's dataset, adds a click event listener to the button that writes content
to the browser's clipboard, and changes the inner HTML to a success message. Finally, it restores the button's inner HTML with its original content after two seconds. Now we need to add the exported hooks to the live socket connection:
// ./assets/js/app.js
//...
import Hooks from './hooks';
//...
const liveSocket = new LiveSocket('/live', Socket, {
hooks: Hooks,
//...
});
To connect the hook to the button in the event type live component, let's edit its template and add both the data-content
and phx-hook
attributes:
# ./lib/calendlex_web/live/admin/components/event_type.html.heex
<div class={"relative flex flex-col p-4 mb-2 border-gray-900 text-gray-400 bg-white cursor-pointer rounded-md shadow-sm hover:shadow-md border-t-4 #{@event_type.color}-border"}>
<header class="mb-4">
<h3 class="mb-1 text-xl text-gray-800"><%= @event_type.name %></h3>
<div class="mb-2 text-sm"><%= @event_type.duration %> mins</div>
<div><%= @event_type.description %></div>
</header>
<div class="flex-1">
<%= live_redirect to: Routes.live_path(@socket, CalendlexWeb.EventTypeLive, @event_type.slug), class: "text-blue-500 hover:underline" do %>View booking page<% end %>
</div>
<footer class="flex items-center h-16 px-4 mt-4 -m-4 text-sm border-t border-gray-200">
<button
+ id={"clipboard_#{@event_type.id}"}
class="text-blue-500"
+ data-content={Routes.live_url(@socket, CalendlexWeb.EventTypeLive, @event_type.slug)}
+ phx-hook="Clipboard">
<i class="far fa-clone"></i> Copy link
</button>
</footer>
</div>
We are also adding the id
attribute since hooks require a unique DOM ID to work correctly. Let's jump back to the browser and see what happens when we now click on the button:
It works like a charm, yay! In the next part, we will continue with event types management, implementing the corresponding live views to edit existing even types and create new ones, using more live components and hooks. In the meantime, you can check the final result in the live demo, or have a look at the source code.
Happy coding!
Top comments (0)