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:
parent
8e5dd7e4c6
commit
c983c8d5bb
10 changed files with 920 additions and 330 deletions
155
test/mv_web/components/bulk_actions_dropdown_test.exs
Normal file
155
test/mv_web/components/bulk_actions_dropdown_test.exs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue