mitgliederverwaltung/test/mv_web/member_live/index_test.exs
Simon 8303a39041 feat(overview): rework filter builder with editable chips and native date ranges
Editable filter chips, uniformly-sized value controls, working field-picker
search, and native date-range inputs with relative presets in place of the
vendored calendar — native inputs give year-jump, manual entry and keyboard
accessibility for free.
2026-07-10 16:27:15 +02:00

2086 lines
70 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MvWeb.MemberLive.IndexTest do
# async: false on purpose: the @slow "boolean filter performance with 150 members"
# test asserts a wall-clock budget (duration < 1000ms). Running this module in
# parallel with others adds CPU contention that inflates that measurement and makes
# the timing assertion flaky, so this module stays synchronous.
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Mv.Fixtures, only: [create_fee_type: 2, create_cycle: 4]
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias MvWeb.MemberLive.Index, as: MemberIndex
# Disables the compact Member/Address composites (per-browser view setting) so
# the individual constituent columns (first_name/last_name, street/…) are
# offered by the column manager and selectable via ?fields=. The composite
# E-Mail column is offered even in compact mode, so email-only tests do not
# need this.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
describe "desktop layout: scroll container and sticky table header" do
@describetag :ui
test "header and filters are outside scroll container; table is in scroll container with lg:max-h and lg:overflow-auto",
%{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
assert html =~ ~r/data-testid="members-table-scroll"/
# Scroll container has lg: overflow and max-height for desktop-only scroll
assert html =~ "lg:overflow-auto"
assert html =~ "lg:max-h-[calc(100vh-14rem)]"
# Header (page title) is present and not inside the scroll container (scroll container comes after filters)
assert html =~ "Members"
assert html =~ "id=\"members\""
end
test "table thead has sticky classes on desktop when sticky_header is set", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
# CoreComponents table with sticky_header adds lg:sticky lg:top-0 bg-base-100 z-10 to th
assert html =~ "lg:sticky"
assert html =~ "lg:top-0"
assert html =~ "bg-base-100"
end
test "members page does not nest a second overflow wrapper inside members-table-scroll", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
assert html =~ ~s(id="members-keyboard")
assert html =~ ~s(class="overflow-visible")
refute html =~ ~s(id="members-keyboard" class="overflow-x-auto")
refute html =~ ~s(id="members-keyboard" class="overflow-auto")
end
test "members table keeps checkbox column sticky while horizontally scrolling", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
Membership.create_member(
%{first_name: "Sticky", last_name: "Column", email: "sticky-column@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
# Contract: first column (select-all header + row checkbox cells) is sticky on the left
assert html =~ "left-0"
assert html =~ "sticky"
assert html =~ "z-30"
assert html =~ "z-20"
end
end
describe "drag-select vs click guard (§3.3)" do
@describetag :ui
test "members table carries the row-selection guard hook", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[phx-hook=RowSelectionGuard][id=members-table-guard]")
end
end
describe "translations" do
@describetag :ui
test "shows translated title and button text by locale", %{conn: conn} do
locales = [
{"de", "Mitglieder", "Speichern",
fn c -> Plug.Test.init_test_session(c, locale: "de") end},
{"en", "Members", "Save",
fn c ->
Gettext.put_locale(MvWeb.Gettext, "en")
c
end}
]
for {_locale, expected_title, expected_button, set_locale} <- locales do
base = conn_with_oidc_user(conn) |> set_locale.()
{:ok, _view, index_html} = live(base, "/members")
assert index_html =~ expected_title
base_form = conn_with_oidc_user(conn) |> set_locale.()
{:ok, _view, form_html} = live(base_form, "/members/new")
assert form_html =~ expected_button
end
end
test "shows translated flash message after creating a member in German", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, form_view, _html} = live(conn, "/members/new")
form_data = %{
"member[first_name]" => "Max",
"member[last_name]" => "Mustermann",
"member[email]" => "max@example.com"
}
# Submit form and follow the redirect to get the flash message
{:ok, index_view, _html} =
form_view
|> form("#member-form", form_data)
|> render_submit()
|> follow_redirect(conn, "/members")
assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt")
end
test "shows translated flash message after creating a member in English", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, form_view, _html} = live(conn, "/members/new")
form_data = %{
"member[first_name]" => "Max",
"member[last_name]" => "Mustermann",
"member[email]" => "max@example.com"
}
# Submit form and follow the redirect to get the flash message
{:ok, index_view, _html} =
form_view
|> form("#member-form", form_data)
|> render_submit()
|> follow_redirect(conn, "/members")
assert has_element?(index_view, "#flash-group", "Member created successfully")
end
end
describe "sorting integration" do
@describetag :ui
# The curated default columns no longer expose the individual name/address
# sort headers. The composite E-Mail column is offered even in compact mode
# (so email-only tests just add it via ?fields=), but the name/address
# constituents are only offered once their composite view setting is off —
# those tests additionally opt out via `non_compact/1`. Either way a `fields`
# param rides on every push_patch, hence the relaxed substring assertions.
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?fields=email")
# The component data test ids are built with the name of the field
# First click should sort ASC
view
|> element("[data-testid='email']")
|> render_click()
path_asc = assert_patch(view)
assert path_asc =~ "sort_field=email"
assert path_asc =~ "sort_order=asc"
# Second click toggles to DESC
view
|> element("[data-testid='email']")
|> render_click()
path_desc = assert_patch(view)
assert path_desc =~ "sort_field=email"
assert path_desc =~ "sort_order=desc"
end
test "clicking different column header resets order to ascending", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
# Click on a different column
view
|> element("[data-testid='first_name']")
|> render_click()
path = assert_patch(view)
assert path =~ "sort_field=first_name"
assert path =~ "sort_order=asc"
end
test "all sortable columns work correctly", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(
conn,
"/members?fields=first_name,email,street,house_number,postal_code,city,country,join_date"
)
# default ascending sorting with first name
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
sortable_fields = [
:email,
:street,
:house_number,
:postal_code,
:city,
:country,
:join_date
]
for field <- sortable_fields do
view
|> element("[data-testid='#{field}']")
|> render_click()
path = assert_patch(view)
assert path =~ "sort_field=#{field}"
assert path =~ "sort_order=asc"
end
end
test "sorting works with search query", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?fields=email&query=test")
view
|> element("[data-testid='email']")
|> render_click()
path = assert_patch(view)
assert path =~ "query=test"
assert path =~ "sort_field=email"
assert path =~ "sort_order=asc"
end
test "sorting maintains search query when toggling order", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc")
view
|> element("[data-testid='email']")
|> render_click()
path = assert_patch(view)
assert path =~ "query=test"
assert path =~ "sort_field=email"
assert path =~ "sort_order=desc"
end
end
describe "URL param handling" do
@describetag :ui
test "handle_params reads sort query and applies it", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc")
# Check that the sort state is correctly applied
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc")
# Should not crash and should show default first name order
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
end
test "handle_params preserves search query with sort params", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=desc")
# Both search and sort should be preserved
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
end
describe "search and sort integration" do
@describetag :ui
test "search maintains sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
# Sort state should be maintained
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "sort maintains search state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc")
# Perform sort
view
|> element("[data-testid='email']")
|> render_click()
# Search state should be maintained
path = assert_patch(view)
assert path =~ "query=test"
assert path =~ "sort_field=email"
assert path =~ "sort_order=desc"
end
end
@tag :ui
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
send(view.pid, {:search_changed, "Friedrich"})
# Rationale: this exercises the handle_info(:search_changed) callback in isolation.
# The search box value is owned by SearchBarComponent (assign_new), and scope is
# recomputed on handle_params rather than this handle_info, so the updated :query
# and :members assigns have no faithful rendered proxy here. The assigns are
# asserted on internal state to preserve the original coverage of the callback.
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.query == "Friedrich"
# Loaded members are held as a `%{id => member}` map (the restream lookup window).
assert is_map(assigns.members)
end
@tag :ui
test "member index does not render Edit or Delete actions", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
Membership.create_member(
%{first_name: "Test", last_name: "User", email: "test@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
refute has_element?(view, "[data-testid='member-edit']")
refute html =~ ~s(data-testid="member-delete")
end
@tag :ui
test "row click navigates to member show", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
{:ok, member} =
Membership.create_member(
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click a data cell (e.g. second column = first name) to trigger row navigation
view
|> element("#row-#{member.id} td:nth-child(2)")
|> render_click()
assert_redirect(view, ~p"/members/#{member}")
end
describe "table row highlight (hover and selected)" do
@describetag :ui
test "clickable rows with sticky first column use hover/focus background highlight", %{
conn: conn
} do
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
Membership.create_member(
%{first_name: "Hover", last_name: "Test", email: "hover@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Sticky-first-column tables: hover/focus fills live in CSS; wrapper is marked for tests.
assert html =~ ~s(data-sticky-first-col-rows="true")
refute html =~ "hover:ring-2"
end
test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
{:ok, member} =
Membership.create_member(
%{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?highlight=#{member.id}")
# Outline is only for checkbox selection; highlight param does not set data-selected
refute has_element?(view, "tr#row-#{member.id}[data-selected='true']")
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
# Opens the bulk-actions dropdown and returns the mailto link's BCC payload
# (everything after "mailto:?bcc="). This is the observable carrier of the
# recipient set / recipient_count, replacing direct socket-assign inspection.
defp mailto_bcc(view) do
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
href =
render(view)
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s([data-testid="bulk-actions-mailto"]))
|> LazyHTML.attribute("href")
|> List.first()
case href do
"mailto:?bcc=" <> bcc -> bcc
other -> other || ""
end
end
# Number of active add-filter chips currently rendered. The builder shows one
# chip per active filter value; this replaces the removed panel's active-count
# badge as the observable carrier of the filter count.
defp chip_count(view) do
render(view)
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s([data-testid="filter-chip"]))
|> Enum.count()
end
# Whether a boolean custom-field filter chip for `field` in the given state
# ("Yes"/"No") is rendered.
defp boolean_chip?(view, field, state) do
render(view) =~ "#{field.name}: #{state}"
end
# Whether the field picker offers a pickable option for the given custom field.
defp picker_has_field?(view, field_id) do
open_picker(view)
has_element?(view, "#member-filter-field-opt-#{field_id}")
end
describe "copy_emails feature" do
setup do
system_actor = SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Membership.create_member(
%{
first_name: "Max",
last_name: "Mustermann",
email: "max@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Erika",
last_name: "Musterfrau",
email: "erika@example.com"
},
actor: system_actor
)
{:ok, member3} =
Membership.create_member(
%{
first_name: "Hans",
last_name: "Müller-Lüdenscheidt",
email: "hans@example.com"
},
actor: system_actor
)
%{member1: member1, member2: member2, member3: member3}
end
test "copy_emails event formats selected members correctly", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id})
render_click(view, "select_member", %{"id" => member2.id})
# Trigger copy_emails event
click_copy_via_dropdown(view)
# Verify flash message shows correct count
assert render(view) =~ "2"
end
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")
# 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", %{})
# 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", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select all members via select_all
view |> element("[phx-click='select_all']") |> render_click()
# Trigger copy_emails event
click_copy_via_dropdown(view)
# Verify flash message shows correct count (3 members)
assert render(view) =~ "3"
end
test "copy_emails handles members with special characters in names", %{
conn: conn,
member3: member3
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select member with umlauts by sending the select_member event directly
render_click(view, "select_member", %{"id" => member3.id})
# Trigger copy_emails event - should not crash
click_copy_via_dropdown(view)
# Verify flash message shows success
assert render(view) =~ "1"
end
test "copy_emails handles case where selected member is deleted before copy", %{
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})
# Delete the member from the database
system_actor = SystemActor.get_system_actor()
Ash.destroy!(member1, actor: system_actor)
# Trigger copy_emails event directly - selection still contains the deleted ID
# but the member is no longer in @members list after reload
result = render_hook(view, "copy_emails", %{})
# Should show error since no visible members match selection
assert result =~ "No email" or result =~ "Keine E-Mail" or result =~ "0"
end
test "copy_emails formats emails as RFC 5322 compliant comma-separated list", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id})
html = render_click(view, "select_member", %{"id" => member2.id})
# Both selected members are observably reflected: their row checkboxes are
# checked and the scope badge shows the selection count ("2").
assert has_element?(view, ~s(input[role="checkbox"][name="#{member1.id}"][checked]))
assert has_element?(view, ~s(input[role="checkbox"][name="#{member2.id}"][checked]))
assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "2"
end
test "email format is 'First Last <email>' with comma separator", %{
conn: conn,
member1: _member1
} do
# Test the format_member_email function indirectly
# by checking the push_event payload structure
conn = conn_with_oidc_user(conn)
# Create a member with known data
system_actor = SystemActor.get_system_actor()
{:ok, test_member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Format",
email: "test.format@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/members")
# Select the test member by sending the select_member event directly
render_click(view, "select_member", %{"id" => test_member.id})
# The format should be "Test Format <test.format@example.com>"
# We verify this by checking the flash shows 1 email was copied
click_copy_via_dropdown(view)
assert render(view) =~ "1"
end
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")
# 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 "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")
render_click(view, "select_member", %{"id" => member1.id})
# 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", %{
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})
# Click copy button
click_copy_via_dropdown(view)
# Flash message should appear
assert has_element?(view, "#flash-group")
end
end
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()
{:ok, m1} =
Membership.create_member(
%{first_name: "Export", last_name: "One", email: "export1@example.com"},
actor: system_actor
)
%{member1: m1}
end
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")
# 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 "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 =~ "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 carries its own trailing chevron.
assert has_element?(view, "[data-testid='bulk-actions-button'] .hero-chevron-down")
end
test "dropdown opens and closes on click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially closed
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
# Click to open
view
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
# Menu should be visible
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
# Click to close
view
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
# Menu should be hidden
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
end
test "dropdown has click-away and ESC handlers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
html = render(view)
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")
# Check that ESC handler is present
assert html =~ ~s(phx-window-keydown="close_dropdown")
assert html =~ ~s(phx-key="Escape")
end
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="bulk-actions-button"]))
|> render_click()
html = render(view)
# Check CSV link
assert html =~ ~s(data-testid="export-csv-link")
assert html =~ "/members/export.csv"
assert html =~ ~s(name="payload")
assert html =~ ~s(type="hidden")
assert html =~ ~s(name="_csrf_token")
# Check PDF link
assert html =~ ~s(data-testid="export-pdf-link")
assert html =~ "/members/export.pdf"
assert html =~ ~s(name="payload")
assert html =~ ~s(type="hidden")
assert html =~ ~s(name="_csrf_token")
# Both forms should have the same payload
csv_form_payload = extract_payload_from_form(html, "/members/export.csv")
pdf_form_payload = extract_payload_from_form(html, "/members/export.pdf")
assert csv_form_payload == pdf_form_payload
assert csv_form_payload != nil
end
test "dropdown has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
html = render(view)
# Button should have aria-haspopup="menu"
assert html =~ ~s(aria-haspopup="menu")
# 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="bulk-actions-dropdown-menu")
# Open dropdown
view
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
html = render(view)
# Button should have aria-expanded="true" when open
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
end
# Helper to extract payload value from form HTML
defp extract_payload_from_form(html, action_path) do
case Regex.run(
~r/<form[^>]*action="#{Regex.escape(action_path)}"[^>]*>.*?<input[^>]*name="payload"[^>]*value="([^"]+)"/s,
html
) do
[_, payload] -> payload
_ -> nil
end
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")
# :all scope renders the muted "all" badge.
assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "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")
# An active search narrows the list, so the scope badge reads "filtered".
assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "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?pay_filter=fully_paid")
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")
html = render_click(view, "select_member", %{"id" => member1.id})
# A selection switches the badge to the emphasized (primary) variant whose
# label is the selected count ("1"), which is the observable proxy for scope == :selection.
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "1"
assert badge |> LazyHTML.attribute("class") |> List.first() =~ "badge-primary"
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")
# The mailto link's BCC is the observable carrier of recipient_count/mailto_bcc.
bcc = mailto_bcc(view)
# Both seeded members have an email, so the no-selection scope covers both.
assert bcc =~ "scope1%40example.com"
assert 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})
bcc = mailto_bcc(view)
assert bcc =~ "scope1%40example.com"
refute bcc =~ "scope2%40example.com"
end
test "selecting a member does not read the DB for mailto recipients (deferred to open)",
%{conn: conn, member1: member1} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# A checkbox toggle is a non-search interaction; it must not issue a member
# read just to precompute a mailto link the user may never open. The
# recipient list is fetched lazily when the bulk-actions dropdown opens.
member_reads =
capture_member_select_queries(fn ->
render_click(view, "select_member", %{"id" => member1.id})
end)
assert member_reads == [],
"select_member must not read members for the mailto recipients; got: #{inspect(member_reads)}"
# Opening the dropdown still surfaces the correct recipients for the selection.
bcc = mailto_bcc(view)
assert bcc =~ "scope1%40example.com"
refute bcc =~ "scope2%40example.com"
end
# Captures every SELECT against the members table emitted while `fun` runs.
defp capture_member_select_queries(fun) do
test_pid = self()
handler_id = "test-member-select-#{System.unique_integer([:positive])}"
:telemetry.attach(
handler_id,
[:mv, :repo, :query],
fn _event, _measurements, metadata, _config ->
sql = metadata[:query] || ""
if String.contains?(sql, "SELECT") and String.contains?(sql, "\"members\"") do
send(test_pid, {:member_query, sql})
end
end,
nil
)
try do
fun.()
after
:telemetry.detach(handler_id)
end
collect_member_queries([])
end
defp collect_member_queries(acc) do
receive do
{:member_query, sql} -> collect_member_queries([sql | acc])
after
0 -> Enum.reverse(acc)
end
end
end
describe "boolean custom field filters" do
# Helper to create a boolean custom field (uses system actor for authorization)
defp create_boolean_custom_field(attrs \\ %{}) do
system_actor = SystemActor.get_system_actor()
default_attrs = %{
name: "test_boolean_#{System.unique_integer([:positive])}",
value_type: :boolean
}
attrs = Map.merge(default_attrs, attrs)
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: system_actor)
end
# Helper to create a non-boolean custom field (uses system actor for authorization)
defp create_string_custom_field(attrs \\ %{}) do
system_actor = SystemActor.get_system_actor()
default_attrs = %{
name: "test_string_#{System.unique_integer([:positive])}",
value_type: :string
}
attrs = Map.merge(default_attrs, attrs)
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: system_actor)
end
@tag :ui
test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Rationale: with no boolean fields and no active filter there is no rendered
# filter control or active-count badge to observe, so the empty initial filter
# map is asserted on internal state directly.
state = :sys.get_state(view.pid)
assert state.socket.assigns.boolean_custom_field_filters == %{}
end
@tag :ui
test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Rationale: the absence of boolean fields means the filter dropdown renders
# no boolean fieldsets; "empty list" has no positive rendered signal, so it is
# asserted on internal state directly.
state = :sys.get_state(view.pid)
assert state.socket.assigns.boolean_custom_fields == []
end
test "mount loads and filters boolean custom fields correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create boolean and non-boolean custom fields
boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
boolean_field2 = create_boolean_custom_field(%{name: "Newsletter Subscription"})
string_field = create_string_custom_field(%{name: "Phone Number"})
{:ok, view, _html} = live(conn, "/members")
open_picker(view)
# Only the boolean fields are offered as pickable filter fields; the string
# field is not filterable on the overview.
assert has_element?(view, "#member-filter-field-opt-#{boolean_field1.id}")
assert has_element?(view, "#member-filter-field-opt-#{boolean_field2.id}")
refute has_element?(view, "#member-filter-field-opt-#{string_field.id}")
end
test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create boolean fields with specific names to test sorting
field_z = create_boolean_custom_field(%{name: "Zebra Field"})
field_a = create_boolean_custom_field(%{name: "Alpha Field"})
field_m = create_boolean_custom_field(%{name: "Middle Field"})
{:ok, view, _html} = live(conn, "/members")
open_picker(view)
# The picker lists the boolean custom fields in name-ascending order.
rendered_order =
render(view)
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s(#member-filter-field-listbox [role="option"]))
|> LazyHTML.attribute("id")
|> Enum.map(&String.replace_prefix(&1, "member-filter-field-opt-", ""))
|> Enum.filter(&(&1 in [field_a.id, field_m.id, field_z.id]))
assert rendered_order == [field_a.id, field_m.id, field_z.id]
end
test "handle_params parses bf_<id> values correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Test true value: a "Yes" chip for the field (the boolean true, not "true").
{:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true")
assert boolean_chip?(view1, boolean_field, "Yes")
refute boolean_chip?(view1, boolean_field, "No")
# Test false value: a "No" chip for the field.
{:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false")
assert boolean_chip?(view2, boolean_field, "No")
end
test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
conn = conn_with_oidc_user(conn)
fake_id = Ecto.UUID.generate()
{:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true")
# A non-existent field is not offered in the picker and produces no chip.
refute picker_has_field?(view, fake_id)
assert chip_count(view) == 0
end
test "handle_params ignores non-boolean custom fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
string_field = create_string_custom_field()
{:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true")
# A string field is never a filterable field, and no filter becomes active.
refute picker_has_field?(view, string_field.id)
assert chip_count(view) == 0
end
test "handle_params ignores invalid filter values", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Test various invalid values
invalid_values = ["1", "0", "yes", "no", "True", "False", "", "invalid", "null"]
for invalid_value <- invalid_values do
{:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
# An invalid value applies no filter, so no chip is rendered.
assert chip_count(view) == 0,
"Invalid value '#{invalid_value}' should leave the filter unset"
end
end
test "handle_params handles multiple boolean filters simultaneously", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field1 = create_boolean_custom_field()
boolean_field2 = create_boolean_custom_field()
{:ok, view, _html} =
live(
conn,
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
# Both filters are reflected as chips: field1 at "Yes", field2 at "No", for
# a total of two active-filter chips.
assert boolean_chip?(view, boolean_field1, "Yes")
assert boolean_chip?(view, boolean_field2, "No")
assert chip_count(view) == 2
end
test "build_query_params includes active boolean filters and excludes nil filters", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
boolean_field1 = create_boolean_custom_field()
boolean_field2 = create_boolean_custom_field()
# Test with active filters
{:ok, view1, _html} =
live(
conn,
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
# Trigger a search to see if filters are preserved in URL
view1
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
# Check that the patch includes boolean filters
path1 = assert_patch(view1)
assert path1 =~ "bf_#{boolean_field1.id}=true"
assert path1 =~ "bf_#{boolean_field2.id}=false"
# Test without filters (nil filters should not appear in URL)
{:ok, view2, _html} = live(conn, "/members")
# Trigger a search
view2
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
# Check that no bf_ params are in URL
path2 = assert_patch(view2)
refute path2 =~ "bf_"
end
test "boolean filters are preserved during navigation actions", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
{:ok, view, _html} =
live(conn, "/members?fields=email&bf_#{boolean_field.id}=true")
# Test sort toggle preserves filter
view
|> element("[data-testid='email']")
|> render_click()
path1 = assert_patch(view)
assert path1 =~ "bf_#{boolean_field.id}=true"
# Test search change preserves filter
view
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
path2 = assert_patch(view)
assert path2 =~ "bf_#{boolean_field.id}=true"
end
test "boolean filters work together with the payment filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
{:ok, view, _html} =
live(
conn,
"/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true"
)
# Both filters are reflected as chips: the boolean field at "Yes" and the
# period-scoped payment filter as "All paid".
assert boolean_chip?(view, boolean_field, "Yes")
assert render(view) =~ "All paid"
# Both should be in URL when triggering search
view
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
path = assert_patch(view)
assert path =~ "pay_filter=fully_paid"
assert path =~ "bf_#{boolean_field.id}=true"
end
test "handle_params removes filter when custom field is deleted", %{conn: conn} do
conn = conn_with_oidc_user(conn)
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
# Set up filter via URL
{:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
assert boolean_chip?(view, boolean_field, "Yes")
# Delete the custom field (requires actor with destroy permission)
Ash.destroy!(boolean_field, actor: system_actor)
# Navigate again - filter should be removed since custom field no longer exists
{:ok, view2, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
# The deleted field is not offered in the picker and no filter is active.
refute picker_has_field?(view2, boolean_field.id)
assert chip_count(view2) == 0
end
test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# URL-encode the custom field ID (though UUIDs shouldn't need encoding normally)
encoded_id = URI.encode(boolean_field.id)
{:ok, view, _html} =
live(conn, "/members?bf_#{encoded_id}=true")
# Phoenix decodes the param, so the filter applies under the original ID:
# a "Yes" chip is rendered for the field.
assert boolean_chip?(view, boolean_field, "Yes")
end
test "handle_params ignores malformed prefix (bf_bf_<uuid>)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Try to send parameter with double prefix
{:ok, view, _html} =
live(conn, "/members?bf_bf_#{boolean_field.id}=true")
# The double-prefixed param is not a valid filter: no chip is active.
assert chip_count(view) == 0
end
test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create 60 boolean custom fields (more than the limit)
boolean_fields = Enum.map(1..60, fn _ -> create_boolean_custom_field() end)
# Build URL with all 60 filters
filter_params =
Enum.map_join(boolean_fields, "&", fn cf -> "bf_#{cf.id}=true" end)
{:ok, view, _html} = live(conn, "/members?#{filter_params}")
# The active-filter chips are the observable carrier of the filter count.
# With 60 requested filters, the DoS cap clamps it to exactly 50.
assert chip_count(view) == 50
end
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Create a fake ID that's way too long (UUIDs are max 36 chars)
fake_long_id = String.duplicate("a", 100)
{:ok, view, _html} =
live(conn, "/members?bf_#{fake_long_id}=true")
# The over-long ID is rejected: no filter is active. (`boolean_field` is
# created only to prove a real field is unaffected.)
assert boolean_field.id
assert chip_count(view) == 0
end
# Helper to create a member with a boolean custom field value
defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do
attrs =
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
|> Map.merge(member_attrs)
{:ok, member} = Membership.create_member(attrs, actor: actor)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "boolean", "_union_value" => value}
})
|> Ash.create(actor: actor)
# Reload member with custom field values
member
|> Ash.load!(:custom_field_values, actor: actor)
end
# Tests for apply_boolean_custom_field_filters/2
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
%{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
{:ok, member_without_value} =
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
member_without_value =
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => true}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
assert length(result) == 1
assert List.first(result).id == member_with_true.id
refute Enum.any?(result, &(&1.id == member_with_false.id))
refute Enum.any?(result, &(&1.id == member_without_value.id))
end
test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
%{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
{:ok, member_without_value} =
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
member_without_value =
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => false}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
assert length(result) == 1
assert List.first(result).id == member_with_false.id
refute Enum.any?(result, &(&1.id == member_with_true.id))
refute Enum.any?(result, &(&1.id == member_without_value.id))
end
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member1 =
create_member_with_boolean_value(
%{first_name: "Member1"},
boolean_field,
true,
system_actor
)
member2 =
create_member_with_boolean_value(
%{first_name: "Member2"},
boolean_field,
false,
system_actor
)
members = [member1, member2]
filters = %{}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
assert length(result) == 2
assert Enum.all?([member1.id, member2.id], fn id ->
Enum.any?(result, &(&1.id == id))
end)
end
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
# Member with both fields = true
{:ok, member_both_true} =
Membership.create_member(
%{
first_name: "BothTrue",
last_name: "Member",
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_both_true.id,
custom_field_id: boolean_field1.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_both_true.id,
custom_field_id: boolean_field2.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
member_both_true = member_both_true |> Ash.load!(:custom_field_values, actor: system_actor)
# Member with field1 = true, field2 = false
{:ok, member_mixed} =
Membership.create_member(
%{
first_name: "Mixed",
last_name: "Member",
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_mixed.id,
custom_field_id: boolean_field1.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
{:ok, _cfv4} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_mixed.id,
custom_field_id: boolean_field2.id,
value: %{"_union_type" => "boolean", "_union_value" => false}
})
|> Ash.create(actor: system_actor)
member_mixed = member_mixed |> Ash.load!(:custom_field_values, actor: system_actor)
members = [member_both_true, member_mixed]
filters = %{
to_string(boolean_field1.id) => true,
to_string(boolean_field2.id) => true
}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
# Only member_both_true should match (both fields = true)
assert length(result) == 1
assert List.first(result).id == member_both_true.id
end
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
fake_id = Ecto.UUID.generate()
member =
create_member_with_boolean_value(
%{first_name: "Member"},
boolean_field,
true,
system_actor
)
members = [member]
filters = %{fake_id => true}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
# Should return all members since fake_id doesn't match any custom field
assert length(result) == 1
end
# Integration tests for boolean custom field filters in load_members
test "boolean filter integration filters members by boolean custom field value via URL parameter",
%{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
_member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
_member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
{:ok, _member_without_value} =
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Test true filter
{:ok, _view, html_true} =
live(conn, "/members?bf_#{boolean_field.id}=true")
assert html_true =~ "TrueMember"
refute html_true =~ "FalseMember"
refute html_true =~ "NoValue"
# Test false filter
{:ok, _view, html_false} =
live(conn, "/members?bf_#{boolean_field.id}=false")
assert html_false =~ "FalseMember"
refute html_false =~ "TrueMember"
refute html_false =~ "NoValue"
end
test "boolean filter integration works together with the payment filter", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with true boolean value and paid status
{:ok, member_paid_true} =
Membership.create_member(
%{
first_name: "PaidTrue",
last_name: "Member",
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: system_actor
)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_paid_true.id,
custom_field_id: boolean_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
create_cycle(
member_paid_true,
fee_type,
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
system_actor
)
# Member with true boolean value but unpaid status
{:ok, member_unpaid_true} =
Membership.create_member(
%{
first_name: "UnpaidTrue",
last_name: "Member",
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: system_actor
)
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_unpaid_true.id,
custom_field_id: boolean_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
create_cycle(
member_unpaid_true,
fee_type,
%{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
system_actor
)
# Test both filters together: fully-paid (0 unpaid cycles in the period)
# plus the boolean field at "Yes".
{:ok, _view, html} =
live(conn, "/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true")
# Only member_paid_true has zero unpaid cycles and the boolean value true.
assert html =~ "PaidTrue"
refute html =~ "UnpaidTrue"
end
test "boolean filter integration works together with search query", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
_member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
_member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
# Test search + boolean filter
{:ok, _view, html} =
live(conn, "/members?query=TrueMember&bf_#{boolean_field.id}=true")
# Only member_with_true should match both search and filter
assert html =~ "TrueMember"
refute html =~ "FalseMember"
end
test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
# Create boolean field with show_in_overview: false
boolean_field = create_boolean_custom_field(%{show_in_overview: false})
_member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
_member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
{:ok, _member_without_value} =
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Test that filter works even though field is not visible in overview
{:ok, _view, html_true} =
live(conn, "/members?bf_#{boolean_field.id}=true")
assert html_true =~ "TrueMember"
refute html_true =~ "FalseMember"
refute html_true =~ "NoValue"
# Test false filter
{:ok, _view, html_false} =
live(conn, "/members?bf_#{boolean_field.id}=false")
assert html_false =~ "FalseMember"
refute html_false =~ "TrueMember"
refute html_false =~ "NoValue"
end
@tag :ui
test "boolean custom field appears in the field picker after being added", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with no boolean custom fields
{:ok, view, _html} = live(conn, "/members")
open_picker(view)
# With no custom fields, the picker renders no "Custom fields" group.
refute has_element?(view, "[data-testid='field-group-header']", "Custom fields")
# Create a new boolean custom field
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
# Navigate again - the new field should appear in the picker.
{:ok, view2, _html} = live(conn, "/members")
open_picker(view2)
assert has_element?(view2, "#member-filter-field-opt-#{new_boolean_field.id}")
assert render(view2) =~ "Newly Added Field"
end
@tag :slow
test "boolean filter performance with 150 members", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Create 150 members - 75 with true, 75 with false
members_with_true =
Enum.map(1..75, fn i ->
create_member_with_boolean_value(
%{
first_name: "TrueMember#{i}",
email: "truemember#{i}@example.com"
},
boolean_field,
true,
system_actor
)
end)
members_with_false =
Enum.map(1..75, fn i ->
create_member_with_boolean_value(
%{
first_name: "FalseMember#{i}",
email: "falsemember#{i}@example.com"
},
boolean_field,
false,
system_actor
)
end)
# Verify all members were created
assert length(members_with_true) == 75
assert length(members_with_false) == 75
# Test filter performance - should complete in reasonable time (< 1 second)
start_time = System.monotonic_time(:millisecond)
{:ok, _view, html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time
# Should complete in less than 1 second (1000ms)
assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
# The overview now keyset-paginates: mount loads only the first page, not
# the whole filtered set (§1.7). The filter still resolves DB-side, so the
# loaded page contains only matching (true) members and never a non-matching
# (false) one.
assert html =~ "TrueMember"
Enum.each(1..75, fn i ->
refute html =~ "FalseMember#{i}"
end)
end
end
end