UI for adding and removing members on the group show page #401

Open
simon wants to merge 5 commits from feature/ui-for-adding-members-groups into main
12 changed files with 3057 additions and 3 deletions

View file

@ -314,9 +314,23 @@ lib/
- Display group name and description - Display group name and description
- List all members in group - List all members in group
- Link to member detail pages - Link to member detail pages
- Add members to group (via modal with search/autocomplete)
- Remove members from group (via remove button per member)
- Edit group button (navigates to `/groups/:slug/edit`) - Edit group button (navigates to `/groups/:slug/edit`)
- Delete group button (with confirmation modal) - Delete group button (with confirmation modal)
**Add Member Functionality:**
- "Add Member" button displayed above member table (only for users with `:update` permission)
- Opens modal with member search/autocomplete
- Search filters out members already in the group
- Selecting a member adds them to the group immediately
- Success/error flash messages provide feedback
**Remove Member Functionality:**
- "Remove" button (icon button) for each member in table (only for users with `:update` permission)
- Clicking remove immediately removes member from group (no confirmation dialog)
- Success/error flash messages provide feedback
**Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`). **Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`).
### Group Form Pages ### Group Form Pages
@ -752,6 +766,7 @@ Each functional unit can be implemented as a **separate issue**:
- **Issue 4:** Groups in Member Detail (Unit 5) - **Issue 4:** Groups in Member Detail (Unit 5)
- **Issue 5:** Groups in Member Search (Unit 6) - **Issue 5:** Groups in Member Search (Unit 6)
- **Issue 6:** Permissions (Unit 7) - **Issue 6:** Permissions (Unit 7)
- **Issue 7:** Add/Remove Members in Group Detail View
**Alternative:** Issues 3 and 4 can be combined, as they both concern the display of groups. **Alternative:** Issues 3 and 4 can be combined, as they both concern the display of groups.
@ -797,6 +812,27 @@ Each functional unit can be implemented as a **separate issue**:
**Estimation:** 3-4h **Estimation:** 3-4h
### Phase 2a: Add/Remove Members in Group Detail View
**Goal:** Enable adding and removing members from groups via UI
**Tasks:**
1. Add "Add Member" button above member table in Group Detail View
2. Implement modal with member search/autocomplete
3. Add "Remove" button for each member in table
4. Implement add/remove functionality with flash messages
5. Ensure proper authorization checks
**Deliverables:**
- Members can be added to groups via UI
- Members can be removed from groups via UI
- Proper feedback via flash messages
- Authorization enforced
**Estimation:** 2-3h
**Note:** This phase extends Phase 2 and can be implemented as Issue 7 after Issue 2 is complete.
### Phase 3: Member Overview Integration ### Phase 3: Member Overview Integration
**Goal:** Display and filter groups in member overview **Goal:** Display and filter groups in member overview
@ -863,9 +899,9 @@ Each functional unit can be implemented as a **separate issue**:
**Estimation:** 1-2h **Estimation:** 1-2h
### Total Estimation: 13-18h ### Total Estimation: 15-21h
**Note:** This aligns with the issue estimation of 15h. **Note:** This includes all 7 issues. The original MVP estimation was 13-15h, with Issue 7 adding 2-3h for the add/remove members functionality in the Group Detail View.
--- ---
@ -958,6 +994,55 @@ Each functional unit can be implemented as a **separate issue**:
- Only admins can manage groups - Only admins can manage groups
- All users can view groups (if they can view members) - All users can view groups (if they can view members)
### Issue 7: Add/Remove Members in Group Detail View
**Type:** Frontend
**Estimation:** 2-3h
**Dependencies:** Issue 1 (Backend must be functional), Issue 2 (Group Detail View must exist)
**Tasks:**
- Add "Add Member" button above member table in Group Detail View (`/groups/:slug`)
- Implement modal for member selection with search/autocomplete
- Add "Remove" button for each member in the member table
- Implement add member functionality (create MemberGroup association)
- Implement remove member functionality (destroy MemberGroup association)
- Add flash messages for success/error feedback
- Ensure proper authorization checks (only users with `:update` permission on Group can add/remove)
- Filter out members already in the group from search results
- Reload group data after add/remove operations
**Acceptance Criteria:**
- "Add Member" button is visible above member table (only for users with `:update` permission)
- Clicking "Add Member" opens a modal with member search/autocomplete
- Search filters members and excludes those already in the group
- Selecting a member from search adds them to the group
- Success flash message is displayed when member is added
- Error flash message is displayed if member is already in group or other error occurs
- Each member row in the table has a "Remove" button (only visible for users with `:update` permission)
- Clicking "Remove" immediately removes the member from the group (no confirmation dialog)
- Success flash message is displayed when member is removed
- Group member list and member count update automatically after add/remove
- Modal closes after successful member addition
- Authorization is enforced server-side in event handlers
- UI respects permission checks (buttons hidden for unauthorized users)
**Technical Notes:**
- Reuse member search pattern from `UserLive.Form` (ComboBox hook with autocomplete)
- Use `Membership.create_member_group/1` for adding members
- Use `Membership.destroy_member_group/1` for removing members
- Filter search results to exclude members already in the group (check `group.members`)
- Reload group with `:members` and `:member_count` after operations
- Follow existing modal patterns (similar to delete confirmation modal)
- Ensure accessibility: proper ARIA labels, keyboard navigation, focus management
**UI/UX Details:**
- Modal title: "Add Member to Group"
- Search input placeholder: "Search for a member..."
- Search results show member name and email
- "Add" button in modal (disabled until member selected)
- "Cancel" button to close modal
- Remove button can be an icon button (trash icon) with tooltip
- Flash messages: "Member added successfully" / "Member removed successfully" / error messages
--- ---
## Testing Strategy ## Testing Strategy

View file

@ -22,7 +22,15 @@ defmodule MvWeb.GroupLive.Show do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, socket} {:ok,
socket
|> assign(:show_add_member_input, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
end end
@impl true @impl true
@ -122,6 +130,120 @@ defmodule MvWeb.GroupLive.Show do
)} )}
</p> </p>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<div class="mb-4">
<%= if assigns[:show_add_member_input] do %>
<div class="join w-full">
<form phx-change="search_members" class="flex-1">
<div class="relative">
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
<%= for member <- @selected_members do %>
<span class="badge badge-outline badge flex items-center gap-1">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
<button
type="button"
class="btn btn-ghost btn-xs p-0 h-4 w-4 min-h-0"
phx-click="remove_selected_member"
phx-value-member_id={member.id}
aria-label={
gettext("Remove %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(member)
)
}
>
<.icon name="hero-x-mark" class="size-3" />
</button>
</span>
<% end %>
<input
type="text"
id="member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-debounce="300"
phx-window-keydown="member_dropdown_keydown"
phx-mounted={JS.focus()}
value={@member_search_query}
placeholder={
if Enum.empty?(@selected_members),
do: gettext("Search for a member..."),
else: ""
}
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
name="member_search"
aria-label={gettext("Search for a member")}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
aria-activedescendant={
if @focused_member_index,
do: "member-option-#{@focused_member_index}",
else: nil
}
autocomplete="off"
/>
</div>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto top-full #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
>
<%= for {member, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
aria-selected={to_string(@focused_member_index == index)}
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class={[
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
if(@focused_member_index == index,
do: "bg-base-300",
else: "hover:bg-base-200"
)
]}
>
<p class="font-medium">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</p>
<p class="text-sm text-base-content/70">
{member.email || gettext("No email")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
</form>
<button
type="button"
class="btn btn-primary join-item"
phx-click="add_selected_members"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
>
<.icon name="hero-plus" class="size-5" />
</button>
</div>
<% else %>
<.button
variant="primary"
phx-click="show_add_member_input"
aria-label={gettext("Add Member")}
>
{gettext("Add Member")}
</.button>
<% end %>
</div>
<% end %>
<%= if Enum.empty?(@group.members || []) do %> <%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic">{gettext("No members in this group")}</p> <p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
<% else %> <% else %>
@ -131,6 +253,9 @@ defmodule MvWeb.GroupLive.Show do
<tr> <tr>
<th>{gettext("Name")}</th> <th>{gettext("Name")}</th>
<th>{gettext("Email")}</th> <th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<th class="w-0">{gettext("Actions")}</th>
<% end %>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -156,6 +281,20 @@ defmodule MvWeb.GroupLive.Show do
<span class="text-base-content/50 italic"></span> <span class="text-base-content/50 italic"></span>
<% end %> <% end %>
</td> </td>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<td>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
phx-click="remove_member"
phx-value-member_id={member.id}
aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</td>
<% end %>
</tr> </tr>
<% end %> <% end %>
</tbody> </tbody>
@ -236,11 +375,13 @@ defmodule MvWeb.GroupLive.Show do
""" """
end end
# Delete Modal Events
@impl true @impl true
def handle_event("open_delete_modal", _params, socket) do def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, show_delete_modal: true, name_confirmation: "")} {:noreply, assign(socket, show_delete_modal: true, name_confirmation: "")}
end end
@impl true
def handle_event("cancel_delete", _params, socket) do def handle_event("cancel_delete", _params, socket) do
{:noreply, {:noreply,
socket socket
@ -248,10 +389,12 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:name_confirmation, "")} |> assign(:name_confirmation, "")}
end end
@impl true
def handle_event("update_name_confirmation", %{"name" => name}, socket) do def handle_event("update_name_confirmation", %{"name" => name}, socket) do
{:noreply, assign(socket, :name_confirmation, name)} {:noreply, assign(socket, :name_confirmation, name)}
end end
@impl true
def handle_event("confirm_delete", %{"slug" => slug}, socket) do def handle_event("confirm_delete", %{"slug" => slug}, socket) do
actor = current_actor(socket) actor = current_actor(socket)
group = socket.assigns.group group = socket.assigns.group
@ -275,6 +418,416 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
# Add Member Events
@impl true
def handle_event("show_add_member_input", _params, socket) do
# Reload group to ensure we have the latest members list
actor = current_actor(socket)
group = socket.assigns.group
socket = reload_group(socket, group.slug, actor)
{:noreply,
socket
|> assign(:show_add_member_input, true)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
end
@impl true
def handle_event("show_member_dropdown", _params, socket) do
# Reload group to ensure we have the latest members list before filtering
actor = current_actor(socket)
group = socket.assigns.group
socket = reload_group(socket, group.slug, actor)
# Load available members with empty query when input is focused
socket =
socket
|> load_available_members("")
|> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
@impl true
def handle_event("hide_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
return_if_dropdown_closed(socket, fn ->
max_index = length(socket.assigns.available_members) - 1
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
index when index < max_index -> index + 1
_ -> current
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
0 -> 0
index -> index - 1
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
return_if_dropdown_closed(socket, fn ->
select_focused_member(socket)
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", _params, socket) do
# Ignore other keys
{:noreply, socket}
end
@impl true
def handle_event("search_members", %{"member_search" => query}, socket) do
# Reload group to ensure we have the latest members list before filtering
actor = current_actor(socket)
group = socket.assigns.group
socket = reload_group(socket, group.slug, actor)
socket =
socket
|> assign(:member_search_query, query)
|> load_available_members(query)
|> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
@impl true
def handle_event("select_member", %{"id" => member_id}, socket) do
# Check if member is already selected
if member_id in socket.assigns.selected_member_ids do
{:noreply, socket}
else
# Find the selected member
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
if selected_member do
socket =
socket
|> assign(:selected_member_ids, [member_id | socket.assigns.selected_member_ids])
|> assign(:selected_members, [selected_member | socket.assigns.selected_members])
|> assign(:member_search_query, "")
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)
{:noreply, socket}
else
{:noreply, socket}
end
end
end
@impl true
def handle_event("remove_selected_member", %{"member_id" => member_id}, socket) do
socket =
socket
|> assign(:selected_member_ids, List.delete(socket.assigns.selected_member_ids, member_id))
|> assign(
:selected_members,
Enum.reject(socket.assigns.selected_members, &(&1.id == member_id))
)
{:noreply, socket}
end
@impl true
def handle_event("add_selected_members", _params, socket) do
actor = current_actor(socket)
group = socket.assigns.group
# Server-side authorization check
if can?(actor, :update, group) do
perform_add_members(socket, group, socket.assigns.selected_member_ids, actor)
else
{:noreply,
socket
|> put_flash(:error, gettext("Not authorized."))
|> redirect(to: ~p"/groups/#{group.slug}")}
end
end
@impl true
def handle_event("remove_member", %{"member_id" => member_id}, socket) do
actor = current_actor(socket)
group = socket.assigns.group
# Server-side authorization check
if can?(actor, :update, group) do
perform_remove_member(socket, group, member_id, actor)
else
{:noreply,
socket
|> put_flash(:error, gettext("Not authorized."))
|> redirect(to: ~p"/groups/#{group.slug}")}
end
end
# Helper functions
defp return_if_dropdown_closed(socket, fun) do
if socket.assigns.show_member_dropdown do
fun.()
else
{:noreply, socket}
end
end
defp select_focused_member(socket) do
case socket.assigns.focused_member_index do
nil ->
{:noreply, socket}
index ->
select_member_by_index(socket, index)
end
end
defp select_member_by_index(socket, index) do
case Enum.at(socket.assigns.available_members, index) do
nil ->
{:noreply, socket}
member ->
add_member_to_selection(socket, member)
end
end
defp add_member_to_selection(socket, member) do
# Check if member is already selected
if member.id in socket.assigns.selected_member_ids do
{:noreply, socket}
else
socket =
socket
|> assign(:selected_member_ids, [member.id | socket.assigns.selected_member_ids])
|> assign(:selected_members, [member | socket.assigns.selected_members])
|> assign(:member_search_query, "")
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
end
defp load_available_members(socket, query) do
require Ash.Query
base_query = available_members_base_query(query)
limited_query = Ash.Query.limit(base_query, 10)
actor = current_actor(socket)
case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do
{:ok, members} ->
current_member_ids = group_member_ids_set(socket.assigns.group)
filtered_members =
Enum.reject(members, fn member ->
MapSet.member?(current_member_ids, member.id)
end)
assign(socket, available_members: filtered_members)
{:error, _} ->
assign(socket, available_members: [])
end
end
defp available_members_base_query(query) do
search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil
if search_query do
Mv.Membership.Member
|> Ash.Query.for_read(:search, %{query: search_query})
else
Mv.Membership.Member
|> Ash.Query.new()
end
end
defp group_member_ids_set(group) do
cond do
is_list(group.members) and group.members != [] ->
group.members
|> Enum.map(& &1.id)
|> MapSet.new()
is_list(group.members) ->
MapSet.new()
true ->
MapSet.new()
end
end
defp perform_add_members(socket, group, member_ids, actor) when is_list(member_ids) do
# Add all members in a transaction-like manner
results =
Enum.map(member_ids, fn member_id ->
Membership.create_member_group(
%{member_id: member_id, group_id: group.id},
actor: actor
)
end)
# Check for errors
errors = Enum.filter(results, &match?({:error, _}, &1))
if Enum.empty?(errors) do
handle_successful_add_members(socket, group, actor)
else
handle_failed_add_members(socket, group, errors, actor)
end
end
defp perform_add_members(socket, _group, _member_ids, _actor) do
{:noreply,
socket
|> put_flash(:error, gettext("No members selected."))}
end
defp handle_successful_add_members(socket, group, actor) do
socket = reload_group(socket, group.slug, actor)
{:noreply,
socket
|> assign(:show_add_member_input, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
end
defp handle_failed_add_members(socket, group, errors, actor) do
error_messages = extract_error_messages(errors)
# Still reload to show any successful additions
socket = reload_group(socket, group.slug, actor)
{:noreply,
socket
|> put_flash(
:error,
gettext("Some members could not be added: %{errors}", errors: error_messages)
)
|> assign(:show_add_member_input, true)}
end
defp extract_error_messages(errors) do
Enum.map(errors, fn {:error, error} ->
format_single_error(error)
end)
|> Enum.uniq()
|> Enum.join(", ")
end
defp format_single_error(%{errors: [%{message: message}]}) when is_binary(message), do: message
defp format_single_error(%{errors: [%{field: :member_id, message: message}]})
when is_binary(message),
do: message
defp format_single_error(error), do: format_error(error)
defp perform_remove_member(socket, group, member_id, actor) do
require Ash.Query
# Find the MemberGroup association
query =
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group.id)
case Ash.read_one(query, actor: actor, domain: Mv.Membership) do
{:ok, nil} ->
{:noreply,
socket
|> put_flash(:error, gettext("Member is not in this group."))}
{:ok, member_group} ->
case Membership.destroy_member_group(member_group, actor: actor) do
:ok ->
# Reload group with members and member_count
socket = reload_group(socket, group.slug, actor)
{:noreply, socket}
{:error, error} ->
error_message = format_error(error)
{:noreply,
socket
|> put_flash(
:error,
gettext("Failed to remove member: %{error}", error: error_message)
)}
end
{:error, error} ->
error_message = format_error(error)
{:noreply,
socket
|> put_flash(
:error,
gettext("Failed to remove member: %{error}", error: error_message)
)}
end
end
defp reload_group(socket, slug, actor) do
require Ash.Query
query =
Mv.Membership.Group
|> Ash.Query.filter(slug == ^slug)
|> Ash.Query.load([:members, :member_count])
case Ash.read_one(query, actor: actor, domain: Mv.Membership) do
{:ok, group} ->
assign(socket, :group, group)
{:error, _} ->
socket
end
end
defp handle_delete_confirmation(socket, group, actor) do defp handle_delete_confirmation(socket, group, actor) do
if socket.assigns.name_confirmation == group.name do if socket.assigns.name_confirmation == group.name do
perform_group_deletion(socket, group, actor) perform_group_deletion(socket, group, actor)

View file

@ -12,6 +12,7 @@ msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "Aktionen" msgstr "Aktionen"
@ -668,6 +669,7 @@ msgstr "Einstellungen erfolgreich gespeichert"
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen." msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen."
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Available members" msgid "Available members"
@ -2258,6 +2260,66 @@ msgstr "Nicht berechtigt."
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfen Sie Ihre Berechtigungen." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfen Sie Ihre Berechtigungen."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add Member"
msgstr "Mitglied hinzufügen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to remove member: %{error}"
msgstr "Mitglied konnte nicht entfernt werden: %{error}"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
msgstr "Mitglied ist nicht in dieser Gruppe."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No email"
msgstr "Keine E-Mail"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove"
msgstr "Entfernen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove member from group"
msgstr "Mitglied aus Gruppe entfernen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Search for a member"
msgstr "Nach einem Mitglied suchen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Search for a member..."
msgstr "Nach einem Mitglied suchen..."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add members"
msgstr "Mitglieder hinzufügen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members selected."
msgstr "Keine Mitglieder ausgewählt."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove %{name}"
msgstr "%{name} entfernen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Some members could not be added: %{errors}"
msgstr "Einige Mitglieder konnten nicht hinzugefügt werden: %{errors}"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"

View file

@ -13,6 +13,7 @@ msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
@ -669,6 +670,7 @@ msgstr ""
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Available members" msgid "Available members"
@ -2259,6 +2261,66 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to remove member: %{error}"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No email"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove member from group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Search for a member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Search for a member..."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add members"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members selected."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove %{name}"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Some members could not be added: %{errors}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"

View file

@ -0,0 +1,301 @@
defmodule MvWeb.GroupLive.ShowAccessibilityTest do
@moduledoc """
Accessibility tests for Add/Remove Member functionality.
Tests ARIA labels, keyboard navigation, and screen reader support.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "ARIA labels and roles" do
test "search input has proper ARIA attributes", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Search input should have proper ARIA attributes
assert html =~ ~r/aria-label/ ||
html =~ ~r/aria-autocomplete/ ||
html =~ ~r/role=["']combobox["']/
end
test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Search input should have ARIA attributes
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
html =~ ~r/aria-autocomplete=["']list["']/
end
test "remove button has aria-label with tooltip text", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
html = render(view)
# Remove button should have aria-label
assert html =~ ~r/aria-label.*[Rr]emove/ ||
html =~ ~r/aria-label.*member/i
end
test "add button has correct aria-label", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Add button should have aria-label
assert html =~ ~r/aria-label.*[Aa]dd/ ||
html =~ ~r/button.*[Aa]dd/
end
end
describe "keyboard navigation" do
test "tab navigation works in modal", %{conn: conn} do
# This test verifies that keyboard navigation is possible
# Actual tab order testing would require more complex setup
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Modal should have focusable elements
assert html =~ ~r/input|button/ ||
html =~ "#member-search-input"
end
test "inline input can be closed", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
assert has_element?(view, "#member-search-input")
# Click Add Member button again to close (or add a member to close it)
# For now, we verify the input is visible when opened
html = render(view)
assert html =~ "#member-search-input" || has_element?(view, "#member-search-input")
end
test "enter/space activates buttons when focused", %{conn: conn} do
# This test verifies that buttons can be activated via keyboard
# Actual keyboard event testing would require more complex setup
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Jones",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Select member
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
# Add button should be enabled and clickable
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Should succeed (member should appear in list)
html = render(view)
assert html =~ "Bob"
end
test "focus management: focus is set to input when opened", %{conn: conn} do
# This test verifies that focus is properly managed
# When inline input opens, focus should move to input field
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Input should be visible and focusable
assert html =~ "#member-search-input" ||
html =~ ~r/autofocus|tabindex/
end
end
describe "screen reader support" do
test "search input has proper label for screen readers", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Input should have aria-label
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
html =~ ~r/aria-label/
end
test "search results are properly announced", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"})
html = render(view)
# Search results should have proper ARIA attributes
assert html =~ ~r/role=["']listbox["']/ ||
html =~ ~r/role=["']option["']/ ||
html =~ "Charlie"
end
test "flash messages are properly announced", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
html = render(view)
# Member should appear in list (no flash message)
assert html =~ "David"
end
end
end

View file

@ -0,0 +1,465 @@
defmodule MvWeb.GroupLive.ShowAddMemberTest do
@moduledoc """
Tests for adding members to groups via the Add Member modal.
Tests successful add, error handling, and edge cases.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "successful add member" do
test "member is added to group after selection and clicking Add", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search and select member
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Alice"})
# Select member
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
# Click Add button
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Verify member appears in group list (no success flash message)
html = render(view)
assert html =~ "Alice"
assert html =~ "Johnson"
end
test "member is successfully added to group (verified in list)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Smith",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input and add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
html = render(view)
# Verify member appears in group list (no success flash message)
assert html =~ "Bob"
assert html =~ "Smith"
end
test "group member list updates automatically after add", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Initially member should NOT be in list
refute html =~ "Charlie"
# Add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Member should now appear in list
html = render(view)
assert html =~ "Charlie"
assert html =~ "Brown"
end
test "member count updates automatically after add", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Get initial count (should be 0)
initial_count = extract_member_count(html)
# Add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Count should have increased
html = render(view)
new_count = extract_member_count(html)
assert new_count == initial_count + 1
end
test "modal closes after successful member addition", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Eve",
last_name: "Davis",
email: "eve@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
assert has_element?(view, "#member-search-input")
# Add member
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Eve"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Inline input should be closed (Add Member button should be visible again)
refute has_element?(view, "#member-search-input")
end
end
describe "error handling" do
test "error flash message when member is already in group", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Frank",
last_name: "Moore",
email: "frank@example.com"
},
actor: system_actor
)
# Add member to group first
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Try to add same member again
view
|> element("button", "Add Member")
|> render_click()
# Member should not appear in search (filtered out)
# But if they do appear somehow, try to add them
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Frank"})
# If member appears in results (shouldn't), try to add
# This tests the server-side duplicate prevention
if has_element?(view, "[data-member-id='#{member.id}']") do
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button", "Add")
|> render_click()
# Should show error
html = render(view)
assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i
end
end
test "error flash message for other errors", %{conn: conn} do
# This test verifies that error handling works for unexpected errors
# We can't easily simulate all error cases, but we test the error path exists
_system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Try to add with invalid member ID (if possible)
# This tests error handling path
# Note: Actual implementation will handle this
end
test "inline input remains open on error (user can correct)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Grace",
last_name: "Taylor",
email: "grace@example.com"
},
actor: system_actor
)
# Add member first
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Inline input should be open
assert has_element?(view, "#member-search-input")
# If error occurs, inline input should remain open
# (Implementation will handle this)
end
test "Add button remains disabled until member selected", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Add button should be disabled
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]")
end
end
describe "edge cases" do
test "add works for group with 0 members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Henry",
last_name: "Anderson",
email: "henry@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member to empty group
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Henry"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Member should be added
html = render(view)
assert html =~ "Henry"
end
test "add works when member is already in other groups", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group1 = Fixtures.group_fixture()
group2 = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Isabel",
last_name: "Martinez",
email: "isabel@example.com"
},
actor: system_actor
)
# Add member to group1
Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group2.slug}")
# Add same member to group2 (should work)
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Isabel"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Member should be added to group2
html = render(view)
assert html =~ "Isabel"
end
end
# Helper function to extract member count from HTML
defp extract_member_count(html) do
case Regex.run(~r/Total:\s*(\d+)/, html) do
[_, count_str] -> String.to_integer(count_str)
_ -> 0
end
end
end

View file

@ -0,0 +1,135 @@
defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
@moduledoc """
UI tests for Add/Remove Member buttons visibility and modal display.
Tests UI rendering and permission-based visibility.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "Add Member button visibility" do
@tag role: :read_only
test "read_only user can access group show page (page permission)", %{conn: conn} do
group = Fixtures.group_fixture()
conn = get(conn, "/groups/#{group.slug}")
assert conn.status == 200
end
test "Add Member button is visible for users with :update permission", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Add Member") or html =~ "Add Member"
end
@tag role: :read_only
test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
refute html =~ gettext("Add Member")
end
test "Add Member button is positioned above member table", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Button should exist
assert has_element?(view, "button", gettext("Add Member")) ||
has_element?(view, "a", gettext("Add Member"))
end
end
describe "Remove button visibility" do
test "Remove button is visible for each member for users with :update permission", %{
conn: conn
} do
group = Fixtures.group_fixture()
member = Fixtures.member_fixture(%{first_name: "Alice", last_name: "Smith"})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should exist (can be icon button with trash icon)
html = render(view)
assert html =~ "Remove" or html =~ "remove" or html =~ "trash" or
html =~ ~r/hero-trash|hero-x-mark/
end
@tag role: :read_only
test "Remove button is NOT visible for users without :update permission", %{conn: conn} do
group = Fixtures.group_fixture()
member = Fixtures.member_fixture(%{first_name: "Bob", last_name: "Jones"})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Remove button should NOT exist (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end
end
describe "inline add member input" do
test "inline input appears when Add Member button is clicked", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Click Add Member button
view
|> element("button", gettext("Add Member"))
|> render_click()
# Inline input should be visible
assert has_element?(view, "#member-search-input")
end
test "search input has correct placeholder", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", gettext("Add Member"))
|> render_click()
html = render(view)
assert html =~ gettext("Search for a member...") ||
html =~ ~r/search.*member/i
end
test "Add button (plus icon) is disabled until member selected", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", gettext("Add Member"))
|> render_click()
html = render(view)
# Add button should exist and be disabled initially
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") ||
html =~ ~r/disabled/
end
end
end

View file

@ -0,0 +1,285 @@
defmodule MvWeb.GroupLive.ShowAuthorizationTest do
@moduledoc """
Tests for authorization and security in Add/Remove Member functionality.
Tests server-side authorization checks and UI permission enforcement.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "server-side authorization" do
test "add member event handler checks :update permission", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input and try to add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Alice"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
# Try to add (should succeed for admin)
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Should succeed (admin has :update permission, member should appear in list)
html = render(view)
assert html =~ "Alice"
end
@tag role: :read_only
test "unauthorized user cannot add member (server-side check)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Jones",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Try to trigger add event directly (even if button is hidden)
# This tests server-side authorization
# Note: If button is hidden, we can't click it, but we test the event handler
# by trying to send the event directly if possible
# For now, we verify that the button is not visible
html = render(view)
refute html =~ "Add Member"
end
test "remove member event handler checks :update permission", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member (should succeed for admin)
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Should succeed (member should no longer be in list)
html = render(view)
refute html =~ "Charlie"
end
@tag role: :read_only
test "unauthorized user cannot remove member (server-side check)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should not be visible
html = render(view)
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end
test "error flash message on unauthorized access", %{conn: conn} do
# This test verifies that error messages are shown for unauthorized access
# Implementation will handle this in event handlers
_system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, _view, _html} = live(conn, "/groups/#{group.slug}")
# For admin, should not see error
# For non-admin, buttons are hidden (UI-level check)
# Server-side check will show error if event is somehow triggered
end
end
describe "UI permission checks" do
test "buttons are hidden for unauthorized users", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Eve",
last_name: "Davis",
email: "eve@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Admin should see buttons
assert html =~ "Add Member" || html =~ "Remove"
end
@tag role: :read_only
test "Add Member button is hidden for read-only users", %{conn: conn} do
_system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Read-only user should NOT see Add Member button
refute html =~ "Add Member"
end
@tag role: :read_only
test "Remove button is hidden for read-only users", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Frank",
last_name: "Moore",
email: "frank@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end
@tag role: :read_only
test "modal cannot be opened for unauthorized users", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Inline input should not be accessible (button hidden)
refute html =~ "Add Member"
refute html =~ "#member-search-input"
end
end
describe "member (own_data) page access" do
# Members have no page permission for /groups or /groups/:slug; they are redirected.
# This tests that limited access for the member role is enforced.
@tag role: :member
test "member is redirected when accessing group show page", %{conn: conn} do
group = Fixtures.group_fixture()
result = live(conn, "/groups/#{group.slug}")
assert {:error, {:redirect, %{to: path, flash: %{"error" => _}}}} = result
assert path =~ ~r|^/users/[^/]+$|
end
@tag role: :member
test "member is redirected when accessing groups index", %{conn: conn} do
result = live(conn, "/groups")
assert {:error, {:redirect, %{to: path, flash: %{"error" => _}}}} = result
assert path =~ ~r|^/users/[^/]+$|
end
end
describe "security edge cases" do
test "slug injection attempts are prevented", %{conn: conn} do
# Try to inject 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 "non-existent member IDs are handled", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, _html} = live(conn, "/groups/#{group.slug}")
# Try to add non-existent member (if possible)
# Implementation should handle this gracefully
# This tests error handling for invalid IDs
end
test "non-existent group IDs are handled", %{conn: conn} do
# Accessing non-existent group should redirect
non_existent_slug = "non-existent-group-#{System.unique_integer([:positive])}"
result = live(conn, "/groups/#{non_existent_slug}")
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
end
end
end

View file

@ -0,0 +1,432 @@
defmodule MvWeb.GroupLive.ShowIntegrationTest do
@moduledoc """
Integration tests for Add/Remove Member functionality.
Tests data consistency, database operations, and multiple operations.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "data consistency" do
test "member appears in group after add (verified in database)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member via UI
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Alice"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Verify in database
require Ash.Query
query =
Mv.Membership.Group
|> Ash.Query.filter(slug == ^group.slug)
|> Ash.Query.load([:members])
{:ok, updated_group} = Ash.read_one(query, actor: system_actor, domain: Mv.Membership)
# Member should be in group
assert Enum.any?(updated_group.members, &(&1.id == member.id))
end
test "member disappears from group after remove (verified in database)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Jones",
email: "bob@example.com"
},
actor: system_actor
)
# Add member to group
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member via UI
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Verify in database
require Ash.Query
query =
Mv.Membership.Group
|> Ash.Query.filter(slug == ^group.slug)
|> Ash.Query.load([:members])
{:ok, updated_group} = Ash.read_one(query, actor: system_actor, domain: Mv.Membership)
# Member should NOT be in group
refute Enum.any?(updated_group.members, &(&1.id == member.id))
end
test "MemberGroup association is created correctly", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Verify MemberGroup association exists
require Ash.Query
{:ok, member_groups} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id),
actor: system_actor,
domain: Mv.Membership
)
assert length(member_groups) == 1
assert hd(member_groups).member_id == member.id
assert hd(member_groups).group_id == group.id
end
test "MemberGroup association is deleted correctly", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
# Add member first
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Verify MemberGroup association is deleted
require Ash.Query
{:ok, member_groups} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id),
actor: system_actor,
domain: Mv.Membership
)
assert member_groups == []
end
test "member itself is NOT deleted (only association)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Eve",
last_name: "Davis",
email: "eve@example.com"
},
actor: system_actor
)
# Add member to group
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member from group
view
|> element("button[phx-click='remove_member']", "")
|> render_click()
# Verify member still exists
{:ok, member_after_remove} =
Ash.get(Mv.Membership.Member, member.id, actor: system_actor)
assert member_after_remove.id == member.id
assert member_after_remove.first_name == "Eve"
end
end
describe "multiple operations" do
test "multiple members can be added sequentially", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member1} =
Membership.create_member(
%{
first_name: "Frank",
last_name: "Moore",
email: "frank@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Grace",
last_name: "Taylor",
email: "grace@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add first member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Frank"})
view
|> element("[data-member-id='#{member1.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Add second member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Grace"})
view
|> element("[data-member-id='#{member2.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Both members should be in list
html = render(view)
assert html =~ "Frank"
assert html =~ "Grace"
end
test "multiple members can be removed sequentially", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member1} =
Membership.create_member(
%{
first_name: "Henry",
last_name: "Anderson",
email: "henry@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Isabel",
last_name: "Martinez",
email: "isabel@example.com"
},
actor: system_actor
)
# Add both members
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}")
# Both should be in list initially
assert html =~ "Henry"
assert html =~ "Isabel"
# Remove first member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> render_click()
# Remove second member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member2.id}']")
|> render_click()
# Both should be removed
html = render(view)
refute html =~ "Henry"
refute html =~ "Isabel"
end
test "add and remove can be mixed", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member1} =
Membership.create_member(
%{
first_name: "Jack",
last_name: "White",
email: "jack@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Kate",
last_name: "Black",
email: "kate@example.com"
},
actor: system_actor
)
# Add member1 first
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member2
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Kate"})
view
|> element("[data-member-id='#{member2.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Remove member1
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> render_click()
# Only member2 should remain
html = render(view)
refute html =~ "Jack"
assert html =~ "Kate"
end
end
end

View file

@ -0,0 +1,339 @@
defmodule MvWeb.GroupLive.ShowMemberSearchTest do
@moduledoc """
UI tests for member search functionality in Add Member modal.
Tests search behavior and filtering of members already in group.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
# Helper to setup authenticated connection for admin
defp setup_admin_conn(conn) do
conn_with_oidc_user(conn, %{email: "admin@example.com"})
end
describe "search functionality" do
test "search finds member by exact name", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan.smith@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Type exact name
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jonathan"})
html = render(view)
assert html =~ "Jonathan"
assert html =~ "Smith"
end
test "search finds member by partial name (fuzzy)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan.smith@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Type partial name
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jon"})
html = render(view)
# Fuzzy search should find Jonathan
assert html =~ "Jonathan"
assert html =~ "Smith"
end
test "search finds member by email", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice.johnson@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search by email
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "alice.johnson"})
html = render(view)
assert html =~ "Alice"
assert html =~ "Johnson"
assert html =~ "alice.johnson@example.com"
end
test "dropdown shows member name and email", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Williams",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Focus and search
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"})
html = render(view)
assert html =~ "Bob"
assert html =~ "Williams"
assert html =~ "bob@example.com"
end
test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Focus input
view
|> element("#member-search-input")
|> render_focus()
html = render(view)
# Dropdown should be visible
assert html =~ ~r/role="listbox"/ || html =~ "listbox"
end
end
describe "filtering members already in group" do
test "members already in group are NOT shown in search results", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
# Create member and add to group
{:ok, member_in_group} =
Membership.create_member(
%{
first_name: "David",
last_name: "Miller",
email: "david@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member_in_group.id, group_id: group.id},
actor: system_actor
)
# Create another member NOT in group
{:ok, _member_not_in_group} =
Membership.create_member(
%{
first_name: "David",
last_name: "Anderson",
email: "david.anderson@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search for "David"
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"})
# Assert only on dropdown (available members), not the members table
dropdown_html = view |> element("#member-dropdown") |> render()
assert dropdown_html =~ "Anderson"
refute dropdown_html =~ "Miller"
end
test "search filters correctly when group has many members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
# Add multiple members to group
Enum.each(1..5, fn i ->
{:ok, m} =
Membership.create_member(
%{
first_name: "Member#{i}",
last_name: "InGroup",
email: "member#{i}@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: m.id, group_id: group.id},
actor: system_actor
)
end)
# Create member NOT in group
{:ok, _member_not_in_group} =
Membership.create_member(
%{
first_name: "Available",
last_name: "Member",
email: "available@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Available"})
# Assert only on dropdown (available members), not the members table
dropdown_html = view |> element("#member-dropdown") |> render()
assert dropdown_html =~ "Available"
assert dropdown_html =~ "Member"
refute dropdown_html =~ "Member1"
refute dropdown_html =~ "Member2"
end
test "search shows no results when all available members are already in group", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
# Create and add all members to group
{:ok, member} =
Membership.create_member(
%{
first_name: "Only",
last_name: "Member",
email: "only@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Only"})
# When no available members, dropdown is not rendered (length(@available_members) == 0)
refute has_element?(view, "#member-dropdown")
end
end
end

View file

@ -0,0 +1,334 @@
defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
@moduledoc """
Tests for removing members from groups via the Remove button.
Tests successful remove, edge cases, and immediate removal (no confirmation).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "successful remove member" do
test "member is removed from group after clicking Remove", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com"
},
actor: system_actor
)
# Add member to group
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially
assert html =~ "Alice"
# Click Remove button
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Member should no longer be in list (no success flash message)
html = render(view)
refute html =~ "Alice"
end
test "member is successfully removed from group (verified in list)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Jones",
email: "bob@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially
assert html =~ "Bob"
# Remove member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
html = render(view)
# Member should no longer be in list (no success flash message)
refute html =~ "Bob"
end
test "group member list updates automatically after remove", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially
assert html =~ "Charlie"
# Remove member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Member should no longer be in list
html = render(view)
refute html =~ "Charlie"
end
test "member count updates automatically after remove", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member1} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Eve",
last_name: "Davis",
email: "eve@example.com"
},
actor: system_actor
)
# Add both members
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}")
# Get initial count (should be 2)
initial_count = extract_member_count(html)
assert initial_count >= 2
# Remove one member (need to get member_id from HTML or use first available)
# For this test, we'll remove the first member
_html_before = render(view)
# Extract first member ID from the rendered HTML or use a different approach
# Since we have member1 and member2, we can target member1 specifically
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> render_click()
# Count should have decreased
html = render(view)
new_count = extract_member_count(html)
assert new_count == initial_count - 1
end
test "no confirmation dialog appears (immediate removal)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Frank",
last_name: "Moore",
email: "frank@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Click Remove - should remove immediately without confirmation
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# No confirmation dialog should appear (immediate removal)
# This is verified by the member being removed without any dialog
# Member should be removed
html = render(view)
refute html =~ "Frank"
end
end
describe "edge cases" do
test "remove works for last member in group (group becomes empty)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Grace",
last_name: "Taylor",
email: "grace@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Member should be in list
assert html =~ "Grace"
# Remove last member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Group should show empty state
html = render(view)
assert html =~ gettext("No members in this group") ||
html =~ ~r/no.*members/i
# Count should be 0
count = extract_member_count(html)
assert count == 0
end
test "remove works when member is in multiple groups (only this group affected)", %{
conn: conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group1 = Fixtures.group_fixture()
group2 = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Henry",
last_name: "Anderson",
email: "henry@example.com"
},
actor: system_actor
)
# Add member to both groups
Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group2.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group1.slug}")
# Remove from group1
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Member should be removed from group1
html = render(view)
refute html =~ "Henry"
# Verify member is still in group2
{:ok, _view2, html2} = live(conn, "/groups/#{group2.slug}")
assert html2 =~ "Henry"
end
test "remove is idempotent (no error if member already removed)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Isabel",
last_name: "Martinez",
email: "isabel@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member first time
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Try to remove again (should not error, just be idempotent)
# Note: Implementation should handle this gracefully
# If button is still visible somehow, try to click again
html = render(view)
if html =~ "Isabel" do
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Should not crash
assert render(view)
end
end
end
# Helper function to extract member count from HTML
defp extract_member_count(html) do
case Regex.run(~r/Total:\s*(\d+)/, html) do
[_, count_str] -> String.to_integer(count_str)
_ -> 0
end
end
end

View file

@ -178,6 +178,7 @@ defmodule MvWeb.ConnCase do
:read_only -> :read_only ->
# Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings # Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only") read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
read_only_user = Mv.Authorization.Actor.ensure_loaded(read_only_user)
authenticated_conn = conn_with_password_user(conn, read_only_user) authenticated_conn = conn_with_password_user(conn, read_only_user)
{authenticated_conn, read_only_user} {authenticated_conn, read_only_user}