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
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #497
This commit is contained in:
commit
c6578662d8
22 changed files with 3022 additions and 105 deletions
9
.deps_audit_ignore
Normal file
9
.deps_audit_ignore
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
2
Justfile
2
Justfile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
454
lib/mv_web/live/member_live/index/date_filter.ex
Normal file
454
lib/mv_web/live/member_live/index/date_filter.ex
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
6
mix.lock
6
mix.lock
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
33
test/mv/constants_test.exs
Normal file
33
test/mv/constants_test.exs
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
319
test/mv_web/live/member_live/date_filter_custom_field_test.exs
Normal file
319
test/mv_web/live/member_live/date_filter_custom_field_test.exs
Normal 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
|
||||||
144
test/mv_web/live/member_live/date_filter_default_test.exs
Normal file
144
test/mv_web/live/member_live/date_filter_default_test.exs
Normal 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
|
||||||
137
test/mv_web/live/member_live/date_filter_property_test.exs
Normal file
137
test/mv_web/live/member_live/date_filter_property_test.exs
Normal 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
|
||||||
628
test/mv_web/live/member_live/date_filter_test.exs
Normal file
628
test/mv_web/live/member_live/date_filter_test.exs
Normal 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
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.CustomFieldValueLookupTest do
|
||||||
|
@moduledoc """
|
||||||
|
Unit tests for the shared custom-field-value lookup helper.
|
||||||
|
|
||||||
|
The lookup must handle both shapes a CFV entry can take on a loaded member:
|
||||||
|
|
||||||
|
* `%{custom_field_id: id, value: ...}` — id present directly
|
||||||
|
* `%{custom_field: %{id: id, ...}, value: ...}` — id nested under loaded relation
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||||
|
|
||||||
|
defp uuid, do: "11111111-2222-3333-4444-555555555555"
|
||||||
|
|
||||||
|
describe "find_by_id/2" do
|
||||||
|
test "matches when custom_field_id key is present" do
|
||||||
|
id = uuid()
|
||||||
|
cfv = %{custom_field_id: id, value: :anything}
|
||||||
|
member = %{custom_field_values: [cfv]}
|
||||||
|
|
||||||
|
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matches when nested custom_field relation is loaded" do
|
||||||
|
id = uuid()
|
||||||
|
cfv = %{custom_field: %{id: id, value_type: :date}, value: :anything}
|
||||||
|
member = %{custom_field_values: [cfv]}
|
||||||
|
|
||||||
|
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "compares stringified ids — accepts atom or binary ids on the cfv side" do
|
||||||
|
id = uuid()
|
||||||
|
cfv = %{custom_field_id: id, value: :v}
|
||||||
|
member = %{custom_field_values: [cfv]}
|
||||||
|
|
||||||
|
# Same id, passed as binary
|
||||||
|
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when no entry has a matching id" do
|
||||||
|
member = %{
|
||||||
|
custom_field_values: [
|
||||||
|
%{custom_field_id: "11111111-1111-1111-1111-111111111111", value: 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when custom_field_values is nil" do
|
||||||
|
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: nil}, uuid()) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when custom_field_values is not loaded (Ash.NotLoaded)" do
|
||||||
|
member = %{custom_field_values: %Ash.NotLoaded{type: :relationship}}
|
||||||
|
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when custom_field_values is empty" do
|
||||||
|
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: []}, uuid()) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "find_by_field/2" do
|
||||||
|
test "matches a custom_field struct via its :id" do
|
||||||
|
id = uuid()
|
||||||
|
cfv = %{custom_field_id: id, value: :v}
|
||||||
|
member = %{custom_field_values: [cfv]}
|
||||||
|
custom_field = %{id: id, value_type: :boolean}
|
||||||
|
|
||||||
|
assert CustomFieldValueLookup.find_by_field(member, custom_field) == cfv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matches when only the nested custom_field is present" do
|
||||||
|
id = uuid()
|
||||||
|
cfv = %{custom_field: %{id: id}, value: :v}
|
||||||
|
member = %{custom_field_values: [cfv]}
|
||||||
|
|
||||||
|
assert CustomFieldValueLookup.find_by_field(member, %{id: id}) == cfv
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when no entry matches" do
|
||||||
|
member = %{custom_field_values: [%{custom_field_id: "other", value: :v}]}
|
||||||
|
assert CustomFieldValueLookup.find_by_field(member, %{id: uuid()}) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
85
test/mv_web/live/member_live/index/filter_params_test.exs
Normal file
85
test/mv_web/live/member_live/index/filter_params_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.FilterParamsTest do
|
||||||
|
@moduledoc """
|
||||||
|
Unit tests for the shared filter-param parsers.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.FilterParams
|
||||||
|
|
||||||
|
describe "parse_prefix_filters/3" do
|
||||||
|
test "extracts only entries whose key starts with the prefix" do
|
||||||
|
params = %{
|
||||||
|
"group_abc" => "in",
|
||||||
|
"group_def" => "not_in",
|
||||||
|
"fee_type_xyz" => "in",
|
||||||
|
"unrelated" => "in",
|
||||||
|
"query" => "alice"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = FilterParams.parse_prefix_filters(params, "group_", & &1)
|
||||||
|
|
||||||
|
assert result == %{"abc" => "in", "def" => "not_in"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "strips exactly one occurrence of the prefix, even when the rest starts with the prefix again" do
|
||||||
|
# Quirky but legal: a key like "p_p_abc" with prefix "p_" must produce id "p_abc".
|
||||||
|
params = %{"p_p_abc" => "v"}
|
||||||
|
result = FilterParams.parse_prefix_filters(params, "p_", & &1)
|
||||||
|
assert result == %{"p_abc" => "v"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "applies parse_value_fn to every value" do
|
||||||
|
params = %{"x_one" => "in", "x_two" => "not_in", "x_three" => "garbage"}
|
||||||
|
|
||||||
|
result =
|
||||||
|
FilterParams.parse_prefix_filters(
|
||||||
|
params,
|
||||||
|
"x_",
|
||||||
|
&FilterParams.parse_in_not_in_value/1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == %{"one" => :in, "two" => :not_in, "three" => nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty map when no key matches the prefix" do
|
||||||
|
params = %{"a" => "1", "b" => "2"}
|
||||||
|
assert FilterParams.parse_prefix_filters(params, "z_", & &1) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores non-binary keys" do
|
||||||
|
params = %{"x_a" => "1", :atom_key => "2", 123 => "3"}
|
||||||
|
result = FilterParams.parse_prefix_filters(params, "x_", & &1)
|
||||||
|
assert result == %{"a" => "1"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty map for empty input" do
|
||||||
|
assert FilterParams.parse_prefix_filters(%{}, "x_", & &1) == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "parse_in_not_in_value/1" do
|
||||||
|
test "maps 'in' to :in" do
|
||||||
|
assert FilterParams.parse_in_not_in_value("in") == :in
|
||||||
|
end
|
||||||
|
|
||||||
|
test "maps 'not_in' to :not_in" do
|
||||||
|
assert FilterParams.parse_in_not_in_value("not_in") == :not_in
|
||||||
|
end
|
||||||
|
|
||||||
|
test "trims whitespace around recognized values" do
|
||||||
|
assert FilterParams.parse_in_not_in_value(" in ") == :in
|
||||||
|
assert FilterParams.parse_in_not_in_value("\tnot_in\n") == :not_in
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for unrecognized strings" do
|
||||||
|
assert FilterParams.parse_in_not_in_value("yes") == nil
|
||||||
|
assert FilterParams.parse_in_not_in_value("") == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for non-binary input" do
|
||||||
|
assert FilterParams.parse_in_not_in_value(nil) == nil
|
||||||
|
assert FilterParams.parse_in_not_in_value(:in) == nil
|
||||||
|
assert FilterParams.parse_in_not_in_value(123) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue