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.
155 lines
5.5 KiB
Elixir
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
|