Merge pull request 'finalize groups' (#437) from feature/finalize-groups into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #437
This commit is contained in:
simon 2026-02-23 17:27:48 +01:00
commit be9d12f181
18 changed files with 388 additions and 474 deletions

View file

@ -1264,6 +1264,8 @@ end
### 3.12 Internationalization: Gettext ### 3.12 Internationalization: Gettext
**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
**Define Translations:** **Define Translations:**
```elixir ```elixir

View file

@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do
require Logger require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization import MvWeb.Authorization
alias Mv.Membership alias Mv.Membership
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
@ -94,13 +97,21 @@ defmodule MvWeb.GroupLive.Show do
</h1> </h1>
<div class="flex gap-2"> <div class="flex gap-2">
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}> <.button
variant="primary"
navigate={~p"/groups/#{@group.slug}/edit"}
data-testid="group-show-edit-btn"
>
{gettext("Edit")} {gettext("Edit")}
</.button> </.button>
<% end %> <% end %>
<%= if can?(@current_user, :destroy, Mv.Membership.Group) do %> <%= if can?(@current_user, :destroy, @group) do %>
<.button class="btn-error" phx-click="open_delete_modal"> <.button
class="btn-error"
phx-click="open_delete_modal"
data-testid="group-show-delete-btn"
>
{gettext("Delete")} {gettext("Delete")}
</.button> </.button>
<% end %> <% end %>
@ -123,7 +134,7 @@ defmodule MvWeb.GroupLive.Show do
<div> <div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2> <h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="mb-4"> <p class="mb-4" data-testid="group-show-member-count">
{ngettext( {ngettext(
"Total: %{count} member", "Total: %{count} member",
"Total: %{count} members", "Total: %{count} members",
@ -132,7 +143,7 @@ defmodule MvWeb.GroupLive.Show do
)} )}
</p> </p>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<div class="mb-4"> <div class="mb-4">
<%= if assigns[:show_add_member_input] do %> <%= if assigns[:show_add_member_input] do %>
<div class="join w-full"> <div class="join w-full">
@ -160,6 +171,7 @@ defmodule MvWeb.GroupLive.Show do
<input <input
type="text" type="text"
id="member-search-input" id="member-search-input"
data-testid="group-show-member-search-input"
role="combobox" role="combobox"
phx-hook="ComboBox" phx-hook="ComboBox"
phx-focus="show_member_dropdown" phx-focus="show_member_dropdown"
@ -228,6 +240,7 @@ defmodule MvWeb.GroupLive.Show do
type="button" type="button"
class="btn btn-primary join-item" class="btn btn-primary join-item"
phx-click="add_selected_members" phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)} disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")} aria-label={gettext("Add members")}
> >
@ -255,15 +268,17 @@ defmodule MvWeb.GroupLive.Show do
<% end %> <% 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" data-testid="group-show-no-members">
{gettext("No members in this group")}
</p>
<% else %> <% else %>
<div class="overflow-x-auto"> <div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<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 %> <%= if can?(@current_user, :update, @group) do %>
<th class="w-0">{gettext("Actions")}</th> <th class="w-0">{gettext("Actions")}</th>
<% end %> <% end %>
</tr> </tr>
@ -291,13 +306,14 @@ 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 %> <%= if can?(@current_user, :update, @group) do %>
<td> <td>
<button <button
type="button" type="button"
class="btn btn-ghost btn-sm text-error" class="btn btn-ghost btn-sm text-error"
phx-click="remove_member" phx-click="remove_member"
phx-value-member_id={member.id} phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")} aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")} data-tooltip={gettext("Remove")}
> >
@ -431,28 +447,31 @@ defmodule MvWeb.GroupLive.Show do
# Add Member Events # Add Member Events
@impl true @impl true
def handle_event("show_add_member_input", _params, socket) do def handle_event("show_add_member_input", _params, socket) do
# Reload group to ensure we have the latest members list # Load candidate members once (single DB read). Search/focus then filter in memory (R2).
actor = current_actor(socket) socket =
group = socket.assigns.group socket
socket = reload_group(socket, group.slug, actor) |> assign(:show_add_member_input, true)
|> assign(:member_search_query, "")
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)
|> load_add_member_candidates()
{:noreply, {:noreply, socket}
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 end
@impl true @impl true
def handle_event("show_member_dropdown", _params, socket) do def handle_event("show_member_dropdown", _params, socket) do
# Use existing group.members for filtering; reload only on add/remove # Filter in memory from preloaded candidates; no DB read (R2).
query = socket.assigns.member_search_query || ""
socket = socket =
socket socket
|> load_available_members("") |> assign(
:available_members,
filter_candidates_in_memory(socket.assigns.add_member_candidates, query)
)
|> assign(:show_member_dropdown, true) |> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
@ -466,6 +485,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
@ -532,11 +552,13 @@ defmodule MvWeb.GroupLive.Show do
@impl true @impl true
def handle_event("search_members", %{"member_search" => query}, socket) do def handle_event("search_members", %{"member_search" => query}, socket) do
# Use existing group.members for filtering; reload only on add/remove # Filter in memory from preloaded candidates; no DB read (R2).
candidates = socket.assigns.add_member_candidates || []
socket = socket =
socket socket
|> assign(:member_search_query, query) |> assign(:member_search_query, query)
|> load_available_members(query) |> assign(:available_members, filter_candidates_in_memory(candidates, query))
|> assign(:show_member_dropdown, true) |> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
@ -660,47 +682,69 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
defp load_available_members(socket, query) do # Load candidate members once when opening add-member UI (single DB read).
defp load_add_member_candidates(socket) do
require Ash.Query require Ash.Query
current_member_ids = group_member_ids_set(socket.assigns.group) group = socket.assigns.group
base_query = available_members_base_query(query) exclude_ids = group_member_ids_set(group) |> MapSet.to_list()
# Fetch more than 10, then exclude already-in-group and take 10 (avoids empty dropdown when first N are all in group)
fetch_limit = 50
limited_query = Ash.Query.limit(base_query, fetch_limit)
actor = current_actor(socket) actor = current_actor(socket)
case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do if exclude_ids == [] do
{:ok, members} -> # No members in group; load first N members
available = query =
members Mv.Membership.Member
|> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end) |> Ash.Query.sort([:last_name, :first_name])
|> Enum.take(10) |> Ash.Query.limit(300)
assign(socket, available_members: available) do_load_add_member_candidates(socket, query, actor)
else
query =
Mv.Membership.Member
|> Ash.Query.filter(expr(id not in ^exclude_ids))
|> Ash.Query.sort([:last_name, :first_name])
|> Ash.Query.limit(300)
do_load_add_member_candidates(socket, query, actor)
end
end
defp do_load_add_member_candidates(socket, query, actor) do
case Ash.read(query, actor: actor, domain: Mv.Membership) do
{:ok, candidates} ->
socket
|> assign(:add_member_candidates, candidates)
|> assign(:available_members, Enum.take(candidates, 10))
{:error, error} -> {:error, error} ->
Logger.warning("Failed to load available members for group: #{inspect(error)}") Logger.warning("Failed to load add-member candidates: #{inspect(error)}")
socket socket
|> put_flash(:error, gettext("Could not load member search. Please try again.")) |> put_flash(:error, gettext("Could not load member list. Please try again."))
|> assign(:add_member_candidates, [])
|> assign(:available_members, []) |> assign(:available_members, [])
end end
end end
defp available_members_base_query(query) do # Filter preloaded candidates by query string (name/email). No DB read. R2.
search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do
q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: ""
if search_query do if q == "" do
Mv.Membership.Member candidates |> Enum.take(10)
|> Ash.Query.for_read(:search, %{query: search_query})
else else
Mv.Membership.Member candidates
|> Ash.Query.new() |> Enum.filter(fn m ->
name = MemberHelpers.display_name(m) |> String.downcase()
email = (m.email || "") |> String.downcase()
String.contains?(name, q) or String.contains?(email, q)
end)
|> Enum.take(10)
end end
end end
defp filter_candidates_in_memory(_, _), do: []
defp group_member_ids_set(group) do defp group_member_ids_set(group) do
members = group.members || [] members = group.members || []
members |> Enum.map(& &1.id) |> MapSet.new() members |> Enum.map(& &1.id) |> MapSet.new()
@ -740,6 +784,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)

View file

@ -2278,11 +2278,6 @@ 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üfe deine Berechtigungen." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr "Mitgliedersuche konnte nicht geladen werden. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Member" msgid "Add Member"
@ -2623,17 +2618,7 @@ msgstr "Import"
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
#~ #: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/group_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)" msgid "Could not load member list. Please try again."
#~ msgstr "Mitglieder exportieren (CSV)" msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut."
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar."
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten."

View file

@ -2279,11 +2279,6 @@ 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 "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Member" msgid "Add Member"
@ -2623,3 +2618,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member list. Please try again."
msgstr ""

View file

@ -2279,11 +2279,6 @@ 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 "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Add Member" msgid "Add Member"
@ -2624,17 +2619,7 @@ msgstr ""
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "" msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/group_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)" msgid "Could not load member list. Please try again."
#~ msgstr "" msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr ""

View file

@ -3,10 +3,10 @@
# mix run priv/repo/seeds.exs # mix run priv/repo/seeds.exs
# #
alias Mv.Membership
alias Mv.Accounts alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType alias Mv.Membership
alias Mv.MembershipFees.CycleGenerator alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query require Ash.Query
@ -579,6 +579,39 @@ Enum.with_index(linked_members)
end end
end) end)
# Create example groups (idempotent: create only if name does not exist)
group_configs = [
%{name: "Vorstand", description: "Gremium Vorstand"},
%{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"},
%{name: "Jugend", description: "Jugendbereich"},
%{name: "Newsletter", description: "Empfänger*innen Newsletter"}
]
existing_groups =
case Membership.list_groups(actor: admin_user_with_role) do
{:ok, list} -> list
{:error, _} -> []
end
existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name))
seed_groups =
Enum.reduce(group_configs, %{}, fn config, acc ->
name = config.name
if MapSet.member?(existing_names_lower, String.downcase(name)) do
group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name)))
Map.put(acc, name, group)
else
group =
Membership.create_group!(%{name: name, description: config.description},
actor: admin_user_with_role
)
Map.put(acc, name, group)
end
end)
# Create sample custom field values for some members # Create sample custom field values for some members
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role) all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role) all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
@ -587,6 +620,35 @@ all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_rol
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
# Assign seed members to groups (idempotent: duplicate create_member_group is skipped)
member_group_assignments = [
{"hans.mueller@example.de", ["Vorstand", "Newsletter"]},
{"greta.schmidt@example.de", ["Jugend", "Newsletter"]},
{"friedrich.wagner@example.de", ["Trainer*innen"]},
{"maria.weber@example.de", ["Newsletter"]},
{"thomas.klein@example.de", ["Newsletter"]}
]
Enum.each(member_group_assignments, fn {email, group_names} ->
member = find_member.(email)
if member do
Enum.each(group_names, fn group_name ->
group = seed_groups[group_name]
if group do
case Membership.create_member_group(
%{member_id: member.id, group_id: group.id},
actor: admin_user_with_role
) do
{:ok, _} -> :ok
{:error, _} -> :ok
end
end
end)
end
end)
# Add custom field values for Hans Müller # Add custom field values for Hans Müller
if hans = find_member.("hans.mueller@example.de") do if hans = find_member.("hans.mueller@example.de") do
[ [
@ -731,6 +793,7 @@ IO.puts(
) )
IO.puts(" - Sample members: Hans, Greta, Friedrich") IO.puts(" - Sample members: Hans, Greta, Friedrich")
IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)")
IO.puts( IO.puts(
" - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de" " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"

View file

@ -19,6 +19,7 @@ defmodule MvWeb.GroupLive.FormTest do
test "form renders with empty fields", %{conn: conn} do test "form renders with empty fields", %{conn: conn} do
{:ok, view, html} = live(conn, "/groups/new") {:ok, view, html} = live(conn, "/groups/new")
# OR-chain for i18n (Create Group / Gruppe erstellen)
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen" assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen"
assert has_element?(view, "form") assert has_element?(view, "form")
end end
@ -65,6 +66,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (required/erforderlich) and validation message wording
assert html =~ gettext("required") or html =~ "name" or html =~ "error" or assert html =~ gettext("required") or html =~ "name" or html =~ "error" or
html =~ "erforderlich" html =~ "erforderlich"
end end
@ -80,6 +82,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (length/Länge) and validation message
assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge" assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge"
end end
@ -98,6 +101,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (length/Länge) and validation message
assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge" assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge"
end end
@ -116,6 +120,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> render_submit() |> render_submit()
# Check for a validation error on the name field in a robust way # Check for a validation error on the name field in a robust way
# OR-chain for i18n and validation message (already taken)
assert html =~ "name" or html =~ gettext("has already been taken") assert html =~ "name" or html =~ gettext("has already been taken")
end end
@ -131,6 +136,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (error/Fehler, invalid/ungültig)
assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig" assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig"
end end
end end
@ -196,6 +202,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (already taken / bereits vergeben) and validation wording
assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or
html =~ "bereits" or html =~ "vergeben" html =~ "bereits" or html =~ "vergeben"
end end
@ -205,7 +212,7 @@ defmodule MvWeb.GroupLive.FormTest do
{:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit") {:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit")
# Slug should not be in form (it's immutable) # Slug should not be in form (it's immutable); regex for input element
refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i
end end
end end

View file

@ -40,13 +40,14 @@ defmodule MvWeb.GroupLive.IndexTest do
assert html =~ "Test Group" assert html =~ "Test Group"
assert html =~ "Test description" assert html =~ "Test description"
# Member count should be displayed (0 for empty group) # OR-chain for i18n (Members/Mitglieder) and alternate copy for count
assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder"
end end
test "displays 'Create Group' button for admin users", %{conn: conn} do test "displays 'Create Group' button for admin users", %{conn: conn} do
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
# OR-chain for i18n (Create Group / Gruppe erstellen) and alternate wording
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or
html =~ "Gruppe erstellen" html =~ "Gruppe erstellen"
end end
@ -54,7 +55,7 @@ defmodule MvWeb.GroupLive.IndexTest do
test "displays empty state when no groups exist", %{conn: conn} do test "displays empty state when no groups exist", %{conn: conn} do
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
# Should show empty state or empty list message # OR-chain for i18n (No groups / Keine Gruppen) and alternate empty state copy
assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or
html =~ "Keine Gruppen" html =~ "Keine Gruppen"
end end
@ -76,6 +77,7 @@ defmodule MvWeb.GroupLive.IndexTest do
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
# Long description may be truncated in UI
assert html =~ long_description or html =~ String.slice(long_description, 0, 100) assert html =~ long_description or html =~ String.slice(long_description, 0, 100)
end end
end end
@ -109,7 +111,7 @@ defmodule MvWeb.GroupLive.IndexTest do
# Should be able to see groups # Should be able to see groups
assert html =~ gettext("Groups") assert html =~ gettext("Groups")
# Should NOT see create button # Read-only must not see create button (OR for i18n)
refute html =~ gettext("Create Group") or html =~ "create" refute html =~ gettext("Create Group") or html =~ "create"
end end
end end
@ -177,7 +179,7 @@ defmodule MvWeb.GroupLive.IndexTest do
final_count = Agent.get(query_count_agent, & &1) final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id) :telemetry.detach(handler_id)
# Member count should be displayed (should be 2) # OR-chain for i18n (Members/Mitglieder) and count display
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
# Verify query count is reasonable (member count should be calculated efficiently) # Verify query count is reasonable (member count should be calculated efficiently)

View file

@ -62,7 +62,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do
assert html =~ "Updated Workflow Test Group" assert html =~ "Updated Workflow Test Group"
assert html =~ "Updated description" assert html =~ "Updated description"
# Slug should remain unchanged # OR-chain: slug may appear as UUID or normalized slug in copy
assert html =~ original_slug or html =~ "workflow-test-group" assert html =~ original_slug or html =~ "workflow-test-group"
end end
@ -101,7 +101,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do
# View group via slug # View group via slug
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Member count should be 2 # OR-chain for i18n (Members/Mitglieder); member names may be first or last
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
assert html =~ member1.first_name or html =~ member1.last_name assert html =~ member1.first_name or html =~ member1.last_name
assert html =~ member2.first_name or html =~ member2.last_name assert html =~ member2.first_name or html =~ member2.last_name

View file

@ -22,12 +22,13 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> element("button", "Add Member") |> element("button", "Add Member")
|> render_click() |> render_click()
html = render(view) # OR-chain: at least one of these ARIA/role attributes must be present
assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]") or
# Search input should have proper ARIA attributes has_element?(
assert html =~ ~r/aria-label/ || view,
html =~ ~r/aria-autocomplete/ || "[data-testid=group-show-member-search-input][aria-autocomplete]"
html =~ ~r/role=["']combobox["']/ ) or
has_element?(view, "[data-testid=group-show-member-search-input][role=combobox]")
end end
test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do
@ -35,16 +36,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(
view,
# Search input should have ARIA attributes "[data-testid=group-show-member-search-input][aria-autocomplete=list]"
assert html =~ ~r/aria-label.*[Ss]earch.*member/ || )
html =~ ~r/aria-autocomplete=["']list["']/
end end
test "remove button has aria-label with tooltip text", %{conn: conn} do test "remove button has aria-label with tooltip text", %{conn: conn} do
@ -67,11 +66,7 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
html = render(view) assert has_element?(view, "[data-testid=group-show-remove-member][aria-label]")
# Remove button should have aria-label
assert html =~ ~r/aria-label.*[Rr]emove/ ||
html =~ ~r/aria-label.*member/i
end end
test "add button has correct aria-label", %{conn: conn} do test "add button has correct aria-label", %{conn: conn} do
@ -79,16 +74,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][aria-label]")
# Add button should have aria-label
assert html =~ ~r/aria-label.*[Aa]dd/ ||
html =~ ~r/button.*[Aa]dd/
end end
end end
@ -100,16 +90,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-member-search-input]")
# Inline add member area should have focusable elements
assert html =~ ~r/input|button/ ||
html =~ "#member-search-input"
end end
test "inline input can be closed", %{conn: conn} do test "inline input can be closed", %{conn: conn} do
@ -117,17 +102,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
assert has_element?(view, "#member-search-input") assert has_element?(view, "[data-testid=group-show-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 end
test "enter/space activates buttons when focused", %{conn: conn} do test "enter/space activates buttons when focused", %{conn: conn} do
@ -148,17 +127,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Select member
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"}) |> render_change(%{"member_search" => "Bob"})
@ -167,14 +143,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> element("[data-member-id='#{member.id}']") |> element("[data-member-id='#{member.id}']")
|> render_click() |> render_click()
# Add button should be enabled and clickable
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Should succeed (member should appear in list) assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
html = render(view)
assert html =~ "Bob"
end end
test "focus management: focus is set to input when opened", %{conn: conn} do test "focus management: focus is set to input when opened", %{conn: conn} do
@ -184,16 +157,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-member-search-input]")
# Input should be visible and focusable
assert html =~ "#member-search-input" ||
html =~ ~r/autofocus|tabindex/
end end
end end
@ -203,16 +171,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]")
# Input should have aria-label
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
html =~ ~r/aria-label/
end end
test "search results are properly announced", %{conn: conn} do test "search results are properly announced", %{conn: conn} do
@ -231,27 +194,20 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"}) |> render_change(%{"member_search" => "Charlie"})
html = render(view) assert has_element?(view, "#member-dropdown[role=listbox]")
assert has_element?(view, "#member-dropdown", "Charlie")
# Search results should have proper ARIA attributes
assert html =~ ~r/role=["']listbox["']/ ||
html =~ ~r/role=["']option["']/ ||
html =~ "Charlie"
end end
test "flash messages are properly announced", %{conn: conn} do test "flash messages are properly announced", %{conn: conn} do
@ -270,16 +226,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"}) |> render_change(%{"member_search" => "David"})
@ -289,13 +243,10 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "David")
# Member should appear in list (no flash message)
assert html =~ "David"
end end
end end
end end

View file

@ -34,9 +34,8 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
select_member(view, member) select_member(view, member)
add_selected(view) add_selected(view)
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
assert html =~ "Alice" assert has_element?(view, "[data-testid=group-show-members-table]", "Johnson")
assert html =~ "Johnson"
end end
test "member is successfully added to group (verified in list)", %{conn: conn} do test "member is successfully added to group (verified in list)", %{conn: conn} do
@ -55,16 +54,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input and add member
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"}) |> render_change(%{"member_search" => "Bob"})
@ -74,14 +71,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
assert has_element?(view, "[data-testid=group-show-members-table]", "Smith")
# Verify member appears in group list (no success flash message)
assert html =~ "Bob"
assert html =~ "Smith"
end end
test "group member list updates automatically after add", %{conn: conn} do test "group member list updates automatically after add", %{conn: conn} do
@ -98,21 +92,18 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Initially member should NOT be in list refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
refute html =~ "Charlie"
# Add member
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"}) |> render_change(%{"member_search" => "Charlie"})
@ -122,13 +113,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Member should now appear in list assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Brown")
assert html =~ "Charlie"
assert html =~ "Brown"
end end
test "member count updates automatically after add", %{conn: conn} do test "member count updates automatically after add", %{conn: conn} do
@ -152,11 +141,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add member # Add member
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -169,7 +158,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Count should have increased # Count should have increased
@ -196,14 +185,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
assert has_element?(view, "#member-search-input") assert has_element?(view, "[data-testid=group-show-member-search-input]")
# Add member # Add member
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -216,11 +205,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Inline input should be closed (Add Member button should be visible again) refute has_element?(view, "[data-testid=group-show-member-search-input]")
refute has_element?(view, "#member-search-input")
end end
test "Cancel button closes inline add member area without adding", %{conn: conn} do test "Cancel button closes inline add member area without adding", %{conn: conn} do
@ -229,7 +217,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
open_add_member(view) open_add_member(view)
assert has_element?(view, "#member-search-input") assert has_element?(view, "[data-testid=group-show-member-search-input]")
assert has_element?(view, "button[phx-click='hide_add_member_input']") assert has_element?(view, "button[phx-click='hide_add_member_input']")
cancel_add_member(view) cancel_add_member(view)
@ -263,7 +251,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Try to add same member again # Try to add same member again
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Member should not appear in search (filtered out) # Member should not appear in search (filtered out)
@ -281,12 +269,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button", "Add") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Should show error # OR-chain for i18n and alternate error wording (already in group / duplicate)
html = render(view) html = render(view)
assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i assert html =~ gettext("already in group") or html =~ ~r/already.*group|duplicate/i
end end
end end
@ -300,7 +288,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Try to add with invalid member ID (if possible) # Try to add with invalid member ID (if possible)
@ -331,11 +319,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Inline input should be open assert has_element?(view, "[data-testid=group-show-member-search-input]")
assert has_element?(view, "#member-search-input")
# If error occurs, inline input should remain open # If error occurs, inline input should remain open
# (Implementation will handle this) # (Implementation will handle this)
@ -348,11 +335,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Add button should be disabled assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]")
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]")
end end
end end
@ -375,11 +361,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add member to empty group # Add member to empty group
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -392,12 +378,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Member should be added assert has_element?(view, "[data-testid=group-show-members-table]", "Henry")
html = render(view)
assert html =~ "Henry"
end end
test "add works when member is already in other groups", %{conn: conn} do test "add works when member is already in other groups", %{conn: conn} do
@ -424,11 +408,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add same member to group2 (should work) # Add same member to group2 (should work)
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -441,12 +425,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Member should be added to group2 assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel")
html = render(view)
assert html =~ "Isabel"
end end
end end

View file

@ -22,18 +22,18 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
test "Add Member button is visible for users with :update permission", %{conn: conn} do test "Add Member button is visible for users with :update permission", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Add Member") or html =~ "Add Member" assert has_element?(view, "button[phx-click='show_add_member_input']")
end end
@tag role: :read_only @tag role: :read_only
test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
refute html =~ gettext("Add Member") refute has_element?(view, "button[phx-click='show_add_member_input']")
end end
test "Add Member button is positioned above member table", %{conn: conn} do test "Add Member button is positioned above member table", %{conn: conn} do
@ -61,11 +61,7 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should exist (can be icon button with trash icon) assert has_element?(view, "[data-testid=group-show-remove-member]")
html = render(view)
assert html =~ "Remove" or html =~ "remove" or html =~ "trash" or
html =~ ~r/hero-trash|hero-x-mark/
end end
@tag role: :read_only @tag role: :read_only
@ -78,10 +74,9 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
actor: system_actor actor: system_actor
) )
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should NOT exist (check for trash icon or remove button specifically) refute has_element?(view, "[data-testid=group-show-remove-member]")
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end end
end end
@ -110,10 +105,7 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|> element("button", gettext("Add Member")) |> element("button", gettext("Add Member"))
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-member-search-input]")
assert html =~ gettext("Search for a member...") ||
html =~ ~r/search.*member/i
end end
test "Add button (plus icon) is disabled until member selected", %{conn: conn} do test "Add button (plus icon) is disabled until member selected", %{conn: conn} do
@ -121,15 +113,11 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", gettext("Add Member")) |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]")
# 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 end
end end

View file

@ -52,8 +52,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|> render_click() |> render_click()
# Should succeed (admin has :update permission, member should appear in list) # Should succeed (admin has :update permission, member should appear in list)
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
assert html =~ "Alice"
end end
@tag role: :read_only @tag role: :read_only
@ -78,9 +77,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
# Note: If button is hidden, we can't click it, but we test the event handler # 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 # by trying to send the event directly if possible
# For now, we verify that the button is not visible refute has_element?(view, "button[phx-click='show_add_member_input']")
html = render(view)
refute html =~ "Add Member"
end end
test "remove member event handler checks :update permission", %{conn: conn} do test "remove member event handler checks :update permission", %{conn: conn} do
@ -103,14 +100,11 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member (should succeed for admin)
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Should succeed (member should no longer be in list) refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
html = render(view)
refute html =~ "Charlie"
end end
@tag role: :read_only @tag role: :read_only
@ -134,11 +128,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should not be visible refute has_element?(view, "[data-testid=group-show-remove-member]")
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 end
test "error flash message on unauthorized access", %{conn: conn} do test "error flash message on unauthorized access", %{conn: conn} do
@ -174,10 +164,10 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
actor: system_actor actor: system_actor
) )
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Admin should see buttons assert has_element?(view, "button[phx-click='show_add_member_input']")
assert html =~ "Add Member" || html =~ "Remove" assert has_element?(view, "[data-testid=group-show-remove-member]")
end end
@tag role: :read_only @tag role: :read_only
@ -185,10 +175,9 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
_system_actor = Mv.Helpers.SystemActor.get_system_actor() _system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Read-only user should NOT see Add Member button refute has_element?(view, "button[phx-click='show_add_member_input']")
refute html =~ "Add Member"
end end
@tag role: :read_only @tag role: :read_only
@ -210,21 +199,18 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
actor: system_actor actor: system_actor
) )
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {: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 has_element?(view, "[data-testid=group-show-remove-member]")
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end end
@tag role: :read_only @tag role: :read_only
test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Inline input should not be accessible (button hidden) refute has_element?(view, "button[phx-click='show_add_member_input']")
refute html =~ "Add Member"
refute html =~ "#member-search-input"
end end
end end

View file

@ -305,9 +305,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Both members should be in list # Both members should be in list
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Frank")
assert html =~ "Frank" assert has_element?(view, "[data-testid=group-show-members-table]", "Grace")
assert html =~ "Grace"
end end
test "multiple members can be removed sequentially", %{conn: conn} do test "multiple members can be removed sequentially", %{conn: conn} do
@ -343,11 +342,11 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Both should be in list initially # Both should be in list initially
assert html =~ "Henry" assert has_element?(view, "[data-testid=group-show-members-table]", "Henry")
assert html =~ "Isabel" assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel")
# Remove first member # Remove first member
view view
@ -360,9 +359,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Both should be removed # Both should be removed
html = render(view) refute has_element?(view, "[data-testid=group-show-members-table]", "Henry")
refute html =~ "Henry" refute has_element?(view, "[data-testid=group-show-members-table]", "Isabel")
refute html =~ "Isabel"
end end
test "add and remove can be mixed", %{conn: conn} do test "add and remove can be mixed", %{conn: conn} do
@ -424,9 +422,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Only member2 should remain # Only member2 should remain
html = render(view) refute has_element?(view, "[data-testid=group-show-members-table]", "Jack")
refute html =~ "Jack" assert has_element?(view, "[data-testid=group-show-members-table]", "Kate")
assert html =~ "Kate"
end end
end end
end end

View file

@ -34,21 +34,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Type exact name
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jonathan"}) |> render_change(%{"member_search" => "Jonathan"})
html = render(view) assert has_element?(view, "#member-dropdown", "Jonathan")
assert has_element?(view, "#member-dropdown", "Smith")
assert html =~ "Jonathan"
assert html =~ "Smith"
end end
test "search finds member by partial name (fuzzy)", %{conn: conn} do test "search finds member by partial name (fuzzy)", %{conn: conn} do
@ -68,22 +63,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Type partial name
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jon"}) |> render_change(%{"member_search" => "Jon"})
html = render(view) assert has_element?(view, "#member-dropdown", "Jonathan")
assert has_element?(view, "#member-dropdown", "Smith")
# Fuzzy search should find Jonathan
assert html =~ "Jonathan"
assert html =~ "Smith"
end end
test "search finds member by email", %{conn: conn} do test "search finds member by email", %{conn: conn} do
@ -103,22 +92,17 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search by email
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "alice.johnson"}) |> render_change(%{"member_search" => "alice.johnson"})
html = render(view) assert has_element?(view, "#member-dropdown", "Alice")
assert has_element?(view, "#member-dropdown", "Johnson")
assert html =~ "Alice" assert has_element?(view, "#member-dropdown", "alice.johnson@example.com")
assert html =~ "Johnson"
assert html =~ "alice.johnson@example.com"
end end
test "dropdown shows member name and email", %{conn: conn} do test "dropdown shows member name and email", %{conn: conn} do
@ -153,11 +137,9 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"}) |> render_change(%{"member_search" => "Bob"})
html = render(view) assert has_element?(view, "#member-dropdown", "Bob")
assert has_element?(view, "#member-dropdown", "Williams")
assert html =~ "Bob" assert has_element?(view, "#member-dropdown", "bob@example.com")
assert html =~ "Williams"
assert html =~ "bob@example.com"
end end
test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do
@ -177,20 +159,15 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Focus input
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
html = render(view) assert has_element?(view, "#member-dropdown[role=listbox]")
# Dropdown should be visible
assert html =~ ~r/role="listbox"/ || html =~ "listbox"
end end
end end
@ -228,21 +205,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search for "David"
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"}) |> render_change(%{"member_search" => "David"})
# Assert only on dropdown (available members), not the members table assert has_element?(view, "#member-dropdown", "Anderson")
dropdown_html = view |> element("#member-dropdown") |> render() refute has_element?(view, "#member-dropdown", "Miller")
assert dropdown_html =~ "Anderson"
refute dropdown_html =~ "Miller"
end end
test "search filters correctly when group has many members", %{conn: conn} do test "search filters correctly when group has many members", %{conn: conn} do
@ -280,23 +252,18 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Available"}) |> render_change(%{"member_search" => "Available"})
# Assert only on dropdown (available members), not the members table assert has_element?(view, "#member-dropdown", "Available")
dropdown_html = view |> element("#member-dropdown") |> render() assert has_element?(view, "#member-dropdown", "Member")
assert dropdown_html =~ "Available" refute has_element?(view, "#member-dropdown", "Member1")
assert dropdown_html =~ "Member" refute has_element?(view, "#member-dropdown", "Member2")
refute dropdown_html =~ "Member1"
refute dropdown_html =~ "Member2"
end end
test "search shows no results when all available members are already in group", %{conn: conn} do test "search shows no results when all available members are already in group", %{conn: conn} do
@ -321,18 +288,14 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Only"}) |> render_change(%{"member_search" => "Only"})
# When no available members, dropdown is not rendered (length(@available_members) == 0)
refute has_element?(view, "#member-dropdown") refute has_element?(view, "#member-dropdown")
end end
end end

View file

@ -31,19 +31,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
assert html =~ "Alice"
# Click Remove button
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Member should no longer be in list (no success flash message) refute has_element?(view, "[data-testid=group-show-members-table]", "Alice")
html = render(view)
refute html =~ "Alice"
end end
test "member is successfully removed from group (verified in list)", %{conn: conn} do test "member is successfully removed from group (verified in list)", %{conn: conn} do
@ -64,20 +60,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
assert html =~ "Bob"
# Remove member
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
html = render(view) refute has_element?(view, "[data-testid=group-show-members-table]", "Bob")
# Member should no longer be in list (no success flash message)
refute html =~ "Bob"
end end
test "group member list updates automatically after remove", %{conn: conn} do test "group member list updates automatically after remove", %{conn: conn} do
@ -98,19 +89,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
assert html =~ "Charlie"
# Remove member
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Member should no longer be in list refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
html = render(view)
refute html =~ "Charlie"
end end
test "member count updates automatically after remove", %{conn: conn} do test "member count updates automatically after remove", %{conn: conn} do
@ -158,7 +145,7 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
# Extract first member ID from the rendered HTML or use a different approach # Extract first member ID from the rendered HTML or use a different approach
# Since we have member1 and member2, we can target member1 specifically # Since we have member1 and member2, we can target member1 specifically
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member1.id}']")
|> render_click() |> render_click()
# Count should have decreased # Count should have decreased
@ -187,17 +174,11 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Click Remove - should remove immediately without confirmation
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# No confirmation dialog should appear (immediate removal) refute has_element?(view, "[data-testid=group-show-members-table]", "Frank")
# This is verified by the member being removed without any dialog
# Member should be removed
html = render(view)
refute html =~ "Frank"
end end
end end
@ -220,23 +201,17 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member should be in list assert has_element?(view, "[data-testid=group-show-members-table]", "Grace")
assert html =~ "Grace"
# Remove last member
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Group should show empty state assert has_element?(view, "[data-testid=group-show-no-members]")
html = render(view) 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) count = extract_member_count(html)
assert count == 0 assert count == 0
end end
@ -269,18 +244,14 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group1.slug}") {:ok, view, _html} = live(conn, "/groups/#{group1.slug}")
# Remove from group1
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Member should be removed from group1 refute has_element?(view, "[data-testid=group-show-members-table]", "Henry")
html = render(view)
refute html =~ "Henry"
# Verify member is still in group2 {:ok, view2, _html2} = live(conn, "/groups/#{group2.slug}")
{:ok, _view2, html2} = live(conn, "/groups/#{group2.slug}") assert has_element?(view2, "[data-testid=group-show-members-table]", "Henry")
assert html2 =~ "Henry"
end end
test "remove is idempotent (no error if member already removed)", %{conn: conn} do test "remove is idempotent (no error if member already removed)", %{conn: conn} do
@ -303,22 +274,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member first time
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Try to remove again (should not error, just be idempotent) if has_element?(view, "[data-testid=group-show-members-table]", "Isabel") do
# Note: Implementation should handle this gracefully
# If button is still visible somehow, try to click again
html = render(view)
if html =~ "Isabel" do
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Should not crash
assert render(view) assert render(view)
end end
end end

View file

@ -22,34 +22,33 @@ defmodule MvWeb.GroupLive.ShowTest do
test "page renders successfully", %{conn: conn} do test "page renders successfully", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name assert has_element?(view, "h1", group.name)
end end
test "displays group name", %{conn: conn} do test "displays group name", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group Name"}) group = Fixtures.group_fixture(%{name: "Test Group Name"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Test Group Name" assert has_element?(view, "h1", "Test Group Name")
end end
test "displays group description when present", %{conn: conn} do test "displays group description when present", %{conn: conn} do
group = Fixtures.group_fixture(%{description: "This is a test description"}) group = Fixtures.group_fixture(%{description: "This is a test description"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "This is a test description" assert has_element?(view, "p", "This is a test description")
end end
test "displays member count", %{conn: conn} do test "displays member count", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member count should be displayed (might be 0 or more) assert has_element?(view, "[data-testid=group-show-member-count]")
assert html =~ "0" or html =~ gettext("Members") or html =~ "member" or html =~ "Mitglied"
end end
test "displays list of members in group", %{conn: conn} do test "displays list of members in group", %{conn: conn} do
@ -67,26 +66,26 @@ defmodule MvWeb.GroupLive.ShowTest do
actor: system_actor actor: system_actor
) )
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Alice" or html =~ "Smith" assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
assert html =~ "Bob" or html =~ "Jones" assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
end end
test "displays edit button for admin users", %{conn: conn} do test "displays edit button for admin users", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Edit") or html =~ "edit" or html =~ "Bearbeiten" assert has_element?(view, "[data-testid=group-show-edit-btn]")
end end
test "displays delete button for admin users", %{conn: conn} do test "displays delete button for admin users", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Delete") or html =~ "delete" or html =~ "Löschen" assert has_element?(view, "[data-testid=group-show-delete-btn]")
end end
end end
@ -94,19 +93,17 @@ defmodule MvWeb.GroupLive.ShowTest do
test "route /groups/:slug works correctly", %{conn: conn} do test "route /groups/:slug works correctly", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Board Members"}) group = Fixtures.group_fixture(%{name: "Board Members"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Board Members" assert has_element?(view, "h1", "Board Members")
# Verify slug is in URL
assert html =~ group.slug or html =~ "board-members"
end end
test "group is found by slug via unique_slug identity", %{conn: conn} do test "group is found by slug via unique_slug identity", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group"}) group = Fixtures.group_fixture(%{name: "Test Group"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name assert has_element?(view, "h1", group.name)
end end
test "non-existent slug returns 404", %{conn: conn} do test "non-existent slug returns 404", %{conn: conn} do
@ -145,28 +142,26 @@ defmodule MvWeb.GroupLive.ShowTest do
test "displays empty group correctly (0 members)", %{conn: conn} do test "displays empty group correctly (0 members)", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "0" or html =~ gettext("No members") or html =~ "empty" or assert has_element?(view, "[data-testid=group-show-no-members]")
html =~ "Keine Mitglieder"
end end
test "handles group without description correctly", %{conn: conn} do test "handles group without description correctly", %{conn: conn} do
group = Fixtures.group_fixture(%{description: nil}) group = Fixtures.group_fixture(%{description: nil})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Should not crash, description should be optional assert has_element?(view, "h1", group.name)
assert html =~ group.name
end end
test "handles slug with special characters correctly", %{conn: conn} do test "handles slug with special characters correctly", %{conn: conn} do
# Create group with name that generates slug with hyphens # Create group with name that generates slug with hyphens
group = Fixtures.group_fixture(%{name: "Test-Group-Name"}) group = Fixtures.group_fixture(%{name: "Test-Group-Name"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Test-Group-Name" or html =~ group.name assert has_element?(view, "h1", group.name)
end end
end end
@ -177,11 +172,11 @@ defmodule MvWeb.GroupLive.ShowTest do
read_only_user = Fixtures.user_with_role_fixture("read_only") read_only_user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_password_user(conn, read_only_user) conn = conn_with_password_user(conn, read_only_user)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name assert has_element?(view, "h1", group.name)
# Should NOT see edit/delete buttons refute has_element?(view, "[data-testid=group-show-edit-btn]")
refute html =~ gettext("Edit") or html =~ gettext("Delete") refute has_element?(view, "[data-testid=group-show-delete-btn]")
end end
@tag role: :unauthenticated @tag role: :unauthenticated
@ -246,14 +241,14 @@ defmodule MvWeb.GroupLive.ShowTest do
handler_id = "test-query-counter-#{System.unique_integer([:positive])}" handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil) :telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
final_count = Agent.get(query_count_agent, & &1) final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id) :telemetry.detach(handler_id)
# All members should be displayed
Enum.each(members, fn member -> Enum.each(members, fn member ->
assert html =~ member.first_name or html =~ member.last_name assert has_element?(view, "[data-testid=group-show-members-table]", member.first_name) or
has_element?(view, "[data-testid=group-show-members-table]", member.last_name)
end) end)
# Verify query count is reasonable (should avoid N+1 queries) # Verify query count is reasonable (should avoid N+1 queries)
@ -267,10 +262,9 @@ defmodule MvWeb.GroupLive.ShowTest do
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
# Should use index for fast lookup {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name assert has_element?(view, "h1", group.name)
end end
end end

View file

@ -13,7 +13,7 @@ defmodule MvWeb.GroupLiveHelpers do
""" """
def open_add_member(view) do def open_add_member(view) do
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
end end
@ -22,7 +22,7 @@ defmodule MvWeb.GroupLiveHelpers do
""" """
def search_member(view, query) do def search_member(view, query) do
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
view view
@ -44,7 +44,7 @@ defmodule MvWeb.GroupLiveHelpers do
""" """
def add_selected(view) do def add_selected(view) do
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
end end