168 lines
5.2 KiB
Elixir
168 lines
5.2 KiB
Elixir
defmodule MvWeb.GroupLive.Index do
|
|
@moduledoc """
|
|
LiveView for displaying and managing the groups list.
|
|
|
|
## Features
|
|
- List all groups with name, description, and member count
|
|
- Create new groups
|
|
- Navigate to group details and edit forms
|
|
- Delete groups (with confirmation)
|
|
|
|
## Security
|
|
- Admin users can create, edit, and delete groups
|
|
- Read-only users can view groups but not manage them
|
|
- Non-admin users are redirected
|
|
"""
|
|
use MvWeb, :live_view
|
|
|
|
import MvWeb.LiveHelpers, only: [current_actor: 1, ash_actor_opts: 1]
|
|
import MvWeb.Authorization
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
actor = current_actor(socket)
|
|
|
|
# Check if user can access the groups page (page permission check)
|
|
if can_access_page?(actor, "/groups") do
|
|
groups = load_groups(actor)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, gettext("Groups"))
|
|
|> assign(:groups, groups)}
|
|
else
|
|
{:ok, redirect(socket, to: ~p"/members")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-2xl font-bold">{gettext("Groups")}</h1>
|
|
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
|
|
<.button navigate={~p"/groups/new"} variant="primary">
|
|
<.icon name="hero-plus" class="size-4 mr-2" />
|
|
{gettext("Create Group")}
|
|
</.button>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%= if Enum.empty?(@groups) do %>
|
|
<div class="text-center py-12">
|
|
<p class="text-base-content/70">{gettext("No groups")}</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra">
|
|
<thead>
|
|
<tr>
|
|
<th>{gettext("Name")}</th>
|
|
<th>{gettext("Description")}</th>
|
|
<th>{gettext("Members")}</th>
|
|
<th>{gettext("Actions")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for group <- @groups do %>
|
|
<tr>
|
|
<td>
|
|
{group.name}
|
|
</td>
|
|
<td>
|
|
<%= if group.description do %>
|
|
{group.description}
|
|
<% else %>
|
|
<span class="text-base-content/50 italic">—</span>
|
|
<% end %>
|
|
</td>
|
|
<td>
|
|
<%= if group.member_count do %>
|
|
{group.member_count}
|
|
<% else %>
|
|
0
|
|
<% end %>
|
|
</td>
|
|
<td>
|
|
<div class="flex gap-2">
|
|
<.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost">
|
|
{gettext("View")}
|
|
</.link>
|
|
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
|
<.link
|
|
navigate={~p"/groups/#{group.slug}/edit"}
|
|
class="btn btn-sm btn-ghost"
|
|
>
|
|
{gettext("Edit")}
|
|
</.link>
|
|
<% end %>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
@spec load_groups(map() | nil) :: [Mv.Membership.Group.t()]
|
|
defp load_groups(actor) do
|
|
require Ash.Query
|
|
|
|
# Load groups without aggregates first (faster)
|
|
query =
|
|
Mv.Membership.Group
|
|
|> Ash.Query.sort(:name)
|
|
|
|
opts = ash_actor_opts(actor)
|
|
|
|
case Ash.read(query, opts) do
|
|
{:ok, groups} ->
|
|
# Load all member counts in a single batch query (avoids N+1)
|
|
member_counts = load_member_counts_batch(groups)
|
|
|
|
# Attach counts to groups
|
|
Enum.map(groups, fn group ->
|
|
Map.put(group, :member_count, Map.get(member_counts, group.id, 0))
|
|
end)
|
|
|
|
{:error, _error} ->
|
|
require Logger
|
|
Logger.warning("Failed to load groups in GroupLive.Index")
|
|
[]
|
|
end
|
|
end
|
|
|
|
# Loads all member counts for groups using DB-side aggregation for better performance
|
|
# This avoids N+1 queries when loading member_count aggregate for each group
|
|
@spec load_member_counts_batch([Mv.Membership.Group.t()]) :: %{
|
|
Ecto.UUID.t() => non_neg_integer()
|
|
}
|
|
defp load_member_counts_batch(groups) do
|
|
group_ids = Enum.map(groups, & &1.id)
|
|
|
|
if Enum.empty?(group_ids) do
|
|
%{}
|
|
else
|
|
# Use Ecto directly for efficient GROUP BY COUNT query
|
|
# This is much more performant than loading aggregates for each group individually
|
|
# Note: We bypass Ash here for performance, but this is a simple read-only query
|
|
import Ecto.Query
|
|
|
|
query =
|
|
from mg in Mv.Membership.MemberGroup,
|
|
where: mg.group_id in ^group_ids,
|
|
group_by: mg.group_id,
|
|
select: {mg.group_id, count(mg.id)}
|
|
|
|
results = Mv.Repo.all(query)
|
|
|
|
results
|
|
|> Enum.into(%{}, fn {group_id, count} -> {group_id, count} end)
|
|
end
|
|
end
|
|
end
|