feat(member-live): wire date filters into LiveView lifecycle

This commit is contained in:
Moritz 2026-05-20 16:28:17 +02:00
parent ddd4a9a878
commit e3295ab4b5
10 changed files with 1037 additions and 140 deletions

View file

@ -438,10 +438,18 @@ defmodule MvWeb.Components.MemberFilterComponent do
payment_filter = parse_payment_filter(params)
group_filters_parsed =
parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1)
FilterParams.parse_prefix_filters(
params,
@group_filter_prefix,
&FilterParams.parse_in_not_in_value/1
)
fee_type_filters_parsed =
parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1)
FilterParams.parse_prefix_filters(
params,
@fee_type_filter_prefix,
&FilterParams.parse_in_not_in_value/1
)
custom_boolean_filters_parsed = parse_custom_boolean_filters(params)
@ -486,17 +494,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
end
end
defp parse_prefix_filters(params, prefix, parse_value_fn) do
prefix_len = String.length(prefix)
params
|> Enum.filter(fn {key, _} -> String.starts_with?(key, prefix) end)
|> Enum.reduce(%{}, fn {key, value_str}, acc ->
id_str = String.slice(key, prefix_len, String.length(key) - prefix_len)
Map.put(acc, id_str, parse_value_fn.(value_str))
end)
end
defp parse_custom_boolean_filters(params) do
params
|> Map.get("custom_boolean", %{})

View file

@ -36,6 +36,8 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.FilterParams
@ -87,6 +89,13 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.filter(&(&1.value_type == :boolean))
|> Enum.sort_by(& &1.name, :asc)
# Date-typed custom fields surface in the new "Custom date fields" filter
# section and are needed by DateFilter.from_params/2 to validate UUIDs.
date_custom_fields =
all_custom_fields
|> Enum.filter(&(&1.value_type == :date))
|> Enum.sort_by(& &1.name, :asc)
# Load groups for filter dropdown (sorted by name)
groups =
Mv.Membership.Group
@ -143,6 +152,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
|> assign(:boolean_custom_fields, boolean_custom_fields)
|> assign(:date_custom_fields, date_custom_fields)
|> assign(:date_filters, DateFilter.default())
|> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> assign(:fields_in_url?, false)
@ -448,6 +459,25 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:date_filters_changed, new_date_filters}, socket) do
socket =
socket
|> assign(:date_filters, new_date_filters)
|> load_members()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket, %{date_filters: new_date_filters}))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
# Backward compatibility: tuple form delegates to map form
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
handle_info(
@ -502,6 +532,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|> load_members()
|> update_selection_assigns()
@ -632,6 +663,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_group_filters(params)
|> maybe_update_fee_type_filters(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_date_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?)
|> assign(:query, params["query"])
@ -683,7 +715,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection,
socket.assigns[:visible_custom_field_ids] || []
socket.assigns[:visible_custom_field_ids] || [],
socket.assigns[:date_filters]
}
end
@ -783,7 +816,12 @@ defmodule MvWeb.MemberLive.Index do
base_params = add_group_filters(base_params, opts.group_filters || %{})
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
base_params = add_show_current_cycle(base_params, opts.show_current_cycle)
add_boolean_filters(base_params, opts.boolean_filters || %{})
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
add_date_filters(base_params, opts.date_filters)
end
defp add_date_filters(params, date_filters) do
Map.merge(params, DateFilter.to_params(date_filters))
end
defp opts_for_query_params(socket, overrides \\ %{}) do
@ -795,7 +833,8 @@ defmodule MvWeb.MemberLive.Index do
group_filters: socket.assigns[:group_filters] || %{},
show_current_cycle: socket.assigns.show_current_cycle,
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
fee_type_filters: socket.assigns[:fee_type_filters] || %{}
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
date_filters: socket.assigns.date_filters
}
|> Map.merge(overrides)
end
@ -941,26 +980,7 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
boolean_custom_fields_map =
socket.assigns.boolean_custom_fields
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
active_boolean_filter_ids =
socket.assigns.boolean_custom_field_filters
|> Map.keys()
|> Enum.filter(fn id_str ->
String.length(id_str) <= @max_uuid_length &&
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
Map.has_key?(boolean_custom_fields_map, id_str)
end)
ids_to_load =
(visible_custom_field_ids ++ active_boolean_filter_ids)
|> Enum.uniq()
query = load_custom_field_values(query, ids_to_load)
query = load_custom_field_values(query, compute_ids_to_load(socket))
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
@ -984,6 +1004,13 @@ defmodule MvWeb.MemberLive.Index do
query =
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types])
# Built-in date filters (join_date, exit_date) are pushed to the DB so
# excluded rows never reach the BEAM. The active_only default is part of
# this — fresh load returns only members without an exit_date or with an
# exit_date strictly in the future.
query =
DateFilter.apply_ash_filter(query, socket.assigns.date_filters)
# Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields
@ -1003,21 +1030,7 @@ defmodule MvWeb.MemberLive.Index do
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
# Apply cycle status filter if set
members =
apply_cycle_status_filter(
members,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
# Apply boolean custom field filters if set
members =
apply_boolean_custom_field_filters(
members,
socket.assigns.boolean_custom_field_filters,
socket.assigns.all_custom_fields
)
members = apply_in_memory_filters(members, socket)
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
@ -1037,6 +1050,55 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :members, members)
end
# Collects every custom field UUID whose values must be loaded for a given
# render — visible columns plus any active boolean or date filter. Kept as a
# standalone helper so load_members/1 stays under the credo complexity bar.
defp compute_ids_to_load(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
boolean_custom_fields_map =
socket.assigns.boolean_custom_fields
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
active_boolean_filter_ids =
socket.assigns.boolean_custom_field_filters
|> Map.keys()
|> Enum.filter(fn id_str ->
String.length(id_str) <= @max_uuid_length &&
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
Map.has_key?(boolean_custom_fields_map, id_str)
end)
date_custom_fields = socket.assigns[:date_custom_fields] || []
active_date_filter_ids =
DateFilter.active_custom_field_ids(
socket.assigns.date_filters,
date_custom_fields
)
(visible_custom_field_ids ++ active_boolean_filter_ids ++ active_date_filter_ids)
|> Enum.uniq()
end
# Post-DB filtering: cycle status, boolean custom fields, and custom date
# fields. Date custom fields are last so they see the already-narrowed list.
defp apply_in_memory_filters(members, socket) do
members
|> apply_cycle_status_filter(
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
|> apply_boolean_custom_field_filters(
socket.assigns.boolean_custom_field_filters,
socket.assigns.all_custom_fields
)
|> DateFilter.apply_in_memory(
socket.assigns.date_filters,
socket.assigns[:date_custom_fields] || []
)
end
defp load_custom_field_values(query, []), do: query
defp load_custom_field_values(query, custom_field_ids) do
@ -1649,24 +1711,22 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_show_current_cycle(socket, _params), do: socket
# URL params are the source of truth for filter state on every navigation.
# When no date filter params are present, this falls through to the
# active_only default — exactly the spec behavior for fresh load (§1.1).
defp maybe_update_date_filters(socket, params) when is_map(params) do
date_custom_fields = socket.assigns[:date_custom_fields] || []
assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields))
end
defp maybe_update_date_filters(socket, _params), do: socket
# -------------------------------------------------------------
# Custom Field Value Helpers
# -------------------------------------------------------------
def get_custom_field_value(member, custom_field) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
end)
_ ->
nil
end
CustomFieldValueLookup.find_by_field(member, custom_field)
end
def get_boolean_custom_field_value(member, custom_field) do
@ -1725,29 +1785,12 @@ defmodule MvWeb.MemberLive.Index do
end
defp matches_filter?(member, custom_field_id_str, filter_value) do
case find_custom_field_value_by_id(member, custom_field_id_str) do
case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do
nil -> false
cfv -> extract_boolean_value(cfv.value) == filter_value
end
end
defp find_custom_field_value_by_id(member, custom_field_id_str) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == custom_field_id_str or
(match?(%{custom_field: %{id: _}}, cfv) &&
to_string(cfv.custom_field.id) == custom_field_id_str)
end)
_ ->
nil
end
end
def format_selected_member_emails(members, selected_members) do
members
|> Enum.filter(fn member ->

View file

@ -0,0 +1,61 @@
defmodule MvWeb.MemberLive.Index.CustomFieldValueLookup do
@moduledoc """
Centralized lookup for a member's `custom_field_values` entry that matches
a given custom field.
Two callable shapes:
* `find_by_id/2` match against a stringified UUID (used by the URL-param
driven date and boolean filter pipelines).
* `find_by_field/2` match against a loaded `%CustomField{}` struct
(used by the table rendering / display path that already has the
field record at hand).
Both forms handle the two CFV layouts that appear on a loaded member:
* the direct foreign key `%{custom_field_id: id, value: ...}`
* the nested loaded relation `%{custom_field: %{id: id, ...}, value: ...}`
All non-loaded or empty containers (`nil`, `%Ash.NotLoaded{}`, empty list)
return `nil`.
"""
@doc """
Returns the CFV entry whose custom field id, compared as a string, equals
`custom_field_id_str`. Returns `nil` when no entry matches or the
`custom_field_values` association is not a list.
"""
@spec find_by_id(map(), String.t()) :: map() | nil
def find_by_id(member, custom_field_id_str) when is_binary(custom_field_id_str) do
member
|> Map.get(:custom_field_values)
|> find_in(fn cfv -> cfv_id_string(cfv) == custom_field_id_str end)
end
@doc """
Returns the CFV entry whose custom field id matches the given
`custom_field` struct's `:id`. The comparison is identity-based (not
stringified) because both sides are typically `Ash.UUID` binaries; falls
back to string comparison so atom-id callers still work.
"""
@spec find_by_field(map(), map()) :: map() | nil
def find_by_field(member, %{id: field_id}) do
member
|> Map.get(:custom_field_values)
|> find_in(fn cfv -> cfv_id(cfv) == field_id end)
end
defp find_in(values, predicate) when is_list(values), do: Enum.find(values, predicate)
defp find_in(_other, _predicate), do: nil
defp cfv_id(%{custom_field_id: id}) when not is_nil(id), do: id
defp cfv_id(%{custom_field: %{id: id}}) when not is_nil(id), do: id
defp cfv_id(_), do: nil
defp cfv_id_string(cfv) do
case cfv_id(cfv) do
nil -> nil
id -> to_string(id)
end
end
end

View file

@ -31,12 +31,25 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
require Ash.Query
import Ash.Expr
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.FilterParams
@join_date_from_param Mv.Constants.join_date_from_param()
@join_date_to_param Mv.Constants.join_date_to_param()
@exit_date_mode_param Mv.Constants.exit_date_mode_param()
@exit_date_from_param Mv.Constants.exit_date_from_param()
@exit_date_to_param Mv.Constants.exit_date_to_param()
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
@max_uuid_length Mv.Constants.max_uuid_length()
# An id stripped from a cdf_-prefixed param still has its `_from` / `_to`
# bound suffix attached when we first see it. The longest legal suffix is
# `_from` (5 chars), so the upper bound on a valid suffixed_id is
# @max_uuid_length + 5. Anything longer cannot map to a known custom date
# field and is rejected before further string work — matching the same
# DoS-protection contract enforced by the boolean / group / fee_type
# filter parsers in `MvWeb.MemberLive.Index`.
@max_suffixed_id_length @max_uuid_length + 5
@doc """
Returns the default date filter state used on fresh page load and after
@ -139,15 +152,41 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
Today's date is captured via `Date.utc_today/0`; callers needing a frozen
clock should wrap the call site, not this function.
The caller is expected to pass an `%Ash.Query{}` (typically built with
`Ash.Query.new/1` or via earlier filter chaining), matching the convention
used by the sibling `apply_search_filter/2`, `apply_group_filters/3`, and
`apply_fee_type_filters/3` helpers in `MvWeb.MemberLive.Index`.
"""
@spec apply_ash_filter(Ash.Query.t() | module(), map()) :: Ash.Query.t()
def apply_ash_filter(query_or_resource, filters) when is_map(filters) do
query_or_resource
|> Ash.Query.new()
|> apply_exit_date_filter(Map.get(filters, :exit_date, %{}))
|> apply_join_date_filter(Map.get(filters, :join_date, %{}))
@spec apply_ash_filter(Ash.Query.t(), map()) :: Ash.Query.t()
def apply_ash_filter(%Ash.Query{} = query, filters) when is_map(filters) do
exit_bounds = normalize_exit_bounds(Map.get(filters, :exit_date, %{}))
join_bounds = normalize_join_bounds(Map.get(filters, :join_date, %{}))
query
|> apply_exit_date_filter(exit_bounds)
|> apply_join_date_filter(join_bounds)
end
# Defensive shape normalization: callers may supply maps where one bound key
# is absent entirely (not just nil). Pattern-match heads require both keys
# present, so we backfill nil here.
defp normalize_exit_bounds(bounds) when is_map(bounds) do
%{
mode: Map.get(bounds, :mode, :active_only),
from: Map.get(bounds, :from),
to: Map.get(bounds, :to)
}
end
defp normalize_exit_bounds(_), do: %{mode: :active_only, from: nil, to: nil}
defp normalize_join_bounds(bounds) when is_map(bounds) do
%{from: Map.get(bounds, :from), to: Map.get(bounds, :to)}
end
defp normalize_join_bounds(_), do: %{from: nil, to: nil}
defp apply_exit_date_filter(query, %{mode: :all}), do: query
defp apply_exit_date_filter(query, %{mode: :active_only}) do
@ -231,6 +270,23 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
end
end
@doc """
Returns the UUID string keys of `filters` that name an active (at-least-one-
bound-set) custom date field. The UUID must appear in `date_custom_fields`
(matched by `to_string(field.id)` and `value_type == :date`); other entries
are dropped.
Use this to compute which custom field values must be loaded so the
in-memory predicate (`apply_in_memory/3`) has the data it needs.
"""
@spec active_custom_field_ids(map(), [map()]) :: [String.t()]
def active_custom_field_ids(filters, date_custom_fields)
when is_map(filters) and is_list(date_custom_fields) do
filters
|> active_custom_date_filters(date_custom_fields)
|> Enum.map(fn {id, _bounds} -> id end)
end
defp matches_all_custom_dates?(member, active_filters) do
Enum.all?(active_filters, fn {id, bounds} ->
member_matches_custom_date?(member, id, bounds)
@ -238,10 +294,7 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
end
defp active_custom_date_filters(filters, date_custom_fields) do
valid_ids =
date_custom_fields
|> Enum.filter(&date_field?/1)
|> MapSet.new(&to_string(field_id(&1)))
valid_ids = valid_custom_date_field_ids(date_custom_fields)
filters
|> Enum.filter(fn
@ -261,25 +314,11 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
end
defp extract_member_date(member, custom_field_id) do
case Map.get(member, :custom_field_values) do
values when is_list(values) ->
values
|> Enum.find(&cfv_matches_id?(&1, custom_field_id))
|> extract_date_from_cfv()
_ ->
nil
end
member
|> CustomFieldValueLookup.find_by_id(custom_field_id)
|> extract_date_from_cfv()
end
defp cfv_matches_id?(%{custom_field_id: cfid}, id) when not is_nil(cfid),
do: to_string(cfid) == id
defp cfv_matches_id?(%{custom_field: %{id: cfid}}, id) when not is_nil(cfid),
do: to_string(cfid) == id
defp cfv_matches_id?(_, _), do: false
defp extract_date_from_cfv(nil), do: nil
defp extract_date_from_cfv(%{value: value}), do: extract_date_value(value)
@ -354,18 +393,16 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
defp parse_exit_date_mode(_), do: :active_only
defp parse_custom_date_filters(params, date_custom_fields, base) do
valid_ids =
date_custom_fields
|> Enum.filter(&date_field?/1)
|> MapSet.new(&to_string(field_id(&1)))
prefix = @custom_date_filter_prefix
valid_ids = valid_custom_date_field_ids(date_custom_fields)
# FilterParams.parse_prefix_filters narrows the params map to the
# cdf_-prefixed subset once; the per-entry work below scales with the
# date filter count, not the full form-param map size.
params
|> Enum.reduce(base, fn {key, value}, acc ->
with true <- is_binary(key),
true <- String.starts_with?(key, prefix),
{id, bound} <- split_custom_date_key(key, prefix),
|> FilterParams.parse_prefix_filters(@custom_date_filter_prefix, & &1)
|> Enum.reduce(base, fn {suffixed_id, value}, acc ->
with true <- bounded_id?(suffixed_id),
{id, bound} <- split_suffix(suffixed_id),
true <- MapSet.member?(valid_ids, id),
%Date{} = date <- parse_date(value) do
update_custom_date_entry(acc, id, bound, date)
@ -375,20 +412,35 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
end)
end
# Reject any suffixed_id that could not possibly fit a UUID + bound suffix
# before doing further string work. This is the DoS-protection contract
# used by the boolean / group / fee_type filter parsers in
# `MvWeb.MemberLive.Index` (see `process_boolean_filter_param/5`,
# `add_group_filter_entry/4`, `add_fee_type_filter_entry/4`).
defp bounded_id?(suffixed_id) when is_binary(suffixed_id),
do: String.length(suffixed_id) <= @max_suffixed_id_length
defp bounded_id?(_), do: false
defp date_field?(%{value_type: :date}), do: true
defp date_field?(_), do: false
defp field_id(%{id: id}), do: id
defp split_custom_date_key(key, prefix) do
rest = String.slice(key, String.length(prefix), String.length(key))
# Single source of truth for the set of valid custom-date-field UUID strings.
# Used both when parsing URL params (to drop bogus UUIDs) and when computing
# which active filter entries actually correspond to a known date field.
defp valid_custom_date_field_ids(date_custom_fields) do
date_custom_fields
|> Enum.filter(&date_field?/1)
|> MapSet.new(&to_string(&1.id))
end
defp split_suffix(suffixed_id) do
cond do
String.ends_with?(rest, "_from") ->
{String.slice(rest, 0, String.length(rest) - 5), :from}
String.ends_with?(suffixed_id, "_from") ->
{String.replace_suffix(suffixed_id, "_from", ""), :from}
String.ends_with?(rest, "_to") ->
{String.slice(rest, 0, String.length(rest) - 3), :to}
String.ends_with?(suffixed_id, "_to") ->
{String.replace_suffix(suffixed_id, "_to", ""), :to}
true ->
:error

View file

@ -1,8 +1,12 @@
defmodule MvWeb.MemberLive.Index.FilterParams do
@moduledoc """
Shared parsing helpers for member list filter URL/params (in/not_in style).
Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs.
Shared parsing helpers for member list filter URL/params.
Used by `MvWeb.MemberLive.Index`, `MvWeb.Components.MemberFilterComponent`,
and `MvWeb.MemberLive.Index.DateFilter` to avoid duplication and to keep
param-extraction logic in one place.
"""
@doc """
Parses a value for group or fee-type filter params.
Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion.
@ -19,4 +23,29 @@ defmodule MvWeb.MemberLive.Index.FilterParams do
end
def parse_in_not_in_value(_), do: nil
@doc """
Selects every `{key, value}` pair in `params` whose `key` is a binary that
starts with `prefix`, strips the prefix from the key, runs `parse_value_fn`
on the value, and accumulates the results into a map.
Non-binary keys are ignored. Exactly one occurrence of the prefix is
stripped (so a key like `"p_p_abc"` with prefix `"p_"` yields id `"p_abc"`).
The prefix-match filter is applied before the reduce so unrelated params
(e.g. `query`, `sort_field`, other-prefix filters) do not enter the
per-entry work keeping the cost proportional to the matched subset on
every `phx-change` keystroke.
"""
@spec parse_prefix_filters(map(), String.t(), (String.t() -> term())) ::
%{optional(String.t()) => term()}
def parse_prefix_filters(params, prefix, parse_value_fn)
when is_map(params) and is_binary(prefix) and is_function(parse_value_fn, 1) do
params
|> Enum.filter(fn {key, _} -> is_binary(key) and String.starts_with?(key, prefix) end)
|> Enum.reduce(%{}, fn {key, value}, acc ->
id = String.replace_prefix(key, prefix, "")
Map.put(acc, id, parse_value_fn.(value))
end)
end
end