Merge pull request 'Add filter for date fields closes #340' (#497) from issue/mitgliederverwaltung-340 into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #497
This commit is contained in:
moritz 2026-06-01 14:42:13 +02:00
commit c6578662d8
22 changed files with 3022 additions and 105 deletions

9
.deps_audit_ignore Normal file
View file

@ -0,0 +1,9 @@
# Temporarily ignored security advisories
#
# Format: one GHSA ID per line.
# Remove an entry once a patched version is available and the dependency is updated.
# cowlib >= 2.9.0 <= 2.16.1 — Cookie Request Header Injection via cow_cookie:cookie/1
# Severity: low. No patched version available as of 2026-05-20.
# Tracked upstream: https://github.com/advisories/GHSA-g2wm-735q-3f56
GHSA-g2wm-735q-3f56

View file

@ -48,7 +48,7 @@ steps:
# Security checks # Security checks
- mix sobelow --config - mix sobelow --config
# Check dependencies for known vulnerabilities # Check dependencies for known vulnerabilities
- mix deps.audit - mix deps.audit --ignore-file .deps_audit_ignore
# Check for dependencies that are not maintained anymore # Check for dependencies that are not maintained anymore
- mix hex.audit - mix hex.audit
# Provide hints for improving code quality # Provide hints for improving code quality
@ -155,7 +155,7 @@ steps:
# Security checks # Security checks
- mix sobelow --config - mix sobelow --config
# Check dependencies for known vulnerabilities # Check dependencies for known vulnerabilities
- mix deps.audit - mix deps.audit --ignore-file .deps_audit_ignore
# Check for dependencies that are not maintained anymore # Check for dependencies that are not maintained anymore
- mix hex.audit - mix hex.audit
# Provide hints for improving code quality # Provide hints for improving code quality

View file

@ -45,7 +45,7 @@ lint:
audit: audit:
mix sobelow --config mix sobelow --config
mix deps.audit mix deps.audit --ignore-file .deps_audit_ignore
mix hex.audit mix hex.audit
# Run all tests # Run all tests

View file

@ -26,6 +26,18 @@ defmodule Mv.Constants do
@fee_type_filter_prefix "fee_type_" @fee_type_filter_prefix "fee_type_"
@join_date_from_param "jd_from"
@join_date_to_param "jd_to"
@exit_date_mode_param "ed_mode"
@exit_date_from_param "ed_from"
@exit_date_to_param "ed_to"
@custom_date_filter_prefix "cdf_"
@max_boolean_filters 50 @max_boolean_filters 50
@max_uuid_length 36 @max_uuid_length 36
@ -84,6 +96,70 @@ defmodule Mv.Constants do
""" """
def fee_type_filter_prefix, do: @fee_type_filter_prefix def fee_type_filter_prefix, do: @fee_type_filter_prefix
@doc """
Returns the URL parameter name for the join_date lower bound filter.
## Examples
iex> Mv.Constants.join_date_from_param()
"jd_from"
"""
def join_date_from_param, do: @join_date_from_param
@doc """
Returns the URL parameter name for the join_date upper bound filter.
## Examples
iex> Mv.Constants.join_date_to_param()
"jd_to"
"""
def join_date_to_param, do: @join_date_to_param
@doc """
Returns the URL parameter name for the exit_date filter mode
(`active_only` | `inactive_only` | `all` | `custom`).
## Examples
iex> Mv.Constants.exit_date_mode_param()
"ed_mode"
"""
def exit_date_mode_param, do: @exit_date_mode_param
@doc """
Returns the URL parameter name for the exit_date lower bound filter
(only relevant when ed_mode=custom).
## Examples
iex> Mv.Constants.exit_date_from_param()
"ed_from"
"""
def exit_date_from_param, do: @exit_date_from_param
@doc """
Returns the URL parameter name for the exit_date upper bound filter
(only relevant when ed_mode=custom).
## Examples
iex> Mv.Constants.exit_date_to_param()
"ed_to"
"""
def exit_date_to_param, do: @exit_date_to_param
@doc """
Returns the prefix for custom date field filter URL parameters
(e.g. cdf_<uuid>_from / cdf_<uuid>_to).
## Examples
iex> Mv.Constants.custom_date_filter_prefix()
"cdf_"
"""
def custom_date_filter_prefix, do: @custom_date_filter_prefix
@doc """ @doc """
Returns the maximum number of boolean custom field filters allowed per request. Returns the maximum number of boolean custom field filters allowed per request.

View file

@ -23,6 +23,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
- `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All). - `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All).
- `:boolean_custom_fields` - List of boolean custom fields to display - `:boolean_custom_fields` - List of boolean custom fields to display
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
- `:date_custom_fields` - List of date-typed custom fields rendered in the
"Custom date fields" section (each with `:id`, `:name`, `:value_type`).
- `:date_filters` - Date filter state map (see `MvWeb.MemberLive.Index.DateFilter`):
built-in `:join_date` / `:exit_date` bounds and mode, plus optional
UUID-keyed custom date field bound entries.
- `:id` - Component ID (required) - `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0) - `:member_count` - Number of filtered members to display in badge (optional, default: 0)
@ -31,13 +36,18 @@ defmodule MvWeb.Components.MemberFilterComponent do
- Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in) - Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in)
- Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in) - Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in)
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
- Sends `{:date_filters_changed, new_filters}` to parent when any date
filter input changes (built-in date bounds, exit_date mode, or custom
date field bounds).
""" """
use MvWeb, :live_component use MvWeb, :live_component
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.FilterParams
@group_filter_prefix Mv.Constants.group_filter_prefix() @group_filter_prefix Mv.Constants.group_filter_prefix()
@fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix()
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
@impl true @impl true
def mount(socket) do def mount(socket) do
@ -50,19 +60,42 @@ defmodule MvWeb.Components.MemberFilterComponent do
socket socket
|> assign(:id, assigns.id) |> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter]) |> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:groups, assigns[:groups] || []) |> assign_group_assigns(assigns)
|> assign(:group_filters, assigns[:group_filters] || %{}) |> assign_fee_type_assigns(assigns)
|> assign(:group_filter_prefix, @group_filter_prefix) |> assign_boolean_assigns(assigns)
|> assign(:fee_types, assigns[:fee_types] || []) |> assign_date_assigns(assigns)
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|> assign(:member_count, assigns[:member_count] || 0) |> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket} {:ok, socket}
end end
defp assign_group_assigns(socket, assigns) do
socket
|> assign(:groups, assigns[:groups] || [])
|> assign(:group_filters, assigns[:group_filters] || %{})
|> assign(:group_filter_prefix, @group_filter_prefix)
end
defp assign_fee_type_assigns(socket, assigns) do
socket
|> assign(:fee_types, assigns[:fee_types] || [])
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
end
defp assign_boolean_assigns(socket, assigns) do
socket
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
end
defp assign_date_assigns(socket, assigns) do
socket
|> assign(:date_custom_fields, assigns[:date_custom_fields] || [])
|> assign(:date_filters, assigns[:date_filters] || DateFilter.default())
|> assign(:custom_date_filter_prefix, @custom_date_filter_prefix)
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -81,7 +114,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
"gap-2", "gap-2",
(@cycle_status_filter || map_size(@group_filters) > 0 || (@cycle_status_filter || map_size(@group_filters) > 0 ||
map_size(@fee_type_filters) > 0 || map_size(@fee_type_filters) > 0 ||
active_boolean_filters_count(@boolean_filters) > 0) && active_boolean_filters_count(@boolean_filters) > 0 ||
date_filters_active?(@date_filters)) &&
"btn-active" "btn-active"
]} ]}
phx-click="toggle_dropdown" phx-click="toggle_dropdown"
@ -99,7 +133,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
@fee_types, @fee_types,
@fee_type_filters, @fee_type_filters,
@boolean_custom_fields, @boolean_custom_fields,
@boolean_filters @boolean_filters,
@date_filters
)} )}
</span> </span>
<.badge <.badge
@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
</.badge> </.badge>
<.badge <.badge
:if={ :if={
(@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) && (@cycle_status_filter || map_size(@group_filters) > 0 ||
map_size(@fee_type_filters) > 0 ||
date_filters_active?(@date_filters)) &&
active_boolean_filters_count(@boolean_filters) == 0 active_boolean_filters_count(@boolean_filters) == 0
} }
variant="primary" variant="primary"
@ -329,6 +366,163 @@ defmodule MvWeb.Components.MemberFilterComponent do
</div> </div>
</div> </div>
<!-- Dates Group (built-in fields: exit_date with mode selector, join_date range) -->
<div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Dates")}
</div>
<fieldset class="border-0 p-0 m-0 min-w-0 mb-3">
<legend class="text-sm font-medium mb-1">
{gettext("Exit date")}
</legend>
<div class="join w-full">
<label
class={"#{exit_mode_label_class(@date_filters, :active_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-active-only"
>
<input
type="radio"
id="ed-mode-active-only"
name="ed_mode"
value="active_only"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :active_only}
/>
<span class="text-xs">{gettext("Active only")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :all)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-all"
>
<input
type="radio"
id="ed-mode-all"
name="ed_mode"
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :all}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :inactive_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-inactive-only"
>
<input
type="radio"
id="ed-mode-inactive-only"
name="ed_mode"
value="inactive_only"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :inactive_only}
/>
<span class="text-xs">{gettext("Inactive only")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :custom)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-custom"
>
<input
type="radio"
id="ed-mode-custom"
name="ed_mode"
value="custom"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :custom}
/>
<span class="text-xs">{gettext("Range")}</span>
</label>
</div>
<div
:if={exit_mode(@date_filters) == :custom}
class="mt-2 flex gap-3 items-end flex-wrap"
>
<.input
type="date"
id="ed-from"
name="ed_from"
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("Exit date from")}
value={date_value_for_input(@date_filters, :exit_date, :from)}
/>
<.input
type="date"
id="ed-to"
name="ed_to"
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("Exit date to")}
value={date_value_for_input(@date_filters, :exit_date, :to)}
/>
</div>
</fieldset>
<fieldset class="border-0 p-0 m-0 min-w-0">
<legend class="text-sm font-medium mb-1">
{gettext("Join date")}
</legend>
<div class="flex gap-3 items-end flex-wrap">
<.input
type="date"
id="jd-from"
name="jd_from"
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("Join date from")}
value={date_value_for_input(@date_filters, :join_date, :from)}
/>
<.input
type="date"
id="jd-to"
name="jd_to"
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("Join date to")}
value={date_value_for_input(@date_filters, :join_date, :to)}
/>
</div>
</fieldset>
</div>
<!-- Custom Date Fields Group (in-memory filter; one row per :date custom field) -->
<div :if={length(@date_custom_fields) > 0} class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Custom date fields")}
</div>
<div class={
if length(@date_custom_fields) > 5, do: "max-h-60 overflow-y-auto pr-2", else: ""
}>
<fieldset
:for={field <- @date_custom_fields}
class="border-0 border-b border-base-200 last:border-b-0 p-0 m-0 min-w-0 py-2"
>
<legend class="text-sm font-medium mb-1">
{field.name}
</legend>
<div class="flex gap-3 items-end flex-wrap">
<.input
type="date"
id={"cdf-#{field.id}-from"}
name={"#{@custom_date_filter_prefix}#{field.id}_from"}
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("%{field} from", field: field.name)}
value={custom_date_value_for_input(@date_filters, field.id, :from)}
/>
<.input
type="date"
id={"cdf-#{field.id}-to"}
name={"#{@custom_date_filter_prefix}#{field.id}_to"}
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("%{field} to", field: field.name)}
value={custom_date_value_for_input(@date_filters, field.id, :to)}
/>
</div>
</fieldset>
</div>
</div>
<!-- Custom Fields Group --> <!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2"> <div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider"> <div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -438,17 +632,27 @@ defmodule MvWeb.Components.MemberFilterComponent do
payment_filter = parse_payment_filter(params) payment_filter = parse_payment_filter(params)
group_filters_parsed = 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 = 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) custom_boolean_filters_parsed = parse_custom_boolean_filters(params)
new_date_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields)
dispatch_payment_filter_change(socket, payment_filter) dispatch_payment_filter_change(socket, payment_filter)
dispatch_group_filter_changes(socket, group_filters_parsed) dispatch_group_filter_changes(socket, group_filters_parsed)
dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed)
dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed)
dispatch_date_filters_change(socket, new_date_filters)
{:noreply, socket} {:noreply, socket}
end end
@ -486,17 +690,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
end end
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 defp parse_custom_boolean_filters(params) do
params params
|> Map.get("custom_boolean", %{}) |> Map.get("custom_boolean", %{})
@ -543,6 +736,12 @@ defmodule MvWeb.Components.MemberFilterComponent do
end) end)
end end
defp dispatch_date_filters_change(socket, new_date_filters) do
if new_date_filters != socket.assigns.date_filters do
send(self(), {:date_filters_changed, new_date_filters})
end
end
# Get display label for button # Get display label for button
defp button_label( defp button_label(
cycle_status_filter, cycle_status_filter,
@ -551,14 +750,16 @@ defmodule MvWeb.Components.MemberFilterComponent do
fee_types, fee_types,
fee_type_filters, fee_type_filters,
boolean_custom_fields, boolean_custom_fields,
boolean_filters boolean_filters,
date_filters
) do ) do
active_count = active_count =
count_active_filter_categories( count_active_filter_categories(
cycle_status_filter, cycle_status_filter,
group_filters, group_filters,
fee_type_filters, fee_type_filters,
boolean_filters boolean_filters,
date_filters
) )
if active_count >= 2 do if active_count >= 2 do
@ -579,6 +780,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
map_size(boolean_filters) > 0 -> map_size(boolean_filters) > 0 ->
boolean_filter_label(boolean_custom_fields, boolean_filters) boolean_filter_label(boolean_custom_fields, boolean_filters)
date_filters_active?(date_filters) ->
gettext("Dates")
true -> true ->
gettext("Apply filters") gettext("Apply filters")
end end
@ -589,17 +793,27 @@ defmodule MvWeb.Components.MemberFilterComponent do
cycle_status_filter, cycle_status_filter,
group_filters, group_filters,
fee_type_filters, fee_type_filters,
boolean_filters boolean_filters,
date_filters
) do ) do
[ [
cycle_status_filter, cycle_status_filter,
map_size(group_filters) > 0, map_size(group_filters) > 0,
map_size(fee_type_filters) > 0, map_size(fee_type_filters) > 0,
map_size(boolean_filters) > 0 map_size(boolean_filters) > 0,
date_filters_active?(date_filters)
] ]
|> Enum.count(& &1) |> Enum.count(& &1)
end end
# Date filter is "active" when its state differs from the default — i.e. the
# user selected something other than active-only with no custom date bounds.
defp date_filters_active?(date_filters) when is_map(date_filters) do
date_filters != DateFilter.default()
end
defp date_filters_active?(_), do: false
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0, defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
do: gettext("All") do: gettext("All")
@ -768,4 +982,35 @@ defmodule MvWeb.Components.MemberFilterComponent do
"#{base_classes} btn-outline" "#{base_classes} btn-outline"
end end
end end
# --- Date filter helpers ----------------------------------------------
defp exit_mode(%{exit_date: %{mode: mode}}), do: mode
defp exit_mode(_), do: :active_only
defp exit_mode_label_class(date_filters, expected) do
base_classes = "join-item btn btn-sm"
if exit_mode(date_filters) == expected do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
end
defp date_value_for_input(date_filters, field, bound) do
case date_filters do
%{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d)
_ -> ""
end
end
defp custom_date_value_for_input(date_filters, field_id, bound) do
key = to_string(field_id)
case Map.get(date_filters, key) do
%{^bound => %Date{} = d} -> Date.to_iso8601(d)
_ -> ""
end
end
end end

View file

@ -36,6 +36,8 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.FilterParams
@ -87,6 +89,13 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.filter(&(&1.value_type == :boolean)) |> Enum.filter(&(&1.value_type == :boolean))
|> Enum.sort_by(& &1.name, :asc) |> 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) # Load groups for filter dropdown (sorted by name)
groups = groups =
Mv.Membership.Group Mv.Membership.Group
@ -143,6 +152,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:custom_fields_visible, custom_fields_visible) |> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields) |> assign(:all_custom_fields, all_custom_fields)
|> assign(:boolean_custom_fields, boolean_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(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection) |> assign(:user_field_selection, initial_selection)
|> assign(:fields_in_url?, false) |> assign(:fields_in_url?, false)
@ -448,6 +459,25 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_patch(socket, to: new_path, replace: true)}
end 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 # Backward compatibility: tuple form delegates to map form
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
handle_info( handle_info(
@ -502,6 +532,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:group_filters, Map.get(opts, :group_filters, %{})) |> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|> load_members() |> load_members()
|> update_selection_assigns() |> update_selection_assigns()
@ -632,6 +663,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_group_filters(params) |> maybe_update_group_filters(params)
|> maybe_update_fee_type_filters(params) |> maybe_update_fee_type_filters(params)
|> maybe_update_boolean_filters(params) |> maybe_update_boolean_filters(params)
|> maybe_update_date_filters(params)
|> maybe_update_show_current_cycle(params) |> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?) |> assign(:fields_in_url?, fields_in_url?)
|> assign(:query, params["query"]) |> assign(:query, params["query"])
@ -683,7 +715,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters, socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection, socket.assigns.user_field_selection,
socket.assigns[:visible_custom_field_ids] || [] socket.assigns[:visible_custom_field_ids] || [],
socket.assigns[:date_filters]
} }
end end
@ -783,7 +816,12 @@ defmodule MvWeb.MemberLive.Index do
base_params = add_group_filters(base_params, opts.group_filters || %{}) 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_fee_type_filters(base_params, opts.fee_type_filters || %{})
base_params = add_show_current_cycle(base_params, opts.show_current_cycle) 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 end
defp opts_for_query_params(socket, overrides \\ %{}) do defp opts_for_query_params(socket, overrides \\ %{}) do
@ -795,7 +833,8 @@ defmodule MvWeb.MemberLive.Index do
group_filters: socket.assigns[:group_filters] || %{}, group_filters: socket.assigns[:group_filters] || %{},
show_current_cycle: socket.assigns.show_current_cycle, show_current_cycle: socket.assigns.show_current_cycle,
boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, 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) |> Map.merge(overrides)
end end
@ -941,26 +980,7 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new() |> Ash.Query.new()
|> Ash.Query.select(@overview_fields) |> Ash.Query.select(@overview_fields)
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] query = load_custom_field_values(query, compute_ids_to_load(socket))
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 = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
@ -984,6 +1004,13 @@ defmodule MvWeb.MemberLive.Index do
query = query =
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) 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) # Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields 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 # Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore # No need for in-memory filtering anymore
# Apply cycle status filter if set members = apply_in_memory_filters(members, socket)
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
)
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) # 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 # 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) assign(socket, :members, members)
end 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, []), do: query
defp load_custom_field_values(query, custom_field_ids) do 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 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 # Custom Field Value Helpers
# ------------------------------------------------------------- # -------------------------------------------------------------
def get_custom_field_value(member, custom_field) do def get_custom_field_value(member, custom_field) do
case member.custom_field_values do CustomFieldValueLookup.find_by_field(member, custom_field)
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
end end
def get_boolean_custom_field_value(member, custom_field) do def get_boolean_custom_field_value(member, custom_field) do
@ -1725,29 +1785,12 @@ defmodule MvWeb.MemberLive.Index do
end end
defp matches_filter?(member, custom_field_id_str, filter_value) do 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 nil -> false
cfv -> extract_boolean_value(cfv.value) == filter_value cfv -> extract_boolean_value(cfv.value) == filter_value
end end
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 def format_selected_member_emails(members, selected_members) do
members members
|> Enum.filter(fn member -> |> Enum.filter(fn member ->

View file

@ -54,6 +54,8 @@
fee_type_filters={@fee_type_filters} fee_type_filters={@fee_type_filters}
boolean_custom_fields={@boolean_custom_fields} boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters} boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields}
date_filters={@date_filters}
member_count={length(@members)} member_count={length(@members)}
/> />
<.tooltip <.tooltip

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

@ -0,0 +1,454 @@
defmodule MvWeb.MemberLive.Index.DateFilter do
@moduledoc """
Encapsulates the complete lifecycle of date-range filters used on the
member overview page.
Owns:
- the default filter state (active members only)
- URL encoding / decoding of filter state
- DB-level Ash expression construction for built-in date fields
(`join_date`, `exit_date`)
- in-memory predicates for custom date-typed custom fields
## Filter state shape
%{
join_date: %{from: nil | %Date{}, to: nil | %Date{}},
exit_date: %{
mode: :active_only | :inactive_only | :all | :custom,
from: nil | %Date{},
to: nil | %Date{}
},
# optional custom date field entries (UUID string keys):
"<uuid>" => %{from: nil | %Date{}, to: nil | %Date{}}
}
The default mode for `exit_date` is `:active_only`, which means
`exit_date IS NULL OR exit_date > today` a member who left today is hidden.
"""
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
"Clear filters". `exit_date` is set to `:active_only`; all other bounds are nil.
"""
@spec default() :: %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
def default do
%{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
end
@doc """
Decodes URL params into a date filter state map.
Recognized keys:
* `"jd_from"` / `"jd_to"` join_date bounds (ISO-8601 dates)
* `"ed_mode"` exit_date mode (`"active_only"` | `"inactive_only"` |
`"all"` | `"custom"`); absent or unknown values fall back to
`:active_only`
* `"ed_from"` / `"ed_to"` exit_date bounds (ISO-8601 dates, used when
`ed_mode=custom`)
* `"cdf_<uuid>_from"` / `"cdf_<uuid>_to"` custom date field bounds;
the UUID must appear (by `to_string/1` on its `:id`) in
`date_custom_fields`, otherwise the entry is dropped
Malformed ISO-8601 strings are silently discarded; the corresponding bound
stays `nil`. No exception is raised for any malformed input.
"""
@spec from_params(map(), list()) :: map()
def from_params(params, date_custom_fields)
when is_map(params) and is_list(date_custom_fields) do
base = %{
join_date: %{
from: parse_date(Map.get(params, @join_date_from_param)),
to: parse_date(Map.get(params, @join_date_to_param))
},
exit_date: %{
mode: parse_exit_date_mode(Map.get(params, @exit_date_mode_param)),
from: parse_date(Map.get(params, @exit_date_from_param)),
to: parse_date(Map.get(params, @exit_date_to_param))
}
}
parse_custom_date_filters(params, date_custom_fields, base)
end
@doc """
Encodes a date filter state map into a URL params map (string keys, string
values).
Encoding rules:
* `join_date` from/to `"jd_from"` / `"jd_to"` (omitted when nil)
* `exit_date` mode
- `:active_only` is the default and is omitted entirely (no `ed_mode`,
no bounds a fresh URL is the canonical representation of the default
state)
- `:all` / `:inactive_only` `"ed_mode"` only; bounds are omitted
- `:custom` `"ed_mode" => "custom"` plus `"ed_from"` / `"ed_to"`
when those bounds are set
* custom date field entries (UUID string keys) `"cdf_<uuid>_from"` /
`"cdf_<uuid>_to"`; each bound is included only when non-nil; an entry
with both bounds nil produces no params
All dates are serialized via `Date.to_iso8601/1`.
"""
@spec to_params(map()) :: %{optional(String.t()) => String.t()}
def to_params(filters) when is_map(filters) do
%{}
|> put_join_date_params(Map.get(filters, :join_date, %{}))
|> put_exit_date_params(Map.get(filters, :exit_date, %{}))
|> put_custom_date_params(filters)
end
@doc """
Applies the DB-level portion of the date filter `join_date` and
`exit_date` constraints to the given Ash query.
Exit_date semantics by mode:
* `:active_only` `is_nil(exit_date) or exit_date > today`
* `:inactive_only` `not is_nil(exit_date) and exit_date <= today`
* `:all` no filter added for exit_date
* `:custom` `not is_nil(exit_date)` plus the active bounds; if both
bounds are nil, no filter is added (the user picked "custom" but
entered nothing)
Join_date is purely a range filter nil join_date is always excluded when
any bound is set:
* `from` set `not is_nil(join_date) and join_date >= from`
* `to` set `not is_nil(join_date) and join_date <= to`
* neither set no filter
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(), 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
today = Date.utc_today()
Ash.Query.filter(query, expr(is_nil(exit_date) or exit_date > ^today))
end
defp apply_exit_date_filter(query, %{mode: :inactive_only}) do
today = Date.utc_today()
Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^today))
end
defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: nil}), do: query
defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: nil}) do
Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date >= ^from))
end
defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: to}) do
Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^to))
end
defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: to}) do
Ash.Query.filter(
query,
expr(not is_nil(exit_date) and exit_date >= ^from and exit_date <= ^to)
)
end
defp apply_exit_date_filter(query, _), do: query
defp apply_join_date_filter(query, %{from: nil, to: nil}), do: query
defp apply_join_date_filter(query, %{from: from, to: nil}) when not is_nil(from) do
Ash.Query.filter(query, expr(not is_nil(join_date) and join_date >= ^from))
end
defp apply_join_date_filter(query, %{from: nil, to: to}) when not is_nil(to) do
Ash.Query.filter(query, expr(not is_nil(join_date) and join_date <= ^to))
end
defp apply_join_date_filter(query, %{from: from, to: to})
when not is_nil(from) and not is_nil(to) do
Ash.Query.filter(
query,
expr(not is_nil(join_date) and join_date >= ^from and join_date <= ^to)
)
end
defp apply_join_date_filter(query, _), do: query
@doc """
Applies the in-memory portion of the date filter custom date fields
whose values live in JSONB-backed `custom_field_values`.
Behavior:
* Only entries whose UUID key matches a `date_custom_fields` entry
(by `to_string(field.id)` and `value_type == :date`) are considered.
* Entries with both bounds nil add no constraint.
* For an active entry, a member is kept iff its custom field value is
present AND the value (unwrapped from `%Ash.Union{type: :date}`)
satisfies `value >= from` (when from set) AND `value <= to`
(when to set).
* Members with `custom_field_values` nil, `%Ash.NotLoaded{}`, an empty
list, or no entry for the active field are excluded.
* Non-date `Ash.Union` types are treated as "no value" and exclude the
member.
Returns the filtered list of members (order preserved).
"""
@spec apply_in_memory([map()], map(), [map()]) :: [map()]
def apply_in_memory(members, filters, date_custom_fields)
when is_list(members) and is_map(filters) and is_list(date_custom_fields) do
active_filters = active_custom_date_filters(filters, date_custom_fields)
if active_filters == [] do
members
else
Enum.filter(members, &matches_all_custom_dates?(&1, active_filters))
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)
end)
end
defp active_custom_date_filters(filters, date_custom_fields) do
valid_ids = valid_custom_date_field_ids(date_custom_fields)
filters
|> Enum.filter(fn
{key, %{from: from, to: to}} when is_binary(key) ->
MapSet.member?(valid_ids, key) and (not is_nil(from) or not is_nil(to))
_ ->
false
end)
end
defp member_matches_custom_date?(member, custom_field_id, %{from: from, to: to}) do
case extract_member_date(member, custom_field_id) do
%Date{} = date -> within_bounds?(date, from, to)
_ -> false
end
end
defp extract_member_date(member, custom_field_id) do
member
|> CustomFieldValueLookup.find_by_id(custom_field_id)
|> extract_date_from_cfv()
end
defp extract_date_from_cfv(nil), do: nil
defp extract_date_from_cfv(%{value: value}), do: extract_date_value(value)
defp extract_date_from_cfv(_), do: nil
defp extract_date_value(%Ash.Union{value: %Date{} = date, type: :date}), do: date
defp extract_date_value(_), do: nil
defp within_bounds?(%Date{} = date, from, to) do
from_ok? = is_nil(from) or Date.compare(date, from) != :lt
to_ok? = is_nil(to) or Date.compare(date, to) != :gt
from_ok? and to_ok?
end
defp put_join_date_params(params, %{from: from, to: to}) do
params
|> maybe_put_date(@join_date_from_param, from)
|> maybe_put_date(@join_date_to_param, to)
end
defp put_join_date_params(params, _), do: params
defp put_exit_date_params(params, %{mode: :active_only}), do: params
defp put_exit_date_params(params, %{mode: mode})
when mode in [:all, :inactive_only] do
Map.put(params, @exit_date_mode_param, Atom.to_string(mode))
end
defp put_exit_date_params(params, %{mode: :custom, from: from, to: to}) do
params
|> Map.put(@exit_date_mode_param, "custom")
|> maybe_put_date(@exit_date_from_param, from)
|> maybe_put_date(@exit_date_to_param, to)
end
defp put_exit_date_params(params, _), do: params
defp put_custom_date_params(params, filters) do
prefix = @custom_date_filter_prefix
filters
|> Enum.filter(fn {key, _value} -> is_binary(key) end)
|> Enum.reduce(params, fn {id, %{from: from, to: to}}, acc ->
acc
|> maybe_put_date("#{prefix}#{id}_from", from)
|> maybe_put_date("#{prefix}#{id}_to", to)
end)
end
defp maybe_put_date(params, _key, nil), do: params
defp maybe_put_date(params, key, %Date{} = date),
do: Map.put(params, key, Date.to_iso8601(date))
defp parse_date(nil), do: nil
defp parse_date(value) when is_binary(value) do
case Date.from_iso8601(String.trim(value)) do
{:ok, date} -> date
_ -> nil
end
end
defp parse_date(_), do: nil
defp parse_exit_date_mode("all"), do: :all
defp parse_exit_date_mode("inactive_only"), do: :inactive_only
defp parse_exit_date_mode("custom"), do: :custom
defp parse_exit_date_mode("active_only"), do: :active_only
defp parse_exit_date_mode(_), do: :active_only
defp parse_custom_date_filters(params, date_custom_fields, base) do
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
|> 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)
else
_ -> acc
end
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
# 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?(suffixed_id, "_from") ->
{String.replace_suffix(suffixed_id, "_from", ""), :from}
String.ends_with?(suffixed_id, "_to") ->
{String.replace_suffix(suffixed_id, "_to", ""), :to}
true ->
:error
end
end
defp update_custom_date_entry(acc, id, bound, date) do
current = Map.get(acc, id, %{from: nil, to: nil})
Map.put(acc, id, Map.put(current, bound, date))
end
end

View file

@ -1,8 +1,12 @@
defmodule MvWeb.MemberLive.Index.FilterParams do defmodule MvWeb.MemberLive.Index.FilterParams do
@moduledoc """ @moduledoc """
Shared parsing helpers for member list filter URL/params (in/not_in style). Shared parsing helpers for member list filter URL/params.
Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs.
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 """ @doc """
Parses a value for group or fee-type filter params. Parses a value for group or fee-type filter params.
Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion. Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion.
@ -19,4 +23,29 @@ defmodule MvWeb.MemberLive.Index.FilterParams do
end end
def parse_in_not_in_value(_), do: nil 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 end

View file

@ -7,7 +7,7 @@
"ash_postgres": {:hex, :ash_postgres, "2.9.1", "bf4229d65706f794650edb47c9f30138a6e2d5af6efe002ca38e619306cca9f6", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "72c0366649985a858d4ef8f906968cee339dfd7519bb0beaa2b4d87f3d5b0bb9"}, "ash_postgres": {:hex, :ash_postgres, "2.9.1", "bf4229d65706f794650edb47c9f30138a6e2d5af6efe002ca38e619306cca9f6", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "72c0366649985a858d4ef8f906968cee339dfd7519bb0beaa2b4d87f3d5b0bb9"},
"ash_sql": {:hex, :ash_sql, "0.6.3", "a708b34ba71b40141dab9e75dc44a095885ae4635b25135d3fd4c3620b299b97", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "3ee461380d96dca32766a210ea60c64783f690ad5565f0434a00cd475e71e8b9"}, "ash_sql": {:hex, :ash_sql, "0.6.3", "a708b34ba71b40141dab9e75dc44a095885ae4635b25135d3fd4c3620b299b97", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "3ee461380d96dca32766a210ea60c64783f690ad5565f0434a00cd475e71e8b9"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.11.0", "dbdd9c9963f146ee9da9860d1ee5b0ffd65cea51fe2aab3f3273df84329d133a", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c949d93a325a28da2333dde5a9ab61986ad2c2b7226347db6a28303b9139865e"}, "bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
@ -16,7 +16,7 @@
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"}, "cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"}, "cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
@ -71,7 +71,7 @@
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"}, "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},

View file

@ -3897,3 +3897,78 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt." msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt."
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} from"
msgstr "%{field} von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} to"
msgstr "%{field} bis"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Active only"
msgstr "Nur aktive"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Custom date fields"
msgstr "Benutzerdefinierte Datumsfelder"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Dates"
msgstr "Daten"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date from"
msgstr "Austrittsdatum von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date to"
msgstr "Austrittsdatum bis"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr "Von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Inactive only"
msgstr "Nur ehemalige"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date"
msgstr "Beitrittsdatum"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date from"
msgstr "Beitrittsdatum von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date to"
msgstr "Beitrittsdatum bis"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr "Zeitraum"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr "Bis"

View file

@ -3897,3 +3897,78 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
msgstr "" msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Active only"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Custom date fields"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Dates"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Inactive only"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""

View file

@ -3897,3 +3897,78 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only."
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Active only"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom date fields"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Dates"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Inactive only"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date to"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""

View file

@ -0,0 +1,33 @@
defmodule Mv.ConstantsTest do
@moduledoc """
Tests for Mv.Constants accessor functions. Focus is on the date filter
URL parameter prefixes that drive the bookmarkable filter state.
"""
use ExUnit.Case, async: true
describe "date filter URL param prefixes" do
test "join_date_from_param/0 returns jd_from" do
assert Mv.Constants.join_date_from_param() == "jd_from"
end
test "join_date_to_param/0 returns jd_to" do
assert Mv.Constants.join_date_to_param() == "jd_to"
end
test "exit_date_mode_param/0 returns ed_mode" do
assert Mv.Constants.exit_date_mode_param() == "ed_mode"
end
test "exit_date_from_param/0 returns ed_from" do
assert Mv.Constants.exit_date_from_param() == "ed_from"
end
test "exit_date_to_param/0 returns ed_to" do
assert Mv.Constants.exit_date_to_param() == "ed_to"
end
test "custom_date_filter_prefix/0 returns cdf_" do
assert Mv.Constants.custom_date_filter_prefix() == "cdf_"
end
end
end

View file

@ -209,6 +209,57 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
# Button should still contain some text (truncated version or indicator) # Button should still contain some text (truncated version or indicator)
assert String.length(button_html) > 0 assert String.length(button_html) > 0
end end
test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=all")
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
# The idle label must not appear; some non-idle label is shown. This is
# the same observable contract as the other filter categories — the
# button visually communicates "a filter is active". The `btn-active`
# CSS class is set by the parent class= attribute but the `<.button>`
# core component currently composes its own class string and drops the
# caller-supplied one — that is a pre-existing component constraint, not
# specific to date filters.
refute button_html =~ gettext("Apply filters")
end
test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15")
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
refute button_html =~ gettext("Apply filters")
end
test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
{:ok, view, _html} =
live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true")
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
# With two distinct filter categories active, the label switches to the
# pluralized "N filters active" form. Without counting date filters as
# a category, this would show only "1 filter active" or the boolean
# field name.
assert button_html =~ "2"
assert button_html =~ gettext("filters active")
end
end end
describe "badge" do describe "badge" do
@ -268,6 +319,293 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
refute dropdown_html =~ "String Field" refute dropdown_html =~ "String Field"
end end
test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ gettext("Dates")
assert dropdown_html =~ gettext("Join date")
assert dropdown_html =~ gettext("Exit date")
# Exit-date segmented control modes.
assert dropdown_html =~ gettext("Active only")
assert dropdown_html =~ gettext("Inactive only")
# Built-in date inputs (always present for join_date and the ed_mode selector).
assert dropdown_html =~ ~s(name="jd_from")
assert dropdown_html =~ ~s(name="jd_to")
assert dropdown_html =~ ~s(name="ed_mode")
end
test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ ~s(name="ed_from")
assert dropdown_html =~ ~s(name="ed_to")
end
test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)",
%{conn: conn} do
# DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's
# `<.input>` wrapper rather than emitting raw `<input>` tags carrying
# DaisyUI component classes (e.g. `input input-sm input-bordered`)
# directly in HEEX. `<.input>` is the project's single source of truth
# for input styling; bypassing it splits styling across many call sites.
#
# The recognizable structural fingerprint of `<.input>` is a wrapping
# `<fieldset class="mb-2 fieldset">` `<label>` chain immediately
# preceding the `<input>`. The raw inline form has no such wrapper —
# the input sits directly inside a sibling `<label>`/`<input>` flex row.
# We assert that fingerprint on each of the date inputs.
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
for name <- ["jd_from", "jd_to", "ed_from", "ed_to"] do
# Match `<fieldset class="mb-2 fieldset">` followed (within a short
# window of HTML) by an `<input>` carrying the expected `name`. The
# window prevents the regex from spanning unrelated `mb-2` /
# `fieldset` occurrences scattered across the dropdown. The wrapper
# is the canonical fingerprint of `MvWeb.CoreComponents.input/1`
# (see `lib/mv_web/components/core_components.ex` — every input
# branch starts with `<fieldset class="mb-2 fieldset">`).
assert Regex.match?(
~r/<fieldset[^>]*class="mb-2 fieldset"[^>]*>\s*<label[^>]*>(?:\s*<span[^>]*>.*?<\/span>)?\s*<input[^>]*name="#{name}"/s,
dropdown_html
),
"expected date input #{name} to be wrapped by MvWeb.CoreComponents.input " <>
"(class=\"mb-2 fieldset\" fieldset wrapper), not a raw inline " <>
"<input type=\"date\"> element"
end
end
test "exit_date defaults to :active_only in the rendered radio", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~
~r/name="ed_mode"[^>]*value="active_only"[^>]*checked|checked[^>]*name="ed_mode"[^>]*value="active_only"/
end
test "Custom date fields section is non-scrollable with 5 or fewer fields (§3.4)", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
for i <- 1..5 do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create!(actor: system_actor)
end
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
section_html = custom_date_section_html(view)
# With ≤ 5 fields the section must NOT carry the scrollable wrapper.
refute section_html =~ "max-h-60"
refute section_html =~ "overflow-y-auto"
end
test "Custom date fields section becomes scrollable with more than 5 fields (§3.4)", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
for i <- 1..6 do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create!(actor: system_actor)
end
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
section_html = custom_date_section_html(view)
# With more than 5 fields the section is wrapped in the scrollable container.
assert section_html =~ "max-h-60"
assert section_html =~ "overflow-y-auto"
end
# Extract the HTML of the rendered "Custom date fields" section. Returns
# "" if the section is not rendered. Used by the threshold tests to avoid
# picking up scrollable classes from sibling sections.
defp custom_date_section_html(view) do
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
label = gettext("Custom date fields")
case String.split(dropdown_html, label, parts: 2) do
[_before, after_label] ->
# Up to the next group header label, or the footer.
after_label
|> String.split(["text-xs font-semibold opacity-70 mb-2 uppercase"], parts: 2)
|> List.first()
_ ->
""
end
end
test "Custom date fields section appears only when date custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view_no_field, _} = live(conn, "/members")
view_no_field
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view_no_field
|> element("#member-filter div[role='dialog']")
|> render()
refute dropdown_html =~ gettext("Custom date fields")
# Add a date-typed custom field and re-load: the section appears.
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Birthday-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create(actor: system_actor)
{:ok, view, _} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ gettext("Custom date fields")
assert dropdown_html =~ field.name
assert dropdown_html =~ "cdf_#{field.id}_from"
assert dropdown_html =~ "cdf_#{field.id}_to"
end
test "update_filters event dispatches a date_filters_changed patch with the new jd_from", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
view
|> form("#member-filter form", %{
"jd_from" => "2024-01-15",
"payment_filter" => "all"
})
|> render_change()
# Parent LiveView receives {:date_filters_changed, ...} and patches the URL.
path = assert_patch(view)
assert path =~ "jd_from=2024-01-15"
end
test "selecting ed_mode=all updates the URL and reveals former members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
today = Date.utc_today()
unique_name = "Zarquon-#{System.unique_integer([:positive])}"
{:ok, former} =
Mv.Membership.create_member(
%{
first_name: unique_name,
last_name: "Exited",
email: "ex-#{System.unique_integer([:positive])}@example.com",
join_date: Date.add(today, -1000),
exit_date: Date.add(today, -30)
},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Fresh load hides the former member.
refute html =~ former.first_name
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
view
|> form("#member-filter form", %{
"ed_mode" => "all",
"payment_filter" => "all"
})
|> render_change()
path = assert_patch(view)
assert path =~ "ed_mode=all"
# Now Eve appears in the rendered list.
assert render(view) =~ former.first_name
end
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)

View file

@ -0,0 +1,319 @@
defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do
@moduledoc """
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
alias MvWeb.MemberLive.Index.DateFilter
# ---- helpers ---------------------------------------------------------
defp date_custom_field(id, name \\ "Birthday") do
%{id: id, value_type: :date, name: name}
end
defp date_cfv(custom_field_id, %Date{} = date) do
%{
custom_field_id: custom_field_id,
value: %Ash.Union{value: date, type: :date}
}
end
defp member_with_dates(id, custom_field_values) do
%{id: id, custom_field_values: custom_field_values}
end
# ---- no-op cases -----------------------------------------------------
describe "apply_in_memory/3 — no-op cases" do
test "returns members unchanged when filters has no custom date entries" do
filters = DateFilter.default()
members = [member_with_dates("m1", []), member_with_dates("m2", [])]
assert DateFilter.apply_in_memory(members, filters, []) == members
end
test "ignores custom date entries whose UUID is not in the date_custom_fields list" do
id = "11111111-2222-3333-4444-555555555555"
other_id = "99999999-8888-7777-6666-555555555555"
filters =
DateFilter.default()
|> Map.put(other_id, %{from: ~D[2024-01-01], to: nil})
m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
end
test "entry with both bounds nil is treated as inactive" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: nil, to: nil})
m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
end
end
# ---- inclusive range semantics --------------------------------------
describe "apply_in_memory/3 — inclusive range semantics" do
setup do
id = "11111111-2222-3333-4444-555555555555"
members = [
member_with_dates("before", [date_cfv(id, ~D[2024-05-31])]),
member_with_dates("from_boundary", [date_cfv(id, ~D[2024-06-01])]),
member_with_dates("inside", [date_cfv(id, ~D[2024-06-15])]),
member_with_dates("to_boundary", [date_cfv(id, ~D[2024-06-30])]),
member_with_dates("after", [date_cfv(id, ~D[2024-07-01])])
]
%{id: id, members: members, fields: [date_custom_field(id)]}
end
test "from-only includes member when value >= from (boundary inclusive)", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil})
ids =
ctx.members
|> DateFilter.apply_in_memory(filters, ctx.fields)
|> Enum.map(& &1.id)
assert ids == ["from_boundary", "inside", "to_boundary", "after"]
end
test "from-only excludes member when value < from", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil})
refute Enum.any?(
DateFilter.apply_in_memory(ctx.members, filters, ctx.fields),
&(&1.id == "before")
)
end
test "to-only includes member when value <= to (boundary inclusive)", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: nil, to: ~D[2024-06-30]})
ids =
ctx.members
|> DateFilter.apply_in_memory(filters, ctx.fields)
|> Enum.map(& &1.id)
assert ids == ["before", "from_boundary", "inside", "to_boundary"]
end
test "from+to applies an inclusive range", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]})
ids =
ctx.members
|> DateFilter.apply_in_memory(filters, ctx.fields)
|> Enum.map(& &1.id)
assert ids == ["from_boundary", "inside", "to_boundary"]
end
end
# ---- exclusion of members without a value ---------------------------
describe "apply_in_memory/3 — members without a value" do
test "excludes member with no custom_field_values when bound is active" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-01-01], to: nil})
members = [
member_with_dates("present", [date_cfv(id, ~D[2024-06-01])]),
member_with_dates("nil_list", nil),
member_with_dates("empty_list", []),
# member missing the specific field but having other CFVs:
member_with_dates("other_field", [
date_cfv("other-aaaa-bbbb-cccc-dddddddddddd", ~D[2024-06-01])
])
]
ids =
members
|> DateFilter.apply_in_memory(filters, [date_custom_field(id)])
|> Enum.map(& &1.id)
assert ids == ["present"]
end
test "treats Ash.NotLoaded custom_field_values as no value (excluded)" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-01-01], to: nil})
m = %{id: "m1", custom_field_values: %Ash.NotLoaded{}}
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == []
end
end
# ---- Ash.Union unwrapping -------------------------------------------
describe "apply_in_memory/3 — Ash.Union unwrapping" do
test "unwraps %Ash.Union{value: %Date{}, type: :date}" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
m =
member_with_dates("m1", [
%{
custom_field_id: id,
value: %Ash.Union{value: ~D[2024-06-15], type: :date}
}
])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
end
test "rejects values whose Ash.Union type is not :date" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
# A boolean-typed value for what the filter believes is a date field —
# treat as no value, exclude the member.
m =
member_with_dates("m1", [
%{
custom_field_id: id,
value: %Ash.Union{value: true, type: :boolean}
}
])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == []
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

View file

@ -0,0 +1,144 @@
defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do
@moduledoc """
Unit tests for DateFilter.default/0 the initial filter map used when
no URL params are present (fresh load) and after "Clear filters".
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.DateFilter
describe "default/0" do
test "returns :active_only mode for exit_date with nil bounds" do
assert %{exit_date: %{mode: :active_only, from: nil, to: nil}} = DateFilter.default()
end
test "returns nil bounds for join_date" do
assert %{join_date: %{from: nil, to: nil}} = DateFilter.default()
end
test "contains only :join_date and :exit_date top-level keys" do
defaults = DateFilter.default()
assert Map.keys(defaults) |> Enum.sort() == [:exit_date, :join_date]
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

View file

@ -0,0 +1,137 @@
defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
@moduledoc """
Property tests for the pure functions on `DateFilter`:
* `to_params/1` `from_params/2` must be the identity for all valid
built-in date filter states (§2.3).
* `apply_in_memory/3` matches the inclusive range predicate
`(from == nil or value >= from) and (to == nil or value <= to)`
for any custom date field value and bound pair (§2.4).
Custom date field round-trip is not part of the URL codec property because
`from_params/2` needs the caller-supplied `date_custom_fields` list to
validate UUIDs; that interaction is covered by example tests in
`MvWeb.MemberLive.Index.DateFilterTest`.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias MvWeb.MemberLive.Index.DateFilter
# Generators -----------------------------------------------------------
defp optional_date_gen do
one_of([
constant(nil),
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
])
end
defp exit_date_mode_gen do
one_of([
constant(:active_only),
constant(:all),
constant(:inactive_only),
constant(:custom)
])
end
defp exit_date_state_gen do
gen all(
mode <- exit_date_mode_gen(),
from <- optional_date_gen(),
to <- optional_date_gen()
) do
%{mode: mode, from: from, to: to}
end
end
defp join_date_state_gen do
gen all(
from <- optional_date_gen(),
to <- optional_date_gen()
) do
%{from: from, to: to}
end
end
# Property -------------------------------------------------------------
defp bound_pair_with_at_least_one_set_gen do
gen all(
from <- optional_date_gen(),
to <- optional_date_gen(),
from != nil or to != nil
) do
{from, to}
end
end
defp value_date_gen do
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
end
# Property -------------------------------------------------------------
property "in-memory date filter matches the inclusive range predicate" do
id = "11111111-2222-3333-4444-555555555555"
field = %{id: id, value_type: :date, name: "Property field"}
check all(
value <- value_date_gen(),
{from, to} <- bound_pair_with_at_least_one_set_gen()
) do
filters =
DateFilter.default()
|> Map.put(id, %{from: from, to: to})
member = %{
id: "m1",
custom_field_values: [
%{
custom_field_id: id,
value: %Ash.Union{value: value, type: :date}
}
]
}
result = DateFilter.apply_in_memory([member], filters, [field])
from_ok? = is_nil(from) or Date.compare(value, from) != :lt
to_ok? = is_nil(to) or Date.compare(value, to) != :gt
expected_included? = from_ok? and to_ok?
actually_included? = result == [member]
assert actually_included? == expected_included?
end
end
property "encoding then decoding built-in date filter state is identity" do
check all(
join_date <- join_date_state_gen(),
exit_date <- exit_date_state_gen()
) do
filters = %{join_date: join_date, exit_date: exit_date}
decoded = DateFilter.from_params(DateFilter.to_params(filters), [])
# join_date round-trips verbatim.
assert decoded.join_date == join_date
# exit_date semantics:
# * :active_only is the default and discards bounds — the canonical
# URL omits them, so decoding restores nil bounds.
# * :all and :inactive_only also drop bounds in the URL — same reason.
# * :custom preserves bounds.
expected_exit_date =
case exit_date.mode do
:active_only -> %{mode: :active_only, from: nil, to: nil}
:all -> %{mode: :all, from: nil, to: nil}
:inactive_only -> %{mode: :inactive_only, from: nil, to: nil}
:custom -> exit_date
end
assert decoded.exit_date == expected_exit_date
end
end
end

View file

@ -0,0 +1,628 @@
defmodule MvWeb.MemberLive.Index.DateFilterTest do
@moduledoc """
Unit tests for DateFilter URL codec and pure helpers.
DB-level filtering against real members is covered by the integration
module below in this file.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.DateFilter
# Synthesize the minimal shape of a date-typed custom field expected by
# from_params/2. Only :id and :value_type are inspected by the decoder.
defp date_custom_field(id) do
%{id: id, value_type: :date, name: "Birthday-#{id}"}
end
describe "from_params/2 — built-in date fields" do
test "parses jd_from as a Date" do
params = %{"jd_from" => "2024-05-01"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.from == ~D[2024-05-01]
assert filters.join_date.to == nil
end
test "parses jd_to as a Date" do
params = %{"jd_to" => "2024-08-31"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.to == ~D[2024-08-31]
assert filters.join_date.from == nil
end
test "ignores malformed jd_from string" do
params = %{"jd_from" => "notadate"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.from == nil
assert filters.join_date.to == nil
end
test "ignores malformed jd_to string" do
params = %{"jd_to" => "2024-13-45"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.to == nil
end
test "parses ed_mode=all" do
params = %{"ed_mode" => "all"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :all
end
test "parses ed_mode=inactive_only" do
params = %{"ed_mode" => "inactive_only"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :inactive_only
end
test "parses ed_mode=custom with bounds" do
params = %{"ed_mode" => "custom", "ed_from" => "2024-01-01", "ed_to" => "2024-12-31"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :custom
assert filters.exit_date.from == ~D[2024-01-01]
assert filters.exit_date.to == ~D[2024-12-31]
end
test "returns :active_only mode when ed_mode is absent" do
filters = DateFilter.from_params(%{}, [])
assert filters.exit_date.mode == :active_only
end
test "treats unknown ed_mode value as :active_only" do
params = %{"ed_mode" => "gibberish"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :active_only
end
end
describe "to_params/1 — built-in date fields" do
test "omits ed_mode when mode is :active_only (default)" do
params = DateFilter.to_params(DateFilter.default())
refute Map.has_key?(params, "ed_mode")
refute Map.has_key?(params, "ed_from")
refute Map.has_key?(params, "ed_to")
refute Map.has_key?(params, "jd_from")
refute Map.has_key?(params, "jd_to")
end
test "encodes ed_mode=all" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "all"
end
test "encodes ed_mode=inactive_only" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :inactive_only, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "inactive_only"
end
test "encodes ed_mode=custom with bounds" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "custom"
assert params["ed_from"] == "2024-01-01"
assert params["ed_to"] == "2024-12-31"
end
test "encodes jd_from as ISO-8601 string" do
filters = %{
join_date: %{from: ~D[2024-05-01], to: nil},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["jd_from"] == "2024-05-01"
refute Map.has_key?(params, "jd_to")
end
test "encodes jd_to as ISO-8601 string" do
filters = %{
join_date: %{from: nil, to: ~D[2024-08-31]},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["jd_to"] == "2024-08-31"
refute Map.has_key?(params, "jd_from")
end
test "omits exit_date bounds when mode is not :custom" do
# Bounds may linger in state for UX (preserve user's last input) but the
# URL should not advertise them while a non-custom mode is active.
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: ~D[2024-01-01], to: ~D[2024-12-31]}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "all"
refute Map.has_key?(params, "ed_from")
refute Map.has_key?(params, "ed_to")
end
end
describe "to_params/1 — custom date field entries" do
test "encodes from/to bounds with cdf_<uuid>_ prefix" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]})
params = DateFilter.to_params(filters)
assert params["cdf_#{id}_from"] == "2024-06-01"
assert params["cdf_#{id}_to"] == "2024-06-30"
end
test "omits nil bounds for custom date field entries" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
params = DateFilter.to_params(filters)
assert params["cdf_#{id}_from"] == "2024-06-01"
refute Map.has_key?(params, "cdf_#{id}_to")
end
test "omits custom date field entry entirely when both bounds are nil" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: nil, to: nil})
params = DateFilter.to_params(filters)
refute Map.has_key?(params, "cdf_#{id}_from")
refute Map.has_key?(params, "cdf_#{id}_to")
end
end
describe "apply_ash_filter/2 — shape contract" do
# The behavioral correctness of apply_ash_filter/2 is verified in the
# integration tests (date_filter_default_test, date_filter_test). Here we
# only assert the shape contract: which inputs leave the query untouched,
# and which add a filter expression. Inspecting Ash internals is brittle —
# we only check `query.filter` is or is not nil.
alias Mv.Membership.Member, as: 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 = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
# No filter was applied — Ash leaves filter as nil when nothing is added.
assert is_nil(query.filter)
end
test "default (:active_only) adds an exit_date filter" do
query = DateFilter.apply_ash_filter(base_query(), DateFilter.default())
refute is_nil(query.filter)
end
test ":inactive_only adds an exit_date filter" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :inactive_only, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test ":custom mode with bounds adds an exit_date filter" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test ":custom mode with both bounds nil adds no filter" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
assert is_nil(query.filter)
end
test "join_date from adds a filter" do
filters = %{
join_date: %{from: ~D[2024-01-01], to: nil},
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 to adds a filter" do
filters = %{
join_date: %{from: nil, 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 "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: :all, from: nil, to: nil}
}
# 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
test "includes entry for known custom date field UUID" do
id = "11111111-2222-3333-4444-555555555555"
params = %{"cdf_#{id}_from" => "2024-06-01", "cdf_#{id}_to" => "2024-06-30"}
filters = DateFilter.from_params(params, [date_custom_field(id)])
assert filters[id] == %{from: ~D[2024-06-01], to: ~D[2024-06-30]}
end
test "ignores UUID not in date_custom_fields list" do
id = "11111111-2222-3333-4444-555555555555"
other = "99999999-8888-7777-6666-555555555555"
params = %{"cdf_#{other}_from" => "2024-06-01"}
filters = DateFilter.from_params(params, [date_custom_field(id)])
refute Map.has_key?(filters, other)
end
test "ignores malformed custom date field bound" do
id = "11111111-2222-3333-4444-555555555555"
params = %{"cdf_#{id}_from" => "notadate"}
filters = DateFilter.from_params(params, [date_custom_field(id)])
# Either no entry, or entry with nil bounds — both satisfy "silently ignored"
case Map.get(filters, id) do
nil -> :ok
%{from: nil, to: nil} -> :ok
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

View file

@ -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

View 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