feat(member): collect member-overview bulk actions into a single dropdown

The growing row of bulk-action buttons above the member overview is replaced
by one "Aktionen" dropdown holding all four actions (open in email program,
copy addresses, export CSV, export PDF). With no selection the actions operate
on all — or the currently filtered — members; the email-program action is
disabled past a recipient cap, because the browser cannot reliably hand a very
long mailto over to the mail client. The trigger shows the active scope as a
badge: an emphasized count when members are selected, a muted "alle"/"gefiltert"
otherwise.
This commit is contained in:
Simon 2026-06-04 16:44:13 +02:00
parent 8e5dd7e4c6
commit c983c8d5bb
10 changed files with 920 additions and 330 deletions

View file

@ -409,6 +409,19 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
# Opens the bulk-actions dropdown and clicks the copy item. The copy item only
# exists in the DOM while the menu is open, so we toggle it open first.
defp click_copy_via_dropdown(view) do
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
view |> element("#bulk-actions-copy") |> render_click()
end
defp scope_badge(html) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
end
describe "copy_emails feature" do
setup do
system_actor = SystemActor.get_system_actor()
@ -460,22 +473,23 @@ defmodule MvWeb.MemberLive.IndexTest do
render_click(view, "select_member", %{"id" => member2.id})
# Trigger copy_emails event
view |> element("#copy-emails-btn") |> render_click()
click_copy_via_dropdown(view)
# Verify flash message shows correct count
assert render(view) =~ "2"
end
test "copy_emails event with no selection shows error flash", %{conn: conn} do
test "copy_emails with no selection copies all members' emails", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Trigger copy_emails event directly (button not visible when no selection)
# This tests the edge case where event is triggered without selection
# Deliberate behaviour change (§3.1): with no selection, copy operates on
# the current scope (all members) instead of erroring "No members selected".
result = render_hook(view, "copy_emails", %{})
# Should show error flash
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
# Three seeded members all have an email → success flash, not an error.
assert result =~ "3"
refute result =~ "No members selected"
end
test "copy_emails event with all members selected formats all emails", %{
@ -488,7 +502,7 @@ defmodule MvWeb.MemberLive.IndexTest do
view |> element("[phx-click='select_all']") |> render_click()
# Trigger copy_emails event
view |> element("#copy-emails-btn") |> render_click()
click_copy_via_dropdown(view)
# Verify flash message shows correct count (3 members)
assert render(view) =~ "3"
@ -505,7 +519,7 @@ defmodule MvWeb.MemberLive.IndexTest do
render_click(view, "select_member", %{"id" => member3.id})
# Trigger copy_emails event - should not crash
view |> element("#copy-emails-btn") |> render_click()
click_copy_via_dropdown(view)
# Verify flash message shows success
assert render(view) =~ "1"
@ -582,37 +596,38 @@ defmodule MvWeb.MemberLive.IndexTest do
# The format should be "Test Format <test.format@example.com>"
# We verify this by checking the flash shows 1 email was copied
view |> element("#copy-emails-btn") |> render_click()
click_copy_via_dropdown(view)
assert render(view) =~ "1"
end
test "copy button is disabled when no members selected", %{conn: conn} do
test "copy and mailto items stay actionable with no selection", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Copy button should be disabled (button element)
assert has_element?(view, "#copy-emails-btn[disabled]")
# Open email button should be disabled (link with tabindex and aria-disabled)
assert has_element?(view, "#open-email-btn[tabindex='-1'][aria-disabled='true']")
# Open the dropdown so its items are in the DOM.
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
# Deliberate behaviour change (§3.1): items are never disabled merely
# because nothing is selected. Copy is a plain button (no disabled attr),
# and mailto is an enabled link (no aria-disabled) carrying a BCC of all
# three seeded members.
refute has_element?(view, ~s([data-testid="bulk-actions-copy"][disabled]))
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
end
test "copy button is enabled after selection", %{
test "trigger shows the selected count after a selection", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id})
# Copy button should now be enabled (no disabled attribute)
refute has_element?(view, "#copy-emails-btn[disabled]")
# Open email button should now be enabled (no tabindex=-1 or aria-disabled)
refute has_element?(view, "#open-email-btn[tabindex='-1']")
refute has_element?(view, "#open-email-btn[aria-disabled='true']")
# Counter should show correct count
assert render(view) =~ "1"
# The scope badge on the trigger reflects the selection count.
badge = scope_badge(render(view))
assert badge |> LazyHTML.text() |> String.trim() == "1"
end
test "copy button click triggers event and shows flash", %{
@ -626,14 +641,47 @@ defmodule MvWeb.MemberLive.IndexTest do
render_click(view, "select_member", %{"id" => member1.id})
# Click copy button
view |> element("#copy-emails-btn") |> render_click()
click_copy_via_dropdown(view)
# Flash message should appear
assert has_element?(view, "#flash-group")
end
test "copy excludes a member whose email is blank from the recipient list", %{conn: conn} do
# The Member create action requires an email, so a blank-email member cannot
# be persisted; we exercise the preserved defensive filter in
# format_selected_member_emails/2 directly. One member has an email, the
# other has a blank one — only the former is a recipient (§1.10).
with_email = %{
id: Ecto.UUID.generate(),
first_name: "Has",
last_name: "Mail",
email: "has@example.com"
}
blank_email = %{id: Ecto.UUID.generate(), first_name: "Blank", last_name: "Mail", email: ""}
selected = MapSet.new([with_email.id, blank_email.id])
emails = MemberIndex.format_selected_member_emails([with_email, blank_email], selected)
assert emails == ["Has Mail <has@example.com>"]
_ = conn
end
end
describe "export dropdown" do
describe "copy_emails empty-recipient feedback" do
test "copy with zero recipients shows 'No email addresses found'", %{conn: conn} do
# No members exist → the no-selection scope yields zero recipients, so the
# preserved empty-recipient feedback fires instead of a clipboard push (§1.11).
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
result = render_hook(view, "copy_emails", %{})
assert result =~ "No email addresses found" or result =~ "Keine E-Mail"
end
end
describe "bulk-actions dropdown" do
setup do
system_actor = SystemActor.get_system_actor()
@ -646,27 +694,72 @@ defmodule MvWeb.MemberLive.IndexTest do
%{member1: m1}
end
test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do
test "trigger is rendered with a muted 'all' scope badge when no selection", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Dropdown button should be present
assert html =~ ~s(data-testid="export-dropdown")
assert html =~ ~s(data-testid="export-dropdown-button")
assert html =~ "Export"
# Button text shows "all" when 0 selected (locale-dependent)
assert html =~ "all" or html =~ "All"
# The single bulk-actions trigger is present.
assert html =~ ~s(data-testid="bulk-actions-dropdown")
assert html =~ ~s(data-testid="bulk-actions-button")
# The scope is shown as a badge, not a parenthetical text suffix. The test
# locale renders the English msgids (German wording lives in de.po):
# "Actions" -> "Aktionen", "all" -> "alle".
assert html =~ "Actions"
refute html =~ "(all)"
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "all"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-neutral"
assert classes =~ "badge-sm"
end
test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do
test "trigger shows an emphasized count badge after select_member", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id})
html = render(view)
assert html =~ "Export"
assert html =~ "(1)"
assert html =~ "Actions"
refute html =~ "(1)"
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "1"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-primary"
assert classes =~ "badge-sm"
end
test "trigger shows a muted 'filtered' badge when a search narrows the list", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=Nobody")
assert html =~ "Actions"
refute html =~ "(filtered)"
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-neutral"
assert classes =~ "badge-sm"
end
test "trigger carries a trailing chevron", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
assert html =~ "hero-chevron-down"
# The bulk-actions trigger and the bespoke member-filter trigger each
# carry their own chevron; assert the filter trigger's chevron is pinned
# independently, so removing it from the filter component fails this test.
assert has_element?(view, ".member-filter-dropdown .hero-chevron-down")
end
test "dropdown opens and closes on click", %{conn: conn} do
@ -674,23 +767,23 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} = live(conn, "/members")
# Initially closed
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
# Click to open
view
|> element(~s([data-testid="export-dropdown-button"]))
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
# Menu should be visible
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
# Click to close
view
|> element(~s([data-testid="export-dropdown-button"]))
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
# Menu should be hidden
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
end
test "dropdown has click-away and ESC handlers", %{conn: conn} do
@ -699,11 +792,11 @@ defmodule MvWeb.MemberLive.IndexTest do
# Open dropdown
view
|> element(~s([data-testid="export-dropdown-button"]))
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
html = render(view)
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
# Check that click-away handler is present
assert html =~ ~s(phx-click-away="close_dropdown")
@ -712,13 +805,37 @@ defmodule MvWeb.MemberLive.IndexTest do
assert html =~ ~s(phx-key="Escape")
end
test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do
test "menu lists the four actions in order, flattened to one level", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
html = render(view)
# All four items present.
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"]))
assert has_element?(view, ~s([data-testid="bulk-actions-copy"]))
assert has_element?(view, ~s([data-testid="export-csv-link"]))
assert has_element?(view, ~s([data-testid="export-pdf-link"]))
# In order: mailto, copy, CSV, PDF.
mailto = :binary.match(html, "bulk-actions-mailto") |> elem(0)
copy = :binary.match(html, "bulk-actions-copy") |> elem(0)
csv = :binary.match(html, "export-csv-link") |> elem(0)
pdf = :binary.match(html, "export-pdf-link") |> elem(0)
assert mailto < copy and copy < csv and csv < pdf
# No nested export submenu — the former standalone export dropdown is gone.
refute html =~ ~s(data-testid="export-dropdown")
end
test "menu contains CSV and PDF export forms with identical payload and CSRF", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element(~s([data-testid="export-dropdown-button"]))
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
html = render(view)
@ -756,11 +873,11 @@ defmodule MvWeb.MemberLive.IndexTest do
# Button should have aria-expanded="false" when closed
assert html =~ ~s(aria-expanded="false")
# Button should have aria-controls pointing to menu
assert html =~ ~s(aria-controls="export-dropdown-menu")
assert html =~ ~s(aria-controls="bulk-actions-dropdown-menu")
# Open dropdown
view
|> element(~s([data-testid="export-dropdown-button"]))
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
html = render(view)
@ -782,6 +899,172 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
describe "bulk-actions header layout" do
test "header renders one bulk-actions trigger and no standalone copy/mailto/export controls",
%{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Exactly one bulk-actions trigger.
assert has_element?(view, ~s([data-testid="bulk-actions-button"]))
# The former standalone controls are gone as top-level header buttons.
refute html =~ ~s(id="copy-emails-btn")
refute html =~ ~s(id="open-email-btn")
refute html =~ ~s(data-testid="export-dropdown-button")
end
test "New Member stays a separate primary button outside the dropdown", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# New Member is present as its own control...
assert has_element?(view, ~s([data-testid="member-new"]))
# ...and it is not an item of the bulk-actions menu (it precedes the menu
# markup but is not nested inside it).
refute html =~ ~r/data-testid="bulk-actions-menu".*data-testid="member-new"/s
end
end
describe "mailto recipient cap on the page" do
# Seeds n members, each with a distinct email so they all count as mailto
# recipients in the no-selection scope.
defp seed_members_with_email(n) do
system_actor = SystemActor.get_system_actor()
Enum.each(1..n, fn i ->
{:ok, _} =
Membership.create_member(
%{
first_name: "Bulk",
last_name: "M#{i}",
email: "bulk#{i}@example.com"
},
actor: system_actor
)
end)
end
test "mailto item is enabled just below the threshold", %{conn: conn} do
seed_members_with_email(Mv.Constants.max_mailto_bulk_recipients() - 1)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
# 49 recipients → enabled, with an actionable BCC link, no aria-disabled.
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
end
test "mailto item is disabled with tooltip at the threshold", %{conn: conn} do
seed_members_with_email(Mv.Constants.max_mailto_bulk_recipients())
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
html = render(view)
# 50 recipients → disabled, with the explanatory tooltip and no BCC link.
# The tooltip renders the English msgid in the test locale (German wording
# is "Zu viele Empfänger für diese Funktion. ..." in de.po).
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
assert html =~ "Too many recipients for this function"
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
end
end
describe "scope-aware selection assigns" do
setup do
system_actor = SystemActor.get_system_actor()
{:ok, m1} =
Membership.create_member(
%{first_name: "Scope", last_name: "One", email: "scope1@example.com"},
actor: system_actor
)
{:ok, m2} =
Membership.create_member(
%{first_name: "Scope", last_name: "Two", email: "scope2@example.com"},
actor: system_actor
)
%{member1: m1, member2: m2}
end
test "scope is :all when nothing selected and no filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.scope == :all
end
test "scope is :filtered when a search term is active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=Scope")
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.scope == :filtered
end
test "scope is :filtered when a non-search filter is active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?cycle_status_filter=paid")
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.scope == :filtered
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-neutral"
assert classes =~ "badge-sm"
end
test "scope is :selection when a member is selected", %{conn: conn, member1: member1} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id})
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.scope == :selection
end
test "with no selection, recipient_count and mailto_bcc cover all members", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
assigns = :sys.get_state(view.pid).socket.assigns
# Both seeded members have an email, so the no-selection scope covers both.
assert assigns.recipient_count == 2
assert assigns.mailto_bcc =~ "scope1%40example.com"
assert assigns.mailto_bcc =~ "scope2%40example.com"
end
test "with a selection, recipient_count and mailto_bcc cover only the selection", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id})
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.recipient_count == 1
assert assigns.mailto_bcc =~ "scope1%40example.com"
refute assigns.mailto_bcc =~ "scope2%40example.com"
end
end
describe "cycle status filter" do
# Helper to create a member (only used in this describe block)
defp create_member(attrs, actor) do