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

Managing event types, part three.
Jan 31, 2022 · 15 min read
elixir
phoenix
liveview

In the last part of the series, we implemented the necessary logic for adding and editing event types. In this part, we will finish the even types' management screen, adding the ability to the user to clone and delete any existing event type. Let's get cracking!

The event type dropdown menu

To display the clone and delete options to the user, we will implement a dropdown menu that we will add to every even type card in the list. The final result will be something similar to the following:

Since this will not be the only dropdown that we will be using in the application, we will create a new function/stateless component to implement it, and it will consist of two parts:

  1. An HTML element that will trigger the event to toggle the dropdown's content when the user clicks on it.
  2. An HTML content shown/hidden to the user when the trigger gets clicked.

The function components that we have been using until now only receive data, and they always render the same HTML. How can this new component accept any HTML from outside, respecting its inner layout? Fortunately, function components have a mechanism called slots to pass them HTML blocks as in regular HTML tags. To see how it works, let's add the new component's module:

# ./lib/calendlex_web/live/admin/components/dropdown.ex

defmodule CalendlexWeb.Admin.Components.Dropdown do
  use Phoenix.Component

  alias Phoenix.LiveView.JS

  def main(assigns) do
    ~H"""
    <div
      id={@id}
      class="relative dropdown"
      phx-click-away={click_away(@id)}>
      <div
        class="flex items-baseline cursor-pointer gap-x-1 dropdown-trigger"
        phx-click={trigger_click(@id)}>
        <%= render_slot(@trigger) %>
      </div>
      <div class="absolute right-0 z-20 flex flex-col hidden py-2 mt-2 overflow-hidden text-sm text-gray-800 bg-white border shadow-md rounded-md dropdown-content">
        <%= render_slot(@inner_block) %>
      </div>
    </div>
    """
  end

  defp click_away(id), do: JS.hide(to: content_selector(id), transition: "hidden", time: 0)

  defp trigger_click(id),
    do: JS.toggle(to: content_selector(id), in: "block", out: "hidden", time: 0)

  defp content_selector(id), do: "##{id} .dropdown-content"
end

The module exposes a main/1 function which renders a regular dropdown, consisting of a parent div containing two divs:

  1. The first one renders the trigger slot element using render_slot(@trigger).
  2. The second one renders the nested content slot using render_slot(@inner_block).

The main difference between both slots is that @trigger is a named slot, and we can call it how we want, whereas @inner_block is a protected name representing the nested DOM elements that we will include between the Dropdown.main enclosing tags. To toggle the visibility of the dropdown, we are using the Phoenix.LiveView.JS module, which provides a series of functions to execute JavaScript utility operations on the client:

  • We have added a phx-click={trigger_click(@id)} to the trigger, which calls JS.toggle/1, passing the corresponding DOM selector, and the necessary classes to show/hide the dropdown's content.
  • The main div has a phx-click-away={click_away(@id)} event handler to hide the dropdown when the user clicks outside of it. The function uses JS.hide/1, passing the corresponding content selector and transition classes.

With the Dropdown.main component ready, let's add it to the event type card:

# ./lib/calendlex_web/live/admin/components/event_type.ex

defmodule CalendlexWeb.Admin.Components.EventType do
  use CalendlexWeb, :live_component

++alias CalendlexWeb.Admin.Components.Dropdown

  # ...
end
# ./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"}>
++<div class="absolute top-4 right-4">
++  <Dropdown.main id={"dropdown_#{@event_type.id}"}>
++    <:trigger>
++      <span class="text-gray-700"><i class="fas fa-cog"></i></span>
++    </:trigger>
++    <div class="w-48">
++      <%= live_redirect to: Routes.live_path(@socket, CalendlexWeb.Admin.EditEventTypeLive, @event_type.id), class: "px-4 py-2 text-left hover:bg-gray-50 flex gap-x-2" do %>
++        <i class="fas fa-pencil-alt"></i> Edit
++      <% end %>
++      <a class="flex px-4 py-2 text-left hover:bg-gray-50 gap-x-2"
++                phx-target={@myself}
++                phx-click="clone_me">
++        <i class="far fa-clone"></i> Clone
++      </a>
++      <a class="flex px-4 py-2 text-left hover:bg-gray-50 gap-x-2"
++                phx-target={@myself}
++                phx-click="delete_me">
++        <i class="fas fa-trash-alt"></i> Delete
++      </a>
++    </div>
++  </Dropdown.main>
++</div>

  # ...
</div>

We invoke it using an HTML tag like any other function component, setting the id attribute it needs to toggle its content. The difference with the rest of the functions components we have implemented so far is that this one has nested content consisting of:

  1. A <:trigger>...</:trigger> holding the content of the trigger named slot.
  2. A set of HTML nodes corresponding to the inner_block slot, or in other words, the content that we want to toggle when the user clicks on the trigger.

If we jump back to the browser and head to http://localhost:4000/admin, we should be able of toggling the dropdown menus of the event types:

How cool is that? Regarding the dropdown options, we have an edit link pointing to the EditEventTypeLive live path we implemented in the last part and two different events for cloning and deleting the given event type, targeting the CalendlexWeb.Admin.Components.EventType live component.

Cloning an event type

Let's take care first of cloning an event type:

# ./lib/calendlex_web/live/admin/components/event_type.ex

defmodule CalendlexWeb.Admin.Components.EventType do
  use CalendlexWeb, :live_component

  alias CalendlexWeb.Admin.Components.Dropdown

  # ...

  def handle_event("clone_me", _params, socket) do
    event_type = socket.assigns.event_type

    case Calendlex.clone_event_type(event_type) do
      {:ok, new_event_type} ->
        {:noreply,
         push_redirect(socket,
           to: Routes.live_path(socket, CalendlexWeb.Admin.EditEventTypeLive, new_event_type.id)
         )}

      {:error, _} ->
        send(self(), :clone_error)
        {:noreply, socket}
    end
  end
end

The event handler takes the event_type from the socket assigns and calls Calendlex.clone_event_type/1, which we will implement in a minute. If everything goes fine, it redirects to the CalendlexWeb.Admin.EditEventTypeLive live path. On the contrary, it sends a :clone_error message to the parent live view to render the corresponding error. Let's implement the clone logic:

# ./lib/calendlex.ex

defmodule Calendlex do
  # ...

  defdelegate clone_event_type(event_type), to: Calendlex.EventType.Repo, as: :clone
end
# ./lib/calendlex/event_type/repo.ex

defmodule Calendlex.EventType.Repo do
  import Ecto.Query, only: [where: 3, order_by: 3]

  # ...

  def clone(%EventType{name: name, slug: slug} = event_type) do
    event_type
    |> Map.from_struct()
    |> Map.put(:name, "#{name} (clone)")
    |> insert()
  end
end

For the sake of simplicity, the clone/1 function takes an %EventType{}, creates a map of it, adds the (clone) string to its name, and inserts it into the database. Let's fulfill the error message in the parent live view:

# ./calendlex_web/live/admin/event_types_live.ex

defmodule CalendlexWeb.Admin.EventTypesLive do
  use CalendlexWeb, :admin_live_view

  # ...

  def handle_info(:clone_error, socket) do
    {:noreply, put_flash(socket, :error, "A similar event type already exists")}
  end
end

The message handler in the parent CalendlexWeb.Admin.EventTypesLive live view puts a flash error in the socket. Let's jump back to the browser and test everything by cloning any event type. If we clone the "15 minute meeting" event type, we should see the following in the list:

There is a new "15 minute meeting (clone)" event type. However, if we try to clone "15 minute meeting" again, we shouldn't see anything happening apart from the following error in the console due to the event type's slug unique constraint:

iex(4)> [debug] QUERY ERROR db=1.9ms queue=0.6ms idle=1604.2ms
INSERT INTO "event_types" ("color","description","duration","name","slug","inserted_at","updated_at","id") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ["blue", "Short meeting call.", 15, "15 minute meeting (clone)", "15-minute-meeting-clone", ~N[2022-02-03 06:11:52], ~N[2022-02-03 06:11:52], <<108, 252, 29, 54, 186, 121, 69, 165, 169, 33, 78, 202, 160, 116, 152, 100>>]

This is far from being the best user experience, so let's take care of displaying the flash messages properly.

Displaying flash messages to the user

Let's edit the admin.html.heex layout template and add the missing flash messages:

# ./lib/calendlex_web/templates/layout/admin.html.heex

<main role="main" class="flex flex-col flex-1">
  <div class="bg-white">
    # ...
  </div>
  <section class="container flex-1 w-3/5 py-12 mx-auto">
    <div id="flash" phx-hook="Flash">
      <%= if Map.has_key?(@flash, "info") do  %>
        <div
            class="fixed px-6 py-2 text-white uppercase bg-green-400 border border-green-500 flash alert alert-info top-10 left-1/2 rounded-md bg-opacity-80 -translate-x-1/2"
            role="alert"
            phx-click="lv:clear-flash"
            phx-value-key="info"
            >
            <%= live_flash(@flash, :info) %>
        </div>
      <% end %>

      <%= if Map.has_key?(@flash, "error") do  %>
        <div
            class="fixed px-6 py-2 text-white uppercase bg-red-400 border border-red-500 flash alert alert-error top-10 left-1/2 rounded-md bg-opacity-80 -translate-x-1/2"
            role="alert"
            phx-click="lv:clear-flash"
            phx-value-key="error"
            >
            <%= live_flash(@flash, :error) %>
        </div>
      <% end %>
    </div>

    <%= @inner_content %>
  </section>
</main>

We have added a new div with the "flash" id and to which we have attached a new hook called Flash. Inside this div, we only render the info and error flash messages if the corresponding keys exist in the @flash assigns. Both of these divs have a live event handler, phx-click="lv:clear-flash", which is a specific LiveView feature to clear flash messages when sent to the server. To finish implementing the flash messages, let's add the Flash hook to our hooks file:

// ./assets/js/hooks.js

const Hooks = {};

// ...

Hooks.Flash = {
  mounted() {
    this.initFlash();
  },
  updated() {
    this.initFlash();
  },

  initFlash() {
    const flash = this.el.querySelector('.flash');

    if (flash) {
      setTimeout(() => {
        this.pushEvent('lv:clear-flash');
      }, 2000);
    }
  },
};

// ...

The hook is straightforward. It checks if there is a flash message inside its el, sending the corresponding lv:clear-flash to the server after two seconds. Let's jump back to the browser and try to clone the "15 minute meeting" event type again and see what happens:

The error message pops up and disappears after two seconds, as expected. Moreover, we should see the corresponding info messages since we have already been adding flash messages in the previous parts. Let's go to any event type's edit page and click on the "Save" button to check that everything is working fine:

Deleting event types

The last thing that we have to implement to finish managing event types is giving the ability to the user to delete a given one. Since scheduled events require an event type, we don't want to lose track of them if we delete any. Therefore, we will implement a simple soft delete mechanism by adding an additional column to the event_types table:

❯ mix ecto.gen.migration add_deleted_at_to_event_types
* creating priv/repo/migrations/20220207062857_add_deleted_at_to_event_types.exs
# ./priv/repo/migrations/20220207062857_add_deleted_at_to_event_types.exs

defmodule Calendlex.Repo.Migrations.AddDeletedAtToEventTypes do
  use Ecto.Migration

  def change do
    alter table(:event_types) do
      add :deleted_at, :utc_datetime
    end
  end
end

Let's apply the change into the database:

❯ mix ecto.migrate

07:32:19.870 [info]  == Running 20220207062857 Calendlex.Repo.Migrations.AddDeletedAtToEventTypes.change/0 forward

07:32:19.874 [info]  alter table event_types

07:32:19.890 [info]  == Migrated 20220207062857 in 0.0s

Now we have to add the new field to the EventType schema:

# ./lib/calendlex/event_type.ex

defmodule Calendlex.EventType do
  use Ecto.Schema
  import Ecto.Changeset

  # ...

  schema "event_types" do
    # ...

    field :deleted_at, :utc_datetime

    # ...
  end

  @fields ~w(name description slug duration color deleted_at)a # We have added deleted_at to the list of fields

  # ...

With the new field ready, let's take care of the delete event handling in the event type component:

# ./lib/calendlex_web/live/admin/components/event_type.ex

defmodule CalendlexWeb.Admin.Components.EventType do
  use CalendlexWeb, :live_component

  # ...

  def handle_event("delete_me", _params, socket) do
    event_type = socket.assigns.event_type

    send(self(), {:confirm_delete, event_type})

    {:noreply, socket}
  end
end

When the component receives a delete_me event, it sends a :confirm_delete message to the main live view containing the selected event type. Since deleting an item is an operation that cannot be undone, we want to make sure that we double-check that the user wants to delete it. Therefore, we will display a confirmation message, alerting the user about the operation and asking whether we should proceed with the deletion. To achieve this, let's add use a new assign in the CalendlexWeb.Admin.EventTypesLive module:

# ./lib/calendlex_web/live/admin/event_types_live.ex

defmodule CalendlexWeb.Admin.EventTypesLive do
  use CalendlexWeb, :admin_live_view

  alias CalendlexWeb.Admin.Components.Modal

  # ...

  def handle_params(_, _, socket) do
    event_types = Calendlex.available_event_types()

    socket =
      socket
      |> assign(event_types: event_types)
      # ...
      |> assign(delete_event_type: nil)

    {:noreply, socket}
  end

  # ...

  def handle_info({:confirm_delete, event_type}, socket) do
    {:noreply, assign(socket, delete_event_type: event_type)}
  end
end

We have added a new :delete_event_type key to the socket's assigns, initialized to null. The handle_info({:confirm_delete, event_type}, socket) event handler assigns the selected event type to the socket in the :delete_event_type key, telling the LiveView that we want to delete it. To display the confirmation message to the user, we will use a modal containing the message and buttons for confirming or dismissing the action. Since we will use another confirmation message in the next episode, we are going to create a new stateless component with the modal's layout:

# ./lib/calendlex_web/live/admin/components/modal.ex

defmodule CalendlexWeb.Admin.Components.Modal do
  use Phoenix.Component

  def content(assigns) do
    ~H"""
    <div class="fixed inset-0 z-20 overflow-y-auto"
      phx-window-keydown="close_modal"
      phx-key="escape">
      <div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
        <div class="fixed inset-0 bg-white bg-opacity-75 transition-opacity" aria-hidden="true"></div>
        <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
        <div class="inline-block overflow-hidden text-left align-bottom bg-white border border-gray-200 shadow-md rounded-md transform transition-all sm:my-8 sm:align-middle sm:max-w-md sm:w-full">
          <div class="p-6 mx-auto mb-2 bg-white">
            <header class="mb-6 text-center">
              <h3 class="mb-3 text-lg font-bold text-gray-900">
                <%= render_slot(@header) %>
              </h3>
            </header>
            <%= render_slot(@inner_block) %>
            <footer class="flex mt-6 gap-x-10">
              <%= render_slot(@footer) %>
            </footer>
          </div>
        </div>
      </div>
    </div>
    """
  end

  def cancel_button(assigns) do
    ~H"""
    <button
      class="flex-1 px-4 py-2 border border-gray-800 rounded-full"
      phx-click="close_modal">
      <%= render_slot(@inner_block) %>
    </button>
    """
  end

  def confirm_button(assigns) do
    ~H"""
    <button
      class="flex-1 px-4 py-2 text-white bg-blue-600 rounded-full"
      phx-click={@event}>
      <%= render_slot(@inner_block) %>
    </button>
    """
  end
end

The modal consists of:

  • On top, there is a parent div representing the semitransparent overlay. It has a phx-window-keydown event binding that sends a modal_close message to the LiveView when the user presses the escape key.
  • Inside the overlay, it has the modal body containing the header, inner_block, and footer slots.
  • The module also exposes a cancel_button/1 and a confirm_button/1 buttons that render the modal buttons.

Let's add the modal to the CalendlexWeb.Admin.EventTypesLive's template:

# ./lib/calendlex_web/live/admin/event_types_live.html.heex

# ...

<%= if @delete_event_type  do %>
  <Modal.content>
    <:header>Delete <%= @delete_event_type.name %></:header>
    <p>Users will be unable to schedule further meetings with deleted event types. Meetings previously scheduled will not be affected.</p>
    <:footer>
      <Modal.cancel_button>No</Modal.cancel_button>
      <Modal.confirm_button event="delete">Yes</Modal.confirm_button>
    </:footer>
  </Modal.content>
<% end %>

It conditional adds the modal component if delete_event_type is not null. Note how we implement the corresponding slots, especially :footer, containing the Modal.confirm_button with its event assign set to delete. If we jump to the browser and try to delete an event type, we should see the following:

Nice! The only thing left is to implement both the close_modal and delete events:

# ./lib/calendlex_web/live/admin/event_types_live.ex

defmodule CalendlexWeb.Admin.EventTypesLive do
  use CalendlexWeb, :admin_live_view

  # ...

  def handle_event("modal_close", _, socket) do
    {:noreply, assign(socket, delete_event_type: nil)}
  end

  def handle_event("delete", _, socket) do
    event_type = socket.assigns.delete_event_type

    {:ok, _} = Calendlex.delete_event_type(event_type)

    socket =
      socket
      |> put_flash(:info, "Deleted")
      |> push_patch(to: Routes.live_path(socket, __MODULE__))

    {:noreply, socket}
  end
end

The close_modal handler is straightforward: it simply sets the delete_event_type socket assign back to nil, which removes the modal from the DOM on the next render. The delete handler is a bit more complex: it takes the assigned delete_event_type and calls Calendlex.delete_event_type/1, which we will implement in a minute, and redirects to the same LiveView path, which will trigger its handle_params/3 callback function, resetting its internal state. Let's add the missing functions:

# ./lib/calendlex.ex

defmodule Calendlex do
  # ...

  defdelegate delete_event_type(event_type), to: Calendlex.EventType.Repo, as: :delete
end
# ./lib/calendlex/event_type/repo.ex

defmodule Calendlex.EventType.Repo do
  # ...

  def delete(event_type) do
    event_type
    |> EventType.delete_changeset()
    |> Repo.update()
  end
end
# ./lib/calendlex/event_type.ex

defmodule Calendlex.EventType do
  use Ecto.Schema
  import Ecto.Changeset
  alias Ecto.Changeset

  # ...

  def delete_changeset(event_type) do
    event_type
    |> with_deleted_changes()
    |> validate_required(@required_fields)
  end

  # ...

  defp with_deleted_changes(%{name: name, slug: slug} = event_type) do
    event_type
    |> Changeset.change()
    |> put_change(:name, "#{name} (deleted)")
    |> put_change(:slug, "#{slug}-deleted-#{:os.system_time(:millisecond)}")
    |> put_change(:deleted_at, DateTime.truncate(DateTime.utc_now(), :second))
  end
end

The delete/1 function takes an event_type, creates the delete_changeset, and updates the event type. The delete_changeset function takes an event type, generating a new changeset with custom name, slug, and deleted_at attributes. There is still one slight change that we need to do, which is removing the deleted event types from the avalilable/0 function result, so they get removed from the UI:

# ./lib/calendlex/event_type/repo.ex

defmodule Calendlex.EventType.Repo do
  # ...

  def available do
    EventType
++  |> where([e], is_nil(e.deleted_at))
    |> order_by([e], e.name)
    |> Repo.all()
  end

  # ...
end

Let's head back to the browser and see what happens when we delete an event type:

After confirming the action, we see the corresponding success flash message, and the deleted event type is no longer in the list. And this is it for this part, and we have finally finished with all the logic behind managing event types. In the next part, we will focus on the last part of this series, which is listing and filtering scheduled events. In the meantime, you don't forget to check the final result in the live demo, or have a look at the source code.

Happy coding!