View Source Form Data

Types provided by this library are intended to be used with nested form data that can be cast to ranges as a single unit. For example, a heex component could be written as follows:

defmodule Web.Components do
  use Phoenix.HTML

  import Phoenix.LiveView.Helpers

  def utc_date_time_range_field(assigns) do
    ~H"""
    <fieldset>
      <%= error_tag @f, @field %>
      <input
        type="datetime-local"
        name={"#{form_name(@f)}[#{@field}][start_at]"}
        value={utc_date_time_part(@f, @field, :start_at)}
        id={"#{form_name(@f)}_#{@field}_start_at"}
      />
      <input
        type="datetime-local"
        name={"#{form_name(@f)}[#{@field}][end_at]"}
        value={utc_date_time_part(@f, @field, :end_at)}
        id={"#{form_name(@f)}_#{@field}_end_at"}
      />
      <input
        type="hidden"
        name={"#{form_name(@f)}[#{@field}][tz]"}
        value="America/Los_Angeles"
        id={"#{form_name(@f)}_#{@field}_tz"}
      />
    </fieldset>
    """
  end

  def time_range_field(assigns) do
  ~H"""
  <fieldset id={@field}>
    <div is="grouped">
      <%= error_tag @f, @field %>
      <input
        type="time"
        name={"#{form_name(@f)}[#{@field}][start_at]"}
        value={time_part(@f, @field, :start_at)}
        id={"#{form_name(@f)}_#{@field}_start_at"}
      />
      <input
        type="time"
        name={"#{form_name(@f)}[#{@field}][end_at]"}
        value={time_part(@f, @field, :end_at)}
        id={"#{form_name(@f)}_#{@field}_end_at"}
      />
    </div>
  </fieldset>
  """
  end

  defp form_name(form), do: form.name

  defp utc_date_time_part(form, field, part) do
    form.source
    |> Ecto.Changeset.get_field(field)
    |> case do
      nil -> DateTime.utc_now()
      %Ecto.DateTimeRange.UTCDateTime{} = range -> Map.get(range, part) |> to_time_zone("America/Los_Angeles")
    end
  end

  defp time_part(form, field, part) do
    form.source
    |> Ecto.Changeset.get_field(field)
    |> case do
      nil -> default_time(part)
      %Ecto.DateTimeRange.Time{} = range -> Map.get(range, part)
    end
    |> to_time_value()
  end

  defp default_time(:start_at), do: ~T[06:00:00]
  defp default_time(:end_at), do: ~T[18:00:00]

  defp to_time_value(%Time{} = time), do: Calendar.strftime(time, "%H:%M")

  defp to_time_zone(%DateTime{} = time, tz), do: DateTime.add(time, tz_offset(tz), :second)

  defp tz_offset(tz) do
    {:ok, %{std_offset: _, utc_offset: offset, zone_abbr: _}} =
      Calendar.get_time_zone_database().time_zone_periods_from_wall_datetime(DateTime.utc_now(), tz)

    offset
  end
end

Components such as this can be used in a parent live view:

defmodule Web.MyLiveView do
  use Web, :live_view
  import Web.Components

  @impl Phoenix.LiveView
  def render(assigns) do
    ~H"""
    <.form let={f} for={@changeset} id="thing-form" phx-change="validate">
      <.utc_date_time_range_field id="performed-during" f={f} field={:performed_during} />
      <%= submit("Save") %>
    </.form>
    """
  end

  @impl Phoenix.LiveView
  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(:changeset, Core.Thing.changeset(%{}))

    {:ok, socket}
  end

  @impl Phoenix.LiveView
  def handle_event("validate", %{"thing" => params}, socket) do
    socket = socket |> assign(changeset: Core.Things.changeset(params))
    {:noreply, socket}
  end

end

When this form is sent to the server via POST or in a LiveView's phx-change or phx-submit, the params will arrive in the following format, which matches that expected by Ecto.DateTimeRange.UTCDateTime.cast/1:

%{
  "thing" => %{
    "performed_during" => %{
        "start_at" => "2022-06-22 01:00:00",
        "end_at" => "2022-06-22 01:00:00",
        "tz" => "Etc/UTC"
      }
  }
}