Building a simple Calendly clone with Phoenix LiveView (pt. 6)

Managing event types, part one.
Dec 20, 2021 · 9 min read
elixir
phoenix
liveview

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!