mitgliederverwaltung/test/mv_web/components/bulk_actions_dropdown_test.exs
Simon c983c8d5bb 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.
2026-06-04 16:44:13 +02:00

155 lines
5.5 KiB
Elixir

defmodule MvWeb.Components.BulkActionsDropdownTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias MvWeb.Components.BulkActionsDropdown
defp render_open(assigns) do
base = %{
id: "bulk-actions-dropdown",
open: true,
export_payload_json: ~s({"selected_ids":[]}),
selected_count: 0,
scope: :all,
mailto_bcc: "a%40example.com",
recipient_count: 1,
mailto_disabled?: false
}
render_component(BulkActionsDropdown, Map.merge(base, assigns))
end
defp scope_badge(html) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
end
describe "trigger scope badge" do
test "shows an emphasized primary count badge when members are selected" do
html =
render_component(BulkActionsDropdown, %{id: "d", scope: :selection, selected_count: 3})
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "3"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-primary"
assert classes =~ "badge-sm"
# The trigger label itself is just the bare action verb, no parenthetical.
assert html =~ "Actions"
refute html =~ "(3)"
end
test "shows a muted neutral 'all' badge when nothing selected and no filter" do
html = render_component(BulkActionsDropdown, %{id: "d", scope: :all, selected_count: 0})
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"
refute html =~ "(all)"
end
test "shows a muted neutral 'filtered' badge when a filter is active" do
html =
render_component(BulkActionsDropdown, %{id: "d", scope: :filtered, selected_count: 0})
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"
refute html =~ "(filtered)"
end
end
describe "trigger affordance" do
test "carries a trailing chevron" do
html = render_component(BulkActionsDropdown, %{id: "d", scope: :all, selected_count: 0})
assert html =~ "hero-chevron-down"
end
end
describe "menu item layout" do
test "all menu items carry whitespace-nowrap to prevent label text wrapping" do
html = render_open(%{})
doc = LazyHTML.from_fragment(html)
# Collect all elements with role="menuitem" (both <a> and <button>)
items = LazyHTML.query(doc, ~s([role="menuitem"]))
classes_list = LazyHTML.attribute(items, "class")
assert length(classes_list) >= 4,
"expected at least 4 menu items, got #{length(classes_list)}"
for classes <- classes_list do
assert classes =~ "whitespace-nowrap",
"expected whitespace-nowrap on menu item, got class: #{inspect(classes)}"
end
end
end
describe "menu items" do
test "lists the four bulk actions in order, flattened to one level" do
html = render_open(%{})
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
assert copy < csv
assert csv < pdf
# No nested "Export" submenu trigger — the export items sit at the top level.
refute html =~ ~s(data-testid="export-dropdown")
end
test "copy item carries the clipboard hook and an un-targeted copy_emails click" do
html = render_open(%{})
assert html =~ ~s(phx-hook="CopyToClipboard")
assert html =~ ~s(phx-click="copy_emails")
# The copy click must NOT be targeted at the component, so it reaches the
# parent LiveView handler.
refute html =~ ~r/phx-click="copy_emails"[^>]*phx-target/
end
end
describe "mailto recipient cap" do
test "mailto item is enabled below the threshold with a BCC link" do
html = render_open(%{mailto_disabled?: false, mailto_bcc: "a%40example.com"})
assert html =~ ~s(href="mailto:?bcc=a%40example.com")
refute html =~ ~r/data-testid="bulk-actions-mailto"[^>]*aria-disabled="true"/s
end
test "mailto item is disabled with the explanatory tooltip at the threshold" do
html = render_open(%{mailto_disabled?: true})
assert html =~ ~s(aria-disabled="true")
assert html =~ ~s(tabindex="-1")
assert html =~ "Too many recipients for this function"
# Disabled mailto must not expose an actionable BCC link.
refute html =~ "href=\"mailto:"
end
end
describe "export forms" do
test "CSV and PDF items keep the CSRF-protected form POST and payload" do
payload = ~s({"selected_ids":["x"]})
html = render_open(%{export_payload_json: payload})
assert html =~ ~s(action="/members/export.csv")
assert html =~ ~s(action="/members/export.pdf")
assert html =~ ~s(name="_csrf_token")
assert html =~ ~s(name="payload")
# The payload lands HTML-escaped in the hidden input value attribute; both
# export forms carry the same payload.
escaped = Phoenix.HTML.html_escape(payload) |> Phoenix.HTML.safe_to_string()
assert html =~ ~s(name="payload" value="#{escaped}")
end
end
end