Merge pull request 'Groups Admin UI closes #372' (#382) from feature/372-groups-management into main
Reviewed-on: #382
This commit is contained in:
commit
1f8fa8a6fb
18 changed files with 2101 additions and 18 deletions
|
|
@ -1010,6 +1010,10 @@ let liveSocket = new LiveSocket("/live", Socket, {
|
|||
|
||||
### 3.8 Code Quality: Credo
|
||||
|
||||
**Static Code Analysis:**
|
||||
|
||||
We use **Credo** for static code analysis to ensure code quality, consistency, and maintainability. Credo checks are **mandatory** and must pass before code can be merged.
|
||||
|
||||
**Run Credo Regularly:**
|
||||
|
||||
```bash
|
||||
|
|
@ -1020,6 +1024,13 @@ mix credo
|
|||
mix credo --strict
|
||||
```
|
||||
|
||||
**CI Enforcement:**
|
||||
|
||||
- ✅ **All Credo checks must pass in CI pipeline**
|
||||
- ✅ Pull requests will be blocked if Credo checks fail
|
||||
- ✅ Run `mix credo --strict` locally before pushing to catch issues early
|
||||
- ✅ Address all Credo warnings and errors before requesting code review
|
||||
|
||||
**Key Credo Checks Enabled:**
|
||||
|
||||
- Consistency checks (spacing, line endings, parameter patterns)
|
||||
|
|
|
|||
|
|
@ -255,9 +255,9 @@ lib/
|
|||
**Route:** `/groups` - Groups management index page
|
||||
|
||||
**Features:**
|
||||
- List all groups in table
|
||||
- Create new group button
|
||||
- Edit group (inline or modal)
|
||||
- List all groups in table (sorted by name via database query)
|
||||
- Create new group button (navigates to `/groups/new`)
|
||||
- Edit group via separate form page (`/groups/:slug/edit`)
|
||||
- Delete group with confirmation modal
|
||||
- Show member count per group
|
||||
|
||||
|
|
@ -268,11 +268,13 @@ lib/
|
|||
- Actions (Edit, Delete)
|
||||
|
||||
**Delete Confirmation Modal:**
|
||||
- Warning: "X members are in this group"
|
||||
- Warning: "X members are in this group" (with proper pluralization)
|
||||
- Confirmation: "All member-group associations will be permanently deleted"
|
||||
- Input field: Enter group name to confirm
|
||||
- Input field: Enter group name to confirm (with `phx-debounce="200"` for better UX)
|
||||
- Delete button disabled until name matches
|
||||
- Modal remains open on name mismatch (allows user to correct input)
|
||||
- Cancel button
|
||||
- Server-side authorization check in delete event handler (security best practice)
|
||||
|
||||
### Member Overview Integration
|
||||
|
||||
|
|
@ -304,18 +306,33 @@ lib/
|
|||
- Add/remove groups inline
|
||||
- Link to group detail page
|
||||
|
||||
### Group Detail View (`/groups/:id`)
|
||||
### Group Detail View (`/groups/:slug`)
|
||||
|
||||
**Route:** `/groups/:id` - Group detail page (uses UUID, slug can be used for future `/groups/:slug` routes)
|
||||
**Route:** `/groups/:slug` - Group detail page (uses slug for URL-friendly routing)
|
||||
|
||||
**Features:**
|
||||
- Display group name and description
|
||||
- List all members in group
|
||||
- Link to member detail pages
|
||||
- Edit group button
|
||||
- Delete group button (with confirmation)
|
||||
- Edit group button (navigates to `/groups/:slug/edit`)
|
||||
- Delete group button (with confirmation modal)
|
||||
|
||||
**Note:** Currently uses UUID for routing. Slug is available for future URL-friendly routes (`/groups/:slug`).
|
||||
**Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`).
|
||||
|
||||
### Group Form Pages
|
||||
|
||||
**Create Form:** `/groups/new`
|
||||
- Separate LiveView page for creating new groups
|
||||
- Form with name and description fields
|
||||
- Slug is auto-generated and not editable
|
||||
- Redirects to `/groups` on success
|
||||
|
||||
**Edit Form:** `/groups/:slug/edit`
|
||||
- Separate LiveView page for editing existing groups
|
||||
- Form pre-populated with current group data
|
||||
- Slug is immutable (not displayed in form)
|
||||
- Redirects to `/groups/:slug` on success
|
||||
- `mount/3` performs authorization check, `handle_params/3` loads group once
|
||||
|
||||
### Accessibility (A11y) Considerations
|
||||
|
||||
|
|
@ -473,6 +490,7 @@ lib/
|
|||
- Paginate member list for large groups (>50 members)
|
||||
- Load member count via calculation (not separate query)
|
||||
- Use `Ash.Query.load` for member details when displaying
|
||||
- Sorting performed at database level (`Ash.Query.sort(:name)`) for efficiency
|
||||
|
||||
### Search Performance
|
||||
|
||||
|
|
@ -1106,7 +1124,7 @@ Groups include automatic slug generation, following the same pattern as CustomFi
|
|||
- Automatically generated from the `name` attribute on create
|
||||
- Immutable after creation (don't change when name is updated)
|
||||
- Unique and URL-friendly
|
||||
- Available for future route enhancements (e.g., `/groups/:slug` instead of `/groups/:id`)
|
||||
- Used for routing (e.g., `/groups/:slug` for group detail pages)
|
||||
|
||||
The implementation reuses the existing `GenerateSlug` change from CustomFields, ensuring consistency across the codebase.
|
||||
|
||||
|
|
|
|||
|
|
@ -244,4 +244,48 @@ defmodule Mv.Membership do
|
|||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
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
|
||||
load = Keyword.get(opts, :load, [])
|
||||
|
||||
require Ash.Query
|
||||
|
||||
query =
|
||||
Mv.Membership.Group
|
||||
|> Ash.Query.filter(slug == ^slug)
|
||||
|> Ash.Query.load(load)
|
||||
|
||||
opts
|
||||
|> Keyword.delete(:load)
|
||||
|> Keyword.put_new(:domain, __MODULE__)
|
||||
|> then(&Ash.read_one(query, &1))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -112,7 +112,10 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
|
||||
|
||||
# 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: [
|
||||
# Home page
|
||||
|
|
@ -141,7 +144,10 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
|
||||
# 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: [
|
||||
"/",
|
||||
|
|
@ -154,7 +160,11 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
# Custom field values overview
|
||||
"/custom_field_values",
|
||||
# Custom field value detail
|
||||
"/custom_field_values/:id"
|
||||
"/custom_field_values/:id",
|
||||
# Groups overview
|
||||
"/groups",
|
||||
# Group detail
|
||||
"/groups/:slug"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
|
@ -181,7 +191,10 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# 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: [
|
||||
"/",
|
||||
|
|
@ -197,7 +210,11 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
# Custom field value detail
|
||||
"/custom_field_values/:id",
|
||||
"/custom_field_values/new",
|
||||
"/custom_field_values/:id/edit"
|
||||
"/custom_field_values/:id/edit",
|
||||
# Groups overview
|
||||
"/groups",
|
||||
# Group detail
|
||||
"/groups/:slug"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
|
@ -233,7 +250,13 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
%{resource: "Role", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :create, 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: [
|
||||
# Wildcard: Admin can access all pages
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
<!-- Nested Admin Menu -->
|
||||
<.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}>
|
||||
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
||||
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
|
||||
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
||||
<.menu_subitem
|
||||
href={~p"/membership_fee_settings"}
|
||||
|
|
|
|||
184
lib/mv_web/live/group_live/form.ex
Normal file
184
lib/mv_web/live/group_live/form.ex
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
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 based on whether we are creating or updating
|
||||
action = if params["slug"], do: :update, else: :create
|
||||
resource = Mv.Membership.Group
|
||||
|
||||
if can?(actor, action, resource) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:actor, actor)
|
||||
|> assign(:group, nil)
|
||||
|> assign(:page_title, page_title_for_params(params))
|
||||
|> assign(:return_to, return_to_for_params(params))}
|
||||
else
|
||||
{:ok, redirect(socket, to: ~p"/groups")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
actor = socket.assigns.actor
|
||||
|
||||
case params do
|
||||
%{"slug" => slug} when is_binary(slug) ->
|
||||
case Membership.get_group_by_slug(slug, actor: actor, load: []) 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(actor)}
|
||||
|
||||
{:error, _error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("Failed to load group."))
|
||||
|> redirect(to: ~p"/groups")}
|
||||
end
|
||||
|
||||
_ ->
|
||||
# New group - ensure form is initialized for create
|
||||
{:noreply, assign_form(socket, actor)}
|
||||
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 = socket.assigns.actor
|
||||
|
||||
case submit_form(socket.assigns.form, group_params, actor) do
|
||||
{:ok, group} ->
|
||||
notify_parent({:saved, group})
|
||||
|
||||
redirect_path =
|
||||
case socket.assigns.return_to do
|
||||
:show -> ~p"/groups/#{group.slug}"
|
||||
_ -> ~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, actor) do
|
||||
group = Map.get(assigns, :group)
|
||||
|
||||
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 page_title_for_params(%{"slug" => _slug}), do: gettext("Edit Group")
|
||||
defp page_title_for_params(_params), do: gettext("Create Group")
|
||||
|
||||
defp return_to_for_params(%{"slug" => _slug}), do: :show
|
||||
defp return_to_for_params(_params), do: :index
|
||||
|
||||
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
|
||||
133
lib/mv_web/live/group_live/index.ex
Normal file
133
lib/mv_web/live/group_live/index.ex
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
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
|
||||
|
||||
query =
|
||||
Mv.Membership.Group
|
||||
|> Ash.Query.load(:member_count)
|
||||
|> Ash.Query.sort(:name)
|
||||
|
||||
opts = ash_actor_opts(actor)
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, groups} ->
|
||||
groups
|
||||
|
||||
{:error, _error} ->
|
||||
require Logger
|
||||
Logger.warning("Failed to load groups in GroupLive.Index")
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
304
lib/mv_web/live/group_live/show.ex
Normal file
304
lib/mv_web/live/group_live/show.ex
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
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
|
||||
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
|
||||
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
|
||||
|
||||
@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">
|
||||
{ngettext(
|
||||
"Total: %{count} member",
|
||||
"Total: %{count} members",
|
||||
@group.member_count || 0,
|
||||
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,
|
||||
count: @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"
|
||||
phx-debounce="200"
|
||||
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)
|
||||
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
|
||||
|
||||
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
|
||||
|
|
@ -74,6 +74,12 @@ defmodule MvWeb.Router do
|
|||
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
|
||||
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)
|
||||
live "/admin/roles", RoleLive.Index, :index
|
||||
live "/admin/roles/new", RoleLive.Form, :new
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ msgstr ""
|
|||
"Language: de\n"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
msgstr "Aktionen"
|
||||
|
|
@ -37,6 +38,7 @@ msgid "City"
|
|||
msgstr "Stadt"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
|
|
@ -46,6 +48,8 @@ msgid "Delete"
|
|||
msgstr "Löschen"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
|
|
@ -62,6 +66,7 @@ msgstr "Bearbeiten"
|
|||
msgid "Edit Member"
|
||||
msgstr "Mitglied bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -166,6 +171,7 @@ msgstr "Mitglied speichern"
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
|
|
@ -257,6 +263,7 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
|
|
@ -269,6 +276,9 @@ msgstr "Abbrechen"
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
|
|
@ -301,6 +311,8 @@ msgid "Listing Users"
|
|||
msgstr "Benutzer*innen auflisten"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
|
|
@ -310,6 +322,9 @@ msgstr "Mitglieder"
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
|
|
@ -752,6 +767,8 @@ msgstr "Alle"
|
|||
msgid "Address"
|
||||
msgstr "Adresse"
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -791,6 +808,7 @@ msgstr "Zahlungen"
|
|||
msgid "Personal Data"
|
||||
msgstr "Persönliche Daten"
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1626,6 +1644,7 @@ msgstr "Hauptnavigation"
|
|||
msgid "New Role"
|
||||
msgstr "Neue Rolle"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2126,6 +2145,114 @@ msgstr "E-Mail"
|
|||
msgid "email %{email} has already been taken"
|
||||
msgstr "E-Mail %{email} wurde bereits verwendet"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Are you sure you want to delete this group? This action cannot be undone."
|
||||
msgstr "Möchtest du diese Gruppe wirklich löschen?"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Back to groups list"
|
||||
msgstr "Zurück zur Gruppenübersicht"
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Create Group"
|
||||
msgstr "Gruppe erstellen"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Group"
|
||||
msgstr "Gruppe löschen"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete group"
|
||||
msgstr "Gruppe löschen"
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Group"
|
||||
msgstr "Gruppe bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Enter the group name to confirm"
|
||||
msgstr "Gebe zur Bestätigung den Gruppennamen ein"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed to delete group: %{error}"
|
||||
msgstr "Gruppe konnte nicht gelöscht werden: %{error}"
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to load group."
|
||||
msgstr "Gruppe konnte nicht geladen werden"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Group deleted successfully."
|
||||
msgstr "Gruppe erfolgreich gelöscht"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group name does not match."
|
||||
msgstr "Gruppenname stimmt nicht überein"
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Group not found."
|
||||
msgstr "Gruppe nicht gefunden."
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Group saved successfully."
|
||||
msgstr "Gruppe erfolgreich gespeichert."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups"
|
||||
msgstr "Gruppen"
|
||||
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No groups"
|
||||
msgstr "Keine Gruppen"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members in this group"
|
||||
msgstr "Keine Mitglieder in dieser Gruppe"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This group has %{count} member. All member-group associations will be permanently deleted."
|
||||
msgid_plural "This group has %{count} members. All member-group associations will be permanently deleted."
|
||||
msgstr[0] "Diese Gruppe hat %{count} Mitglied. Alle Mitglied-Gruppen-Zuordnungen werden dauerhaft gelöscht."
|
||||
msgstr[1] "Diese Gruppe hat %{count} Mitglieder. Alle Mitglied-Gruppen-Zuordnungen werden dauerhaft gelöscht."
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "To confirm deletion, please enter the group name:"
|
||||
msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:"
|
||||
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View"
|
||||
msgstr "Anzeigen"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Total: %{count} member"
|
||||
msgid_plural "Total: %{count} members"
|
||||
msgstr[0] "Insgesamt: %{count} Mitglied"
|
||||
msgstr[1] "Insgesamt: %{count} Mitglieder"
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This user cannot be edited."
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ msgid ""
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
|
@ -38,6 +39,7 @@ msgid "City"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
|
|
@ -47,6 +49,8 @@ msgid "Delete"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
|
|
@ -63,6 +67,7 @@ msgstr ""
|
|||
msgid "Edit Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -167,6 +172,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
|
|
@ -258,6 +264,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
|
|
@ -270,6 +277,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
|
|
@ -302,6 +312,8 @@ msgid "Listing Users"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
|
|
@ -311,6 +323,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
|
|
@ -753,6 +768,8 @@ msgstr ""
|
|||
msgid "Address"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -792,6 +809,7 @@ msgstr ""
|
|||
msgid "Personal Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1627,6 +1645,7 @@ msgstr ""
|
|||
msgid "New Role"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2127,6 +2146,114 @@ msgstr ""
|
|||
msgid "email %{email} has already been taken"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure you want to delete this group? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Back to groups list"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create Group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete Group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Enter the group name to confirm"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to delete group: %{error}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to load group."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group deleted successfully."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group name does not match."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group not found."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group saved successfully."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No groups"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members in this group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This group has %{count} member. All member-group associations will be permanently deleted."
|
||||
msgid_plural "This group has %{count} members. All member-group associations will be permanently deleted."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "To confirm deletion, please enter the group name:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Total: %{count} member"
|
||||
msgid_plural "Total: %{count} members"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This user cannot be edited."
|
||||
|
|
@ -2136,3 +2263,8 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "This user cannot be viewed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not authorized."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ msgstr ""
|
|||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
|
@ -38,6 +39,7 @@ msgid "City"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
|
|
@ -47,6 +49,8 @@ msgid "Delete"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
|
|
@ -63,6 +67,7 @@ msgstr ""
|
|||
msgid "Edit Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -167,6 +172,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
|
|
@ -258,6 +264,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
|
|
@ -270,6 +277,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
|
|
@ -302,6 +312,8 @@ msgid "Listing Users"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
|
|
@ -311,6 +323,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
|
|
@ -753,6 +768,8 @@ msgstr ""
|
|||
msgid "Address"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -792,6 +809,7 @@ msgstr ""
|
|||
msgid "Personal Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1627,6 +1645,7 @@ msgstr ""
|
|||
msgid "New Role"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -2127,6 +2146,114 @@ msgstr ""
|
|||
msgid "email %{email} has already been taken"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Are you sure you want to delete this group? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Back to groups list"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Create Group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Enter the group name to confirm"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed to delete group: %{error}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to load group."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Group deleted successfully."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group name does not match."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Group not found."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Group saved successfully."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No groups"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members in this group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This group has %{count} member. All member-group associations will be permanently deleted."
|
||||
msgid_plural "This group has %{count} members. All member-group associations will be permanently deleted."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "To confirm deletion, please enter the group name:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Total: %{count} member"
|
||||
msgid_plural "Total: %{count} members"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This user cannot be edited."
|
||||
|
|
|
|||
290
test/mv_web/live/group_live/form_test.exs
Normal file
290
test/mv_web/live/group_live/form_test.exs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
defmodule MvWeb.GroupLive.FormTest do
|
||||
@moduledoc """
|
||||
Tests for the group create/edit form.
|
||||
|
||||
Tests cover:
|
||||
- Creating groups
|
||||
- Editing groups
|
||||
- Form validations
|
||||
- Edge cases
|
||||
- Security
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "create form" do
|
||||
test "form renders with empty fields", %{conn: conn} do
|
||||
{:ok, view, html} = live(conn, "/groups/new")
|
||||
|
||||
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen"
|
||||
assert has_element?(view, "form")
|
||||
end
|
||||
|
||||
test "creates group successfully with name and description", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "New Group",
|
||||
"description" => "Group description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Should redirect to groups list or show page
|
||||
assert_redirect(view, "/groups")
|
||||
end
|
||||
|
||||
test "creates group successfully with name only (description optional)", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Group Without Description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert_redirect(view, "/groups")
|
||||
end
|
||||
|
||||
test "shows error when name is missing", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"description" => "Description without name"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ gettext("required") or html =~ "name" or html =~ "error" or
|
||||
html =~ "erforderlich"
|
||||
end
|
||||
|
||||
test "shows error when name exceeds 100 characters", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
long_name = String.duplicate("a", 101)
|
||||
form_data = %{"name" => long_name}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge"
|
||||
end
|
||||
|
||||
test "shows error when description exceeds 500 characters", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
long_description = String.duplicate("a", 501)
|
||||
|
||||
form_data = %{
|
||||
"name" => "Test Group",
|
||||
"description" => long_description
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge"
|
||||
end
|
||||
|
||||
test "shows error when name already exists (case-insensitive)", %{conn: conn} do
|
||||
_existing_group = Fixtures.group_fixture(%{name: "Existing Group"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "EXISTING GROUP"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Check for a validation error on the name field in a robust way
|
||||
assert html =~ "name" or html =~ gettext("has already been taken")
|
||||
end
|
||||
|
||||
test "shows error when name generates empty slug", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "!!!"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edit form" do
|
||||
test "form renders with existing group data", %{conn: conn} do
|
||||
group =
|
||||
Fixtures.group_fixture(%{name: "Original Name", description: "Original Description"})
|
||||
|
||||
{:ok, view, html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
assert html =~ "Original Name"
|
||||
assert html =~ "Original Description"
|
||||
assert has_element?(view, "form")
|
||||
end
|
||||
|
||||
test "updates group name successfully (slug remains unchanged)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Original Name"})
|
||||
original_slug = group.slug
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Updated Name"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Verify slug didn't change by checking redirect URL or reloading
|
||||
assert_redirect(view, "/groups/#{original_slug}")
|
||||
end
|
||||
|
||||
test "updates group description successfully", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{description: "Old Description"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
form_data = %{
|
||||
"description" => "New Description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert_redirect(view, "/groups/#{group.slug}")
|
||||
end
|
||||
|
||||
test "shows error when updating to duplicate name (case-insensitive)", %{conn: conn} do
|
||||
_group1 = Fixtures.group_fixture(%{name: "Group One"})
|
||||
group2 = Fixtures.group_fixture(%{name: "Group Two"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group2.slug}/edit")
|
||||
|
||||
form_data = %{
|
||||
"name" => "GROUP ONE"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or
|
||||
html =~ "bereits" or html =~ "vergeben"
|
||||
end
|
||||
|
||||
test "slug is not displayed in form (immutable)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
# Slug should not be in form (it's immutable)
|
||||
refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "handles whitespace trimming correctly", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => " Trimmed Group "
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Name should be trimmed
|
||||
assert_redirect(view, "/groups")
|
||||
end
|
||||
|
||||
test "handles umlauts in name correctly", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Müller Gruppe"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert_redirect(view, "/groups")
|
||||
end
|
||||
end
|
||||
|
||||
describe "security" do
|
||||
@tag role: :member
|
||||
test "non-admin users cannot access create form", %{conn: conn} do
|
||||
result = live(conn, "/groups/new")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: _}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: _}}}, result)
|
||||
end
|
||||
|
||||
@tag role: :member
|
||||
test "non-admin users cannot access edit form", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
result = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: _}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: _}}}, result)
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "unauthenticated users are redirected to login", %{conn: conn} do
|
||||
result = live(conn, "/groups/new")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/sign-in"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/sign-in"}}}, result)
|
||||
end
|
||||
|
||||
test "user cannot edit group with non-existent slug", %{conn: conn} do
|
||||
non_existent_slug = "non-existent-group-slug"
|
||||
|
||||
result = live(conn, "/groups/#{non_existent_slug}/edit")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
|
||||
test "user cannot edit group with wrong slug (case mismatch)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group"})
|
||||
wrong_case_slug = String.upcase(group.slug)
|
||||
|
||||
result = live(conn, "/groups/#{wrong_case_slug}/edit")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
end
|
||||
end
|
||||
151
test/mv_web/live/group_live/index_test.exs
Normal file
151
test/mv_web/live/group_live/index_test.exs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
defmodule MvWeb.GroupLive.IndexTest do
|
||||
@moduledoc """
|
||||
Tests for the groups overview page.
|
||||
|
||||
Tests cover:
|
||||
- Displaying list of groups
|
||||
- Creating new groups
|
||||
- Deleting groups with confirmation
|
||||
- Permission checks
|
||||
- Edge cases
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "mount and display" do
|
||||
test "page renders successfully for admin user", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ gettext("Groups")
|
||||
end
|
||||
|
||||
test "lists all groups", %{conn: conn} do
|
||||
group1 = Fixtures.group_fixture(%{name: "Group One"})
|
||||
group2 = Fixtures.group_fixture(%{name: "Group Two"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ group1.name
|
||||
assert html =~ group2.name
|
||||
end
|
||||
|
||||
test "displays group name, description, and member count", %{conn: conn} do
|
||||
_group = Fixtures.group_fixture(%{name: "Test Group", description: "Test description"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ "Test Group"
|
||||
assert html =~ "Test description"
|
||||
# Member count should be displayed (0 for empty group)
|
||||
assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder"
|
||||
end
|
||||
|
||||
test "displays 'Create Group' button for admin users", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or
|
||||
html =~ "Gruppe erstellen"
|
||||
end
|
||||
|
||||
test "displays empty state when no groups exist", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Should show empty state or empty list message
|
||||
assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or
|
||||
html =~ "Keine Gruppen"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "handles very long group names correctly", %{conn: conn} do
|
||||
long_name = String.duplicate("a", 100)
|
||||
_group = Fixtures.group_fixture(%{name: long_name})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ long_name
|
||||
end
|
||||
|
||||
test "handles very long descriptions correctly", %{conn: conn} do
|
||||
long_description = String.duplicate("a", 500)
|
||||
_group = Fixtures.group_fixture(%{description: long_description})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ long_description or html =~ String.slice(long_description, 0, 100)
|
||||
end
|
||||
end
|
||||
|
||||
describe "security" do
|
||||
@tag role: :member
|
||||
test "non-admin users cannot access groups page", %{conn: conn} do
|
||||
# Should redirect or show 403
|
||||
result = live(conn, "/groups")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: _}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: _}}}, result)
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "unauthenticated users are redirected to login", %{conn: conn} do
|
||||
result = live(conn, "/groups")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/sign-in"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/sign-in"}}}, result)
|
||||
end
|
||||
|
||||
@tag role: :member
|
||||
test "read-only users can view groups but not create", %{conn: conn} do
|
||||
# Create read-only user
|
||||
read_only_user = Fixtures.user_with_role_fixture("read_only")
|
||||
conn = conn_with_password_user(conn, read_only_user)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Should be able to see groups
|
||||
assert html =~ gettext("Groups")
|
||||
|
||||
# Should NOT see create button
|
||||
refute html =~ gettext("Create Group") or html =~ "create"
|
||||
end
|
||||
end
|
||||
|
||||
describe "performance" do
|
||||
test "page loads efficiently with many groups", %{conn: conn} do
|
||||
# Create multiple groups
|
||||
Enum.each(1..10, fn _ -> Fixtures.group_fixture() end)
|
||||
|
||||
# Should load without N+1 queries
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Verify all groups are displayed
|
||||
assert html =~ gettext("Groups")
|
||||
end
|
||||
|
||||
test "member count is loaded efficiently via calculation", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
member1 = Fixtures.member_fixture()
|
||||
member2 = Fixtures.member_fixture()
|
||||
|
||||
# Add members to group
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Member count should be displayed (should be 2)
|
||||
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
|
||||
end
|
||||
end
|
||||
end
|
||||
209
test/mv_web/live/group_live/integration_test.exs
Normal file
209
test/mv_web/live/group_live/integration_test.exs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
defmodule MvWeb.GroupLive.IntegrationTest do
|
||||
@moduledoc """
|
||||
Integration tests for complete group management workflows.
|
||||
|
||||
Tests cover:
|
||||
- Complete workflows (Create → View → Edit → Delete)
|
||||
- Slug-based navigation
|
||||
- Member-group associations
|
||||
- URL persistence
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "complete workflow" do
|
||||
test "create → view via slug → edit → view via slug (slug unchanged)", %{
|
||||
conn: conn,
|
||||
current_user: current_user
|
||||
} do
|
||||
# Create group
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Workflow Test Group",
|
||||
"description" => "Initial description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Get the created group by name using current user (tests authorization)
|
||||
{:ok, groups} = Membership.list_groups(actor: current_user)
|
||||
group = Enum.find(groups, &(&1.name == "Workflow Test Group"))
|
||||
original_slug = group.slug
|
||||
|
||||
# View via slug
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Workflow Test Group"
|
||||
assert html =~ "Initial description"
|
||||
|
||||
# Edit group
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Updated Workflow Test Group",
|
||||
"description" => "Updated description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# View again via slug (should still work with original slug)
|
||||
{:ok, _view, html} = live(conn, "/groups/#{original_slug}")
|
||||
|
||||
assert html =~ "Updated Workflow Test Group"
|
||||
assert html =~ "Updated description"
|
||||
# Slug should remain unchanged
|
||||
assert html =~ original_slug or html =~ "workflow-test-group"
|
||||
end
|
||||
|
||||
test "create group → add members → view (member count updated)", %{
|
||||
conn: conn,
|
||||
current_user: current_user
|
||||
} do
|
||||
# Create group
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Member Test Group"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Get the created group using current user (tests authorization)
|
||||
{:ok, groups} = Membership.list_groups(actor: current_user)
|
||||
group = Enum.find(groups, &(&1.name == "Member Test Group"))
|
||||
|
||||
# Add members to group (use system_actor for test data setup)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
member1 = Fixtures.member_fixture()
|
||||
member2 = Fixtures.member_fixture()
|
||||
|
||||
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# View group via slug
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# Member count should be 2
|
||||
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
|
||||
assert html =~ member1.first_name or html =~ member1.last_name
|
||||
assert html =~ member2.first_name or html =~ member2.last_name
|
||||
end
|
||||
|
||||
test "create group → add members → delete (cascade works)", %{
|
||||
conn: conn,
|
||||
current_user: current_user
|
||||
} do
|
||||
# Create group
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Delete Test Group"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#group-form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Get the created group using current user (tests authorization)
|
||||
{:ok, groups} = Membership.list_groups(actor: current_user)
|
||||
group = Enum.find(groups, &(&1.name == "Delete Test Group"))
|
||||
|
||||
# Add member to group (use system_actor for test data setup)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Delete group (via UI - would need delete confirmation modal test)
|
||||
# For now, test via API that cascade works
|
||||
# Use current_user to test authorization (admin can delete)
|
||||
:ok = Membership.destroy_group(group, actor: current_user)
|
||||
|
||||
# Verify member still exists (use system_actor for verification - this is test verification, not user action)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, member.id, actor: system_actor)
|
||||
assert member_reloaded != nil
|
||||
|
||||
# Verify member-group association is deleted
|
||||
{:ok, mgs} =
|
||||
Ash.read(
|
||||
Mv.Membership.MemberGroup
|
||||
|> Ash.Query.filter(expr(group_id == ^group.id)),
|
||||
actor: system_actor,
|
||||
domain: Mv.Membership
|
||||
)
|
||||
|
||||
assert mgs == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug-based navigation" do
|
||||
test "links to group detail use slug (not ID)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Navigation Test Group"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Links should use slug, not UUID
|
||||
assert html =~ "/groups/#{group.slug}"
|
||||
refute html =~ "/groups/#{group.id}"
|
||||
end
|
||||
|
||||
test "deep links to group detail per slug work", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Deep Link Test"})
|
||||
|
||||
# Direct navigation to slug URL
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Deep Link Test"
|
||||
end
|
||||
|
||||
test "browser back button works correctly", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
# Navigate to group detail
|
||||
{:ok, _view1, _html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# Navigate to edit
|
||||
{:ok, view2, _html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
# Browser back should work (tested via navigation)
|
||||
assert view2 != nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "URL persistence" do
|
||||
test "slug-based URLs persist across page reloads", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Persistent URL Test"})
|
||||
|
||||
# First visit
|
||||
{:ok, _view1, html1} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html1 =~ "Persistent URL Test"
|
||||
|
||||
# Simulate page reload (new connection)
|
||||
{:ok, _view2, html2} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html2 =~ "Persistent URL Test"
|
||||
end
|
||||
end
|
||||
end
|
||||
287
test/mv_web/live/group_live/show_test.exs
Normal file
287
test/mv_web/live/group_live/show_test.exs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
defmodule MvWeb.GroupLive.ShowTest do
|
||||
@moduledoc """
|
||||
Tests for the group detail page.
|
||||
|
||||
Tests cover:
|
||||
- Displaying group information
|
||||
- Slug-based routing
|
||||
- Member list display
|
||||
- Navigation
|
||||
- Error handling
|
||||
- Delete functionality
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "mount and display" do
|
||||
test "page renders successfully", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ group.name
|
||||
end
|
||||
|
||||
test "displays group name", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group Name"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Test Group Name"
|
||||
end
|
||||
|
||||
test "displays group description when present", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{description: "This is a test description"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "This is a test description"
|
||||
end
|
||||
|
||||
test "displays member count", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# Member count should be displayed (might be 0 or more)
|
||||
assert html =~ "0" or html =~ gettext("Members") or html =~ "member" or html =~ "Mitglied"
|
||||
end
|
||||
|
||||
test "displays list of members in group", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
member1 = Fixtures.member_fixture(%{first_name: "Alice", last_name: "Smith"})
|
||||
member2 = Fixtures.member_fixture(%{first_name: "Bob", last_name: "Jones"})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Alice" or html =~ "Smith"
|
||||
assert html =~ "Bob" or html =~ "Jones"
|
||||
end
|
||||
|
||||
test "displays edit button for admin users", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ gettext("Edit") or html =~ "edit" or html =~ "Bearbeiten"
|
||||
end
|
||||
|
||||
test "displays delete button for admin users", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ gettext("Delete") or html =~ "delete" or html =~ "Löschen"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug-based routing" do
|
||||
test "route /groups/:slug works correctly", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Board Members"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Board Members"
|
||||
# Verify slug is in URL
|
||||
assert html =~ group.slug or html =~ "board-members"
|
||||
end
|
||||
|
||||
test "group is found by slug via unique_slug identity", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ group.name
|
||||
end
|
||||
|
||||
test "non-existent slug returns 404", %{conn: conn} do
|
||||
non_existent_slug = "non-existent-group-slug"
|
||||
|
||||
result = live(conn, "/groups/#{non_existent_slug}")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
|
||||
test "slug is case-sensitive (exact match required)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group"})
|
||||
# Slug should be lowercase
|
||||
wrong_case_slug = String.upcase(group.slug)
|
||||
|
||||
result = live(conn, "/groups/#{wrong_case_slug}")
|
||||
|
||||
# Should not find group with wrong case
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
|
||||
test "URL is readable and user-friendly", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Board Members"})
|
||||
|
||||
{:ok, _view, _html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# URL should contain readable slug, not UUID
|
||||
assert group.slug == "board-members"
|
||||
refute String.contains?(group.slug, "-") == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "displays empty group correctly (0 members)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "0" or html =~ gettext("No members") or html =~ "empty" or
|
||||
html =~ "Keine Mitglieder"
|
||||
end
|
||||
|
||||
test "handles group without description correctly", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{description: nil})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# Should not crash, description should be optional
|
||||
assert html =~ group.name
|
||||
end
|
||||
|
||||
test "handles slug with special characters correctly", %{conn: conn} do
|
||||
# Create group with name that generates slug with hyphens
|
||||
group = Fixtures.group_fixture(%{name: "Test-Group-Name"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Test-Group-Name" or html =~ group.name
|
||||
end
|
||||
end
|
||||
|
||||
describe "security" do
|
||||
@tag role: :member
|
||||
test "read-only users can view group detail page", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
read_only_user = Fixtures.user_with_role_fixture("read_only")
|
||||
conn = conn_with_password_user(conn, read_only_user)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ group.name
|
||||
# Should NOT see edit/delete buttons
|
||||
refute html =~ gettext("Edit") or html =~ gettext("Delete")
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "unauthenticated users are redirected to login", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
result = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/sign-in"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/sign-in"}}}, result)
|
||||
end
|
||||
|
||||
test "slug injection attempts are prevented", %{conn: conn} do
|
||||
# Try to inject SQL or other malicious content in slug
|
||||
malicious_slug = "'; DROP TABLE groups; --"
|
||||
|
||||
result = live(conn, "/groups/#{malicious_slug}")
|
||||
|
||||
# Should not execute SQL, should return 404 or error
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
|
||||
test "user cannot delete group with non-existent slug", %{conn: conn} do
|
||||
# This test verifies that delete action (if accessed via URL manipulation)
|
||||
# cannot be performed with non-existent slug
|
||||
# Note: Actual delete functionality will be tested when delete modal is implemented
|
||||
non_existent_slug = "non-existent-group-slug"
|
||||
|
||||
# Attempting to access show page with non-existent slug should fail
|
||||
result = live(conn, "/groups/#{non_existent_slug}")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
end
|
||||
|
||||
describe "performance" do
|
||||
test "member list is loaded efficiently (no N+1 queries)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
# Create multiple members
|
||||
members = Enum.map(1..5, fn _ -> Fixtures.member_fixture() end)
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Add all members to group
|
||||
Enum.each(members, fn member ->
|
||||
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
end)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# All members should be displayed
|
||||
Enum.each(members, fn member ->
|
||||
assert html =~ member.first_name or html =~ member.last_name
|
||||
end)
|
||||
end
|
||||
|
||||
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
# Should use index for fast lookup
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ group.name
|
||||
end
|
||||
end
|
||||
|
||||
describe "navigation" do
|
||||
test "back button navigates to groups list", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element(
|
||||
"a[aria-label*='Back'], button[aria-label*='Back'], a[aria-label*='groups list'], button[aria-label*='groups list']"
|
||||
)
|
||||
|> render_click()
|
||||
|
||||
assert to == "/groups"
|
||||
end
|
||||
|
||||
test "edit button navigates to edit form", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# Button with navigate is rendered as <a> tag via <.link>
|
||||
# Use element with text content to find the link
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element("a", gettext("Edit"))
|
||||
|> render_click()
|
||||
|
||||
assert to == "/groups/#{group.slug}/edit"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -170,7 +170,8 @@ defmodule MvWeb.ConnCase do
|
|||
|
||||
:member ->
|
||||
# Create member user for role-based testing
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("member")
|
||||
# "member" role uses "own_data" permission set (Mitglied role)
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
authenticated_conn = conn_with_password_user(conn, member_user)
|
||||
{authenticated_conn, member_user}
|
||||
|
||||
|
|
|
|||
|
|
@ -262,4 +262,39 @@ defmodule Mv.Fixtures do
|
|||
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a group with default or custom attributes.
|
||||
|
||||
Uses system_actor for authorization to bypass permission checks in tests.
|
||||
|
||||
## Parameters
|
||||
- `attrs` - Map or keyword list of attributes to override defaults
|
||||
|
||||
## Returns
|
||||
- Group struct
|
||||
|
||||
## Examples
|
||||
|
||||
iex> group_fixture()
|
||||
%Mv.Membership.Group{name: "Test Group", slug: "test-group", ...}
|
||||
|
||||
iex> group_fixture(%{name: "Board Members", description: "Board members group"})
|
||||
%Mv.Membership.Group{name: "Board Members", slug: "board-members", ...}
|
||||
|
||||
"""
|
||||
def group_fixture(attrs \\ %{}) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
name: "Test Group #{System.unique_integer([:positive])}",
|
||||
description: "Test description"
|
||||
})
|
||||
|> Mv.Membership.create_group(actor: system_actor)
|
||||
|> case do
|
||||
{:ok, group} -> group
|
||||
{:error, error} -> raise "Failed to create group: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue