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

@ -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

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