feat(date-filter): introduce DateFilter module with URL codec and Ash query expressions
This commit is contained in:
parent
143c0c5c24
commit
ddd4a9a878
7 changed files with 1195 additions and 0 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
402
lib/mv_web/live/member_live/index/date_filter.ex
Normal file
402
lib/mv_web/live/member_live/index/date_filter.ex
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
@spec apply_ash_filter(Ash.Query.t() | module(), map()) :: Ash.Query.t()
|
||||||
|
def apply_ash_filter(query_or_resource, filters) when is_map(filters) do
|
||||||
|
query_or_resource
|
||||||
|
|> Ash.Query.new()
|
||||||
|
|> apply_exit_date_filter(Map.get(filters, :exit_date, %{}))
|
||||||
|
|> apply_join_date_filter(Map.get(filters, :join_date, %{}))
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 =
|
||||||
|
date_custom_fields
|
||||||
|
|> Enum.filter(&date_field?/1)
|
||||||
|
|> MapSet.new(&to_string(field_id(&1)))
|
||||||
|
|
||||||
|
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
|
||||||
|
case Map.get(member, :custom_field_values) do
|
||||||
|
values when is_list(values) ->
|
||||||
|
values
|
||||||
|
|> Enum.find(&cfv_matches_id?(&1, custom_field_id))
|
||||||
|
|> extract_date_from_cfv()
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cfv_matches_id?(%{custom_field_id: cfid}, id) when not is_nil(cfid),
|
||||||
|
do: to_string(cfid) == id
|
||||||
|
|
||||||
|
defp cfv_matches_id?(%{custom_field: %{id: cfid}}, id) when not is_nil(cfid),
|
||||||
|
do: to_string(cfid) == id
|
||||||
|
|
||||||
|
defp cfv_matches_id?(_, _), do: false
|
||||||
|
|
||||||
|
defp extract_date_from_cfv(nil), do: nil
|
||||||
|
|
||||||
|
defp extract_date_from_cfv(%{value: value}), do: extract_date_value(value)
|
||||||
|
|
||||||
|
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 =
|
||||||
|
date_custom_fields
|
||||||
|
|> Enum.filter(&date_field?/1)
|
||||||
|
|> MapSet.new(&to_string(field_id(&1)))
|
||||||
|
|
||||||
|
prefix = @custom_date_filter_prefix
|
||||||
|
|
||||||
|
params
|
||||||
|
|> Enum.reduce(base, fn {key, value}, acc ->
|
||||||
|
with true <- is_binary(key),
|
||||||
|
true <- String.starts_with?(key, prefix),
|
||||||
|
{id, bound} <- split_custom_date_key(key, prefix),
|
||||||
|
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
|
||||||
|
|
||||||
|
defp date_field?(%{value_type: :date}), do: true
|
||||||
|
defp date_field?(_), do: false
|
||||||
|
|
||||||
|
defp field_id(%{id: id}), do: id
|
||||||
|
|
||||||
|
defp split_custom_date_key(key, prefix) do
|
||||||
|
rest = String.slice(key, String.length(prefix), String.length(key))
|
||||||
|
|
||||||
|
cond do
|
||||||
|
String.ends_with?(rest, "_from") ->
|
||||||
|
{String.slice(rest, 0, String.length(rest) - 5), :from}
|
||||||
|
|
||||||
|
String.ends_with?(rest, "_to") ->
|
||||||
|
{String.slice(rest, 0, String.length(rest) - 3), :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
|
||||||
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
|
||||||
210
test/mv_web/live/member_live/date_filter_custom_field_test.exs
Normal file
210
test/mv_web/live/member_live/date_filter_custom_field_test.exs
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
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`.
|
||||||
|
"""
|
||||||
|
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
|
||||||
24
test/mv_web/live/member_live/date_filter_default_test.exs
Normal file
24
test/mv_web/live/member_live/date_filter_default_test.exs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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
|
||||||
134
test/mv_web/live/member_live/date_filter_property_test.exs
Normal file
134
test/mv_web/live/member_live/date_filter_property_test.exs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
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).
|
||||||
|
|
||||||
|
Custom date field entries are not part of this property because
|
||||||
|
`from_params/2` needs the caller-supplied `date_custom_fields` list to
|
||||||
|
validate UUIDs; the standalone property for the in-memory predicate (§2.4)
|
||||||
|
is covered in S8 after the predicate exists.
|
||||||
|
"""
|
||||||
|
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
|
||||||
316
test/mv_web/live/member_live/date_filter_test.exs
Normal file
316
test/mv_web/live/member_live/date_filter_test.exs
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.DateFilterTest do
|
||||||
|
@moduledoc """
|
||||||
|
Unit tests for DateFilter URL codec and pure helpers.
|
||||||
|
DB-level filtering and in-memory custom field filtering are covered in
|
||||||
|
separate integration tests (date_filter_default_test, date_filter_test,
|
||||||
|
date_filter_custom_field_test).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
defp base_query, do: 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 "accepts a non-resource Ash.Query as input" do
|
||||||
|
filters = %{
|
||||||
|
join_date: %{from: nil, to: nil},
|
||||||
|
exit_date: %{mode: :inactive_only, from: nil, to: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should also work when given a pre-built Ash.Query (not just a resource).
|
||||||
|
query =
|
||||||
|
MemberResource
|
||||||
|
|> Ash.Query.new()
|
||||||
|
|> DateFilter.apply_ash_filter(filters)
|
||||||
|
|
||||||
|
refute is_nil(query.filter)
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue