feat(member-filter): add date filter sections with active-count badge and reset support
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
e3295ab4b5
commit
d6671daf1a
7 changed files with 834 additions and 18 deletions
|
|
@ -209,6 +209,57 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
# 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
|
||||
|
|
@ -268,6 +319,293 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue