feat(overview): rework filter builder with editable chips and native date ranges

Editable filter chips, uniformly-sized value controls, working field-picker
search, and native date-range inputs with relative presets in place of the
vendored calendar — native inputs give year-jump, manual entry and keyboard
accessibility for free.
This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent c0d1550401
commit 8303a39041
23 changed files with 1905 additions and 565 deletions

View file

@ -61,4 +61,96 @@ defmodule MvWeb.MemberLive.IndexActiveFormerTest do
assert has_element?(view, "#row-#{ctx.future.id}")
assert has_element?(view, "#row-#{ctx.former.id}")
end
describe "Stichtag paragraph (§1.24)" do
test "the active/former control carries a separate 'active on reference date' input", ctx do
{:ok, view, _html} = live(ctx.conn, ~p"/members")
pick_field(view, "active_former")
# The Stichtag input sits in its own paragraph, not inside the
# Active/Former button-join.
assert has_element?(view, "[data-testid='stichtag-paragraph'] input[name='stichtag']")
refute has_element?(view, ".join input[name='stichtag']")
end
test "setting a reference date activates the point-in-time membership filter", ctx do
{:ok, view, _html} = live(ctx.conn, ~p"/members")
pick_field(view, "active_former")
view
|> element("[data-testid='value-control-active-former']")
|> render_change(%{"quick" => "active", "stichtag" => "2024-06-15"})
_ = render(view)
path = assert_patch(view)
assert path =~ "stichtag=2024-06-15"
end
end
describe "Eigener Zeitraum exit-date range (§1.24)" do
test "the exit-date control carries a separate native From/To range paragraph", ctx do
{:ok, view, _html} = live(ctx.conn, ~p"/members")
pick_field(view, "active_former")
# Third paragraph, same native two-input range control the join-date
# builder uses — separate from the Active/Former join and the Stichtag box.
assert has_element?(view, "[data-testid='value-control-exit-range']")
assert has_element?(
view,
"[data-testid='value-control-exit-range'] input[type='date'][name='ed_from']"
)
assert has_element?(
view,
"[data-testid='value-control-exit-range'] input[type='date'][name='ed_to']"
)
end
test "typing an exit-date range commits ed_mode=custom with bounds and filters to it", ctx do
from = Date.add(Date.utc_today(), -60)
to = Date.add(Date.utc_today(), -1)
{:ok, view, _html} = live(ctx.conn, ~p"/members")
pick_field(view, "active_former")
view
|> element("[data-testid='value-control-exit-range']")
|> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)})
_ = render(view)
path = assert_patch(view)
assert path =~ "ed_mode=custom"
assert path =~ "ed_from=#{Date.to_iso8601(from)}"
assert path =~ "ed_to=#{Date.to_iso8601(to)}"
# Only the member whose exit date falls within [from, to] is shown.
assert has_element?(view, "#row-#{ctx.former.id}")
refute has_element?(view, "#row-#{ctx.active.id}")
refute has_element?(view, "#row-#{ctx.future.id}")
end
test "an exit-date range preserves an existing join-date filter", ctx do
from = Date.add(Date.utc_today(), -60)
to = Date.add(Date.utc_today(), -1)
{:ok, view, _html} = live(ctx.conn, ~p"/members?jd_from=2020-01-01")
pick_field(view, "active_former")
view
|> element("[data-testid='value-control-exit-range']")
|> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)})
_ = render(view)
path = assert_patch(view)
# The exit range must not wipe the pre-existing join-date bound.
assert path =~ "jd_from=2020-01-01"
assert path =~ "ed_mode=custom"
end
end
end

View file

@ -0,0 +1,91 @@
defmodule MvWeb.MemberLive.IndexChipEditTest do
@moduledoc """
§1.18 clicking an applied-filter chip body re-opens that filter's value
control pre-filled with the current value; a changed selection re-commits and
updates the chip; the × still removes the filter.
"""
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: "Edit", last_name: "Member", email: "edit@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), group: group}
end
test "clicking the chip body opens the value control pre-filled with the current value", %{
conn: conn,
group: group
} do
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
refute has_element?(view, "[data-testid='value-control-group']")
view |> element("[data-testid='filter-chip-edit']") |> render_click()
# The group control opens with this group's "is" (in) operator pre-selected.
assert has_element?(view, "[data-testid='value-control-group']")
assert has_element?(view, "input[name='group_#{group.id}'][value='in'][checked]")
end
test "the value control opens anchored under the edited chip, not as a new pending chip", %{
conn: conn,
group: group
} do
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
view |> element("[data-testid='filter-chip-edit']") |> render_click()
# §1.18: the control is a descendant of the chip's own list item (anchored in
# place under that chip), and no separate pending chip is spun up at the row.
assert has_element?(
view,
"[data-testid='filter-chip-item'] [data-testid='value-control-group']"
)
refute has_element?(view, "[data-testid='pending-chip']")
refute has_element?(view, "[data-testid='pending-filter']")
end
test "changing the value from the re-opened control re-commits and updates the URL", %{
conn: conn,
group: group
} do
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
view |> element("[data-testid='filter-chip-edit']") |> render_click()
view
|> element("[data-testid='value-control-group']")
|> render_change(%{"group_#{group.id}" => "not_in"})
_ = render(view)
path = assert_patch(view)
assert path =~ "group_#{group.id}=not_in"
end
test "the × still removes the filter", %{conn: conn, group: group} do
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
view |> element("[data-testid='filter-chip-remove']") |> render_click()
path = assert_patch(view)
refute path =~ "group_#{group.id}"
refute has_element?(view, "[data-testid='filter-chip']")
end
end

View file

@ -0,0 +1,74 @@
defmodule MvWeb.MemberLive.IndexDateRangeTest do
@moduledoc """
§1.25 / §3.12 (native-first) the date-range value control combines the
relative presets with a separate "Eigener Zeitraum" (custom range) paragraph
holding two native `<input type="date">` fields (Von/Bis). cally is removed.
Picking a preset commits a concrete range; typing into the native Von/Bis
inputs commits the custom range through the shared date-filter dispatch.
"""
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: "Range", last_name: "Member", email: "range@example.com"},
actor: actor
)
%{conn: conn_with_oidc_user(conn)}
end
test "the join-date control shows presets and native Von/Bis date inputs", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "join_date")
assert has_element?(view, "[data-testid='value-control-date-range']")
assert has_element?(view, "[data-testid='date-presets']")
# Separate "Eigener Zeitraum" paragraph with two native date inputs side by side.
assert has_element?(view, "[data-testid='custom-range']")
assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_from']")
assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_to']")
end
test "cally is fully removed from the date-range control", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "join_date")
refute has_element?(view, "[data-testid='date-range-cally']")
refute has_element?(view, "calendar-range")
refute has_element?(view, "[phx-hook='DateRangeCally']")
end
test "picking a preset commits a concrete range to the URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "join_date")
view |> element("[data-testid='date-preset-this_year']") |> render_click()
path = assert_patch(view)
today = Date.utc_today()
assert path =~ "jd_from=#{today.year}-01-01"
assert path =~ "jd_to=#{Date.to_iso8601(today)}"
end
test "typing a native custom range commits it to the URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "join_date")
view
|> element("[data-testid='value-control-date-range']")
|> render_change(%{"jd_from" => "2024-03-01", "jd_to" => "2024-03-31"})
path = assert_patch(view)
assert path =~ "jd_from=2024-03-01"
assert path =~ "jd_to=2024-03-31"
end
end

View file

@ -0,0 +1,48 @@
defmodule MvWeb.MemberLive.IndexFilterRowTest do
@moduledoc """
§1.19 the filter row renders "+ Add filter", the applied-filter chips and
"Clear all" as one uniform button size (all share the `btn-sm` sizing), and
each chip is composed of real, focusable buttons.
"""
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: "Row", last_name: "Member", email: "row@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), group: group}
end
test "add-filter trigger, chips and clear-all share the uniform btn-sm size", %{
conn: conn,
group: group
} do
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
assert has_element?(view, "[data-testid='add-filter-trigger'].btn-sm")
assert has_element?(view, "[data-testid='clear-all-filters'].btn-sm")
# The chip body and its remove control are real, focusable buttons at btn-sm.
assert has_element?(view, "button[data-testid='filter-chip-edit'].btn-sm")
assert has_element?(view, "button[data-testid='filter-chip-remove'].btn-sm")
# The join seam must be a single hairline: the remove button drops its own
# left border so the body's right border is the only one at the seam (no
# doubled 1px border that sub-pixel-rounds to 1px/2px and flips on zoom).
assert has_element?(view, "button[data-testid='filter-chip-remove'].border-l-0")
end
end

View file

@ -57,7 +57,7 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
assert html =~ "Paid"
end
test "the badge is a focusable control that drills into the member fees history", %{
test "the badge is a real navigation link that drills into the member fees history", %{
conn: conn
} do
fee_type = create_fee_type(%{interval: :yearly})
@ -66,13 +66,33 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
{:ok, view, _html} = live(conn, "/members")
assert has_element?(
view,
"button[data-testid='payment-badge']##{"payment-badge-#{member.id}"}"
)
badge = "a[data-testid='payment-badge']#payment-badge-#{member.id}"
href = "/members/#{member.id}?tab=membership_fees"
# A tooltip popover lists the open cycles for the period.
assert has_element?(view, "##{"payment-tip-#{member.id}"}[popover]")
# A semantic <a> navigating to the member's membership-fees section — not a
# div with JS.navigate — so it is keyboard- and right-click-friendly.
assert has_element?(view, "#{badge}[href='#{href}']")
# Its hover/focus tooltip is a top-layer native Popover opened via the
# HoverPopover hook (no SortTooltip id-linking on the badge anymore).
assert has_element?(view, "#{badge}[phx-hook='HoverPopover']")
assert has_element?(view, "#{badge}[data-popover-target='payment-tip-#{member.id}']")
refute has_element?(view, "[data-testid='payment-badge'][data-tooltip-id]")
# The tooltip popover lists the open cycles for the period.
assert has_element?(view, "#payment-tip-#{member.id}[popover]")
end
test "a fully-paid member renders the badge but no tooltip popover", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{first_name: "Clean", membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "a[data-testid='payment-badge']#payment-badge-#{member.id}")
refute has_element?(view, "#payment-tip-#{member.id}")
refute has_element?(view, "a#payment-badge-#{member.id}[phx-hook]")
end
test "members without cycles render without error", %{conn: conn} do

View file

@ -75,6 +75,20 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
tip = PaymentAging.period_tooltip(%{from: nil, to: nil})
assert is_binary(tip) and tip =~ "outstanding"
end
test "explains that the values refer to the filtered contribution period" do
# The header badge falls back to a plain "filtered" label whenever no
# compact short code fits (arbitrary or open-ended ranges); its tooltip
# must spell out what "filtered" means, not just repeat the range.
for period <- [
%{from: nil, to: nil},
%{from: ~D[2026-02-03], to: ~D[2026-08-17]},
%{from: ~D[2026-01-01], to: nil}
] do
tip = PaymentAging.period_tooltip(period)
assert tip =~ "refer to the filtered contribution period"
end
end
end
describe "payment-count filter codec" do

View file

@ -88,6 +88,7 @@ defmodule MvWeb.MemberLive.IndexPaymentWiringTest do
badge = view |> element("[data-testid='payment-period-badge']") |> render()
assert badge =~ "03.02.2026"
assert badge =~ "17.08.2026"
assert badge =~ "refer to the filtered contribution period"
end
end
end

View file

@ -1411,9 +1411,9 @@ defmodule MvWeb.MemberLive.IndexTest do
)
# Both filters are reflected as chips: the boolean field at "Yes" and the
# period-scoped payment filter as "Fully paid".
# period-scoped payment filter as "All paid".
assert boolean_chip?(view, boolean_field, "Yes")
assert render(view) =~ "Fully paid"
assert render(view) =~ "All paid"
# Both should be in URL when triggering search
view

View file

@ -0,0 +1,257 @@
defmodule MvWeb.MemberLive.IndexValueControlTest do
@moduledoc """
§1.20 / §1.21 / §1.22 / §1.23 the reworked value controls of the add-filter
builder:
* no "All"/"Alle" option in the boolean / group value controls (§1.20);
* the field picker's search input is a live type-ahead (§1.21);
* the payment control reveals a two-sided open-cycle count range under
"Has unpaid" and offers a suspended-status option (§1.22, §1.23).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.CustomField
alias Mv.Membership.Group
setup %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, _member} =
Mv.Membership.create_member(
%{first_name: "Val", last_name: "Member", email: "val@example.com"},
actor: actor
)
{:ok, group} =
Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "newsletter_#{System.unique_integer([:positive])}",
value_type: :boolean,
show_in_overview: true
})
|> Ash.create(actor: actor)
%{conn: conn_with_oidc_user(conn), group: group, field: field}
end
describe "unified popover shell (§3.11)" do
test "every value control shares the 'Filter: <field>' shell title", %{
conn: conn,
field: field
} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "group")
assert has_element?(view, "[data-testid='control-title']", "Filter:")
pick_field(view, field.id)
assert has_element?(view, "[data-testid='control-title']", "Filter:")
pick_field(view, "payment")
assert has_element?(view, "[data-testid='control-title']", "Filter:")
end
end
describe "no All option (§1.20)" do
test "the group control offers is / is-not but no 'all' option", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "group")
assert has_element?(view, "[data-testid='value-control-group'] input[value='in']")
assert has_element?(view, "[data-testid='value-control-group'] input[value='not_in']")
refute has_element?(view, "[data-testid='value-control-group'] input[value='all']")
end
test "the group operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "group")
# Same join-toggle presentation as the boolean custom-field control: a
# check icon on the ":in" (Ja) option and an x-mark on ":not_in" (Nein),
# with a visible selected state.
assert has_element?(view, "[data-testid='value-control-group'] .join .hero-check-circle")
assert has_element?(view, "[data-testid='value-control-group'] .join .hero-x-circle")
assert has_element?(
view,
"[data-testid='value-control-group'] label.has-\\[\\:checked\\]\\:btn-primary"
)
end
test "the fee-type operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do
_fee_type = Mv.Fixtures.create_fee_type()
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "fee_type")
assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-check-circle")
assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-x-circle")
end
test "the boolean control offers Yes / No but no 'all' option", %{conn: conn, field: field} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, field.id)
assert has_element?(view, "[data-testid='value-control-boolean'] input[value='true']")
assert has_element?(view, "[data-testid='value-control-boolean'] input[value='false']")
refute has_element?(view, "[data-testid='value-control-boolean'] input[value='all']")
end
test "the boolean control renders a check-icon Yes and an x-icon No join toggle", %{
conn: conn,
field: field
} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, field.id)
# Join-toggle with a check icon on Yes and an x-mark icon on No (Task 5).
assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-check-circle")
assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-x-circle")
# A checked option shows a visible selected state (btn-primary via has-[:checked]).
assert has_element?(
view,
"[data-testid='value-control-boolean'] label.has-\\[\\:checked\\]\\:btn-primary"
)
end
end
describe "field picker search (§1.21)" do
test "the search input is a live type-ahead hook and narrows the field list", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_picker(view)
assert has_element?(view, "[data-testid='field-combobox'][phx-hook='FilterComboboxSearch']")
# The search input must live inside a form carrying the change binding —
# LiveView rejects `phx-change` on an input that is not inside a <form>
# ("form events require the input to be inside a form"), which silently
# broke the live filtering in the browser (§1.21).
assert has_element?(view, "form[phx-change='filter_fields'] [data-testid='field-combobox']")
# A non-matching query removes the Group option from the listbox.
view
|> form("[data-testid='field-search-form']", %{"picker_query" => "zzzznomatch"})
|> render_change()
refute has_element?(view, "#member-filter-field-opt-group")
end
end
describe "payment status labels" do
test "the toggle reads 'All paid' / 'Open contributions'", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "payment")
assert has_element?(view, "[data-testid='value-control-payment']", "All paid")
assert has_element?(view, "[data-testid='value-control-payment']", "Open contributions")
refute has_element?(view, "[data-testid='value-control-payment']", "Fully paid")
refute has_element?(view, "[data-testid='value-control-payment']", "Has unpaid")
end
test "the applied fully-paid chip reads 'All paid'", %{conn: conn} do
{:ok, view, _html} = live(conn, "/members?pay_filter=fully_paid")
assert has_element?(view, "[data-testid='filter-chip']", "All paid")
refute render(view) =~ "Fully paid"
end
end
describe "payment control (§1.22, §1.23)" do
test "'Has unpaid' reveals the two-sided open-cycle count range", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "payment")
refute has_element?(view, "[data-testid='payment-count-range']")
view
|> element("[data-testid='value-control-payment']")
|> render_change(%{
"__payment" => "1",
"pay" => "has_unpaid",
"pay_min" => "1",
"pay_max" => ""
})
_ = render(view)
assert_patch(view)
assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_min']")
assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_max']")
end
test "the suspended option activates the suspended-status filter in the URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "payment")
assert has_element?(view, "[data-testid='payment-suspended'] input[name='suspended']")
view
|> element("[data-testid='value-control-payment']")
|> render_change(%{"__payment" => "1", "suspended" => "1"})
_ = render(view)
path = assert_patch(view)
assert path =~ "suspended=1"
end
end
describe "unified date-range controls" do
# Every date-range value control (payment period, join date, exit date, and
# custom date fields) renders the same shared native Von/Bis row: a
# non-wrapping flex row whose two `<input type="date">` are each wrapped in
# `min-w-0 flex-1` so they share width equally and stay on one line.
test "payment period, join, exit and custom date all render the shared row", %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, date_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "birthday_#{System.unique_integer([:positive])}",
value_type: :date,
show_in_overview: true
})
|> Ash.create(actor: actor)
{:ok, view, _html} = live(conn, ~p"/members")
assert_shared_range(view, "payment", "payment-period-range", "pay_from", "pay_to")
assert_shared_range(view, "join_date", "custom-range", "jd_from", "jd_to")
assert_shared_range(view, "active_former", "exit-custom-range", "ed_from", "ed_to")
cd_from = "cdf_#{date_field.id}_from"
cd_to = "cdf_#{date_field.id}_to"
assert_shared_range(view, date_field.id, "custom-range", cd_from, cd_to)
end
end
# Opens the given field's value control and asserts its date-range row is the
# shared native Von/Bis layout: a `flex` (non-wrapping) row whose two date
# inputs are each wrapped in `min-w-0 flex-1` (equal width, single line).
defp assert_shared_range(view, field, testid, from_name, to_name) do
pick_field(view, field)
assert has_element?(view, "[data-testid='#{testid}'].flex")
refute has_element?(view, "[data-testid='#{testid}'].flex-wrap")
assert has_element?(
view,
"[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{from_name}']"
)
assert has_element?(
view,
"[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{to_name}']"
)
end
end

View file

@ -60,16 +60,23 @@ defmodule MvWeb.FilterBuilderHelpers do
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 `""`.
Applies a payment status filter (and optionally a period) through the builder.
`status` is `"fully_paid"` or `"has_unpaid"`; `from`/`to` are ISO-8601 strings
or `""`. For `"has_unpaid"` the open-cycle range defaults to `min` 1 / no max.
"""
def apply_payment_filter(view, filter, from \\ "", to \\ "") do
def apply_payment_filter(view, status, from \\ "", to \\ "", min \\ "1", max \\ "") do
pick_field(view, "payment")
view
|> element("[data-testid='value-control-payment']")
|> render_change(%{"pay" => filter, "pay_from" => from, "pay_to" => to})
|> render_change(%{
"__payment" => "1",
"pay" => status,
"pay_min" => min,
"pay_max" => max,
"pay_from" => from,
"pay_to" => to
})
flush_and_patch(view)
end