Add filter for date fields closes #340 #497
10 changed files with 1037 additions and 140 deletions
|
|
@ -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", %{})
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do
|
|||
Unit tests for `DateFilter.apply_in_memory/3` — the post-`Ash.read!`
|
||||
predicate that filters members by custom date field values stored as
|
||||
JSONB `Ash.Union` types in `custom_field_values`.
|
||||
|
||||
Integration coverage against a real database lives in the second module
|
||||
in this file (DateFilterCustomFieldIntegrationTest).
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
|
|
@ -208,3 +211,109 @@ defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldIntegrationTest do
|
||||
@moduledoc """
|
||||
Integration tests for custom date field filtering on /members (§1.13, §1.14).
|
||||
|
||||
Creates a real `:date`-typed CustomField plus members with corresponding
|
||||
CustomFieldValue rows, then asserts visibility through the LiveView with
|
||||
`cdf_<uuid>_from` and `cdf_<uuid>_to` URL params.
|
||||
"""
|
||||
# async: false because we mutate global custom_fields and custom_field_values tables.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||
|
||||
setup do
|
||||
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,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, alice} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, bob} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, carla} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Carla", last_name: "Carter", email: "carla@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, dan_no_value} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Dan", last_name: "Dixon", email: "dan@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
create_cfv(system_actor, alice.id, field.id, ~D[2020-05-15])
|
||||
create_cfv(system_actor, bob.id, field.id, ~D[2022-08-01])
|
||||
create_cfv(system_actor, carla.id, field.id, ~D[2024-02-20])
|
||||
|
||||
%{
|
||||
field: field,
|
||||
alice: alice,
|
||||
bob: bob,
|
||||
carla: carla,
|
||||
dan_no_value: dan_no_value
|
||||
}
|
||||
end
|
||||
|
||||
defp create_cfv(actor, member_id, custom_field_id, %Date{} = date) do
|
||||
{:ok, cfv} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_id,
|
||||
custom_field_id: custom_field_id,
|
||||
value: %{"_union_type" => "date", "_union_value" => Date.to_iso8601(date)}
|
||||
})
|
||||
|> Ash.create(actor: actor, domain: Mv.Membership)
|
||||
|
||||
cfv
|
||||
end
|
||||
|
||||
describe "custom date field URL filter" do
|
||||
test "from-only includes members with value >= bound (§1.13)",
|
||||
%{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2022-01-01")
|
||||
refute html =~ alice.first_name
|
||||
assert html =~ bob.first_name
|
||||
assert html =~ carla.first_name
|
||||
end
|
||||
|
||||
test "from+to applies inclusive range (§1.14)",
|
||||
%{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
url = "/members?cdf_#{field.id}_from=2022-01-01&cdf_#{field.id}_to=2023-12-31"
|
||||
{:ok, _view, html} = live(conn, url)
|
||||
refute html =~ alice.first_name
|
||||
assert html =~ bob.first_name
|
||||
refute html =~ carla.first_name
|
||||
end
|
||||
|
||||
test "excludes member with no value for the active custom date field (§1.13)",
|
||||
%{conn: conn, field: field, dan_no_value: dan} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2000-01-01")
|
||||
refute html =~ dan.first_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,3 +22,123 @@ defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule MvWeb.MemberLive.Index.DateFilterDefaultIntegrationTest do
|
||||
@moduledoc """
|
||||
Integration tests for the default exit_date filter behavior on the member
|
||||
overview page (§1.1, §1.2, §1.3, §1.4, §1.6 in the issue specs).
|
||||
|
||||
These exercise the full `mount/3` → `handle_params/3` → `load_members/1`
|
||||
pipeline against a real database, asserting that the active-only default
|
||||
is applied to a fresh page load and overridden when the URL says so.
|
||||
"""
|
||||
# async: false because we mutate the global member table.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
today = Date.utc_today()
|
||||
|
||||
{:ok, active_no_exit} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Anna", last_name: "Active", email: "anna@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, future_exit} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Felix",
|
||||
last_name: "Future",
|
||||
email: "felix@example.com",
|
||||
join_date: Date.add(today, -365),
|
||||
exit_date: Date.add(today, 30)
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, exit_today} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Tina",
|
||||
last_name: "Today",
|
||||
email: "tina@example.com",
|
||||
join_date: Date.add(today, -365),
|
||||
exit_date: today
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, past_exit} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Paula",
|
||||
last_name: "Past",
|
||||
email: "paula@example.com",
|
||||
join_date: Date.add(today, -365),
|
||||
exit_date: Date.add(today, -1)
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{
|
||||
active_no_exit: active_no_exit,
|
||||
future_exit: future_exit,
|
||||
exit_today: exit_today,
|
||||
past_exit: past_exit
|
||||
}
|
||||
end
|
||||
|
||||
describe "fresh load — no URL params" do
|
||||
test "hides member with exit_date strictly before today (§1.1)", %{
|
||||
conn: conn,
|
||||
past_exit: past
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
refute html =~ past.first_name
|
||||
end
|
||||
|
||||
test "hides member whose exit_date equals today (§1.4)", %{
|
||||
conn: conn,
|
||||
exit_today: exit_today
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
refute html =~ exit_today.first_name
|
||||
end
|
||||
|
||||
test "shows member with no exit_date (§1.2)", %{conn: conn, active_no_exit: anna} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
assert html =~ anna.first_name
|
||||
end
|
||||
|
||||
test "shows member with exit_date strictly in the future (§1.3)", %{
|
||||
conn: conn,
|
||||
future_exit: felix
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
assert html =~ felix.first_name
|
||||
end
|
||||
end
|
||||
|
||||
describe "URL with ed_mode=all overrides the default (§1.6)" do
|
||||
test "shows former members when URL contains ed_mode=all", %{
|
||||
conn: conn,
|
||||
past_exit: past,
|
||||
exit_today: today,
|
||||
active_no_exit: anna,
|
||||
future_exit: felix
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?ed_mode=all")
|
||||
assert html =~ past.first_name
|
||||
assert html =~ today.first_name
|
||||
assert html =~ anna.first_name
|
||||
assert html =~ felix.first_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
defmodule MvWeb.MemberLive.Index.DateFilterTest do
|
||||
@moduledoc """
|
||||
Unit tests for DateFilter URL codec and pure helpers.
|
||||
DB-level filtering and in-memory custom field filtering are covered in
|
||||
separate integration tests (date_filter_default_test, date_filter_test,
|
||||
date_filter_custom_field_test).
|
||||
DB-level filtering against real members is covered by the integration
|
||||
module below in this file.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
|
|
@ -201,7 +200,12 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do
|
|||
|
||||
alias Mv.Membership.Member, as: MemberResource
|
||||
|
||||
defp base_query, do: MemberResource
|
||||
# The production caller (`MvWeb.MemberLive.Index.load_members/1`) hands
|
||||
# `apply_ash_filter/2` an already-built `%Ash.Query{}`, matching the
|
||||
# convention of the sibling `apply_*_filters` helpers. The shape contract
|
||||
# tests mirror that convention so they exercise the exact call shape used
|
||||
# in production.
|
||||
defp base_query, do: Ash.Query.new(MemberResource)
|
||||
|
||||
test ":all mode and empty join_date is a no-op" do
|
||||
filters = %{
|
||||
|
|
@ -269,20 +273,102 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do
|
|||
refute is_nil(query.filter)
|
||||
end
|
||||
|
||||
test "accepts a non-resource Ash.Query as input" do
|
||||
test "raises FunctionClauseError when caller passes a bare resource module" do
|
||||
# The function now requires `%Ash.Query{}` — the production convention
|
||||
# used by every sibling `apply_*_filters` helper in
|
||||
# `MvWeb.MemberLive.Index`. A bare resource module is no longer accepted.
|
||||
filters = %{
|
||||
join_date: %{from: nil, to: nil},
|
||||
exit_date: %{mode: :inactive_only, from: nil, to: nil}
|
||||
exit_date: %{mode: :all, from: nil, to: nil}
|
||||
}
|
||||
|
||||
# Should also work when given a pre-built Ash.Query (not just a resource).
|
||||
query =
|
||||
MemberResource
|
||||
|> Ash.Query.new()
|
||||
|> DateFilter.apply_ash_filter(filters)
|
||||
# Indirect through a variable so the compiler's static type analysis
|
||||
# does not flag the deliberately invalid call shape we want to assert on.
|
||||
bare_resource = Function.identity(MemberResource)
|
||||
|
||||
assert_raise FunctionClauseError, fn ->
|
||||
DateFilter.apply_ash_filter(bare_resource, filters)
|
||||
end
|
||||
end
|
||||
|
||||
test "join_date map missing :to key still applies the from bound" do
|
||||
# A caller-supplied map can omit one bound key entirely (not just set it
|
||||
# to nil). The Ash filter must still be applied for the bound that is
|
||||
# present.
|
||||
filters = %{
|
||||
join_date: %{from: ~D[2024-01-01]},
|
||||
exit_date: %{mode: :all, from: nil, to: nil}
|
||||
}
|
||||
|
||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
||||
refute is_nil(query.filter)
|
||||
end
|
||||
|
||||
test "join_date map missing :from key still applies the to bound" do
|
||||
filters = %{
|
||||
join_date: %{to: ~D[2024-12-31]},
|
||||
exit_date: %{mode: :all, from: nil, to: nil}
|
||||
}
|
||||
|
||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
||||
refute is_nil(query.filter)
|
||||
end
|
||||
|
||||
test ":custom exit_date with only :from key still applies the bound" do
|
||||
filters = %{
|
||||
join_date: %{from: nil, to: nil},
|
||||
exit_date: %{mode: :custom, from: ~D[2024-01-01]}
|
||||
}
|
||||
|
||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
||||
refute is_nil(query.filter)
|
||||
end
|
||||
|
||||
test ":custom exit_date with only :to key still applies the bound" do
|
||||
filters = %{
|
||||
join_date: %{from: nil, to: nil},
|
||||
exit_date: %{mode: :custom, to: ~D[2024-12-31]}
|
||||
}
|
||||
|
||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
||||
refute is_nil(query.filter)
|
||||
end
|
||||
end
|
||||
|
||||
describe "active_custom_field_ids/2" do
|
||||
test "returns UUID keys with at least one bound set whose UUID matches a date field" do
|
||||
id_a = "11111111-1111-1111-1111-111111111111"
|
||||
id_b = "22222222-2222-2222-2222-222222222222"
|
||||
id_unknown = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
filters =
|
||||
DateFilter.default()
|
||||
|> Map.put(id_a, %{from: ~D[2024-01-01], to: nil})
|
||||
|> Map.put(id_b, %{from: nil, to: nil})
|
||||
|> Map.put(id_unknown, %{from: ~D[2024-06-01], to: nil})
|
||||
|
||||
ids =
|
||||
DateFilter.active_custom_field_ids(filters, [
|
||||
date_custom_field(id_a),
|
||||
date_custom_field(id_b)
|
||||
])
|
||||
|
||||
assert ids == [id_a]
|
||||
end
|
||||
|
||||
test "ignores non-binary keys (built-in atoms)" do
|
||||
assert DateFilter.active_custom_field_ids(DateFilter.default(), []) == []
|
||||
end
|
||||
|
||||
test "returns [] when no date custom fields are supplied" do
|
||||
id_a = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
filters =
|
||||
DateFilter.default()
|
||||
|> Map.put(id_a, %{from: ~D[2024-01-01], to: nil})
|
||||
|
||||
assert DateFilter.active_custom_field_ids(filters, []) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "from_params/2 — custom date field entries" do
|
||||
|
|
@ -312,5 +398,231 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do
|
|||
other -> flunk("expected nil bound for malformed input, got #{inspect(other)}")
|
||||
end
|
||||
end
|
||||
|
||||
test "strips only the trailing _from / _to suffix, not internal substrings" do
|
||||
# Construct an id that itself contains "_from" / "_to" — a quirky but
|
||||
# legal MapSet key. Trailing-only suffix stripping must leave the
|
||||
# internal substrings intact and recover the original id.
|
||||
id_with_internal = "abc_from_xyz_to_def"
|
||||
|
||||
params = %{
|
||||
"cdf_#{id_with_internal}_from" => "2024-06-01",
|
||||
"cdf_#{id_with_internal}_to" => "2024-06-30"
|
||||
}
|
||||
|
||||
filters =
|
||||
DateFilter.from_params(params, [date_custom_field(id_with_internal)])
|
||||
|
||||
assert filters[id_with_internal] ==
|
||||
%{from: ~D[2024-06-01], to: ~D[2024-06-30]}
|
||||
end
|
||||
|
||||
test "drops cdf_-prefixed keys whose id exceeds the UUID length cap" do
|
||||
# Matches the DoS-protection contract enforced by the sibling boolean,
|
||||
# group, and fee_type filter parsers: an over-long id-portion (post
|
||||
# prefix-strip, pre suffix-strip) is rejected without invoking the
|
||||
# heavier String.replace_suffix / MapSet.member? path. The id we
|
||||
# construct is well past `Mv.Constants.max_uuid_length()` (36).
|
||||
known_id = "11111111-2222-3333-4444-555555555555"
|
||||
over_long_id = String.duplicate("a", 200)
|
||||
|
||||
params = %{
|
||||
"cdf_#{over_long_id}_from" => "2024-06-01",
|
||||
"cdf_#{over_long_id}_to" => "2024-06-30"
|
||||
}
|
||||
|
||||
filters = DateFilter.from_params(params, [date_custom_field(known_id)])
|
||||
|
||||
refute Map.has_key?(filters, over_long_id)
|
||||
refute Map.has_key?(filters, "#{over_long_id}_from")
|
||||
refute Map.has_key?(filters, "#{over_long_id}_to")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule MvWeb.MemberLive.Index.DateFilterIntegrationTest do
|
||||
@moduledoc """
|
||||
Integration tests for the date filter URL → query → result-set pipeline.
|
||||
|
||||
Covers §1.7, §1.8, §1.9, §1.10, §1.11, §1.12, §1.15, §1.16, §1.17, §1.18,
|
||||
§1.19. Custom date field filters are covered in the dedicated custom-field
|
||||
integration test.
|
||||
"""
|
||||
# async: false: mutates the global member table.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
today = Date.utc_today()
|
||||
|
||||
{:ok, alice} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
join_date: ~D[2020-01-01]
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, bob} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com",
|
||||
join_date: ~D[2022-06-15]
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, carla} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Carla",
|
||||
last_name: "Carter",
|
||||
email: "carla@example.com",
|
||||
join_date: ~D[2024-03-20]
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, dan} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Dan",
|
||||
last_name: "Dixon",
|
||||
email: "dan@example.com"
|
||||
# no join_date — should be excluded by any join_date range filter
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, former} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Frida",
|
||||
last_name: "Former",
|
||||
email: "frida@example.com",
|
||||
join_date: Date.add(today, -1000),
|
||||
exit_date: Date.add(today, -10)
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{alice: alice, bob: bob, carla: carla, dan: dan, former: former, today: today}
|
||||
end
|
||||
|
||||
describe "join_date filters" do
|
||||
test "jd_from includes members with join_date >= bound; excludes nil (§1.7, §1.17)",
|
||||
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01")
|
||||
refute html =~ alice.first_name
|
||||
assert html =~ bob.first_name
|
||||
assert html =~ carla.first_name
|
||||
refute html =~ dan.first_name
|
||||
end
|
||||
|
||||
test "jd_to excludes members with join_date > bound (§1.8)",
|
||||
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?jd_to=2022-12-31")
|
||||
assert html =~ alice.first_name
|
||||
assert html =~ bob.first_name
|
||||
refute html =~ carla.first_name
|
||||
refute html =~ dan.first_name
|
||||
end
|
||||
|
||||
test "jd_from and jd_to combine into an inclusive range (§1.9)",
|
||||
%{conn: conn, alice: alice, bob: bob, carla: carla} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01&jd_to=2023-12-31")
|
||||
refute html =~ alice.first_name
|
||||
assert html =~ bob.first_name
|
||||
refute html =~ carla.first_name
|
||||
end
|
||||
|
||||
test "no active filter imposes no constraint on the join_date field (§1.18)",
|
||||
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
|
||||
# Nil join_date members are still visible when no join_date filter is active.
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
assert html =~ alice.first_name
|
||||
assert html =~ bob.first_name
|
||||
assert html =~ carla.first_name
|
||||
assert html =~ dan.first_name
|
||||
end
|
||||
end
|
||||
|
||||
describe "exit_date filters" do
|
||||
test "ed_mode=inactive_only shows only former members (§1.10)",
|
||||
%{conn: conn, alice: alice, former: former} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?ed_mode=inactive_only")
|
||||
refute html =~ alice.first_name
|
||||
assert html =~ former.first_name
|
||||
end
|
||||
|
||||
test "ed_mode=all shows all members regardless of exit_date (§1.11)",
|
||||
%{conn: conn, alice: alice, former: former} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?ed_mode=all")
|
||||
assert html =~ alice.first_name
|
||||
assert html =~ former.first_name
|
||||
end
|
||||
|
||||
test "ed_mode=custom range hides members outside the range (§1.12)",
|
||||
%{conn: conn, alice: alice, former: former, today: today} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
from = Date.add(today, -30) |> Date.to_iso8601()
|
||||
to = Date.to_iso8601(today)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members?ed_mode=custom&ed_from=#{from}&ed_to=#{to}")
|
||||
refute html =~ alice.first_name
|
||||
assert html =~ former.first_name
|
||||
end
|
||||
end
|
||||
|
||||
describe "filter combination and URL persistence" do
|
||||
test "join_date filter combined with the default (active-only) shows only active members in range (§1.15)",
|
||||
%{conn: conn, alice: alice, bob: bob, former: former} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?jd_from=2020-01-01")
|
||||
# Default active-only hides the former member even though they match join_date range.
|
||||
refute html =~ former.first_name
|
||||
assert html =~ alice.first_name
|
||||
assert html =~ bob.first_name
|
||||
end
|
||||
|
||||
test "date filter survives reload via URL params (§1.16)",
|
||||
%{conn: conn, alice: alice, bob: bob, carla: carla} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
url = "/members?jd_from=2022-01-01&jd_to=2023-12-31"
|
||||
{:ok, _view, html1} = live(conn, url)
|
||||
{:ok, _view, html2} = live(conn, url)
|
||||
# Same URL → same visible result set.
|
||||
for member <- [alice, carla] do
|
||||
refute html1 =~ member.first_name
|
||||
refute html2 =~ member.first_name
|
||||
end
|
||||
|
||||
assert html1 =~ bob.first_name
|
||||
assert html2 =~ bob.first_name
|
||||
end
|
||||
|
||||
test "malformed jd_from is silently ignored (§1.19)",
|
||||
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?jd_from=notadate")
|
||||
# The filter is dropped, so default behavior (no join_date filter) applies
|
||||
# and every member shows up.
|
||||
assert html =~ alice.first_name
|
||||
assert html =~ bob.first_name
|
||||
assert html =~ carla.first_name
|
||||
assert html =~ dan.first_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
defmodule MvWeb.MemberLive.Index.CustomFieldValueLookupTest do
|
||||
@moduledoc """
|
||||
Unit tests for the shared custom-field-value lookup helper.
|
||||
|
||||
The lookup must handle both shapes a CFV entry can take on a loaded member:
|
||||
|
||||
* `%{custom_field_id: id, value: ...}` — id present directly
|
||||
* `%{custom_field: %{id: id, ...}, value: ...}` — id nested under loaded relation
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||
|
||||
defp uuid, do: "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
describe "find_by_id/2" do
|
||||
test "matches when custom_field_id key is present" do
|
||||
id = uuid()
|
||||
cfv = %{custom_field_id: id, value: :anything}
|
||||
member = %{custom_field_values: [cfv]}
|
||||
|
||||
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
|
||||
end
|
||||
|
||||
test "matches when nested custom_field relation is loaded" do
|
||||
id = uuid()
|
||||
cfv = %{custom_field: %{id: id, value_type: :date}, value: :anything}
|
||||
member = %{custom_field_values: [cfv]}
|
||||
|
||||
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
|
||||
end
|
||||
|
||||
test "compares stringified ids — accepts atom or binary ids on the cfv side" do
|
||||
id = uuid()
|
||||
cfv = %{custom_field_id: id, value: :v}
|
||||
member = %{custom_field_values: [cfv]}
|
||||
|
||||
# Same id, passed as binary
|
||||
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
|
||||
end
|
||||
|
||||
test "returns nil when no entry has a matching id" do
|
||||
member = %{
|
||||
custom_field_values: [
|
||||
%{custom_field_id: "11111111-1111-1111-1111-111111111111", value: 1}
|
||||
]
|
||||
}
|
||||
|
||||
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
|
||||
end
|
||||
|
||||
test "returns nil when custom_field_values is nil" do
|
||||
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: nil}, uuid()) == nil
|
||||
end
|
||||
|
||||
test "returns nil when custom_field_values is not loaded (Ash.NotLoaded)" do
|
||||
member = %{custom_field_values: %Ash.NotLoaded{type: :relationship}}
|
||||
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
|
||||
end
|
||||
|
||||
test "returns nil when custom_field_values is empty" do
|
||||
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: []}, uuid()) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "find_by_field/2" do
|
||||
test "matches a custom_field struct via its :id" do
|
||||
id = uuid()
|
||||
cfv = %{custom_field_id: id, value: :v}
|
||||
member = %{custom_field_values: [cfv]}
|
||||
custom_field = %{id: id, value_type: :boolean}
|
||||
|
||||
assert CustomFieldValueLookup.find_by_field(member, custom_field) == cfv
|
||||
end
|
||||
|
||||
test "matches when only the nested custom_field is present" do
|
||||
id = uuid()
|
||||
cfv = %{custom_field: %{id: id}, value: :v}
|
||||
member = %{custom_field_values: [cfv]}
|
||||
|
||||
assert CustomFieldValueLookup.find_by_field(member, %{id: id}) == cfv
|
||||
end
|
||||
|
||||
test "returns nil when no entry matches" do
|
||||
member = %{custom_field_values: [%{custom_field_id: "other", value: :v}]}
|
||||
assert CustomFieldValueLookup.find_by_field(member, %{id: uuid()}) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
85
test/mv_web/live/member_live/index/filter_params_test.exs
Normal file
85
test/mv_web/live/member_live/index/filter_params_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
defmodule MvWeb.MemberLive.Index.FilterParamsTest do
|
||||
@moduledoc """
|
||||
Unit tests for the shared filter-param parsers.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MvWeb.MemberLive.Index.FilterParams
|
||||
|
||||
describe "parse_prefix_filters/3" do
|
||||
test "extracts only entries whose key starts with the prefix" do
|
||||
params = %{
|
||||
"group_abc" => "in",
|
||||
"group_def" => "not_in",
|
||||
"fee_type_xyz" => "in",
|
||||
"unrelated" => "in",
|
||||
"query" => "alice"
|
||||
}
|
||||
|
||||
result = FilterParams.parse_prefix_filters(params, "group_", & &1)
|
||||
|
||||
assert result == %{"abc" => "in", "def" => "not_in"}
|
||||
end
|
||||
|
||||
test "strips exactly one occurrence of the prefix, even when the rest starts with the prefix again" do
|
||||
# Quirky but legal: a key like "p_p_abc" with prefix "p_" must produce id "p_abc".
|
||||
params = %{"p_p_abc" => "v"}
|
||||
result = FilterParams.parse_prefix_filters(params, "p_", & &1)
|
||||
assert result == %{"p_abc" => "v"}
|
||||
end
|
||||
|
||||
test "applies parse_value_fn to every value" do
|
||||
params = %{"x_one" => "in", "x_two" => "not_in", "x_three" => "garbage"}
|
||||
|
||||
result =
|
||||
FilterParams.parse_prefix_filters(
|
||||
params,
|
||||
"x_",
|
||||
&FilterParams.parse_in_not_in_value/1
|
||||
)
|
||||
|
||||
assert result == %{"one" => :in, "two" => :not_in, "three" => nil}
|
||||
end
|
||||
|
||||
test "returns empty map when no key matches the prefix" do
|
||||
params = %{"a" => "1", "b" => "2"}
|
||||
assert FilterParams.parse_prefix_filters(params, "z_", & &1) == %{}
|
||||
end
|
||||
|
||||
test "ignores non-binary keys" do
|
||||
params = %{"x_a" => "1", :atom_key => "2", 123 => "3"}
|
||||
result = FilterParams.parse_prefix_filters(params, "x_", & &1)
|
||||
assert result == %{"a" => "1"}
|
||||
end
|
||||
|
||||
test "returns empty map for empty input" do
|
||||
assert FilterParams.parse_prefix_filters(%{}, "x_", & &1) == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_in_not_in_value/1" do
|
||||
test "maps 'in' to :in" do
|
||||
assert FilterParams.parse_in_not_in_value("in") == :in
|
||||
end
|
||||
|
||||
test "maps 'not_in' to :not_in" do
|
||||
assert FilterParams.parse_in_not_in_value("not_in") == :not_in
|
||||
end
|
||||
|
||||
test "trims whitespace around recognized values" do
|
||||
assert FilterParams.parse_in_not_in_value(" in ") == :in
|
||||
assert FilterParams.parse_in_not_in_value("\tnot_in\n") == :not_in
|
||||
end
|
||||
|
||||
test "returns nil for unrecognized strings" do
|
||||
assert FilterParams.parse_in_not_in_value("yes") == nil
|
||||
assert FilterParams.parse_in_not_in_value("") == nil
|
||||
end
|
||||
|
||||
test "returns nil for non-binary input" do
|
||||
assert FilterParams.parse_in_not_in_value(nil) == nil
|
||||
assert FilterParams.parse_in_not_in_value(:in) == nil
|
||||
assert FilterParams.parse_in_not_in_value(123) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue