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:
parent
7d1a71b1fd
commit
c547ca19af
35 changed files with 2817 additions and 3149 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
88
test/mv_web/member_live/index_add_filter_flow_test.exs
Normal file
88
test/mv_web/member_live/index_add_filter_flow_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
39
test/mv_web/member_live/index_date_presets_test.exs
Normal file
39
test/mv_web/member_live/index_date_presets_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
92
test/mv_web/member_live/index_filter_chips_test.exs
Normal file
92
test/mv_web/member_live/index_filter_chips_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
test/mv_web/member_live/index_payment_wiring_test.exs
Normal file
72
test/mv_web/member_live/index_payment_wiring_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ defmodule MvWeb.ConnCase do
|
|||
import Plug.Conn
|
||||
import Phoenix.ConnTest
|
||||
import MvWeb.ConnCase
|
||||
import MvWeb.FilterBuilderHelpers
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
83
test/support/filter_builder_helpers.ex
Normal file
83
test/support/filter_builder_helpers.ex
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
defmodule MvWeb.FilterBuilderHelpers do
|
||||
@moduledoc """
|
||||
Test helpers for driving the member-overview `AddFilterBuilderComponent`.
|
||||
|
||||
The add-filter flow is: open the picker → pick a field → change the focused
|
||||
value control (which commits the filter and re-loads the list). These helpers
|
||||
encapsulate that sequence so tests read at the intent level and stay robust to
|
||||
the builder's internal markup.
|
||||
|
||||
Each `apply_*` returns the patched path (the new `/members?…` URL) so callers
|
||||
can assert the URL-encoded filter contract.
|
||||
"""
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
@builder_id "member-filter"
|
||||
|
||||
@doc "Opens the field picker."
|
||||
def open_picker(view) do
|
||||
view |> element("[data-testid='add-filter-trigger']") |> render_click()
|
||||
end
|
||||
|
||||
@doc "Opens the picker and selects the field with the given descriptor key."
|
||||
def pick_field(view, key) do
|
||||
open_picker(view)
|
||||
view |> element("##{@builder_id}-field-opt-#{key}") |> render_click()
|
||||
end
|
||||
|
||||
@doc "Applies a group in/not_in/all filter through the builder."
|
||||
def apply_group_filter(view, group_id, value) do
|
||||
pick_field(view, "group")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-group']")
|
||||
|> render_change(%{"group_#{group_id}" => to_string(value)})
|
||||
|
||||
flush_and_patch(view)
|
||||
end
|
||||
|
||||
@doc "Applies a fee-type in/not_in/all filter through the builder."
|
||||
def apply_fee_type_filter(view, fee_type_id, value) do
|
||||
pick_field(view, "fee_type")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-fee-type']")
|
||||
|> render_change(%{"fee_type_#{fee_type_id}" => to_string(value)})
|
||||
|
||||
flush_and_patch(view)
|
||||
end
|
||||
|
||||
@doc "Applies a boolean custom-field filter (true/false/all) through the builder."
|
||||
def apply_boolean_filter(view, field_id, value) do
|
||||
pick_field(view, field_id)
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-boolean']")
|
||||
|> render_change(%{"custom_boolean" => %{"#{field_id}" => to_string(value)}})
|
||||
|
||||
flush_and_patch(view)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies a payment-count filter (and optionally a period) through the builder.
|
||||
`filter` is one of `"all"`, `"fully_paid"`, `"unpaid_1"`, `"unpaid_2"`,
|
||||
`"unpaid_3"`; `from`/`to` are ISO-8601 strings or `""`.
|
||||
"""
|
||||
def apply_payment_filter(view, filter, from \\ "", to \\ "") do
|
||||
pick_field(view, "payment")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-payment']")
|
||||
|> render_change(%{"pay" => filter, "pay_from" => from, "pay_to" => to})
|
||||
|
||||
flush_and_patch(view)
|
||||
end
|
||||
|
||||
# Forces the LiveView to process the queued filter message(s) and returns the
|
||||
# resulting patched path.
|
||||
defp flush_and_patch(view) do
|
||||
_ = render(view)
|
||||
assert_patch(view)
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue