Feature

Calendar Basic

Preview

A basic calendar build with Phoenix LiveView.

lib/phoenix_features_web/live/components/calendar_simple.ex
defmodule PhoenixFeaturesWeb.Components.CalendarSimple do
  use PhoenixFeaturesWeb, :live_component
  use Timex

  alias PhoenixFeaturesWeb.Components.CalendarDay

  @week_start_at :mon

  def update(assigns, socket) do
    current_date = assigns |> extract_from_params()
    current_month = assigns |> extract_from_params()

    {:ok,
      socket
      |> assign(assigns)
      |> assign(:current_date, current_date)
      |> assign(:current_month, current_month)
      |> assign(:day_names, day_names(@week_start_at))
      |> assign(:week_rows, week_rows(current_date))
    }
  end

  def handle_event("prev-month", _, socket) do
    current_month = Timex.shift(socket.assigns.current_month, months: -1)

    {
      :noreply,
      socket
      |> assign(:current_month, current_month)
      |> assign(:week_rows, week_rows(current_month))
    }
  end

  def handle_event("next-month", _, socket) do
    current_month = Timex.shift(socket.assigns.current_month, months: 1)

    {
      :noreply,
      socket
      |> assign(:current_month, current_month)
      |> assign(:week_rows, week_rows(current_month))
    }
  end

  def handle_event("pick-date", %{"date" => date}, socket) do
    current_date = Timex.parse!(date, "{YYYY}-{0M}-{D}")

    {
      :noreply,
      socket
      |> assign(:current_date, current_date)
    }
  end

  defp extract_from_params(%{params: %{"date" => "" <> date}}), do: Timex.parse!(date, "{YYYY}-{0M}-{D}")
  defp extract_from_params(_), do: Timex.now

  defp day_names(:sun), do:  [7, 1, 2, 3, 4, 5, 6] |> Enum.map(&Timex.day_shortname/1)
  defp day_names(_), do:  [1, 2, 3, 4, 5, 6, 7] |> Enum.map(&Timex.day_shortname/1)

  defp week_rows(current_date) do
    first =
      current_date
      |> Timex.beginning_of_month()
      |> Timex.beginning_of_week(@week_start_at)

    last =
      current_date
      |> Timex.end_of_month()
      |> Timex.end_of_week(@week_start_at)

    Interval.new(from: first, until: last)
    |> Enum.map(& &1)
    |> Enum.chunk_every(7)
  end
end
<div id="<%= @id %>">
  <div class="flex items-baseline justify-between">
    <h3 class="ml-4 text-lg font-semibold text-gray-600">
      <%= Timex.format!(@current_month, "%B %Y", :strftime) %>
    </h3>
    <div>
      <a href="#" phx-target="#<%= @id %>" phx-click="prev-month" class="inline-block text-sm bg-white p-2 rounded shadow text-gray-600 border border-gray-200">&laquo; Prev</a>
      <a href="#" phx-target="#<%= @id %>" phx-click="next-month" class="inline-block text-sm bg-white p-2 rounded shadow text-gray-600 border border-gray-200">&raquo; Next</a>
    </div>
  </div>

  <table class="w-full mt-4 border border-gray-200 rounded-lg shadow-lg">
    <thead>
      <tr>
        <%= for day_name <- @day_names do %>
          <th class="text-xs p-2 text-gray-600 border border-gray-200">
            <%= day_name %>
          </th>
        <% end %>
      </tr>
    </thead>
    <tbody>
      <%= for week <- @week_rows do %>
        <tr>
          <%= for day <- week do %>
            <%= live_component @socket, CalendarDay, id: day, parent_id: @id, day: day, current_month: @current_month, current_date: @current_date %>
          <% end %>
        </tr>
      <% end %>
    </tbody>
  </table>
</div>
defmodule PhoenixFeaturesWeb.Components.CalendarDay do
  use PhoenixFeaturesWeb, :live_component
  use Timex

  def update(assigns, socket) do
    {
      :ok,
      socket
      |> assign(assigns)
      |> assign(:day_class, day_class(assigns))
    }
  end

  def render(assigns) do
    ~L"""
    <td
      id="td-<%= Timex.format!(@day, "%Y%m%d", :strftime) %>"
      phx-target="#<%= @parent_id %>"
      phx-click="pick-date"
      phx-value-date="<%= Timex.format!(@day, "%Y-%m-%d", :strftime) %>"
      class="<%= @day_class %>"
    >
      <%= Timex.format!(@day, "%d", :strftime) %>
    </td>
    """
  end

  defp day_class(assigns) do
    cond do
      today?(assigns) ->
        "text-xs p-2 text-gray-600 border border-gray-200 bg-green-200 hover:bg-green-300 cursor-pointer"
      current_date?(assigns) ->
        "text-xs p-2 text-gray-600 border border-gray-200 bg-blue-100 cursor-pointer"
      other_month?(assigns) ->
        "text-xs p-2 text-gray-400 border border-gray-200 bg-gray-200 cursor-not-allowed"
      true ->
        "text-xs p-2 text-gray-600 border border-gray-200 bg-white hover:bg-blue-100 cursor-pointer"
    end
  end

  defp current_date?(assigns) do
    Map.take(assigns.day, [:year, :month, :day]) == Map.take(assigns.current_date, [:year, :month, :day])
  end

  defp today?(assigns) do
    Map.take(assigns.day, [:year, :month, :day]) == Map.take(Timex.now, [:year, :month, :day])
  end

  defp other_month?(assigns) do
    Map.take(assigns.day, [:year, :month]) != Map.take(assigns.current_month, [:year, :month])
  end
end
defmodule PhoenixFeaturesWeb.Components.CalendarSimpleTest do
  use PhoenixFeaturesWeb.ConnCase
  import Phoenix.LiveViewTest

  describe "CalendarSimple" do
    test "shows the calendar", %{conn: conn} do
      {:ok, view, _html} = live(conn, Routes.demos_show_path(conn, :show, "calendar_simple"))

      assert view |> element("#calendar_simple") |> has_element?()
    end

    test "shows the calendar for a specific month and highlights selected day", %{conn: conn} do
      {:ok, view, html} = live(conn, Routes.demos_show_path(conn, :show, "calendar_simple", date: "2021-01-23"))

      assert html =~ "January 2021"
      refute view |> has_element?("#td-20210122.bg-blue-100")
      assert view |> has_element?("#td-20210123.bg-blue-100")
    end

    test "click next shows the calendar for next month", %{conn: conn} do
      {:ok, view, _html} = live(conn, Routes.demos_show_path(conn, :show, "calendar_simple", date: "2021-01-23"))

      assert view |> element("a", "Next") |> render_click() =~ "February 2021"
    end

    test "click previous shows the calendar for previous month", %{conn: conn} do
      {:ok, view, _html} = live(conn, Routes.demos_show_path(conn, :show, "calendar_simple", date: "2021-01-23"))

      assert view |> element("a", "Prev") |> render_click() =~ "December 2020"
    end

    test "click a date selects it", %{conn: conn} do
      {:ok, view, _html} = live(conn, Routes.demos_show_path(conn, :show, "calendar_simple", date: "2021-01-23"))

      refute view |> has_element?("#td-20210110.bg-blue-100")
      assert view |> element("#td-20210110") |> render_click()
      assert view |> has_element?("#td-20210110.bg-blue-100")
      refute view |> has_element?("#td-20210123.bg-blue-100")
    end
  end
end