defmodule MvWeb.GroupLive.Show do @moduledoc """ LiveView for displaying a single group's details. ## Features - Display group information (name, description, member count) - List all members in the group - Navigate to edit form - Return to groups list - Delete group (with confirmation) ## Security - All users with read permission can view groups - Only admin users can edit/delete groups """ use MvWeb, :live_view require Logger import Ash.Expr import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.Authorization alias Mv.Membership alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:show_add_member_input, false) |> assign(:member_search_query, "") |> assign(:available_members, []) |> assign(:add_member_candidates, []) |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil)} end @impl true def handle_params(%{"slug" => slug}, _url, socket) do actor = current_actor(socket) # Check if user can read groups if can?(actor, :read, Mv.Membership.Group) do load_group_by_slug(socket, slug, actor) else {:noreply, redirect(socket, to: ~p"/members")} end end defp load_group_by_slug(socket, slug, actor) do # Load group with members and member_count # Using explicit load ensures efficient preloading of members relationship require Ash.Query query = Mv.Membership.Group |> Ash.Query.filter(slug == ^slug) |> Ash.Query.load([:members, :member_count]) case Ash.read_one(query, actor: actor, domain: Mv.Membership) do {:ok, nil} -> {:noreply, socket |> put_flash(:error, gettext("Group not found.")) |> redirect(to: ~p"/groups")} {:ok, group} -> {:noreply, socket |> assign(:page_title, group.name) |> assign(:group, group)} {:error, _error} -> {:noreply, socket |> put_flash(:error, gettext("Failed to load group.")) |> redirect(to: ~p"/groups")} end end @impl true def render(assigns) do ~H""" <.header> {@group.name} <:actions> <.button navigate={~p"/groups"} variant="neutral" aria-label={gettext("Back to groups list")} > <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} <%= if can?(@current_user, :update, @group) do %> <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"} data-testid="group-show-edit-btn" > {gettext("Edit group")} <% end %> <%= if can?(@current_user, :destroy, @group) do %> <.button variant="danger" phx-click="open_delete_modal" data-testid="group-show-delete-btn" > {gettext("Delete")} <% end %>
<%!-- Group Information --%>

{gettext("Description")}

<%= if @group.description && String.trim(@group.description) != "" do %>

{@group.description}

<% else %>

{gettext("No description")}

<% end %>

{gettext("Members")}

{ngettext( "Total: %{count} member", "Total: %{count} members", @group.member_count || 0, count: @group.member_count || 0 )}

<%= if can?(@current_user, :update, @group) do %>
<%= if assigns[:show_add_member_input] do %>
<%= for member <- @selected_members do %> {MvWeb.Helpers.MemberHelpers.display_name(member)} <.tooltip content={gettext("Remove")} position="top"> <.button type="button" variant="icon" size="sm" phx-click="remove_selected_member" phx-value-member_id={member.id} aria-label={ gettext("Remove %{name}", name: MvWeb.Helpers.MemberHelpers.display_name(member) ) } class="p-0 h-4 w-4 min-h-0" > <.icon name="hero-x-mark" class="size-3" /> <% end %>
<%= if length(@available_members) > 0 do %>
<%= for {member, index} <- Enum.with_index(@available_members) do %>

{MvWeb.Helpers.MemberHelpers.display_name(member)}

{member.email || gettext("No email")}

<% end %>
<% end %>
<.button type="button" variant="primary" phx-click="add_selected_members" data-testid="group-show-add-selected-members-btn" disabled={Enum.empty?(@selected_member_ids)} aria-label={gettext("Add members")} class="join-item" > <.icon name="hero-plus" class="size-5" /> <.button type="button" variant="neutral" phx-click="hide_add_member_input" aria-label={gettext("Cancel")} class="join-item" > {gettext("Cancel")}
<% else %> <.button variant="primary" phx-click="show_add_member_input" aria-label={gettext("Add Member")} > {gettext("Add Member")} <% end %>
<% end %> <%= if Enum.empty?(@group.members || []) do %>

{gettext("No members in this group")}

<% else %>
<%= if can?(@current_user, :update, @group) do %> <% end %> <%= for member <- @group.members do %> <%= if can?(@current_user, :update, @group) do %> <% end %> <% end %>
{gettext("Name")} {gettext("Email")}{gettext("Actions")}
<.link navigate={~p"/members/#{member.id}"} class="link link-primary" > {MvWeb.Helpers.MemberHelpers.display_name(member)} <%= if member.email do %> {member.email} <% else %> <% end %> <.tooltip content={gettext("Remove")} position="left"> <.button type="button" variant="danger" size="sm" phx-click="remove_member" phx-value-member_id={member.id} data-testid="group-show-remove-member" aria-label={gettext("Remove member from group")} > <.icon name="hero-trash" class="size-4" />
<% end %>
<%!-- Delete Confirmation Modal --%> <%= if assigns[:show_delete_modal] do %> <% end %>
""" end # Delete Modal Events @impl true def handle_event("open_delete_modal", _params, socket) do {:noreply, assign(socket, show_delete_modal: true, name_confirmation: "")} end @impl true def handle_event("cancel_delete", _params, socket) do {:noreply, socket |> assign(:show_delete_modal, false) |> assign(:name_confirmation, "")} end @impl true def handle_event("update_name_confirmation", %{"name" => name}, socket) do {:noreply, assign(socket, :name_confirmation, name)} end @impl true def handle_event("confirm_delete", %{"slug" => slug}, socket) do actor = current_actor(socket) group = socket.assigns.group # Verify slug matches the group in assigns (prevents tampering) if group.slug == slug do # Server-side authorization check on the specific group record if can?(actor, :destroy, group) do handle_delete_confirmation(socket, group, actor) else {:noreply, socket |> put_flash(:error, gettext("Not authorized.")) |> redirect(to: ~p"/groups")} end else {:noreply, socket |> put_flash(:error, gettext("Group not found.")) |> redirect(to: ~p"/groups")} end end # Add Member Events @impl true def handle_event("show_add_member_input", _params, socket) do # Load candidate members once (single DB read). Search/focus then filter in memory (R2). socket = socket |> assign(:show_add_member_input, true) |> assign(:member_search_query, "") |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil) |> load_add_member_candidates() {:noreply, socket} end @impl true def handle_event("show_member_dropdown", _params, socket) do # Filter in memory from preloaded candidates; no DB read (R2). query = socket.assigns.member_search_query || "" socket = socket |> assign( :available_members, filter_candidates_in_memory(socket.assigns.add_member_candidates, query) ) |> assign(:show_member_dropdown, true) |> assign(:focused_member_index, nil) {:noreply, socket} end @impl true def handle_event("hide_add_member_input", _params, socket) do {:noreply, socket |> assign(:show_add_member_input, false) |> assign(:member_search_query, "") |> assign(:available_members, []) |> assign(:add_member_candidates, []) |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil)} end @impl true def handle_event("hide_member_dropdown", _params, socket) do {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} end @impl true def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do return_if_dropdown_closed(socket, fn -> max_index = length(socket.assigns.available_members) - 1 current = socket.assigns.focused_member_index new_index = case current do nil -> 0 index when index < max_index -> index + 1 _ -> current end {:noreply, assign(socket, focused_member_index: new_index)} end) end @impl true def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do return_if_dropdown_closed(socket, fn -> current = socket.assigns.focused_member_index new_index = case current do nil -> 0 0 -> 0 index -> index - 1 end {:noreply, assign(socket, focused_member_index: new_index)} end) end @impl true def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do return_if_dropdown_closed(socket, fn -> select_focused_member(socket) end) end @impl true def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do return_if_dropdown_closed(socket, fn -> {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} end) end @impl true def handle_event("member_dropdown_keydown", _params, socket) do # Ignore other keys {:noreply, socket} end @impl true def handle_event("search_members", %{"member_search" => query}, socket) do # Filter in memory from preloaded candidates; no DB read (R2). candidates = socket.assigns.add_member_candidates || [] socket = socket |> assign(:member_search_query, query) |> assign(:available_members, filter_candidates_in_memory(candidates, query)) |> assign(:show_member_dropdown, true) |> assign(:focused_member_index, nil) {:noreply, socket} end @impl true def handle_event("select_member", %{"id" => member_id}, socket) do # Check if member is already selected if member_id in socket.assigns.selected_member_ids do {:noreply, socket} else # Find the selected member selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id)) if selected_member do socket = socket |> assign(:selected_member_ids, [member_id | socket.assigns.selected_member_ids]) |> assign(:selected_members, [selected_member | socket.assigns.selected_members]) |> assign(:member_search_query, "") |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil) {:noreply, socket} else {:noreply, socket} end end end @impl true def handle_event("remove_selected_member", %{"member_id" => member_id}, socket) do socket = socket |> assign(:selected_member_ids, List.delete(socket.assigns.selected_member_ids, member_id)) |> assign( :selected_members, Enum.reject(socket.assigns.selected_members, &(&1.id == member_id)) ) {:noreply, socket} end @impl true def handle_event("add_selected_members", _params, socket) do actor = current_actor(socket) group = socket.assigns.group # Server-side authorization check if can?(actor, :update, group) do member_ids = Enum.uniq(socket.assigns.selected_member_ids) perform_add_members(socket, group, member_ids, actor) else {:noreply, socket |> put_flash(:error, gettext("Not authorized.")) |> redirect(to: ~p"/groups/#{group.slug}")} end end @impl true def handle_event("remove_member", %{"member_id" => member_id}, socket) do actor = current_actor(socket) group = socket.assigns.group # Server-side authorization check if can?(actor, :update, group) do perform_remove_member(socket, group, member_id, actor) else {:noreply, socket |> put_flash(:error, gettext("Not authorized.")) |> redirect(to: ~p"/groups/#{group.slug}")} end end # Helper functions defp return_if_dropdown_closed(socket, fun) do if socket.assigns.show_member_dropdown do fun.() else {:noreply, socket} end end defp select_focused_member(socket) do case socket.assigns.focused_member_index do nil -> {:noreply, socket} index -> select_member_by_index(socket, index) end end defp select_member_by_index(socket, index) do case Enum.at(socket.assigns.available_members, index) do nil -> {:noreply, socket} member -> add_member_to_selection(socket, member) end end defp add_member_to_selection(socket, member) do # Check if member is already selected if member.id in socket.assigns.selected_member_ids do {:noreply, socket} else socket = socket |> assign(:selected_member_ids, [member.id | socket.assigns.selected_member_ids]) |> assign(:selected_members, [member | socket.assigns.selected_members]) |> assign(:member_search_query, "") |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil) {:noreply, socket} end end # Load candidate members once when opening add-member UI (single DB read). defp load_add_member_candidates(socket) do require Ash.Query group = socket.assigns.group exclude_ids = group_member_ids_set(group) |> MapSet.to_list() actor = current_actor(socket) if exclude_ids == [] do # No members in group; load first N members query = Mv.Membership.Member |> Ash.Query.sort([:last_name, :first_name]) |> Ash.Query.limit(300) do_load_add_member_candidates(socket, query, actor) else query = Mv.Membership.Member |> Ash.Query.filter(expr(id not in ^exclude_ids)) |> Ash.Query.sort([:last_name, :first_name]) |> Ash.Query.limit(300) do_load_add_member_candidates(socket, query, actor) end end defp do_load_add_member_candidates(socket, query, actor) do case Ash.read(query, actor: actor, domain: Mv.Membership) do {:ok, candidates} -> socket |> assign(:add_member_candidates, candidates) |> assign(:available_members, Enum.take(candidates, 10)) {:error, error} -> Logger.warning("Failed to load add-member candidates: #{inspect(error)}") socket |> put_flash(:error, gettext("Could not load member list. Please try again.")) |> assign(:add_member_candidates, []) |> assign(:available_members, []) end end # Filter preloaded candidates by query string (name/email). No DB read. R2. defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: "" if q == "" do candidates |> Enum.take(10) else candidates |> Enum.filter(fn m -> name = MemberHelpers.display_name(m) |> String.downcase() email = (m.email || "") |> String.downcase() String.contains?(name, q) or String.contains?(email, q) end) |> Enum.take(10) end end defp filter_candidates_in_memory(_, _), do: [] defp group_member_ids_set(group) do members = group.members || [] members |> Enum.map(& &1.id) |> MapSet.new() end defp perform_add_members(socket, group, member_ids, actor) when is_list(member_ids) do # Add all members in a transaction-like manner results = Enum.map(member_ids, fn member_id -> Membership.create_member_group( %{member_id: member_id, group_id: group.id}, actor: actor ) end) # Check for errors errors = Enum.filter(results, &match?({:error, _}, &1)) if Enum.empty?(errors) do handle_successful_add_members(socket, group, actor) else handle_failed_add_members(socket, group, errors, actor) end end defp perform_add_members(socket, _group, _member_ids, _actor) do {:noreply, socket |> put_flash(:error, gettext("No members selected."))} end defp handle_successful_add_members(socket, group, actor) do socket = reload_group(socket, group.slug, actor) {:noreply, socket |> assign(:show_add_member_input, false) |> assign(:member_search_query, "") |> assign(:available_members, []) |> assign(:add_member_candidates, []) |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil)} end defp handle_failed_add_members(socket, group, errors, actor) do error_messages = extract_error_messages(errors) # Still reload to show any successful additions socket = reload_group(socket, group.slug, actor) {:noreply, socket |> put_flash( :error, gettext("Some members could not be added: %{errors}", errors: error_messages) ) |> assign(:show_add_member_input, true)} end defp extract_error_messages(errors) do Enum.map(errors, fn {:error, error} -> format_single_error(error) end) |> Enum.uniq() |> Enum.join(", ") end defp format_single_error(%{errors: [%{message: message}]}) when is_binary(message), do: message defp format_single_error(%{errors: [%{field: :member_id, message: message}]}) when is_binary(message), do: message defp format_single_error(error), do: format_error(error) defp perform_remove_member(socket, group, member_id, actor) do require Ash.Query # Find the MemberGroup association query = Mv.Membership.MemberGroup |> Ash.Query.filter(member_id == ^member_id and group_id == ^group.id) case Ash.read_one(query, actor: actor, domain: Mv.Membership) do {:ok, nil} -> {:noreply, socket |> put_flash(:error, gettext("Member is not in this group."))} {:ok, member_group} -> case Membership.destroy_member_group(member_group, actor: actor) do :ok -> # Reload group with members and member_count socket = reload_group(socket, group.slug, actor) {:noreply, socket} {:error, error} -> error_message = format_error(error) {:noreply, socket |> put_flash( :error, gettext("Failed to remove member: %{error}", error: error_message) )} end {:error, error} -> error_message = format_error(error) {:noreply, socket |> put_flash( :error, gettext("Failed to remove member: %{error}", error: error_message) )} end end defp reload_group(socket, slug, actor) do require Ash.Query query = Mv.Membership.Group |> Ash.Query.filter(slug == ^slug) |> Ash.Query.load([:members, :member_count]) case Ash.read_one(query, actor: actor, domain: Mv.Membership) do {:ok, group} -> assign(socket, :group, group) {:error, _} -> socket end end defp handle_delete_confirmation(socket, group, actor) do if socket.assigns.name_confirmation == group.name do perform_group_deletion(socket, group, actor) else {:noreply, socket |> put_flash(:error, gettext("Group name does not match.")) |> assign(:show_delete_modal, true)} end end defp perform_group_deletion(socket, group, actor) do case Membership.destroy_group(group, actor: actor) do :ok -> {:noreply, socket |> put_flash(:info, gettext("Group deleted successfully.")) |> redirect(to: ~p"/groups")} {:error, error} -> error_message = format_error(error) {:noreply, socket |> put_flash( :error, gettext("Failed to delete group: %{error}", error: error_message) ) |> assign(:show_delete_modal, false) |> assign(:name_confirmation, "")} end end defp format_error(%{message: message}) when is_binary(message), do: message defp format_error(error), do: inspect(error) end