defmodule Usher.Web.Components.InvitationFormComponent do @moduledoc false use Usher.Web, :live_component alias Usher.Invitation defmodule InvitationFormData do @moduledoc """ Embedded schema and changeset functions for the invitation form. Reasons why we're not directly using an `Usher.Invitation` changeset: 1. We need to convert between `expires_at` (a `DateTime`) and `expires_on` (a `Date`) for user-friendly date input. 2. We want to handle the optional expiration date with a checkbox, which doesn't map directly to the `Invitation` schema. While we could user a `:virtual` field for this, Usher Web can't modify the original schema. """ use Ecto.Schema import Ecto.Changeset embedded_schema do field(:name, :string) field(:token, :string) field(:set_expiration, :boolean, default: false) field(:expires_on, :date) end def create_changeset(%Invitation{} = invitation) do expires_on = if invitation.expires_at do DateTime.to_date(invitation.expires_at) else nil end attrs = invitation |> Map.from_struct() |> Map.take([:name, :token]) |> Map.put(:set_expiration, not is_nil(invitation.expires_at)) |> Map.put(:expires_on, expires_on) %__MODULE__{} |> cast(attrs, [:name, :token, :expires_on, :set_expiration]) end def update_changeset(struct, attrs) do struct |> cast(attrs, [:name, :token, :expires_on, :set_expiration]) |> validate_required([:name]) |> maybe_clear_expires_on() end defp maybe_clear_expires_on(changeset) do case get_field(changeset, :set_expiration) do true -> validate_required(changeset, [:expires_on]) false -> put_change(changeset, :expires_on, nil) end end end @impl Phoenix.LiveComponent def render(assigns) do ~H"""
<.simple_form :let={f} as="invitation" id="invitation-form" for={@form} phx-target={@myself} phx-change="validate" phx-submit="save" phx-hook="GetUserTimezone" >

{@title}

<.input field={f[:name]} type="text" label="Name" placeholder="Enter invitation name" required />
<%!-- --%>

A unique token will be automatically generated for this invitation

{@original_invitation.token}

<.input type="checkbox" field={f[:set_expiration]} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-800" />
<.input type="date" label="Expires on" field={f[:expires_on]} min={current_date(@timezone)} phx-target={@myself} />

Invitation will expire at the end of this day

<.button type="button" variant="light" phx-click={JS.patch(usher_path())}> Cancel <.button type="submit"> Save
""" end @impl Phoenix.LiveComponent def update(%{invitation: invitation} = assigns, socket) do changeset = InvitationFormData.create_changeset(invitation) # Convert expires_at datetime to expires_on date for form display expires_on = if invitation.expires_at do DateTime.to_date(invitation.expires_at) else nil end socket = socket |> assign(:title, assigns.title) |> assign(:action, assigns.action) |> assign(:original_invitation, invitation) |> assign(:expires_on, expires_on) |> assign(:form, to_form(changeset)) |> assign_new(:timezone, fn -> "Etc/UTC" end) {:ok, socket} end def handle_event("user_timezone", unsigned_params, socket) do %{"timezone" => timezone} = unsigned_params socket = socket |> assign(:timezone, timezone) {:noreply, socket} end @impl Phoenix.LiveComponent def handle_event("validate", %{"invitation" => invitation_params}, socket) do updated_changeset = socket.assigns.form.data |> InvitationFormData.update_changeset(invitation_params) {:noreply, assign(socket, form: to_form(updated_changeset))} end @impl Phoenix.LiveComponent def handle_event("save", %{"invitation" => invitation_params}, socket) do form_changeset = socket.assigns.form.data |> InvitationFormData.update_changeset(invitation_params) |> Ecto.Changeset.apply_action(:validate) case socket.assigns.action do :new -> create_invitation(socket, form_changeset) :edit -> update_invitation(socket, form_changeset) end end defp create_invitation(socket, {:ok, form_data}) do invitation_attrs = Map.new() |> Map.put(:name, form_data.name) |> Map.put_new_lazy(:expires_at, fn -> convert_expires_on_to_expires_at( form_data.expires_on, form_data.set_expiration, socket.assigns.timezone ) end) case Usher.create_invitation(invitation_attrs) do {:ok, invitation} -> notify_parent({:saved, invitation}) {:noreply, socket |> put_flash(:info, "Invitation created successfully") |> push_patch(to: usher_path())} {:error, %Ecto.Changeset{} = changeset} -> error_message = Enum.map_join(changeset.errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end) {:noreply, socket |> put_flash(:error, "Failed to create invitation: #{error_message}") |> assign(form: to_form(changeset))} end end defp create_invitation(socket, {:error, changeset}) do {:noreply, assign(socket, form: to_form(changeset))} end defp update_invitation(socket, {:ok, form_data}) do original_invitation = socket.assigns.original_invitation invitation_attrs = Map.new() |> Map.put(:name, form_data.name) |> Map.put(:token, original_invitation.token) |> Map.put_new_lazy(:expires_at, fn -> convert_expires_on_to_expires_at( form_data.expires_on, form_data.set_expiration, socket.assigns.timezone ) end) update_changeset = Usher.change_invitation(original_invitation, invitation_attrs) case Usher.Config.repo().update(update_changeset) do {:ok, invitation} -> notify_parent({:updated, invitation}) {:noreply, socket |> put_flash(:info, "Invitation updated successfully") |> push_patch(to: usher_path())} {:error, %Ecto.Changeset{} = changeset} -> error_message = Enum.map_join(changeset.errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end) form_changeset = InvitationFormData.create_changeset(original_invitation) {:noreply, socket |> put_flash(:error, "Failed to create invitation: #{error_message}") |> assign(form: to_form(form_changeset))} end end defp update_invitation(socket, {:error, changeset}) do {:noreply, assign(socket, form: to_form(changeset))} end defp convert_expires_on_to_expires_at(expires_on, set_expiration, timezone) do if set_expiration && expires_on && expires_on != "" do date_to_end_of_day_utc!(expires_on, timezone) else nil end end defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) end