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

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