feat(member-live): wire date filters into LiveView lifecycle
This commit is contained in:
parent
ddd4a9a878
commit
e3295ab4b5
10 changed files with 1037 additions and 140 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue