Compare commits

..

1 commit

Author SHA1 Message Date
c9c5062c4d
fix: update debian image to trixie (stable) to fix imprintor glibc version mismatch
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-23 18:12:45 +01:00
18 changed files with 474 additions and 388 deletions

View file

@ -1264,8 +1264,6 @@ 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,12 +17,10 @@ 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
@ -31,7 +29,6 @@ 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)
@ -97,21 +94,13 @@ defmodule MvWeb.GroupLive.Show do
</h1> </h1>
<div class="flex gap-2"> <div class="flex gap-2">
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.button <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}>
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, @group) do %> <%= if can?(@current_user, :destroy, Mv.Membership.Group) do %>
<.button <.button class="btn-error" phx-click="open_delete_modal">
class="btn-error"
phx-click="open_delete_modal"
data-testid="group-show-delete-btn"
>
{gettext("Delete")} {gettext("Delete")}
</.button> </.button>
<% end %> <% end %>
@ -134,7 +123,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" data-testid="group-show-member-count"> <p class="mb-4">
{ngettext( {ngettext(
"Total: %{count} member", "Total: %{count} member",
"Total: %{count} members", "Total: %{count} members",
@ -143,7 +132,7 @@ defmodule MvWeb.GroupLive.Show do
)} )}
</p> </p>
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, Mv.Membership.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">
@ -171,7 +160,6 @@ 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"
@ -240,7 +228,6 @@ 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")}
> >
@ -268,17 +255,15 @@ 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" data-testid="group-show-no-members"> <p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
{gettext("No members in this group")}
</p>
<% else %> <% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table"> <div class="overflow-x-auto">
<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, @group) do %> <%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<th class="w-0">{gettext("Actions")}</th> <th class="w-0">{gettext("Actions")}</th>
<% end %> <% end %>
</tr> </tr>
@ -306,14 +291,13 @@ 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, @group) do %> <%= if can?(@current_user, :update, Mv.Membership.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")}
> >
@ -447,31 +431,28 @@ 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
# Load candidate members once (single DB read). Search/focus then filter in memory (R2). # Reload group to ensure we have the latest members list
socket = actor = current_actor(socket)
socket group = socket.assigns.group
|> assign(:show_add_member_input, true) socket = reload_group(socket, group.slug, actor)
|> 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, socket} {: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 end
@impl true @impl true
def handle_event("show_member_dropdown", _params, socket) do def handle_event("show_member_dropdown", _params, socket) do
# Filter in memory from preloaded candidates; no DB read (R2). # Use existing group.members for filtering; reload only on add/remove
query = socket.assigns.member_search_query || ""
socket = socket =
socket socket
|> assign( |> load_available_members("")
: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)
@ -485,7 +466,6 @@ 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)
@ -552,13 +532,11 @@ 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
# Filter in memory from preloaded candidates; no DB read (R2). # Use existing group.members for filtering; reload only on add/remove
candidates = socket.assigns.add_member_candidates || []
socket = socket =
socket socket
|> assign(:member_search_query, query) |> assign(:member_search_query, query)
|> assign(:available_members, filter_candidates_in_memory(candidates, query)) |> load_available_members(query)
|> assign(:show_member_dropdown, true) |> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
@ -682,69 +660,47 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
# Load candidate members once when opening add-member UI (single DB read). defp load_available_members(socket, query) do
defp load_add_member_candidates(socket) do
require Ash.Query require Ash.Query
group = socket.assigns.group current_member_ids = group_member_ids_set(socket.assigns.group)
exclude_ids = group_member_ids_set(group) |> MapSet.to_list() base_query = available_members_base_query(query)
# 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)
if exclude_ids == [] do case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do
# No members in group; load first N members {:ok, members} ->
query = available =
Mv.Membership.Member members
|> Ash.Query.sort([:last_name, :first_name]) |> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end)
|> Ash.Query.limit(300) |> Enum.take(10)
do_load_add_member_candidates(socket, query, actor) assign(socket, available_members: available)
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 add-member candidates: #{inspect(error)}") Logger.warning("Failed to load available members for group: #{inspect(error)}")
socket socket
|> put_flash(:error, gettext("Could not load member list. Please try again.")) |> put_flash(:error, gettext("Could not load member search. Please try again."))
|> assign(:add_member_candidates, [])
|> assign(:available_members, []) |> assign(:available_members, [])
end end
end end
# Filter preloaded candidates by query string (name/email). No DB read. R2. defp available_members_base_query(query) do
defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil
q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: ""
if q == "" do if search_query do
candidates |> Enum.take(10) Mv.Membership.Member
|> Ash.Query.for_read(:search, %{query: search_query})
else else
candidates Mv.Membership.Member
|> Enum.filter(fn m -> |> Ash.Query.new()
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()
@ -784,7 +740,6 @@ 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,6 +2278,11 @@ 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"
@ -2618,7 +2623,17 @@ 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/group_live/show.ex #~ #: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format, fuzzy
msgid "Could not load member list. Please try again." #~ msgid "Export Members (CSV)"
msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut." #~ msgstr "Mitglieder exportieren (CSV)"
#~ #: 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,6 +2279,11 @@ 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"
@ -2618,8 +2623,3 @@ 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,6 +2279,11 @@ 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"
@ -2619,7 +2624,17 @@ msgstr ""
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 #~ #: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format, fuzzy
msgid "Could not load member list. Please try again." #~ msgid "Export Members (CSV)"
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.Accounts
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees.CycleGenerator alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.CycleGenerator
require Ash.Query require Ash.Query
@ -579,39 +579,6 @@ 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)
@ -620,35 +587,6 @@ 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
[ [
@ -793,7 +731,6 @@ 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,7 +19,6 @@ 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
@ -66,7 +65,6 @@ 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
@ -82,7 +80,6 @@ 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
@ -101,7 +98,6 @@ 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
@ -120,7 +116,6 @@ 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
@ -136,7 +131,6 @@ 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
@ -202,7 +196,6 @@ 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
@ -212,7 +205,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); regex for input element # Slug should not be in form (it's immutable)
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,14 +40,13 @@ defmodule MvWeb.GroupLive.IndexTest do
assert html =~ "Test Group" assert html =~ "Test Group"
assert html =~ "Test description" assert html =~ "Test description"
# OR-chain for i18n (Members/Mitglieder) and alternate copy for count # Member count should be displayed (0 for empty group)
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
@ -55,7 +54,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")
# OR-chain for i18n (No groups / Keine Gruppen) and alternate empty state copy # Should show empty state or empty list message
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
@ -77,7 +76,6 @@ 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
@ -111,7 +109,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")
# Read-only must not see create button (OR for i18n) # Should NOT see create button
refute html =~ gettext("Create Group") or html =~ "create" refute html =~ gettext("Create Group") or html =~ "create"
end end
end end
@ -179,7 +177,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)
# OR-chain for i18n (Members/Mitglieder) and count display # Member count should be displayed (should be 2)
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"
# OR-chain: slug may appear as UUID or normalized slug in copy # Slug should remain unchanged
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}")
# OR-chain for i18n (Members/Mitglieder); member names may be first or last # Member count should be 2
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,13 +22,12 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> element("button", "Add Member") |> element("button", "Add Member")
|> render_click() |> render_click()
# OR-chain: at least one of these ARIA/role attributes must be present html = render(view)
assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]") or
has_element?( # Search input should have proper ARIA attributes
view, assert html =~ ~r/aria-label/ ||
"[data-testid=group-show-member-search-input][aria-autocomplete]" html =~ ~r/aria-autocomplete/ ||
) or html =~ ~r/role=["']combobox["']/
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
@ -36,14 +35,16 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?( html = render(view)
view,
"[data-testid=group-show-member-search-input][aria-autocomplete=list]" # Search input should have ARIA attributes
) 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
@ -66,7 +67,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-remove-member][aria-label]") html = render(view)
# 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
@ -74,11 +79,16 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][aria-label]") html = render(view)
# Add button should have aria-label
assert html =~ ~r/aria-label.*[Aa]dd/ ||
html =~ ~r/button.*[Aa]dd/
end end
end end
@ -90,11 +100,16 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") html = render(view)
# 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
@ -102,11 +117,17 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") 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 end
test "enter/space activates buttons when focused", %{conn: conn} do test "enter/space activates buttons when focused", %{conn: conn} do
@ -127,14 +148,17 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Select member
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#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"})
@ -143,11 +167,14 @@ 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("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") # Should succeed (member should appear in list)
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
@ -157,11 +184,16 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") html = render(view)
# Input should be visible and focusable
assert html =~ "#member-search-input" ||
html =~ ~r/autofocus|tabindex/
end end
end end
@ -171,11 +203,16 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]") html = render(view)
# 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
@ -194,20 +231,27 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Search
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#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"})
assert has_element?(view, "#member-dropdown[role=listbox]") html = render(view)
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
@ -226,14 +270,16 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#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"})
@ -243,10 +289,13 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "David") html = render(view)
# Member should appear in list (no flash message)
assert html =~ "David"
end end
end end
end end

View file

@ -34,8 +34,9 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
select_member(view, member) select_member(view, member)
add_selected(view) add_selected(view)
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") html = render(view)
assert has_element?(view, "[data-testid=group-show-members-table]", "Johnson") assert html =~ "Alice"
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
@ -54,14 +55,16 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#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"})
@ -71,11 +74,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") html = render(view)
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
@ -92,18 +98,21 @@ 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}")
refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Initially member should NOT be in list
refute html =~ "Charlie"
# Add member
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#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"})
@ -113,11 +122,13 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Member should now appear in list
assert has_element?(view, "[data-testid=group-show-members-table]", "Brown") html = render(view)
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
@ -141,11 +152,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add member # Add member
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#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
@ -158,7 +169,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
# Count should have increased # Count should have increased
@ -185,14 +196,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") assert has_element?(view, "#member-search-input")
# Add member # Add member
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#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
@ -205,10 +216,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-member-search-input]") # Inline input should be closed (Add Member button should be visible again)
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
@ -217,7 +229,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, "[data-testid=group-show-member-search-input]") assert has_element?(view, "#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)
@ -251,7 +263,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Try to add same member again # Try to add same member again
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Member should not appear in search (filtered out) # Member should not appear in search (filtered out)
@ -269,12 +281,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button", "Add")
|> render_click() |> render_click()
# OR-chain for i18n and alternate error wording (already in group / duplicate) # Should show error
html = render(view) html = render(view)
assert html =~ gettext("already in group") or html =~ ~r/already.*group|duplicate/i assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i
end end
end end
@ -288,7 +300,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Try to add with invalid member ID (if possible) # Try to add with invalid member ID (if possible)
@ -319,10 +331,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") # Inline input should be open
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)
@ -335,10 +348,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]") # Add button should be disabled
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]")
end end
end end
@ -361,11 +375,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add member to empty group # Add member to empty group
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#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
@ -378,10 +392,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Henry") # Member should be added
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
@ -408,11 +424,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add same member to group2 (should work) # Add same member to group2 (should work)
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#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
@ -425,10 +441,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel") # Member should be added to group2
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 has_element?(view, "button[phx-click='show_add_member_input']") assert html =~ gettext("Add Member") or html =~ "Add Member"
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 has_element?(view, "button[phx-click='show_add_member_input']") refute html =~ gettext("Add Member")
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,7 +61,11 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-remove-member]") # 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 end
@tag role: :read_only @tag role: :read_only
@ -74,9 +78,10 @@ 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}")
refute has_element?(view, "[data-testid=group-show-remove-member]") # 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
end end
@ -105,7 +110,10 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|> element("button", gettext("Add Member")) |> element("button", gettext("Add Member"))
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") html = render(view)
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
@ -113,11 +121,15 @@ 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[phx-click='show_add_member_input']") |> element("button", gettext("Add Member"))
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]") 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 end
end end

View file

@ -52,7 +52,8 @@ 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)
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") html = render(view)
assert html =~ "Alice"
end end
@tag role: :read_only @tag role: :read_only
@ -77,7 +78,9 @@ 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
refute has_element?(view, "button[phx-click='show_add_member_input']") # For now, we verify that the button is not visible
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
@ -100,11 +103,14 @@ 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("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Should succeed (member should no longer be in list)
html = render(view)
refute html =~ "Charlie"
end end
@tag role: :read_only @tag role: :read_only
@ -128,7 +134,11 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
refute has_element?(view, "[data-testid=group-show-remove-member]") # 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 end
test "error flash message on unauthorized access", %{conn: conn} do test "error flash message on unauthorized access", %{conn: conn} do
@ -164,10 +174,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}")
assert has_element?(view, "button[phx-click='show_add_member_input']") # Admin should see buttons
assert has_element?(view, "[data-testid=group-show-remove-member]") assert html =~ "Add Member" || html =~ "Remove"
end end
@tag role: :read_only @tag role: :read_only
@ -175,9 +185,10 @@ 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}")
refute has_element?(view, "button[phx-click='show_add_member_input']") # Read-only user should NOT see Add Member button
refute html =~ "Add Member"
end end
@tag role: :read_only @tag role: :read_only
@ -199,18 +210,21 @@ 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}")
refute has_element?(view, "[data-testid=group-show-remove-member]") # 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
@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}")
refute has_element?(view, "button[phx-click='show_add_member_input']") # Inline input should not be accessible (button hidden)
refute html =~ "Add Member"
refute html =~ "#member-search-input"
end end
end end

View file

@ -305,8 +305,9 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Both members should be in list # Both members should be in list
assert has_element?(view, "[data-testid=group-show-members-table]", "Frank") html = render(view)
assert has_element?(view, "[data-testid=group-show-members-table]", "Grace") assert html =~ "Frank"
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
@ -342,11 +343,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 has_element?(view, "[data-testid=group-show-members-table]", "Henry") assert html =~ "Henry"
assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel") assert html =~ "Isabel"
# Remove first member # Remove first member
view view
@ -359,8 +360,9 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Both should be removed # Both should be removed
refute has_element?(view, "[data-testid=group-show-members-table]", "Henry") html = render(view)
refute has_element?(view, "[data-testid=group-show-members-table]", "Isabel") refute html =~ "Henry"
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
@ -422,8 +424,9 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Only member2 should remain # Only member2 should remain
refute has_element?(view, "[data-testid=group-show-members-table]", "Jack") html = render(view)
assert has_element?(view, "[data-testid=group-show-members-table]", "Kate") refute html =~ "Jack"
assert html =~ "Kate"
end end
end end
end end

View file

@ -34,16 +34,21 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> 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"})
assert has_element?(view, "#member-dropdown", "Jonathan") html = render(view)
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
@ -63,16 +68,22 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> 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"})
assert has_element?(view, "#member-dropdown", "Jonathan") html = render(view)
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
@ -92,17 +103,22 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> 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"})
assert has_element?(view, "#member-dropdown", "Alice") html = render(view)
assert has_element?(view, "#member-dropdown", "Johnson")
assert has_element?(view, "#member-dropdown", "alice.johnson@example.com") assert html =~ "Alice"
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
@ -137,9 +153,11 @@ 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"})
assert has_element?(view, "#member-dropdown", "Bob") html = render(view)
assert has_element?(view, "#member-dropdown", "Williams")
assert has_element?(view, "#member-dropdown", "bob@example.com") assert html =~ "Bob"
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
@ -159,15 +177,20 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Focus input
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
assert has_element?(view, "#member-dropdown[role=listbox]") html = render(view)
# Dropdown should be visible
assert html =~ ~r/role="listbox"/ || html =~ "listbox"
end end
end end
@ -205,16 +228,21 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> 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 has_element?(view, "#member-dropdown", "Anderson") # Assert only on dropdown (available members), not the members table
refute has_element?(view, "#member-dropdown", "Miller") dropdown_html = view |> element("#member-dropdown") |> render()
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
@ -252,18 +280,23 @@ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> 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 has_element?(view, "#member-dropdown", "Available") # Assert only on dropdown (available members), not the members table
assert has_element?(view, "#member-dropdown", "Member") dropdown_html = view |> element("#member-dropdown") |> render()
refute has_element?(view, "#member-dropdown", "Member1") assert dropdown_html =~ "Available"
refute has_element?(view, "#member-dropdown", "Member2") assert dropdown_html =~ "Member"
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
@ -288,14 +321,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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> 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,15 +31,19 @@ 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}")
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") # Member should be in list initially
assert html =~ "Alice"
# Click Remove button
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Alice") # Member should no longer be in list (no success flash message)
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
@ -60,15 +64,20 @@ 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}")
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") # Member should be in list initially
assert html =~ "Bob"
# Remove member
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Bob") html = render(view)
# 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
@ -89,15 +98,19 @@ 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}")
assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Member should be in list initially
assert html =~ "Charlie"
# Remove member
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Member should no longer be in list
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
@ -145,7 +158,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("[data-testid=group-show-remove-member][phx-value-member_id='#{member1.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> render_click() |> render_click()
# Count should have decreased # Count should have decreased
@ -174,11 +187,17 @@ 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("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Frank") # 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
end end
@ -201,17 +220,23 @@ 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}")
assert has_element?(view, "[data-testid=group-show-members-table]", "Grace") # Member should be in list
assert html =~ "Grace"
# Remove last member
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-no-members]") # Group should show empty state
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
@ -244,14 +269,18 @@ 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("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Henry") # Member should be removed from group1
html = render(view)
refute html =~ "Henry"
{:ok, view2, _html2} = live(conn, "/groups/#{group2.slug}") # Verify member is still in group2
assert has_element?(view2, "[data-testid=group-show-members-table]", "Henry") {:ok, _view2, html2} = live(conn, "/groups/#{group2.slug}")
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
@ -274,15 +303,22 @@ 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("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
if has_element?(view, "[data-testid=group-show-members-table]", "Isabel") do # 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 view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='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,33 +22,34 @@ 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 has_element?(view, "h1", group.name) assert html =~ 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 has_element?(view, "h1", "Test Group Name") assert html =~ "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 has_element?(view, "p", "This is a test description") assert html =~ "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}")
assert has_element?(view, "[data-testid=group-show-member-count]") # Member count should be displayed (might be 0 or more)
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
@ -66,26 +67,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 has_element?(view, "[data-testid=group-show-members-table]", "Alice") assert html =~ "Alice" or html =~ "Smith"
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") assert html =~ "Bob" or html =~ "Jones"
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 has_element?(view, "[data-testid=group-show-edit-btn]") assert html =~ gettext("Edit") or html =~ "edit" or html =~ "Bearbeiten"
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 has_element?(view, "[data-testid=group-show-delete-btn]") assert html =~ gettext("Delete") or html =~ "delete" or html =~ "Löschen"
end end
end end
@ -93,17 +94,19 @@ 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 has_element?(view, "h1", "Board Members") assert html =~ "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 has_element?(view, "h1", group.name) assert html =~ group.name
end end
test "non-existent slug returns 404", %{conn: conn} do test "non-existent slug returns 404", %{conn: conn} do
@ -142,26 +145,28 @@ 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 has_element?(view, "[data-testid=group-show-no-members]") assert html =~ "0" or html =~ gettext("No members") or html =~ "empty" or
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}")
assert has_element?(view, "h1", group.name) # Should not crash, description should be optional
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 has_element?(view, "h1", group.name) assert html =~ "Test-Group-Name" or html =~ group.name
end end
end end
@ -172,11 +177,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 has_element?(view, "h1", group.name) assert html =~ group.name
refute has_element?(view, "[data-testid=group-show-edit-btn]") # Should NOT see edit/delete buttons
refute has_element?(view, "[data-testid=group-show-delete-btn]") refute html =~ gettext("Edit") or html =~ gettext("Delete")
end end
@tag role: :unauthenticated @tag role: :unauthenticated
@ -241,14 +246,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 has_element?(view, "[data-testid=group-show-members-table]", member.first_name) or assert html =~ member.first_name or html =~ member.last_name
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)
@ -262,9 +267,10 @@ 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()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") # Should use index for fast lookup
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "h1", group.name) assert html =~ 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[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> 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("[data-testid=group-show-member-search-input]") |> element("#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("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
end end