feat(overview): replace filter panel with add-filter builder and aging column

Swaps the unbounded vertical filter panel for a Polaris-style add-filter
builder (searchable grouped field picker, type-aware value control, applied
chips) and colour-codes the fees column with each member's period-scoped
unpaid-cycle count.
This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent 7d1a71b1fd
commit c547ca19af
35 changed files with 2817 additions and 3149 deletions

View file

@ -1,660 +0,0 @@
defmodule MvWeb.Components.MemberFilterComponentTest do
@moduledoc """
Unit tests for the MemberFilterComponent.
Tests cover:
- Rendering Payment Filter and Boolean Custom Fields
- Boolean filter selection and event emission
- Button label and badge logic
- Filtering to show only boolean custom fields
"""
# Kept async: false. The deferrable-FK migration removed the concurrent
# create_member deadlock, but this file additionally showed an async-isolation
# failure under load (filtered members from a parallel test leaking in), so it
# is not trivially async-safe; resolving that is a separate follow-up.
use MvWeb.ConnCase, async: false
use Gettext, backend: MvWeb.Gettext
import Phoenix.LiveViewTest
alias Mv.Membership.CustomField
# Helper to create a boolean custom field (uses system_actor - only admin can create)
defp create_boolean_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.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 - only admin can create)
defp create_string_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.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
describe "rendering" do
test "trigger carries a trailing chevron affordance", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Mirror the shared dropdown affordance: a trailing chevron inside the
# bespoke filter trigger button.
chevron =
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down))
assert Enum.count(chevron) == 1
end
test "renders boolean custom fields when present", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Active Member"})
{:ok, view, _html} = live(conn, "/members")
# Should show the boolean custom field name in the dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
assert html =~ boolean_field.name
end
test "renders payment and custom fields groups when boolean fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
_boolean_field = create_boolean_custom_field()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Should have both "Payments" and "Custom Fields" group labels
assert html =~ gettext("Payments") || html =~ "Payment"
assert html =~ gettext("Individual datafields")
end
test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create a non-boolean field to ensure it's not shown
_string_field = create_string_custom_field()
{:ok, view, _html} = live(conn, "/members")
# Component should exist with correct ID
assert has_element?(view, "#member-filter")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Should show payment filter options (check both English and translated)
assert html =~ "All" || html =~ gettext("All")
assert html =~ "Paid" || html =~ gettext("Paid")
assert html =~ "Unpaid" || html =~ gettext("Unpaid")
# Should not show any boolean field names (since none exist)
# We can't easily check this without knowing field names, but the structure should be correct
end
end
describe "boolean filter selection" do
test "selecting boolean filter sends correct event", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
# Select "True" option for the boolean field using radio input
# Radio inputs trigger phx-change on the form, so we use render_change on the form
view
|> form("#member-filter form", %{
"custom_boolean" => %{to_string(boolean_field.id) => "true"}
})
|> render_change()
# The event should be sent to the parent LiveView
# We verify this by checking that the URL is updated
assert_patch(view)
end
test "payment filter still works after component extension", %{conn: conn} do
conn = conn_with_oidc_user(conn)
_boolean_field = create_boolean_custom_field()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option using radio input
# Radio inputs trigger phx-change on the form, so we use render_change on the form
view
|> form("#member-filter form", %{"payment_filter" => "paid"})
|> render_change()
# URL should be updated with cycle_status_filter=paid
path = assert_patch(view)
assert path =~ "cycle_status_filter=paid"
end
end
describe "button label" do
test "shows active boolean filter names in button label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
boolean_field2 = create_boolean_custom_field(%{name: "Newsletter"})
# Set filters via URL
{:ok, view, _html} =
live(
conn,
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
# Component should exist
assert has_element?(view, "#member-filter")
# Button label should contain the custom field names
# The exact format depends on implementation, but should show active filters
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
assert button_html =~ boolean_field1.name || button_html =~ boolean_field2.name
end
test "truncates long custom field names in button label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create field with very long name (>30 characters)
long_name = String.duplicate("A", 50)
boolean_field = create_boolean_custom_field(%{name: long_name})
# Set filter via URL
{:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
# Component should exist
assert has_element?(view, "#member-filter")
# Get button label text
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
# Button label should be truncated - full name should NOT appear in button
# (it may appear in dropdown when opened, but not in the button label itself)
# Check that button doesn't contain the full 50-character name
refute button_html =~ long_name
# Button should still contain some text (truncated version or indicator)
assert String.length(button_html) > 0
end
test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=all")
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
# The idle label must not appear; some non-idle label is shown. This is
# the same observable contract as the other filter categories — the
# button visually communicates "a filter is active". The `btn-active`
# CSS class is set by the parent class= attribute but the `<.button>`
# core component currently composes its own class string and drops the
# caller-supplied one — that is a pre-existing component constraint, not
# specific to date filters.
refute button_html =~ gettext("Apply filters")
end
test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15")
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
refute button_html =~ gettext("Apply filters")
end
test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
{:ok, view, _html} =
live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true")
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
# With two distinct filter categories active, the label switches to the
# pluralized "N filters active" form. Without counting date filters as
# a category, this would show only "1 filter active" or the boolean
# field name.
assert button_html =~ "2"
assert button_html =~ gettext("filters active")
end
end
describe "badge" do
test "shows total count of active boolean filters in badge", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
# Set two filters via URL
{:ok, view, _html} =
live(
conn,
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
# Component should exist
assert has_element?(view, "#member-filter")
# Badge should be visible when boolean filters are active
assert has_element?(view, "#member-filter .badge")
# Badge should show count of active boolean filters (2 in this case)
badge_html =
view
|> element("#member-filter .badge")
|> render()
assert badge_html =~ "2"
end
end
describe "filtering" do
test "only boolean custom fields are displayed, not string or integer fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Boolean Field"})
_string_field = create_string_custom_field(%{name: "String Field"})
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
# Should show boolean field in the dropdown panel
# Extract only the dropdown panel HTML to check
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
# Should show boolean field in dropdown
assert dropdown_html =~ boolean_field.name
# Should not show string field name in the filter dropdown
# (String fields should not appear in boolean filter section)
refute dropdown_html =~ "String Field"
end
test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ gettext("Dates")
assert dropdown_html =~ gettext("Join date")
assert dropdown_html =~ gettext("Exit date")
# Exit-date segmented control modes.
assert dropdown_html =~ gettext("Active only")
assert dropdown_html =~ gettext("Inactive only")
# Built-in date inputs (always present for join_date and the ed_mode selector).
assert dropdown_html =~ ~s(name="jd_from")
assert dropdown_html =~ ~s(name="jd_to")
assert dropdown_html =~ ~s(name="ed_mode")
end
test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ ~s(name="ed_from")
assert dropdown_html =~ ~s(name="ed_to")
end
test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)",
%{conn: conn} do
# DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's
# `<.input>` wrapper rather than emitting raw `<input>` tags carrying
# DaisyUI component classes (e.g. `input input-sm input-bordered`)
# directly in HEEX. `<.input>` is the project's single source of truth
# for input styling; bypassing it splits styling across many call sites.
#
# The recognizable structural fingerprint of `<.input>` is a wrapping
# `<fieldset class="mb-2 fieldset">` `<label>` chain immediately
# preceding the `<input>`. The raw inline form has no such wrapper —
# the input sits directly inside a sibling `<label>`/`<input>` flex row.
# We assert that fingerprint on each of the date inputs.
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
for name <- ["jd_from", "jd_to", "ed_from", "ed_to"] do
# Match `<fieldset class="mb-2 fieldset">` followed (within a short
# window of HTML) by an `<input>` carrying the expected `name`. The
# window prevents the regex from spanning unrelated `mb-2` /
# `fieldset` occurrences scattered across the dropdown. The wrapper
# is the canonical fingerprint of `MvWeb.CoreComponents.input/1`
# (see `lib/mv_web/components/core_components.ex` — every input
# branch starts with `<fieldset class="mb-2 fieldset">`).
assert Regex.match?(
~r/<fieldset[^>]*class="mb-2 fieldset"[^>]*>\s*<label[^>]*>(?:\s*<span[^>]*>.*?<\/span>)?\s*<input[^>]*name="#{name}"/s,
dropdown_html
),
"expected date input #{name} to be wrapped by MvWeb.CoreComponents.input " <>
"(class=\"mb-2 fieldset\" fieldset wrapper), not a raw inline " <>
"<input type=\"date\"> element"
end
end
test "exit_date defaults to :active_only in the rendered radio", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~
~r/name="ed_mode"[^>]*value="active_only"[^>]*checked|checked[^>]*name="ed_mode"[^>]*value="active_only"/
end
test "Custom date fields section is non-scrollable with 5 or fewer fields (§3.4)", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
for i <- 1..5 do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create!(actor: system_actor)
end
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
section_html = custom_date_section_html(view)
# With ≤ 5 fields the section must NOT carry the scrollable wrapper.
refute section_html =~ "max-h-60"
refute section_html =~ "overflow-y-auto"
end
test "Custom date fields section becomes scrollable with more than 5 fields (§3.4)", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
for i <- 1..6 do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create!(actor: system_actor)
end
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
section_html = custom_date_section_html(view)
# With more than 5 fields the section is wrapped in the scrollable container.
assert section_html =~ "max-h-60"
assert section_html =~ "overflow-y-auto"
end
# Extract the HTML of the rendered "Custom date fields" section. Returns
# "" if the section is not rendered. Used by the threshold tests to avoid
# picking up scrollable classes from sibling sections.
defp custom_date_section_html(view) do
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
label = gettext("Custom date fields")
case String.split(dropdown_html, label, parts: 2) do
[_before, after_label] ->
# Up to the next group header label, or the footer.
after_label
|> String.split(["text-xs font-semibold opacity-70 mb-2 uppercase"], parts: 2)
|> List.first()
_ ->
""
end
end
test "Custom date fields section appears only when date custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view_no_field, _} = live(conn, "/members")
view_no_field
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view_no_field
|> element("#member-filter div[role='dialog']")
|> render()
refute dropdown_html =~ gettext("Custom date fields")
# Add a date-typed custom field and re-load: the section appears.
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Birthday-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create(actor: system_actor)
{:ok, view, _} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ gettext("Custom date fields")
assert dropdown_html =~ field.name
assert dropdown_html =~ "cdf_#{field.id}_from"
assert dropdown_html =~ "cdf_#{field.id}_to"
end
test "update_filters event dispatches a date_filters_changed patch with the new jd_from", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
view
|> form("#member-filter form", %{
"jd_from" => "2024-01-15",
"payment_filter" => "all"
})
|> render_change()
# Parent LiveView receives {:date_filters_changed, ...} and patches the URL.
path = assert_patch(view)
assert path =~ "jd_from=2024-01-15"
end
test "selecting ed_mode=all updates the URL and reveals former members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
today = Date.utc_today()
unique_name = "Zarquon-#{System.unique_integer([:positive])}"
{:ok, former} =
Mv.Membership.create_member(
%{
first_name: unique_name,
last_name: "Exited",
email: "ex-#{System.unique_integer([:positive])}@example.com",
join_date: Date.add(today, -1000),
exit_date: Date.add(today, -30)
},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Fresh load hides the former member.
refute html =~ former.first_name
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
view
|> form("#member-filter form", %{
"ed_mode" => "all",
"payment_filter" => "all"
})
|> render_change()
path = assert_patch(view)
assert path =~ "ed_mode=all"
# Now Eve appears in the rendered list.
assert render(view) =~ former.first_name
end
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create 15 boolean custom fields (more than typical, should trigger scrollbar)
boolean_fields =
Enum.map(1..15, fn i ->
create_boolean_custom_field(%{name: "Field #{i}"})
end)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
# Extract dropdown panel HTML
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
# Should have scrollbar classes: max-h-60 overflow-y-auto pr-2
# Check for the scrollable container (the div with max-h-60 and overflow-y-auto)
assert dropdown_html =~ "max-h-60"
assert dropdown_html =~ "overflow-y-auto"
# Verify all fields are present in the dropdown
Enum.each(boolean_fields, fn field ->
assert dropdown_html =~ field.name
end)
end
end
end

View file

@ -56,8 +56,11 @@ defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
assert payload.query == "ada"
assert payload.sort_field == "last_name"
assert payload.sort_order == "desc"
# Decoupled from the removed overview cycle toggle (§3.3): the export basis
# is always the current cycle and no cycle-status row filter is applied,
# regardless of any legacy cycle assigns.
assert payload.show_current_cycle == true
assert payload.cycle_status_filter == "unpaid"
assert payload.cycle_status_filter == nil
assert payload.boolean_filters == %{"cf-x" => true}
end
@ -79,7 +82,7 @@ defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
assert payload.query == nil
assert payload.sort_field == nil
assert payload.sort_order == nil
assert payload.show_current_cycle == false
assert payload.show_current_cycle == true
assert payload.cycle_status_filter == nil
assert payload.boolean_filters == %{}
end

View file

@ -1,78 +0,0 @@
defmodule MvWeb.MemberLive.Index.OverviewQueryCycleStatusTest do
@moduledoc """
§1.13 the paid/unpaid cycle-status filter, under the current or last-completed
cycle view, returns the same members as the previous in-memory classifier,
computed DB-side via the `cycle_end`-backed aggregates.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
@today ~D[2024-07-15]
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
ft = create_fee_type(%{interval: :yearly}, actor)
# current 2024 paid, last-completed 2023 unpaid
paid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
create_cycle(paid_now, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor)
create_cycle(paid_now, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
# current 2024 unpaid, last-completed 2023 paid
unpaid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2023-01-01], status: :paid}, actor)
# no cycles
_no_cycles = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
%{actor: actor, paid_now: paid_now, unpaid_now: unpaid_now}
end
defp db_ids(status, show_current, actor) do
OverviewQuery.build(%{
cycle_status_filter: status,
show_current_cycle: show_current,
today: @today
})
|> Ash.read!(actor: actor)
|> MapSet.new(& &1.id)
end
defp oracle_ids(status, show_current, actor) do
Member
|> Ash.Query.load(membership_fee_cycles: [:membership_fee_type])
|> Ash.Query.load(:membership_fee_type)
|> Ash.read!(actor: actor)
|> Enum.filter(fn m ->
MembershipFeeStatus.get_cycle_status_for_member(m, show_current, @today) == status
end)
|> MapSet.new(& &1.id)
end
# {status, show_current, member-key expected in the result}
@cases [
{:paid, true, :paid_now},
{:unpaid, true, :unpaid_now},
{:paid, false, :unpaid_now},
{:unpaid, false, :paid_now}
]
for {status, show_current, expected_key} <- @cases do
test "#{status} under #{if show_current, do: "current", else: "last"} cycle matches oracle",
%{actor: actor} = ctx do
status = unquote(status)
show_current = unquote(show_current)
expected = Map.fetch!(ctx, unquote(expected_key))
db = db_ids(status, show_current, actor)
assert db == oracle_ids(status, show_current, actor)
assert expected.id in db
end
end
end

View file

@ -0,0 +1,88 @@
defmodule MvWeb.MemberLive.IndexAddFilterFlowTest do
@moduledoc """
§1.1§1.3, §1.11, §3.1 the Polaris-style add-filter flow of the
`AddFilterBuilderComponent`: field pick focused value control
commit-on-select / drop-on-dismiss, with no detail "Add" button, and the
removal of the old vertical filter panel.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Group
setup %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member} =
Mv.Membership.create_member(
%{first_name: "Flow", last_name: "Member", email: "flow@example.com"},
actor: actor
)
{:ok, group} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board"})
|> Ash.create(actor: actor)
{:ok, _mg} =
Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor)
%{conn: conn_with_oidc_user(conn), member: member, group: group}
end
test "the add-filter builder replaces the old vertical filter panel (§1.11)", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='add-filter-trigger']")
# No residue of the removed MemberFilterComponent.
refute html =~ ~s(data-testid="member-filter-form")
refute html =~ "member-filter-dropdown"
refute has_element?(view, ~s(button[aria-label="Filter members"]))
end
test "picking a field closes the picker and opens a focused value control, no Add button (§1.1)",
%{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_picker(view)
assert has_element?(view, "#member-filter-field-listbox[role='listbox']")
view |> element("#member-filter-field-opt-group") |> render_click()
# Picker closes, the group value control opens; there is no detail "Add" button.
refute has_element?(view, "#member-filter-field-listbox")
assert has_element?(view, "[data-testid='value-control-group']")
refute has_element?(view, "[data-testid='pending-filter'] button[type='submit']")
end
test "a valid value selection commits a chip and reloads (§1.2)", %{conn: conn, group: group} do
{:ok, view, _html} = live(conn, ~p"/members")
path = apply_group_filter(view, group.id, "in")
assert path =~ "group_#{group.id}=in"
assert has_element?(view, "[data-testid='filter-chip']")
end
test "dismissing a pending filter without a selection drops it, no chip, no reload (§1.3)",
%{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "group")
assert has_element?(view, "[data-testid='value-control-group']")
# No chip has been added and no navigation happened yet (pending, uncommitted).
refute has_element?(view, "[data-testid='filter-chip']")
refute_patched(view)
# Dismiss (Escape/click-away) drops the pending control without committing.
render_keydown(element(view, "#member-filter"), %{"key" => "Escape"})
refute has_element?(view, "[data-testid='value-control-group']")
refute has_element?(view, "[data-testid='filter-chip']")
end
defp refute_patched(view) do
assert_raise ArgumentError, fn -> assert_patch(view, 50) end
end
end

View file

@ -23,9 +23,9 @@ defmodule MvWeb.MemberLive.IndexColumnManagerTest do
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Both the filter panel trigger and the column-manager trigger render in the
# same toolbar.
assert has_element?(view, ~s(button[aria-label="Filter members"]))
# Both the add-filter builder trigger and the column-manager trigger render
# in the same toolbar.
assert has_element?(view, "[data-testid='add-filter-trigger']")
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
end

View file

@ -0,0 +1,39 @@
defmodule MvWeb.MemberLive.IndexDatePresetsTest do
@moduledoc """
§1.8 a date field's value control offers presets; picking "This year"
applies the Jan 1 Dec 31 range of the current year and writes the equivalent
range params to the URL.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
setup %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, _member} =
Mv.Membership.create_member(
%{first_name: "Preset", last_name: "Member", email: "preset@example.com"},
actor: actor
)
%{conn: conn_with_oidc_user(conn)}
end
test "the 'This year' preset applies the current-year range and writes URL params", %{
conn: conn
} do
{:ok, view, _html} = live(conn, ~p"/members")
# Open the Join date value control and apply the "This year" preset.
pick_field(view, "join_date")
assert has_element?(view, "[data-testid='date-presets']")
view |> element("[data-testid='date-preset-this_year']") |> render_click()
path = assert_patch(view)
year = Date.utc_today().year
assert path =~ "jd_from=#{year}-01-01"
assert path =~ "jd_to=#{year}-12-31"
end
end

View file

@ -40,7 +40,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
# Fee type, fee status, groups, join date headers.
assert has_element?(view, "[data-testid='membership_fee_type']")
assert has_element?(view, "[data-testid='join_date']")
assert has_element?(view, "th", "Membership Fee Status")
# The former "Membership Fee Status" column is now the period-scoped payment
# aging column (§3.3), headed "Fees".
assert has_element?(view, "th", "Fees")
assert has_element?(view, "[data-testid='groups']")
end

View file

@ -0,0 +1,92 @@
defmodule MvWeb.MemberLive.IndexFilterChipsTest do
@moduledoc """
§1.4, §1.5, §3.2 applied-filter chips render one per value, each is an
independently removable real button, and "Clear all" removes every active
filter in a single reload with the URL reset to the unfiltered state.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Group
setup %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member} =
Mv.Membership.create_member(
%{first_name: "Chip", last_name: "Member", email: "chip@example.com"},
actor: actor
)
{:ok, group1} =
Group |> Ash.Changeset.for_create(:create, %{name: "Alpha"}) |> Ash.create(actor: actor)
{:ok, group2} =
Group |> Ash.Changeset.for_create(:create, %{name: "Beta"}) |> Ash.create(actor: actor)
{:ok, _mg} =
Mv.Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: actor
)
%{conn: conn_with_oidc_user(conn), group1: group1, group2: group2}
end
test "an applied filter renders a removable chip that is a real button (§1.4, §3.2)", %{
conn: conn,
group1: group1
} do
{:ok, view, _html} = live(conn, "/members?group_#{group1.id}=in")
# The chip's remove control is a real button whose accessible name names the
# filter it removes (locale-independent: it contains the group name).
assert has_element?(
view,
"button[data-testid='filter-chip-remove'][aria-label*='Alpha']"
)
end
test "removing a chip drops its URL param and reloads (§1.4)", %{conn: conn, group1: group1} do
{:ok, view, _html} = live(conn, "/members?group_#{group1.id}=in")
view |> element("[data-testid='filter-chip-remove']") |> render_click()
path = assert_patch(view)
refute path =~ "group_#{group1.id}"
end
test "Clear all removes every active filter in one reload and resets the URL (§1.5)", %{
conn: conn,
group1: group1,
group2: group2
} do
{:ok, view, _html} =
live(conn, "/members?group_#{group1.id}=in&group_#{group2.id}=not_in")
assert has_element?(view, "[data-testid='clear-all-filters']")
view |> element("[data-testid='clear-all-filters']") |> render_click()
path = assert_patch(view)
refute path =~ "group_#{group1.id}"
refute path =~ "group_#{group2.id}"
refute has_element?(view, "[data-testid='filter-chip']")
end
test "each value renders its own chip (one chip per value)", %{
conn: conn,
group1: group1,
group2: group2
} do
{:ok, view, _html} =
live(conn, "/members?group_#{group1.id}=in&group_#{group2.id}=not_in")
chips =
render(view)
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s([data-testid="filter-chip"]))
|> Enum.count()
assert chips == 2
end
end

View file

@ -0,0 +1,50 @@
defmodule MvWeb.MemberLive.IndexFilterHeightPropertyTest do
@moduledoc """
§2.3 the resting filter UI's size depends only on the number of active
filters, never on the size of the available-field catalog. The builder renders
one chip per active filter regardless of how many groups/fee types/custom
fields are pickable, so the chip count is a function of the active-filter count
alone.
"""
use ExUnit.Case, async: true
use ExUnitProperties
import Phoenix.LiveViewTest
alias MvWeb.Components.AddFilterBuilderComponent
defp group(i), do: %{id: "g#{i}", name: "Group #{i}"}
defp chip_count(html) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s([data-testid="filter-chip"]))
|> Enum.count()
end
property "resting chip count depends only on active-filter count, not catalog size" do
check all(
catalog_size <- integer(0..8),
active_count <- integer(0..5)
) do
# active filters must reference real catalog ids, so ensure enough groups.
total = max(catalog_size, active_count)
groups = Enum.map(1..max(total, 1)//1, &group/1)
groups = if total == 0, do: [], else: groups
group_filters =
groups
|> Enum.take(active_count)
|> Map.new(&{&1.id, :in})
html =
render_component(AddFilterBuilderComponent,
id: "member-filter",
groups: groups,
group_filters: group_filters
)
assert chip_count(html) == active_count
end
end
end

View file

@ -1,79 +0,0 @@
defmodule MvWeb.MemberLive.IndexFilterPanelTest do
@moduledoc """
§1.18 The existing "Apply filters" panel is unchanged: same fieldset/legend
form structure and payment-status controls, no chip/add-filter-builder
paradigm (that is #548). Its filters now resolve DB-side via the `:overview`
read action.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Panel", last_name: "Member", email: "panel@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp open_filter(view) do
view
|> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
|> render_click()
end
test "the filter panel keeps its fieldset form structure and payment controls", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_filter(view)
# Semantic form structure (fieldset/legend, radio controls) is preserved.
assert has_element?(view, ~s(form[data-testid="member-filter-form"]))
assert has_element?(view, "#payment-filter-all")
assert has_element?(view, "#payment-filter-paid")
assert has_element?(view, "#payment-filter-unpaid")
end
test "no chip / add-filter-builder paradigm is present (#548 is out of scope)", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/members")
open_filter(view)
html = html <> render(view)
refute html =~ ~s(data-testid="add-filter")
refute html =~ ~s(data-testid="filter-chip")
refute html =~ "filter-builder"
end
test "the panel's payment filter resolves DB-side via :overview", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
fee_type = Mv.Fixtures.create_fee_type(%{interval: :yearly}, system_actor)
last_year_start = Date.new!(Date.utc_today().year - 1, 1, 1)
{:ok, paid} =
Mv.Membership.create_member(
%{
first_name: "PaidPanel",
last_name: "X",
email: "paidpanel@example.com",
membership_fee_type_id: fee_type.id
},
actor: system_actor
)
Mv.Fixtures.create_cycle(
paid,
fee_type,
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
system_actor
)
{:ok, _view, html} = live(conn, ~p"/members?cycle_status_filter=paid")
assert html =~ "PaidPanel"
refute html =~ "Panel Member"
end
end

View file

@ -8,8 +8,8 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
The unchanged DB filters (group, fee-type) are applied identically in both
arms; the property isolates the filters that moved from memory to the DB
(boolean custom fields, date custom fields, paid/unpaid cycle status) by
applying them DB-side in one arm and in-memory in the other.
(boolean custom fields, date custom fields) plus the period-scoped payment
aging filter, by applying them DB-side in one arm and in-memory in the other.
"""
use Mv.DataCase, async: false
use ExUnitProperties
@ -21,10 +21,8 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
@today ~D[2024-07-15]
@cycle_starts [~D[2023-01-01], ~D[2024-01-01], ~D[2025-01-01]]
@date_values [~D[2023-06-01], ~D[2024-06-15], ~D[2025-06-01]]
@ -74,8 +72,14 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
defp filter_params_gen do
StreamData.fixed_map(%{
cycle_status: StreamData.member_of([nil, :paid, :unpaid]),
show_current: StreamData.boolean(),
payment:
StreamData.member_of([
nil,
:fully_paid,
{:has_unpaid, 1},
{:has_unpaid, 2},
{:has_unpaid, 3}
]),
bool: StreamData.member_of([nil, true, false]),
date_range: StreamData.member_of([nil, {~D[2024-01-01], ~D[2024-12-31]}]),
group: StreamData.member_of([nil, :in, :not_in]),
@ -144,16 +148,16 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
Map.merge(opts, %{fee_type_filters: %{to_string(ctx.ft1.id) => dir}, fee_types: [ctx.ft1]})
defp moved_opts(params, ctx) do
%{today: @today}
|> put_cycle(params.cycle_status, params.show_current)
%{}
|> put_payment(params.payment)
|> put_bool(params.bool, ctx)
|> put_date(params.date_range, ctx)
end
defp put_cycle(opts, nil, _show), do: opts
defp put_payment(opts, nil), do: opts
defp put_cycle(opts, status, show),
do: Map.merge(opts, %{cycle_status_filter: status, show_current_cycle: show})
defp put_payment(opts, filter),
do: Map.merge(opts, %{payment_filter: filter, payment_period: %{from: nil, to: nil}})
defp put_bool(opts, nil, _ctx), do: opts
@ -195,7 +199,7 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
base_members
|> apply_oracle_bool(params, ctx)
|> apply_oracle_date(params, ctx)
|> apply_oracle_cycle(params)
|> apply_oracle_payment(params)
|> MapSet.new(& &1.id)
end
@ -219,14 +223,22 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
)
end
defp apply_oracle_cycle(members, %{cycle_status: nil}), do: members
defp apply_oracle_payment(members, %{payment: nil}), do: members
defp apply_oracle_cycle(members, %{cycle_status: status, show_current: show}) do
Enum.filter(members, fn m ->
MembershipFeeStatus.get_cycle_status_for_member(m, show, @today) == status
end)
defp apply_oracle_payment(members, %{payment: filter}) do
Enum.filter(members, fn m -> payment_match?(unpaid_count(m), filter) end)
end
# Member-relative count of unpaid cycles (all-time period, so every unpaid
# cycle counts); suspended and paid are excluded, mirroring the calculation.
defp unpaid_count(member) do
member.membership_fee_cycles
|> Enum.count(&(&1.status == :unpaid))
end
defp payment_match?(count, :fully_paid), do: count == 0
defp payment_match?(count, {:has_unpaid, n}), do: count >= n
property "DB-backed filter result set equals the in-memory oracle", ctx do
check all(
specs <- StreamData.list_of(member_spec_gen(), min_length: 1, max_length: 5),

View file

@ -65,21 +65,22 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
end
@tag :ui
test "filter dropdown has group presence section with legend", %{
test "field picker exposes a combobox with a Group field option", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open filter dropdown
view
|> element("button[aria-label='Filter members']")
|> render_click()
# Open the add-filter picker (combobox + listbox of grouped fields)
open_picker(view)
html = render(view)
# Groups section: legend "Member has groups" and radios (Any / Yes / No)
assert html =~ ~r/[Gg]roups/
assert has_element?(view, "[data-testid='member-filter-form']")
assert has_element?(view, "[data-testid='field-combobox'][role='combobox']")
assert has_element?(view, "#member-filter-field-listbox[role='listbox']")
assert has_element?(view, "#member-filter-field-opt-group[role='option']")
# Picking the Group field opens its value control with per-group legends.
view |> element("#member-filter-field-opt-group") |> render_click()
assert has_element?(view, "[data-testid='value-control-group']")
end
@tag :ui
@ -102,13 +103,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
@ -140,13 +135,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name

View file

@ -62,20 +62,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
end
defp open_filter_and_set_group(view, group_id, value) do
view
|> element("button[aria-label='Filter members']")
|> render_click()
key = "group_#{group_id}"
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{key => value, "payment_filter" => "all"})
# Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
_ = render(view)
# Wait for patch; return path so callers can assert URL contains expected filter param
path = assert_patch(view)
path = apply_group_filter(view, group_id, value)
{view, path}
end

View file

@ -103,13 +103,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
@ -174,10 +168,10 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} =
live(conn, "/members?group_#{group1.id}=in&cycle_status_filter=paid")
live(conn, "/members?group_#{group1.id}=in&pay_filter=fully_paid")
assert html =~ "Members"
# member1 has a group and a paid cycle; page should load with both filters
# member1 has a group and no unpaid cycles; both filters keep it visible.
assert html =~ member1.first_name
end
@ -191,13 +185,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
apply_group_filter(view, group1.id, "in")
# Apply search (this tests that filter and search work together;
# search form is in SearchBarComponent with phx-submit="search")
@ -242,13 +230,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
apply_group_filter(view, group1.id, "in")
# Apply sorting
view

View file

@ -102,13 +102,7 @@ defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
{:ok, view, _html} = live(conn, "/members")
# Open filter and apply "Yes" for group1 (even-indexed members are in group1)
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
apply_group_filter(view, group1.id, "in")
# Force LiveView to process {:group_filter_changed, ...}
html = render(view)

View file

@ -73,13 +73,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name

View file

@ -1,16 +1,15 @@
defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
@moduledoc """
Tests for membership fee status column in member list view.
Tests for the period-scoped payment aging column in the member overview
(§1.13, §3.3). The column shows, per member, the count of unpaid cycles whose
`cycle_end` falls in the active period (default: all outstanding); 0 "Paid";
suspended cycles are excluded from the count.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
# Helper to create a member
defp create_member(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -26,253 +25,90 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
member
end
describe "status column display" do
test "shows status column in member list", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
describe "payment aging column" do
test "header names the payment column", %{conn: conn} do
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Fees"
end
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid,
replace_existing: true
})
test "a member with unpaid cycles shows the unpaid count; suspended excluded", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{first_name: "OwesTwo", membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :unpaid})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
# A suspended cycle must never be counted.
create_cycle(member, fee_type, %{cycle_start: ~D[2024-01-01], status: :suspended})
{:ok, _view, html} = live(conn, "/members")
# Should show membership fee status column
assert html =~ "Membership Fee Status" || html =~ "Mitgliedsbeitrag Status"
assert html =~ "2 open"
refute html =~ "3 open"
end
test "shows last completed cycle status by default", %{conn: conn} do
test "a member with no unpaid cycles reads Paid", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = create_member(%{first_name: "AllPaid", membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :paid,
replace_existing: true
})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :unpaid,
replace_existing: true
})
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Paid"
end
test "the badge is a focusable control that drills into the member fees history", %{
conn: conn
} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{first_name: "Drill", membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members")
# Should show unpaid status (2023 is last completed)
html = render(view)
assert html =~ "hero-x-circle" || html =~ "unpaid"
assert has_element?(
view,
"button[data-testid='payment-badge']##{"payment-badge-#{member.id}"}"
)
# A tooltip popover lists the open cycles for the period.
assert has_element?(view, "##{"payment-tip-#{member.id}"}[popover]")
end
test "toggle switches to current cycle view", %{conn: conn} do
test "members without cycles render without error", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = create_member(%{first_name: "NoCycles", membership_fee_type_id: fee_type.id})
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid,
replace_existing: true
})
create_cycle(member, fee_type, %{
cycle_start: current_year_start,
status: :suspended,
replace_existing: true
})
{:ok, view, _html} = live(conn, "/members")
# Toggle to current cycle (use the button in the header)
view
|> element("[data-testid=toggle-cycle-view]")
|> render_click()
html = render(view)
# Should show suspended status (current cycle)
assert html =~ "hero-pause-circle" || html =~ "suspended"
# The toggle button exposes its pressed state to assistive tech.
assert has_element?(view, ~s([data-testid=toggle-cycle-view][aria-pressed="true"]))
end
test "shows correct color coding for paid status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid,
replace_existing: true
})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-success" || html =~ "hero-check-circle"
end
test "shows correct color coding for unpaid status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :unpaid,
replace_existing: true
})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-error" || html =~ "hero-x-circle"
end
test "shows correct color coding for suspended status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :suspended,
replace_existing: true
})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-base-content/60" || html =~ "hero-pause-circle"
end
test "handles members without cycles gracefully", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# No cycles created
{:ok, view, _html} = live(conn, "/members")
html = render(view)
# Should not crash, may show empty or default state
{:ok, _view, html} = live(conn, "/members")
assert html =~ member.first_name
end
end
describe "filters" do
test "filter unpaid in last cycle works", %{conn: conn} do
describe "payment filter" do
setup do
fee_type = create_fee_type(%{interval: :yearly})
# Member with unpaid last cycle
member1 = create_member(%{first_name: "UnpaidMember", membership_fee_type_id: fee_type.id})
unpaid = create_member(%{first_name: "HasUnpaid", membership_fee_type_id: fee_type.id})
create_cycle(unpaid, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
create_cycle(member1, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :unpaid,
replace_existing: true
})
paid = create_member(%{first_name: "FullyPaid", membership_fee_type_id: fee_type.id})
create_cycle(paid, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
# Member with paid last cycle
member2 = create_member(%{first_name: "PaidMember", membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid,
replace_existing: true
})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Verify cycles exist in database
cycles1 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member1.id)
|> Ash.read!(actor: system_actor)
cycles2 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member2.id)
|> Ash.read!(actor: system_actor)
refute Enum.empty?(cycles1)
refute Enum.empty?(cycles2)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
assert html =~ "UnpaidMember"
refute html =~ "PaidMember"
%{}
end
test "filter unpaid in current cycle works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
test "has-unpaid ≥ 1 selects only members with unpaid cycles", %{conn: conn} do
{:ok, _view, html} = live(conn, "/members?pay_filter=unpaid_1")
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
# Member with unpaid current cycle
member1 = create_member(%{first_name: "UnpaidCurrent", membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{
cycle_start: current_year_start,
status: :unpaid,
replace_existing: true
})
# Member with paid current cycle
member2 = create_member(%{first_name: "PaidCurrent", membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{
cycle_start: current_year_start,
status: :paid,
replace_existing: true
})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Verify cycles exist in database
cycles1 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member1.id)
|> Ash.read!(actor: system_actor)
cycles2 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member2.id)
|> Ash.read!(actor: system_actor)
refute Enum.empty?(cycles1)
refute Enum.empty?(cycles2)
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
assert html =~ "UnpaidCurrent"
refute html =~ "PaidCurrent"
assert html =~ "HasUnpaid"
refute html =~ "FullyPaid"
end
end
@moduletag :slow
describe "performance" do
test "loads cycles efficiently without N+1 queries", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
test "fully-paid selects only members with zero unpaid cycles", %{conn: conn} do
{:ok, _view, html} = live(conn, "/members?pay_filter=fully_paid")
# Create multiple members with cycles
Enum.each(1..5, fn _ ->
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid,
replace_existing: true
})
end)
{:ok, _view, html} = live(conn, "/members")
# Should render without errors (N+1 would cause performance issues)
assert html =~ "Members" || html =~ "Mitglieder"
assert html =~ "FullyPaid"
refute html =~ "HasUnpaid"
end
end
end

View file

@ -93,17 +93,19 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
end
describe "badge/1" do
test "0 renders as Paid (success)" do
test "0 renders as All paid (success/green)" do
badge = PaymentAging.badge(0)
assert badge.variant == :success
assert badge.label == "Paid"
assert badge.color_class == "badge-success"
assert badge.label == "All paid"
end
test "N > 0 renders as N unpaid (error)" do
test "N > 0 renders as N open (error/red)" do
badge = PaymentAging.badge(2)
assert badge.variant == :error
assert badge.color_class == "badge-error"
assert badge.label =~ "2"
assert badge.label =~ "unpaid"
assert badge.label =~ "open"
end
end
@ -114,11 +116,11 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
%{actor: actor, ft: ft}
end
test "lists in-period unpaid cycles and flags suspended separately; paid excluded",
test "lists in-period unpaid and suspended cycles chronologically; paid/out-of-period excluded",
%{actor: actor, ft: ft} do
member = member_fixture_with_actor(%{}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :suspended}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :paid}, actor)
# out of period
@ -127,10 +129,13 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
member = Ash.load!(member, :membership_fee_cycles, actor: actor)
result = PaymentAging.open_cycles(member, %{from: ~D[2024-01-01], to: ~D[2024-12-31]})
assert length(result.unpaid) == 2
assert length(result.suspended) == 1
assert Enum.all?(result.unpaid, &(&1.status == :unpaid))
assert Enum.all?(result.suspended, &(&1.status == :suspended))
assert Enum.map(result, & &1.status) == [:unpaid, :unpaid, :suspended]
assert Enum.map(result, & &1.cycle_start) == [
~D[2024-01-01],
~D[2024-02-01],
~D[2024-03-01]
]
end
test "nil bounds include every unpaid/suspended cycle", %{actor: actor, ft: ft} do
@ -141,8 +146,22 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
member = Ash.load!(member, :membership_fee_cycles, actor: actor)
result = PaymentAging.open_cycles(member, PaymentAging.default_period())
assert length(result.unpaid) == 1
assert length(result.suspended) == 1
assert Enum.map(result, & &1.status) == [:unpaid, :suspended]
end
end
describe "tooltip_cycles/1 (five-slot cap)" do
test "five or fewer cycles show in full with no overflow" do
cycles = for i <- 1..5, do: %{n: i}
assert PaymentAging.tooltip_cycles(cycles) == {cycles, 0}
end
test "more than five collapses to first four cycles plus the overflow count" do
cycles = for i <- 1..19, do: %{n: i}
{shown, overflow} = PaymentAging.tooltip_cycles(cycles)
assert shown == Enum.take(cycles, 4)
assert overflow == 15
end
end
end

View file

@ -0,0 +1,72 @@
defmodule MvWeb.MemberLive.IndexPaymentWiringTest do
@moduledoc """
§1.16 / §1.17 the member overview LiveView honours the period-scoped
payment-count filter and payment-period URL contract (`pay_filter`,
`pay_from`, `pay_to`), driving the same DB result set the OverviewQuery unit
tests pin, but through `handle_params` and the query wiring.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
setup %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
ft = create_fee_type(%{interval: :monthly}, actor)
# Deterministic id set: drop the seeded members.
Mv.Membership.Member
|> Ash.read!(actor: actor)
|> Enum.each(&Ash.destroy!(&1, actor: actor))
%{conn: conn_with_oidc_user(conn), actor: actor, ft: ft}
end
defp member_with_unpaid(n, actor, ft) do
member = member_fixture_with_actor(%{}, actor)
Enum.each(1..max(n, 0)//1, fn i ->
create_cycle(member, ft, %{cycle_start: Date.new!(2024, i, 1), status: :unpaid}, actor)
end)
member
end
test "has-unpaid >= 2 shows only members with at least two in-period unpaid cycles", ctx do
one = member_with_unpaid(1, ctx.actor, ctx.ft)
two = member_with_unpaid(2, ctx.actor, ctx.ft)
{:ok, view, _html} =
live(ctx.conn, "/members?pay_filter=unpaid_2&pay_from=2024-01-01&pay_to=2024-12-31")
assert has_element?(view, "#row-#{two.id}")
refute has_element?(view, "#row-#{one.id}")
end
test "fully_paid shows only members with zero in-period unpaid cycles", ctx do
paid = member_with_unpaid(0, ctx.actor, ctx.ft)
create_cycle(paid, ctx.ft, %{cycle_start: ~D[2024-05-01], status: :paid}, ctx.actor)
unpaid = member_with_unpaid(1, ctx.actor, ctx.ft)
{:ok, view, _html} =
live(ctx.conn, "/members?pay_filter=fully_paid&pay_from=2024-01-01&pay_to=2024-12-31")
assert has_element?(view, "#row-#{paid.id}")
refute has_element?(view, "#row-#{unpaid.id}")
end
test "the active period scopes the filter: a cycle outside the period does not count", ctx do
# One unpaid cycle in 2023, none in 2024. With a 2024 period, has-unpaid>=1
# must not select this member; with the all-outstanding default it must.
member = member_fixture_with_actor(%{}, ctx.actor)
create_cycle(member, ctx.ft, %{cycle_start: ~D[2023-03-01], status: :unpaid}, ctx.actor)
{:ok, scoped, _} =
live(ctx.conn, "/members?pay_filter=unpaid_1&pay_from=2024-01-01&pay_to=2024-12-31")
refute has_element?(scoped, "#row-#{member.id}")
{:ok, all_time, _} = live(ctx.conn, "/members?pay_filter=unpaid_1")
assert has_element?(all_time, "#row-#{member.id}")
end
end

View file

@ -473,17 +473,26 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
# Opens the member-filter dropdown so its boolean filter controls are rendered.
defp open_member_filter(view) do
view
|> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
|> render_click()
# 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
# Returns the rendered HTML of the member-filter dropdown (with it open).
defp member_filter_html(view) do
open_member_filter(view)
render(view)
# 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
@ -797,10 +806,8 @@ defmodule MvWeb.MemberLive.IndexTest do
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")
# 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
@ -1055,7 +1062,7 @@ defmodule MvWeb.MemberLive.IndexTest do
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")
{:ok, _view, html} = live(conn, "/members?pay_filter=fully_paid")
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
@ -1162,230 +1169,6 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
describe "cycle status filter" do
# Helper to create a member (only used in this describe block)
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
{:ok, member} = Membership.create_member(attrs, actor: actor)
member
end
test "filter shows only members with paid status in last cycle", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
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 paid last cycle
paid_member =
create_member(
%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
paid_member,
fee_type,
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
system_actor
)
# Member with unpaid last cycle
unpaid_member =
create_member(
%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
system_actor
)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
assert html =~ "PaidLast"
refute html =~ "UnpaidLast"
end
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
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 paid last cycle
paid_member =
create_member(
%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
paid_member,
fee_type,
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
system_actor
)
# Member with unpaid last cycle
unpaid_member =
create_member(
%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
system_actor
)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
refute html =~ "PaidLast"
assert html =~ "UnpaidLast"
end
test "filter shows only members with paid status in current cycle", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
paid_member =
create_member(
%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
paid_member,
fee_type,
%{cycle_start: current_year_start, status: :paid, replace_existing: true},
system_actor
)
# Member with unpaid current cycle
unpaid_member =
create_member(
%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
system_actor
)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
assert html =~ "PaidCurrent"
refute html =~ "UnpaidCurrent"
end
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
paid_member =
create_member(
%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
paid_member,
fee_type,
%{cycle_start: current_year_start, status: :paid, replace_existing: true},
system_actor
)
# Member with unpaid current cycle
unpaid_member =
create_member(
%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
system_actor
)
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
refute html =~ "PaidCurrent"
assert html =~ "UnpaidCurrent"
end
test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with last cycle view and paid filter
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Toggle to current cycle - this should update URL and preserve filter
# Use the button in the toolbar
view
|> element("button[phx-click='toggle_cycle_view']")
|> render_click()
# Wait for patch to complete
path = assert_patch(view)
# URL should contain both filter and show_current_cycle
assert path =~ "cycle_status_filter=paid"
assert path =~ "show_current_cycle=true"
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
@ -1454,12 +1237,13 @@ defmodule MvWeb.MemberLive.IndexTest do
string_field = create_string_custom_field(%{name: "Phone Number"})
{:ok, view, _html} = live(conn, "/members")
open_member_filter(view)
open_picker(view)
# Only the boolean fields render a tri-state filter control; the string field does not.
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-all"}")
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-all"}")
refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-all"}")
# 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
@ -1471,19 +1255,16 @@ defmodule MvWeb.MemberLive.IndexTest do
field_m = create_boolean_custom_field(%{name: "Middle Field"})
{:ok, view, _html} = live(conn, "/members")
html = member_filter_html(view)
open_picker(view)
# The rendered boolean filter controls appear in name-ascending order.
# The picker lists the boolean custom fields in name-ascending order.
rendered_order =
html
render(view)
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s(input[id$="-all"][name^="custom_boolean"]))
|> LazyHTML.query(~s(#member-filter-field-listbox [role="option"]))
|> LazyHTML.attribute("id")
|> Enum.map(
&(&1
|> String.replace_prefix("custom-boolean-filter-", "")
|> String.replace_suffix("-all", ""))
)
|> 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
@ -1492,17 +1273,14 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Test true value: the "Yes" radio is checked (the boolean true, not the string "true").
# 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")
open_member_filter(view1)
assert has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
refute has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
assert boolean_chip?(view1, boolean_field, "Yes")
refute boolean_chip?(view1, boolean_field, "No")
# Test false value: the "No" radio is checked.
# Test false value: a "No" chip for the field.
{:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false")
open_member_filter(view2)
assert has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-false"}[checked]")
refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
assert boolean_chip?(view2, boolean_field, "No")
end
test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
@ -1510,11 +1288,10 @@ defmodule MvWeb.MemberLive.IndexTest do
fake_id = Ecto.UUID.generate()
{:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true")
open_member_filter(view)
# No filter control exists for a non-existent field, and no active-filter badge appears.
refute has_element?(view, "##{"custom-boolean-filter-#{fake_id}-true"}")
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
# 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
@ -1522,11 +1299,10 @@ defmodule MvWeb.MemberLive.IndexTest do
string_field = create_string_custom_field()
{:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true")
open_member_filter(view)
# A string field is never rendered as a boolean filter, and no filter becomes active.
refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-true"}")
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
# 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
@ -1538,11 +1314,10 @@ defmodule MvWeb.MemberLive.IndexTest do
for invalid_value <- invalid_values do
{:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
open_member_filter(view)
# An invalid value leaves the field's filter at "All" (no filter applied).
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]"),
"Invalid value '#{invalid_value}' should leave the filter at All"
# 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
@ -1557,16 +1332,11 @@ defmodule MvWeb.MemberLive.IndexTest do
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
open_member_filter(view)
# Both filters are reflected: field1 at "Yes", field2 at "No", and the
# active-filter count badge shows 2.
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-true"}[checked]")
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-false"}[checked]")
assert view
|> element(~s(button[aria-label="Filter members"] .badge), "2")
|> has_element?()
# 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", %{
@ -1630,22 +1400,20 @@ defmodule MvWeb.MemberLive.IndexTest do
assert path2 =~ "bf_#{boolean_field.id}=true"
end
test "boolean filters work together with cycle_status_filter", %{conn: conn} do
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?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
"/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true"
)
open_member_filter(view)
# Both filters are reflected in the rendered controls: the boolean field at
# "Yes" and the payment-status filter at "paid".
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
assert has_element?(view, "#payment-filter-paid[checked]")
# Both filters are reflected as chips: the boolean field at "Yes" and the
# period-scoped payment filter as "Fully paid".
assert boolean_chip?(view, boolean_field, "Yes")
assert render(view) =~ "Fully paid"
# Both should be in URL when triggering search
view
@ -1653,7 +1421,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> render_change(%{value: "test"})
path = assert_patch(view)
assert path =~ "cycle_status_filter=paid"
assert path =~ "pay_filter=fully_paid"
assert path =~ "bf_#{boolean_field.id}=true"
end
@ -1666,8 +1434,7 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
open_member_filter(view)
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
assert boolean_chip?(view, boolean_field, "Yes")
# Delete the custom field (requires actor with destroy permission)
Ash.destroy!(boolean_field, actor: system_actor)
@ -1676,11 +1443,9 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view2, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
open_member_filter(view2)
# The deleted field renders no filter control and no filter is active.
refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-true"}")
refute has_element?(view2, ~s(button[aria-label="Filter members"].btn-active))
# 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
@ -1693,11 +1458,9 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_#{encoded_id}=true")
open_member_filter(view)
# Phoenix decodes the param, so the filter applies under the original ID:
# the "Yes" radio for the field is checked.
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
# 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
@ -1708,12 +1471,8 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_bf_#{boolean_field.id}=true")
open_member_filter(view)
# The double-prefixed param is not a valid filter: the field stays at "All"
# and no filter is active.
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
# 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
@ -1728,11 +1487,9 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} = live(conn, "/members?#{filter_params}")
# The active-filter count badge is the observable carrier of the filter count.
# 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 view
|> element(~s(button[aria-label="Filter members"] .badge), "50")
|> has_element?()
assert chip_count(view) == 50
end
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
@ -1745,12 +1502,10 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_#{fake_long_id}=true")
open_member_filter(view)
# The over-long ID is rejected: the real field stays at "All" and no filter
# is active.
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
# 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
@ -2092,7 +1847,7 @@ defmodule MvWeb.MemberLive.IndexTest do
refute html_false =~ "NoValue"
end
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
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()
@ -2156,11 +1911,12 @@ defmodule MvWeb.MemberLive.IndexTest do
system_actor
)
# Test both filters together
# Test both filters together: fully-paid (0 unpaid cycles in the period)
# plus the boolean field at "Yes".
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
live(conn, "/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true")
# Only member_paid_true should match both filters
# Only member_paid_true has zero unpaid cycles and the boolean value true.
assert html =~ "PaidTrue"
refute html =~ "UnpaidTrue"
end
@ -2246,25 +2002,25 @@ defmodule MvWeb.MemberLive.IndexTest do
end
@tag :ui
test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
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_member_filter(view)
open_picker(view)
# No boolean field control is rendered yet.
refute has_element?(view, ~s(input[name^="custom_boolean"]))
# 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 filter dropdown.
# Navigate again - the new field should appear in the picker.
{:ok, view2, _html} = live(conn, "/members")
html_after = member_filter_html(view2)
open_picker(view2)
assert has_element?(view2, "##{"custom-boolean-filter-#{new_boolean_field.id}-all"}")
assert html_after =~ "Newly Added Field"
assert has_element?(view2, "#member-filter-field-opt-#{new_boolean_field.id}")
assert render(view2) =~ "Newly Added Field"
end
@tag :slow