feat: add groups administration #372
This commit is contained in:
parent
f05fae3ea3
commit
6faa9847f4
9 changed files with 701 additions and 7 deletions
|
|
@ -244,4 +244,48 @@ defmodule Mv.Membership do
|
||||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||||
|> Ash.update(domain: __MODULE__)
|
|> Ash.update(domain: __MODULE__)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a group by its slug.
|
||||||
|
|
||||||
|
Uses `Ash.Query.filter` to efficiently find a group by its slug.
|
||||||
|
The unique index on `slug` ensures efficient lookup performance.
|
||||||
|
The slug lookup is case-sensitive (exact match required).
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- `slug` - The slug to search for (case-sensitive)
|
||||||
|
- `opts` - Options including `:actor` for authorization
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- `{:ok, group}` - Found group (with members and member_count loaded)
|
||||||
|
- `{:ok, nil}` - Group not found
|
||||||
|
- `{:error, error}` - Error reading group
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> {:ok, group} = Mv.Membership.get_group_by_slug("board-members", actor: actor)
|
||||||
|
iex> group.name
|
||||||
|
"Board Members"
|
||||||
|
|
||||||
|
iex> {:ok, nil} = Mv.Membership.get_group_by_slug("non-existent", actor: actor)
|
||||||
|
{:ok, nil}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_group_by_slug(slug, opts \\ []) do
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
load_opts = Keyword.get(opts, :load, [:members, :member_count])
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
query =
|
||||||
|
Mv.Membership.Group
|
||||||
|
|> Ash.Query.filter(slug == ^slug)
|
||||||
|
|> Ash.Query.load(load_opts)
|
||||||
|
|
||||||
|
opts_with_actor = if actor, do: [actor: actor], else: []
|
||||||
|
|
||||||
|
Ash.read_one(query, opts_with_actor)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,10 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
|
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
|
||||||
|
|
||||||
# CustomField: Can read all (needed for forms)
|
# CustomField: Can read all (needed for forms)
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||||
|
|
||||||
|
# Group: Can read all (needed for viewing groups)
|
||||||
|
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
# Home page
|
# Home page
|
||||||
|
|
@ -141,7 +144,10 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||||
|
|
||||||
# CustomField: Can read all
|
# CustomField: Can read all
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||||
|
|
||||||
|
# Group: Can read all
|
||||||
|
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
|
|
@ -154,7 +160,11 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
# Custom field values overview
|
# Custom field values overview
|
||||||
"/custom_field_values",
|
"/custom_field_values",
|
||||||
# Custom field value detail
|
# Custom field value detail
|
||||||
"/custom_field_values/:id"
|
"/custom_field_values/:id",
|
||||||
|
# Groups overview
|
||||||
|
"/groups",
|
||||||
|
# Group detail
|
||||||
|
"/groups/:slug"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -181,7 +191,10 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||||
|
|
||||||
# CustomField: Read only (admin manages definitions)
|
# CustomField: Read only (admin manages definitions)
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||||
|
|
||||||
|
# Group: Can read all
|
||||||
|
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
|
|
@ -197,7 +210,11 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
# Custom field value detail
|
# Custom field value detail
|
||||||
"/custom_field_values/:id",
|
"/custom_field_values/:id",
|
||||||
"/custom_field_values/new",
|
"/custom_field_values/new",
|
||||||
"/custom_field_values/:id/edit"
|
"/custom_field_values/:id/edit",
|
||||||
|
# Groups overview
|
||||||
|
"/groups",
|
||||||
|
# Group detail
|
||||||
|
"/groups/:slug"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -233,7 +250,13 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{resource: "Role", action: :read, scope: :all, granted: true},
|
%{resource: "Role", action: :read, scope: :all, granted: true},
|
||||||
%{resource: "Role", action: :create, scope: :all, granted: true},
|
%{resource: "Role", action: :create, scope: :all, granted: true},
|
||||||
%{resource: "Role", action: :update, scope: :all, granted: true},
|
%{resource: "Role", action: :update, scope: :all, granted: true},
|
||||||
%{resource: "Role", action: :destroy, scope: :all, granted: true}
|
%{resource: "Role", action: :destroy, scope: :all, granted: true},
|
||||||
|
|
||||||
|
# Group: Full CRUD (admin manages groups)
|
||||||
|
%{resource: "Group", action: :read, scope: :all, granted: true},
|
||||||
|
%{resource: "Group", action: :create, scope: :all, granted: true},
|
||||||
|
%{resource: "Group", action: :update, scope: :all, granted: true},
|
||||||
|
%{resource: "Group", action: :destroy, scope: :all, granted: true}
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
# Wildcard: Admin can access all pages
|
# Wildcard: Admin can access all pages
|
||||||
|
|
|
||||||
201
lib/mv_web/live/group_live/form.ex
Normal file
201
lib/mv_web/live/group_live/form.ex
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
defmodule MvWeb.GroupLive.Form do
|
||||||
|
@moduledoc """
|
||||||
|
LiveView form for creating and editing groups.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Create new groups with name and description
|
||||||
|
- Edit existing group details (name and description)
|
||||||
|
- Slug is automatically generated and immutable
|
||||||
|
- Form validation with visual feedback
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Only admin users can create/edit groups
|
||||||
|
- Non-admin users are redirected
|
||||||
|
"""
|
||||||
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
import MvWeb.Authorization
|
||||||
|
|
||||||
|
alias Mv.Membership
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(params, _session, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
action = if params["slug"], do: :update, else: :create
|
||||||
|
resource = Mv.Membership.Group
|
||||||
|
|
||||||
|
unless can?(actor, action, resource) do
|
||||||
|
{:ok, redirect(socket, to: ~p"/groups")}
|
||||||
|
else
|
||||||
|
socket =
|
||||||
|
case params["slug"] do
|
||||||
|
nil ->
|
||||||
|
# New group
|
||||||
|
socket
|
||||||
|
|> assign(:group, nil)
|
||||||
|
|> assign(:page_title, gettext("Create Group"))
|
||||||
|
|> assign(:return_to, "index")
|
||||||
|
|
||||||
|
slug ->
|
||||||
|
# Edit existing group
|
||||||
|
case Membership.get_group_by_slug(slug, actor: actor) do
|
||||||
|
{:ok, nil} ->
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("Group not found."))
|
||||||
|
|> redirect(to: ~p"/groups")
|
||||||
|
|
||||||
|
{:ok, group} ->
|
||||||
|
socket
|
||||||
|
|> assign(:group, group)
|
||||||
|
|> assign(:page_title, gettext("Edit Group"))
|
||||||
|
|> assign(:return_to, "show")
|
||||||
|
|
||||||
|
{:error, _error} ->
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("Failed to load group."))
|
||||||
|
|> redirect(to: ~p"/groups")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, assign_form(socket)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_params(params, _url, socket) do
|
||||||
|
# Handle slug-based routing for edit
|
||||||
|
case params do
|
||||||
|
%{"slug" => slug} when is_binary(slug) ->
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
case Membership.get_group_by_slug(slug, actor: actor) do
|
||||||
|
{:ok, nil} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("Group not found."))
|
||||||
|
|> redirect(to: ~p"/groups")}
|
||||||
|
|
||||||
|
{:ok, group} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:group, group)
|
||||||
|
|> assign(:page_title, gettext("Edit Group"))
|
||||||
|
|> assign(:return_to, "show")
|
||||||
|
|> assign_form()}
|
||||||
|
|
||||||
|
{:error, _error} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("Failed to load group."))
|
||||||
|
|> redirect(to: ~p"/groups")}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
|
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save">
|
||||||
|
<%!-- Header with Back button, Title, and Save button --%>
|
||||||
|
<div class="flex items-center justify-between gap-4 pb-4">
|
||||||
|
<.button navigate={return_path(@return_to, @group)} type="button">
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-center flex-1">
|
||||||
|
{@page_title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
|
{gettext("Save")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-2xl space-y-4">
|
||||||
|
<.input field={@form[:name]} label={gettext("Name")} required />
|
||||||
|
<.input
|
||||||
|
field={@form[:description]}
|
||||||
|
type="textarea"
|
||||||
|
label={gettext("Description")}
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"group" => group_params}, socket) do
|
||||||
|
validated_form = AshPhoenix.Form.validate(socket.assigns.form, group_params)
|
||||||
|
{:noreply, assign(socket, form: validated_form)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save", %{"group" => group_params}, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
case submit_form(socket.assigns.form, group_params, actor) do
|
||||||
|
{:ok, group} ->
|
||||||
|
notify_parent({:saved, group})
|
||||||
|
|
||||||
|
redirect_path =
|
||||||
|
if socket.assigns.return_to == "show" do
|
||||||
|
~p"/groups/#{group.slug}"
|
||||||
|
else
|
||||||
|
~p"/groups"
|
||||||
|
end
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, gettext("Group saved successfully."))
|
||||||
|
|> push_navigate(to: redirect_path)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
{:error, form} ->
|
||||||
|
{:noreply, assign(socket, form: form)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||||
|
|
||||||
|
defp assign_form(%{assigns: assigns} = socket) do
|
||||||
|
group = assigns.group
|
||||||
|
actor = assigns.current_user
|
||||||
|
|
||||||
|
form =
|
||||||
|
if group do
|
||||||
|
AshPhoenix.Form.for_update(
|
||||||
|
group,
|
||||||
|
:update,
|
||||||
|
api: Membership,
|
||||||
|
as: "group",
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
else
|
||||||
|
AshPhoenix.Form.for_create(
|
||||||
|
Mv.Membership.Group,
|
||||||
|
:create,
|
||||||
|
api: Membership,
|
||||||
|
as: "group",
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, form: to_form(form))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp return_path("index", _group), do: ~p"/groups"
|
||||||
|
defp return_path("show", group) when not is_nil(group), do: ~p"/groups/#{group.slug}"
|
||||||
|
defp return_path("show", _group), do: ~p"/groups"
|
||||||
|
defp return_path(_, group) when not is_nil(group), do: ~p"/groups/#{group.slug}"
|
||||||
|
defp return_path(_, _group), do: ~p"/groups"
|
||||||
|
end
|
||||||
132
lib/mv_web/live/group_live/index.ex
Normal file
132
lib/mv_web/live/group_live/index.ex
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
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 read groups
|
||||||
|
unless can?(actor, :read, Mv.Membership.Group) do
|
||||||
|
{:ok, redirect(socket, to: ~p"/members")}
|
||||||
|
else
|
||||||
|
groups = load_groups(actor)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, gettext("Groups"))
|
||||||
|
|> assign(:groups, groups)}
|
||||||
|
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>
|
||||||
|
<.link navigate={~p"/groups/#{group.slug}"} class="link link-primary">
|
||||||
|
{group.name}
|
||||||
|
</.link>
|
||||||
|
</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
|
||||||
|
|
||||||
|
query =
|
||||||
|
Mv.Membership.Group
|
||||||
|
|> Ash.Query.load(:member_count)
|
||||||
|
|
||||||
|
opts = ash_actor_opts(actor)
|
||||||
|
|
||||||
|
case Ash.read(query, opts) do
|
||||||
|
{:ok, groups} ->
|
||||||
|
Enum.sort_by(groups, & &1.name)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
284
lib/mv_web/live/group_live/show.ex
Normal file
284
lib/mv_web/live/group_live/show.ex
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
import MvWeb.Authorization
|
||||||
|
|
||||||
|
alias Mv.Membership
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_params(%{"slug" => slug}, _url, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
# Check if user can read groups
|
||||||
|
unless can?(actor, :read, Mv.Membership.Group) do
|
||||||
|
{:noreply, redirect(socket, to: ~p"/members")}
|
||||||
|
else
|
||||||
|
case Membership.get_group_by_slug(slug, actor: actor, load: [:members, :member_count]) 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
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
|
<%!-- Header with Back button, Name, and Edit/Delete buttons --%>
|
||||||
|
<div class="flex items-center justify-between gap-4 pb-4">
|
||||||
|
<.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}>
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-center flex-1">
|
||||||
|
{@group.name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
||||||
|
<.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}>
|
||||||
|
{gettext("Edit")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
|
<%= if can?(@current_user, :destroy, Mv.Membership.Group) do %>
|
||||||
|
<.button class="btn-error" phx-click="open_delete_modal">
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Group Information --%>
|
||||||
|
<div class="max-w-2xl space-y-6 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
|
||||||
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
|
<%= if @group.description && String.trim(@group.description) != "" do %>
|
||||||
|
<p class="whitespace-pre-wrap">{@group.description}</p>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-base-content/50 italic">{gettext("No description")}</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
||||||
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
|
<p class="mb-4">
|
||||||
|
{gettext("Total: %{count} member(s)", count: @group.member_count || 0)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= if Enum.empty?(@group.members || []) do %>
|
||||||
|
<p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{gettext("Name")}</th>
|
||||||
|
<th>{gettext("Email")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%= for member <- @group.members do %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<.link
|
||||||
|
navigate={~p"/members/#{member.id}"}
|
||||||
|
class="link link-primary"
|
||||||
|
>
|
||||||
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
|
</.link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<%= if member.email do %>
|
||||||
|
<a
|
||||||
|
href={"mailto:#{member.email}"}
|
||||||
|
class="text-blue-700 hover:text-blue-800 underline"
|
||||||
|
>
|
||||||
|
{member.email}
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-base-content/50 italic">—</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Delete Confirmation Modal --%>
|
||||||
|
<%= if assigns[:show_delete_modal] do %>
|
||||||
|
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
|
||||||
|
<p class="mb-4">
|
||||||
|
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
|
||||||
|
</p>
|
||||||
|
<%= if @group.member_count && @group.member_count > 0 do %>
|
||||||
|
<div class="alert alert-warning mb-4">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
|
<span>
|
||||||
|
{ngettext(
|
||||||
|
"This group has %{count} member. All member-group associations will be permanently deleted.",
|
||||||
|
"This group has %{count} members. All member-group associations will be permanently deleted.",
|
||||||
|
@group.member_count
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div>
|
||||||
|
<label for="group-name-confirmation" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
{gettext("To confirm deletion, please enter the group name:")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
|
||||||
|
{@group.name}
|
||||||
|
</div>
|
||||||
|
<form phx-change="update_name_confirmation">
|
||||||
|
<input
|
||||||
|
id="group-name-confirmation"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={@name_confirmation || ""}
|
||||||
|
placeholder={gettext("Enter the group name to confirm")}
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full input input-bordered"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
phx-click="cancel_delete"
|
||||||
|
aria-label={gettext("Cancel")}
|
||||||
|
>
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error"
|
||||||
|
phx-click="confirm_delete"
|
||||||
|
phx-value-slug={@group.slug}
|
||||||
|
disabled={(@name_confirmation || "") != @group.name}
|
||||||
|
aria-label={gettext("Delete group")}
|
||||||
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<% end %>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("open_delete_modal", _params, socket) do
|
||||||
|
{:noreply, assign(socket, show_delete_modal: true, name_confirmation: "")}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("cancel_delete", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> assign(:name_confirmation, "")}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
|
||||||
|
{:noreply, assign(socket, :name_confirmation, name)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("confirm_delete", %{"slug" => slug}, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
case Membership.get_group_by_slug(slug, actor: actor) do
|
||||||
|
{:ok, nil} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("Group not found."))
|
||||||
|
|> redirect(to: ~p"/groups")}
|
||||||
|
|
||||||
|
{:ok, group} ->
|
||||||
|
if socket.assigns.name_confirmation == group.name 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
|
||||||
|
else
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("Group name does not match."))
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> assign(:name_confirmation, "")}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _error} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("Failed to load group."))
|
||||||
|
|> redirect(to: ~p"/groups")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_error(%{message: message}) when is_binary(message), do: message
|
||||||
|
defp format_error(error), do: inspect(error)
|
||||||
|
end
|
||||||
|
|
@ -74,6 +74,12 @@ defmodule MvWeb.Router do
|
||||||
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
|
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
|
||||||
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
|
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
|
||||||
|
|
||||||
|
# Groups Management
|
||||||
|
live "/groups", GroupLive.Index, :index
|
||||||
|
live "/groups/new", GroupLive.Form, :new
|
||||||
|
live "/groups/:slug", GroupLive.Show, :show
|
||||||
|
live "/groups/:slug/edit", GroupLive.Form, :edit
|
||||||
|
|
||||||
# Role Management (Admin only)
|
# Role Management (Admin only)
|
||||||
live "/admin/roles", RoleLive.Index, :index
|
live "/admin/roles", RoleLive.Index, :index
|
||||||
live "/admin/roles/new", RoleLive.Form, :new
|
live "/admin/roles/new", RoleLive.Form, :new
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
{:ok, view, _html} = live(conn, "/groups/new")
|
{:ok, view, _html} = live(conn, "/groups/new")
|
||||||
|
|
||||||
long_description = String.duplicate("a", 501)
|
long_description = String.duplicate("a", 501)
|
||||||
|
|
||||||
form_data = %{
|
form_data = %{
|
||||||
"name" => "Test Group",
|
"name" => "Test Group",
|
||||||
"description" => long_description
|
"description" => long_description
|
||||||
|
|
@ -134,7 +135,8 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
|
|
||||||
describe "edit form" do
|
describe "edit form" do
|
||||||
test "form renders with existing group data", %{conn: conn} do
|
test "form renders with existing group data", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture(%{name: "Original Name", description: "Original Description"})
|
group =
|
||||||
|
Fixtures.group_fixture(%{name: "Original Name", description: "Original Description"})
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/groups/#{group.slug}/edit")
|
{:ok, view, html} = live(conn, "/groups/#{group.slug}/edit")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ defmodule MvWeb.GroupLive.IndexTest do
|
||||||
|
|
||||||
# Add members to group
|
# Add members to group
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
member2 = Fixtures.member_fixture(%{first_name: "Bob", last_name: "Jones"})
|
member2 = Fixtures.member_fixture(%{first_name: "Bob", last_name: "Jones"})
|
||||||
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue