From 143c0c5c24ea70f9989c98fb96ea0bb7675f5b34 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:16:27 +0200 Subject: [PATCH 01/26] chore(deps): suppress cowlib advisory and bump bandit, cowboy, plug --- .deps_audit_ignore | 9 +++++++++ Justfile | 2 +- mix.lock | 6 +++--- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .deps_audit_ignore diff --git a/.deps_audit_ignore b/.deps_audit_ignore new file mode 100644 index 0000000..27c623d --- /dev/null +++ b/.deps_audit_ignore @@ -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 diff --git a/Justfile b/Justfile index cae8cfb..e0bd0d3 100644 --- a/Justfile +++ b/Justfile @@ -45,7 +45,7 @@ lint: audit: mix sobelow --config - mix deps.audit + mix deps.audit --ignore-file .deps_audit_ignore mix hex.audit # Run all tests diff --git a/mix.lock b/mix.lock index 12acd0a..0a36e9e 100644 --- a/mix.lock +++ b/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_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"}, - "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"}, "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"}, @@ -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"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "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"}, "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"}, @@ -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_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"}, - "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_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"}, From ddd4a9a878f7f061ebe9e63706ca750764e94bed Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:24:08 +0200 Subject: [PATCH 02/26] feat(date-filter): introduce DateFilter module with URL codec and Ash query expressions --- lib/mv/constants.ex | 76 ++++ .../live/member_live/index/date_filter.ex | 402 ++++++++++++++++++ test/mv/constants_test.exs | 33 ++ .../date_filter_custom_field_test.exs | 210 +++++++++ .../member_live/date_filter_default_test.exs | 24 ++ .../member_live/date_filter_property_test.exs | 134 ++++++ .../live/member_live/date_filter_test.exs | 316 ++++++++++++++ 7 files changed, 1195 insertions(+) create mode 100644 lib/mv_web/live/member_live/index/date_filter.ex create mode 100644 test/mv/constants_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_custom_field_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_default_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_property_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_test.exs diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 517ad2f..4d09c89 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -26,6 +26,18 @@ defmodule Mv.Constants do @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_uuid_length 36 @@ -84,6 +96,70 @@ defmodule Mv.Constants do """ 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__from / cdf__to). + + ## Examples + + iex> Mv.Constants.custom_date_filter_prefix() + "cdf_" + """ + def custom_date_filter_prefix, do: @custom_date_filter_prefix + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex new file mode 100644 index 0000000..2a3e04e --- /dev/null +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -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): + "" => %{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__from"` / `"cdf__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__from"` / + `"cdf__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 diff --git a/test/mv/constants_test.exs b/test/mv/constants_test.exs new file mode 100644 index 0000000..4ae689f --- /dev/null +++ b/test/mv/constants_test.exs @@ -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 diff --git a/test/mv_web/live/member_live/date_filter_custom_field_test.exs b/test/mv_web/live/member_live/date_filter_custom_field_test.exs new file mode 100644 index 0000000..2959e77 --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_custom_field_test.exs @@ -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 diff --git a/test/mv_web/live/member_live/date_filter_default_test.exs b/test/mv_web/live/member_live/date_filter_default_test.exs new file mode 100644 index 0000000..84400ef --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_default_test.exs @@ -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 diff --git a/test/mv_web/live/member_live/date_filter_property_test.exs b/test/mv_web/live/member_live/date_filter_property_test.exs new file mode 100644 index 0000000..693531c --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_property_test.exs @@ -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 diff --git a/test/mv_web/live/member_live/date_filter_test.exs b/test/mv_web/live/member_live/date_filter_test.exs new file mode 100644 index 0000000..424e6f1 --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_test.exs @@ -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__ 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 From e3295ab4b5cea20e7107d7d99cb189a53ac2611e Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:28:17 +0200 Subject: [PATCH 03/26] feat(member-live): wire date filters into LiveView lifecycle --- .../components/member_filter_component.ex | 23 +- lib/mv_web/live/member_live/index.ex | 181 ++++++---- .../index/custom_field_value_lookup.ex | 61 ++++ .../live/member_live/index/date_filter.ex | 142 +++++--- .../live/member_live/index/filter_params.ex | 33 +- .../date_filter_custom_field_test.exs | 109 ++++++ .../member_live/date_filter_default_test.exs | 120 +++++++ .../live/member_live/date_filter_test.exs | 334 +++++++++++++++++- .../index/custom_field_value_lookup_test.exs | 89 +++++ .../member_live/index/filter_params_test.exs | 85 +++++ 10 files changed, 1037 insertions(+), 140 deletions(-) create mode 100644 lib/mv_web/live/member_live/index/custom_field_value_lookup.ex create mode 100644 test/mv_web/live/member_live/index/custom_field_value_lookup_test.exs create mode 100644 test/mv_web/live/member_live/index/filter_params_test.exs diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index ddd3538..71227be 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -438,10 +438,18 @@ defmodule MvWeb.Components.MemberFilterComponent do payment_filter = parse_payment_filter(params) group_filters_parsed = - parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1) + FilterParams.parse_prefix_filters( + params, + @group_filter_prefix, + &FilterParams.parse_in_not_in_value/1 + ) fee_type_filters_parsed = - parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1) + FilterParams.parse_prefix_filters( + params, + @fee_type_filter_prefix, + &FilterParams.parse_in_not_in_value/1 + ) custom_boolean_filters_parsed = parse_custom_boolean_filters(params) @@ -486,17 +494,6 @@ defmodule MvWeb.Components.MemberFilterComponent do end end - defp parse_prefix_filters(params, prefix, parse_value_fn) do - prefix_len = String.length(prefix) - - params - |> Enum.filter(fn {key, _} -> String.starts_with?(key, prefix) end) - |> Enum.reduce(%{}, fn {key, value_str}, acc -> - id_str = String.slice(key, prefix_len, String.length(key) - prefix_len) - Map.put(acc, id_str, parse_value_fn.(value_str)) - end) - end - defp parse_custom_boolean_filters(params) do params |> Map.get("custom_boolean", %{}) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c258d5f..cd32513 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -36,6 +36,8 @@ defmodule MvWeb.MemberLive.Index do alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter + alias MvWeb.MemberLive.Index.CustomFieldValueLookup + alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FilterParams @@ -87,6 +89,13 @@ defmodule MvWeb.MemberLive.Index do |> Enum.filter(&(&1.value_type == :boolean)) |> Enum.sort_by(& &1.name, :asc) + # Date-typed custom fields surface in the new "Custom date fields" filter + # section and are needed by DateFilter.from_params/2 to validate UUIDs. + date_custom_fields = + all_custom_fields + |> Enum.filter(&(&1.value_type == :date)) + |> Enum.sort_by(& &1.name, :asc) + # Load groups for filter dropdown (sorted by name) groups = Mv.Membership.Group @@ -143,6 +152,8 @@ defmodule MvWeb.MemberLive.Index do |> assign(:custom_fields_visible, custom_fields_visible) |> assign(:all_custom_fields, all_custom_fields) |> assign(:boolean_custom_fields, boolean_custom_fields) + |> assign(:date_custom_fields, date_custom_fields) + |> assign(:date_filters, DateFilter.default()) |> assign(:all_available_fields, all_available_fields) |> assign(:user_field_selection, initial_selection) |> assign(:fields_in_url?, false) @@ -448,6 +459,25 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_patch(socket, to: new_path, replace: true)} end + @impl true + def handle_info({:date_filters_changed, new_date_filters}, socket) do + socket = + socket + |> assign(:date_filters, new_date_filters) + |> load_members() + |> update_selection_assigns() + + query_params = + build_query_params(opts_for_query_params(socket, %{date_filters: new_date_filters})) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_patch(socket, to: new_path, replace: true)} + end + # Backward compatibility: tuple form delegates to map form def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do handle_info( @@ -502,6 +532,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:group_filters, Map.get(opts, :group_filters, %{})) |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) + |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) |> load_members() |> update_selection_assigns() @@ -632,6 +663,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_group_filters(params) |> maybe_update_fee_type_filters(params) |> maybe_update_boolean_filters(params) + |> maybe_update_date_filters(params) |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) |> assign(:query, params["query"]) @@ -683,7 +715,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, - socket.assigns[:visible_custom_field_ids] || [] + socket.assigns[:visible_custom_field_ids] || [], + socket.assigns[:date_filters] } end @@ -783,7 +816,12 @@ defmodule MvWeb.MemberLive.Index do base_params = add_group_filters(base_params, opts.group_filters || %{}) base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) base_params = add_show_current_cycle(base_params, opts.show_current_cycle) - add_boolean_filters(base_params, opts.boolean_filters || %{}) + base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) + add_date_filters(base_params, opts.date_filters) + end + + defp add_date_filters(params, date_filters) do + Map.merge(params, DateFilter.to_params(date_filters)) end defp opts_for_query_params(socket, overrides \\ %{}) do @@ -795,7 +833,8 @@ defmodule MvWeb.MemberLive.Index do group_filters: socket.assigns[:group_filters] || %{}, show_current_cycle: socket.assigns.show_current_cycle, boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, - fee_type_filters: socket.assigns[:fee_type_filters] || %{} + fee_type_filters: socket.assigns[:fee_type_filters] || %{}, + date_filters: socket.assigns.date_filters } |> Map.merge(overrides) end @@ -941,26 +980,7 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.new() |> Ash.Query.select(@overview_fields) - visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] - - boolean_custom_fields_map = - socket.assigns.boolean_custom_fields - |> Map.new(fn cf -> {to_string(cf.id), cf} end) - - active_boolean_filter_ids = - socket.assigns.boolean_custom_field_filters - |> Map.keys() - |> Enum.filter(fn id_str -> - String.length(id_str) <= @max_uuid_length && - match?({:ok, _}, Ecto.UUID.cast(id_str)) && - Map.has_key?(boolean_custom_fields_map, id_str) - end) - - ids_to_load = - (visible_custom_field_ids ++ active_boolean_filter_ids) - |> Enum.uniq() - - query = load_custom_field_values(query, ids_to_load) + query = load_custom_field_values(query, compute_ids_to_load(socket)) query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) @@ -984,6 +1004,13 @@ defmodule MvWeb.MemberLive.Index do query = apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) + # Built-in date filters (join_date, exit_date) are pushed to the DB so + # excluded rows never reach the BEAM. The active_only default is part of + # this — fresh load returns only members without an exit_date or with an + # exit_date strictly in the future. + query = + DateFilter.apply_ash_filter(query, socket.assigns.date_filters) + # Use ALL custom fields for sorting (not just show_in_overview subset) custom_fields_for_sort = socket.assigns.all_custom_fields @@ -1003,21 +1030,7 @@ defmodule MvWeb.MemberLive.Index do # Custom field values are already filtered at the database level in load_custom_field_values/2 # No need for in-memory filtering anymore - # Apply cycle status filter if set - members = - apply_cycle_status_filter( - members, - socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle - ) - - # Apply boolean custom field filters if set - members = - apply_boolean_custom_field_filters( - members, - socket.assigns.boolean_custom_field_filters, - socket.assigns.all_custom_fields - ) + members = apply_in_memory_filters(members, socket) # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status @@ -1037,6 +1050,55 @@ defmodule MvWeb.MemberLive.Index do assign(socket, :members, members) end + # Collects every custom field UUID whose values must be loaded for a given + # render — visible columns plus any active boolean or date filter. Kept as a + # standalone helper so load_members/1 stays under the credo complexity bar. + defp compute_ids_to_load(socket) do + visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] + + boolean_custom_fields_map = + socket.assigns.boolean_custom_fields + |> Map.new(fn cf -> {to_string(cf.id), cf} end) + + active_boolean_filter_ids = + socket.assigns.boolean_custom_field_filters + |> Map.keys() + |> Enum.filter(fn id_str -> + String.length(id_str) <= @max_uuid_length && + match?({:ok, _}, Ecto.UUID.cast(id_str)) && + Map.has_key?(boolean_custom_fields_map, id_str) + end) + + date_custom_fields = socket.assigns[:date_custom_fields] || [] + + active_date_filter_ids = + DateFilter.active_custom_field_ids( + socket.assigns.date_filters, + date_custom_fields + ) + + (visible_custom_field_ids ++ active_boolean_filter_ids ++ active_date_filter_ids) + |> Enum.uniq() + end + + # Post-DB filtering: cycle status, boolean custom fields, and custom date + # fields. Date custom fields are last so they see the already-narrowed list. + defp apply_in_memory_filters(members, socket) do + members + |> apply_cycle_status_filter( + socket.assigns.cycle_status_filter, + socket.assigns.show_current_cycle + ) + |> apply_boolean_custom_field_filters( + socket.assigns.boolean_custom_field_filters, + socket.assigns.all_custom_fields + ) + |> DateFilter.apply_in_memory( + socket.assigns.date_filters, + socket.assigns[:date_custom_fields] || [] + ) + end + defp load_custom_field_values(query, []), do: query defp load_custom_field_values(query, custom_field_ids) do @@ -1649,24 +1711,22 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_show_current_cycle(socket, _params), do: socket + # URL params are the source of truth for filter state on every navigation. + # When no date filter params are present, this falls through to the + # active_only default — exactly the spec behavior for fresh load (§1.1). + defp maybe_update_date_filters(socket, params) when is_map(params) do + date_custom_fields = socket.assigns[:date_custom_fields] || [] + assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields)) + end + + defp maybe_update_date_filters(socket, _params), do: socket + # ------------------------------------------------------------- # Custom Field Value Helpers # ------------------------------------------------------------- def get_custom_field_value(member, custom_field) do - case member.custom_field_values do - nil -> - nil - - values when is_list(values) -> - Enum.find(values, fn cfv -> - cfv.custom_field_id == custom_field.id or - (match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id) - end) - - _ -> - nil - end + CustomFieldValueLookup.find_by_field(member, custom_field) end def get_boolean_custom_field_value(member, custom_field) do @@ -1725,29 +1785,12 @@ defmodule MvWeb.MemberLive.Index do end defp matches_filter?(member, custom_field_id_str, filter_value) do - case find_custom_field_value_by_id(member, custom_field_id_str) do + case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do nil -> false cfv -> extract_boolean_value(cfv.value) == filter_value end end - defp find_custom_field_value_by_id(member, custom_field_id_str) do - case member.custom_field_values do - nil -> - nil - - values when is_list(values) -> - Enum.find(values, fn cfv -> - to_string(cfv.custom_field_id) == custom_field_id_str or - (match?(%{custom_field: %{id: _}}, cfv) && - to_string(cfv.custom_field.id) == custom_field_id_str) - end) - - _ -> - nil - end - end - def format_selected_member_emails(members, selected_members) do members |> Enum.filter(fn member -> diff --git a/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex b/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex new file mode 100644 index 0000000..6d2298c --- /dev/null +++ b/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex @@ -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 diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index 2a3e04e..162524a 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -31,12 +31,25 @@ defmodule MvWeb.MemberLive.Index.DateFilter do require Ash.Query import Ash.Expr + alias MvWeb.MemberLive.Index.CustomFieldValueLookup + alias MvWeb.MemberLive.Index.FilterParams + @join_date_from_param Mv.Constants.join_date_from_param() @join_date_to_param Mv.Constants.join_date_to_param() @exit_date_mode_param Mv.Constants.exit_date_mode_param() @exit_date_from_param Mv.Constants.exit_date_from_param() @exit_date_to_param Mv.Constants.exit_date_to_param() @custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix() + @max_uuid_length Mv.Constants.max_uuid_length() + + # An id stripped from a cdf_-prefixed param still has its `_from` / `_to` + # bound suffix attached when we first see it. The longest legal suffix is + # `_from` (5 chars), so the upper bound on a valid suffixed_id is + # @max_uuid_length + 5. Anything longer cannot map to a known custom date + # field and is rejected before further string work — matching the same + # DoS-protection contract enforced by the boolean / group / fee_type + # filter parsers in `MvWeb.MemberLive.Index`. + @max_suffixed_id_length @max_uuid_length + 5 @doc """ Returns the default date filter state used on fresh page load and after @@ -139,15 +152,41 @@ defmodule MvWeb.MemberLive.Index.DateFilter do Today's date is captured via `Date.utc_today/0`; callers needing a frozen clock should wrap the call site, not this function. + + The caller is expected to pass an `%Ash.Query{}` (typically built with + `Ash.Query.new/1` or via earlier filter chaining), matching the convention + used by the sibling `apply_search_filter/2`, `apply_group_filters/3`, and + `apply_fee_type_filters/3` helpers in `MvWeb.MemberLive.Index`. """ - @spec apply_ash_filter(Ash.Query.t() | module(), map()) :: Ash.Query.t() - def apply_ash_filter(query_or_resource, filters) when is_map(filters) do - query_or_resource - |> Ash.Query.new() - |> apply_exit_date_filter(Map.get(filters, :exit_date, %{})) - |> apply_join_date_filter(Map.get(filters, :join_date, %{})) + @spec apply_ash_filter(Ash.Query.t(), map()) :: Ash.Query.t() + def apply_ash_filter(%Ash.Query{} = query, filters) when is_map(filters) do + exit_bounds = normalize_exit_bounds(Map.get(filters, :exit_date, %{})) + join_bounds = normalize_join_bounds(Map.get(filters, :join_date, %{})) + + query + |> apply_exit_date_filter(exit_bounds) + |> apply_join_date_filter(join_bounds) end + # Defensive shape normalization: callers may supply maps where one bound key + # is absent entirely (not just nil). Pattern-match heads require both keys + # present, so we backfill nil here. + defp normalize_exit_bounds(bounds) when is_map(bounds) do + %{ + mode: Map.get(bounds, :mode, :active_only), + from: Map.get(bounds, :from), + to: Map.get(bounds, :to) + } + end + + defp normalize_exit_bounds(_), do: %{mode: :active_only, from: nil, to: nil} + + defp normalize_join_bounds(bounds) when is_map(bounds) do + %{from: Map.get(bounds, :from), to: Map.get(bounds, :to)} + end + + defp normalize_join_bounds(_), do: %{from: nil, to: nil} + defp apply_exit_date_filter(query, %{mode: :all}), do: query defp apply_exit_date_filter(query, %{mode: :active_only}) do @@ -231,6 +270,23 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end end + @doc """ + Returns the UUID string keys of `filters` that name an active (at-least-one- + bound-set) custom date field. The UUID must appear in `date_custom_fields` + (matched by `to_string(field.id)` and `value_type == :date`); other entries + are dropped. + + Use this to compute which custom field values must be loaded so the + in-memory predicate (`apply_in_memory/3`) has the data it needs. + """ + @spec active_custom_field_ids(map(), [map()]) :: [String.t()] + def active_custom_field_ids(filters, date_custom_fields) + when is_map(filters) and is_list(date_custom_fields) do + filters + |> active_custom_date_filters(date_custom_fields) + |> Enum.map(fn {id, _bounds} -> id end) + end + defp matches_all_custom_dates?(member, active_filters) do Enum.all?(active_filters, fn {id, bounds} -> member_matches_custom_date?(member, id, bounds) @@ -238,10 +294,7 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end defp active_custom_date_filters(filters, date_custom_fields) do - valid_ids = - date_custom_fields - |> Enum.filter(&date_field?/1) - |> MapSet.new(&to_string(field_id(&1))) + valid_ids = valid_custom_date_field_ids(date_custom_fields) filters |> Enum.filter(fn @@ -261,25 +314,11 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end defp extract_member_date(member, custom_field_id) do - case Map.get(member, :custom_field_values) do - values when is_list(values) -> - values - |> Enum.find(&cfv_matches_id?(&1, custom_field_id)) - |> extract_date_from_cfv() - - _ -> - nil - end + member + |> CustomFieldValueLookup.find_by_id(custom_field_id) + |> extract_date_from_cfv() end - defp cfv_matches_id?(%{custom_field_id: cfid}, id) when not is_nil(cfid), - do: to_string(cfid) == id - - defp cfv_matches_id?(%{custom_field: %{id: cfid}}, id) when not is_nil(cfid), - do: to_string(cfid) == id - - defp cfv_matches_id?(_, _), do: false - defp extract_date_from_cfv(nil), do: nil defp extract_date_from_cfv(%{value: value}), do: extract_date_value(value) @@ -354,18 +393,16 @@ defmodule MvWeb.MemberLive.Index.DateFilter do defp parse_exit_date_mode(_), do: :active_only defp parse_custom_date_filters(params, date_custom_fields, base) do - valid_ids = - date_custom_fields - |> Enum.filter(&date_field?/1) - |> MapSet.new(&to_string(field_id(&1))) - - prefix = @custom_date_filter_prefix + valid_ids = valid_custom_date_field_ids(date_custom_fields) + # FilterParams.parse_prefix_filters narrows the params map to the + # cdf_-prefixed subset once; the per-entry work below scales with the + # date filter count, not the full form-param map size. params - |> Enum.reduce(base, fn {key, value}, acc -> - with true <- is_binary(key), - true <- String.starts_with?(key, prefix), - {id, bound} <- split_custom_date_key(key, prefix), + |> FilterParams.parse_prefix_filters(@custom_date_filter_prefix, & &1) + |> Enum.reduce(base, fn {suffixed_id, value}, acc -> + with true <- bounded_id?(suffixed_id), + {id, bound} <- split_suffix(suffixed_id), true <- MapSet.member?(valid_ids, id), %Date{} = date <- parse_date(value) do update_custom_date_entry(acc, id, bound, date) @@ -375,20 +412,35 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end) end + # Reject any suffixed_id that could not possibly fit a UUID + bound suffix + # before doing further string work. This is the DoS-protection contract + # used by the boolean / group / fee_type filter parsers in + # `MvWeb.MemberLive.Index` (see `process_boolean_filter_param/5`, + # `add_group_filter_entry/4`, `add_fee_type_filter_entry/4`). + defp bounded_id?(suffixed_id) when is_binary(suffixed_id), + do: String.length(suffixed_id) <= @max_suffixed_id_length + + defp bounded_id?(_), do: false + defp date_field?(%{value_type: :date}), do: true defp date_field?(_), do: false - defp field_id(%{id: id}), do: id - - defp split_custom_date_key(key, prefix) do - rest = String.slice(key, String.length(prefix), String.length(key)) + # Single source of truth for the set of valid custom-date-field UUID strings. + # Used both when parsing URL params (to drop bogus UUIDs) and when computing + # which active filter entries actually correspond to a known date field. + defp valid_custom_date_field_ids(date_custom_fields) do + date_custom_fields + |> Enum.filter(&date_field?/1) + |> MapSet.new(&to_string(&1.id)) + end + defp split_suffix(suffixed_id) do cond do - String.ends_with?(rest, "_from") -> - {String.slice(rest, 0, String.length(rest) - 5), :from} + String.ends_with?(suffixed_id, "_from") -> + {String.replace_suffix(suffixed_id, "_from", ""), :from} - String.ends_with?(rest, "_to") -> - {String.slice(rest, 0, String.length(rest) - 3), :to} + String.ends_with?(suffixed_id, "_to") -> + {String.replace_suffix(suffixed_id, "_to", ""), :to} true -> :error diff --git a/lib/mv_web/live/member_live/index/filter_params.ex b/lib/mv_web/live/member_live/index/filter_params.ex index 9b5e800..790b31f 100644 --- a/lib/mv_web/live/member_live/index/filter_params.ex +++ b/lib/mv_web/live/member_live/index/filter_params.ex @@ -1,8 +1,12 @@ defmodule MvWeb.MemberLive.Index.FilterParams do @moduledoc """ - Shared parsing helpers for member list filter URL/params (in/not_in style). - Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs. + Shared parsing helpers for member list filter URL/params. + + Used by `MvWeb.MemberLive.Index`, `MvWeb.Components.MemberFilterComponent`, + and `MvWeb.MemberLive.Index.DateFilter` to avoid duplication and to keep + param-extraction logic in one place. """ + @doc """ Parses a value for group or fee-type filter params. Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion. @@ -19,4 +23,29 @@ defmodule MvWeb.MemberLive.Index.FilterParams do end def parse_in_not_in_value(_), do: nil + + @doc """ + Selects every `{key, value}` pair in `params` whose `key` is a binary that + starts with `prefix`, strips the prefix from the key, runs `parse_value_fn` + on the value, and accumulates the results into a map. + + Non-binary keys are ignored. Exactly one occurrence of the prefix is + stripped (so a key like `"p_p_abc"` with prefix `"p_"` yields id `"p_abc"`). + + The prefix-match filter is applied before the reduce so unrelated params + (e.g. `query`, `sort_field`, other-prefix filters) do not enter the + per-entry work — keeping the cost proportional to the matched subset on + every `phx-change` keystroke. + """ + @spec parse_prefix_filters(map(), String.t(), (String.t() -> term())) :: + %{optional(String.t()) => term()} + def parse_prefix_filters(params, prefix, parse_value_fn) + when is_map(params) and is_binary(prefix) and is_function(parse_value_fn, 1) do + params + |> Enum.filter(fn {key, _} -> is_binary(key) and String.starts_with?(key, prefix) end) + |> Enum.reduce(%{}, fn {key, value}, acc -> + id = String.replace_prefix(key, prefix, "") + Map.put(acc, id, parse_value_fn.(value)) + end) + end end diff --git a/test/mv_web/live/member_live/date_filter_custom_field_test.exs b/test/mv_web/live/member_live/date_filter_custom_field_test.exs index 2959e77..f8fff15 100644 --- a/test/mv_web/live/member_live/date_filter_custom_field_test.exs +++ b/test/mv_web/live/member_live/date_filter_custom_field_test.exs @@ -3,6 +3,9 @@ defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do Unit tests for `DateFilter.apply_in_memory/3` — the post-`Ash.read!` predicate that filters members by custom date field values stored as JSONB `Ash.Union` types in `custom_field_values`. + + Integration coverage against a real database lives in the second module + in this file (DateFilterCustomFieldIntegrationTest). """ use ExUnit.Case, async: true @@ -208,3 +211,109 @@ defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do end end end + +defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldIntegrationTest do + @moduledoc """ + Integration tests for custom date field filtering on /members (§1.13, §1.14). + + Creates a real `:date`-typed CustomField plus members with corresponding + CustomFieldValue rows, then asserts visibility through the LiveView with + `cdf__from` and `cdf__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 diff --git a/test/mv_web/live/member_live/date_filter_default_test.exs b/test/mv_web/live/member_live/date_filter_default_test.exs index 84400ef..66d69cb 100644 --- a/test/mv_web/live/member_live/date_filter_default_test.exs +++ b/test/mv_web/live/member_live/date_filter_default_test.exs @@ -22,3 +22,123 @@ defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do end end end + +defmodule MvWeb.MemberLive.Index.DateFilterDefaultIntegrationTest do + @moduledoc """ + Integration tests for the default exit_date filter behavior on the member + overview page (§1.1, §1.2, §1.3, §1.4, §1.6 in the issue specs). + + These exercise the full `mount/3` → `handle_params/3` → `load_members/1` + pipeline against a real database, asserting that the active-only default + is applied to a fresh page load and overridden when the URL says so. + """ + # async: false because we mutate the global member table. + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + today = Date.utc_today() + + {:ok, active_no_exit} = + Mv.Membership.create_member( + %{first_name: "Anna", last_name: "Active", email: "anna@example.com"}, + actor: system_actor + ) + + {:ok, future_exit} = + Mv.Membership.create_member( + %{ + first_name: "Felix", + last_name: "Future", + email: "felix@example.com", + join_date: Date.add(today, -365), + exit_date: Date.add(today, 30) + }, + actor: system_actor + ) + + {:ok, exit_today} = + Mv.Membership.create_member( + %{ + first_name: "Tina", + last_name: "Today", + email: "tina@example.com", + join_date: Date.add(today, -365), + exit_date: today + }, + actor: system_actor + ) + + {:ok, past_exit} = + Mv.Membership.create_member( + %{ + first_name: "Paula", + last_name: "Past", + email: "paula@example.com", + join_date: Date.add(today, -365), + exit_date: Date.add(today, -1) + }, + actor: system_actor + ) + + %{ + active_no_exit: active_no_exit, + future_exit: future_exit, + exit_today: exit_today, + past_exit: past_exit + } + end + + describe "fresh load — no URL params" do + test "hides member with exit_date strictly before today (§1.1)", %{ + conn: conn, + past_exit: past + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + refute html =~ past.first_name + end + + test "hides member whose exit_date equals today (§1.4)", %{ + conn: conn, + exit_today: exit_today + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + refute html =~ exit_today.first_name + end + + test "shows member with no exit_date (§1.2)", %{conn: conn, active_no_exit: anna} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ anna.first_name + end + + test "shows member with exit_date strictly in the future (§1.3)", %{ + conn: conn, + future_exit: felix + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ felix.first_name + end + end + + describe "URL with ed_mode=all overrides the default (§1.6)" do + test "shows former members when URL contains ed_mode=all", %{ + conn: conn, + past_exit: past, + exit_today: today, + active_no_exit: anna, + future_exit: felix + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?ed_mode=all") + assert html =~ past.first_name + assert html =~ today.first_name + assert html =~ anna.first_name + assert html =~ felix.first_name + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_test.exs b/test/mv_web/live/member_live/date_filter_test.exs index 424e6f1..7d01c01 100644 --- a/test/mv_web/live/member_live/date_filter_test.exs +++ b/test/mv_web/live/member_live/date_filter_test.exs @@ -1,9 +1,8 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do @moduledoc """ Unit tests for DateFilter URL codec and pure helpers. - DB-level filtering and in-memory custom field filtering are covered in - separate integration tests (date_filter_default_test, date_filter_test, - date_filter_custom_field_test). + DB-level filtering against real members is covered by the integration + module below in this file. """ use ExUnit.Case, async: true @@ -201,7 +200,12 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do alias Mv.Membership.Member, as: MemberResource - defp base_query, do: MemberResource + # The production caller (`MvWeb.MemberLive.Index.load_members/1`) hands + # `apply_ash_filter/2` an already-built `%Ash.Query{}`, matching the + # convention of the sibling `apply_*_filters` helpers. The shape contract + # tests mirror that convention so they exercise the exact call shape used + # in production. + defp base_query, do: Ash.Query.new(MemberResource) test ":all mode and empty join_date is a no-op" do filters = %{ @@ -269,20 +273,102 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do refute is_nil(query.filter) end - test "accepts a non-resource Ash.Query as input" do + test "raises FunctionClauseError when caller passes a bare resource module" do + # The function now requires `%Ash.Query{}` — the production convention + # used by every sibling `apply_*_filters` helper in + # `MvWeb.MemberLive.Index`. A bare resource module is no longer accepted. filters = %{ join_date: %{from: nil, to: nil}, - exit_date: %{mode: :inactive_only, from: nil, to: nil} + exit_date: %{mode: :all, from: nil, to: nil} } - # Should also work when given a pre-built Ash.Query (not just a resource). - query = - MemberResource - |> Ash.Query.new() - |> DateFilter.apply_ash_filter(filters) + # Indirect through a variable so the compiler's static type analysis + # does not flag the deliberately invalid call shape we want to assert on. + bare_resource = Function.identity(MemberResource) + assert_raise FunctionClauseError, fn -> + DateFilter.apply_ash_filter(bare_resource, filters) + end + end + + test "join_date map missing :to key still applies the from bound" do + # A caller-supplied map can omit one bound key entirely (not just set it + # to nil). The Ash filter must still be applied for the bound that is + # present. + filters = %{ + join_date: %{from: ~D[2024-01-01]}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) refute is_nil(query.filter) end + + test "join_date map missing :from key still applies the to bound" do + filters = %{ + join_date: %{to: ~D[2024-12-31]}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test ":custom exit_date with only :from key still applies the bound" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, from: ~D[2024-01-01]} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test ":custom exit_date with only :to key still applies the bound" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, to: ~D[2024-12-31]} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + end + + describe "active_custom_field_ids/2" do + test "returns UUID keys with at least one bound set whose UUID matches a date field" do + id_a = "11111111-1111-1111-1111-111111111111" + id_b = "22222222-2222-2222-2222-222222222222" + id_unknown = "33333333-3333-3333-3333-333333333333" + + filters = + DateFilter.default() + |> Map.put(id_a, %{from: ~D[2024-01-01], to: nil}) + |> Map.put(id_b, %{from: nil, to: nil}) + |> Map.put(id_unknown, %{from: ~D[2024-06-01], to: nil}) + + ids = + DateFilter.active_custom_field_ids(filters, [ + date_custom_field(id_a), + date_custom_field(id_b) + ]) + + assert ids == [id_a] + end + + test "ignores non-binary keys (built-in atoms)" do + assert DateFilter.active_custom_field_ids(DateFilter.default(), []) == [] + end + + test "returns [] when no date custom fields are supplied" do + id_a = "11111111-1111-1111-1111-111111111111" + + filters = + DateFilter.default() + |> Map.put(id_a, %{from: ~D[2024-01-01], to: nil}) + + assert DateFilter.active_custom_field_ids(filters, []) == [] + end end describe "from_params/2 — custom date field entries" do @@ -312,5 +398,231 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do other -> flunk("expected nil bound for malformed input, got #{inspect(other)}") end end + + test "strips only the trailing _from / _to suffix, not internal substrings" do + # Construct an id that itself contains "_from" / "_to" — a quirky but + # legal MapSet key. Trailing-only suffix stripping must leave the + # internal substrings intact and recover the original id. + id_with_internal = "abc_from_xyz_to_def" + + params = %{ + "cdf_#{id_with_internal}_from" => "2024-06-01", + "cdf_#{id_with_internal}_to" => "2024-06-30" + } + + filters = + DateFilter.from_params(params, [date_custom_field(id_with_internal)]) + + assert filters[id_with_internal] == + %{from: ~D[2024-06-01], to: ~D[2024-06-30]} + end + + test "drops cdf_-prefixed keys whose id exceeds the UUID length cap" do + # Matches the DoS-protection contract enforced by the sibling boolean, + # group, and fee_type filter parsers: an over-long id-portion (post + # prefix-strip, pre suffix-strip) is rejected without invoking the + # heavier String.replace_suffix / MapSet.member? path. The id we + # construct is well past `Mv.Constants.max_uuid_length()` (36). + known_id = "11111111-2222-3333-4444-555555555555" + over_long_id = String.duplicate("a", 200) + + params = %{ + "cdf_#{over_long_id}_from" => "2024-06-01", + "cdf_#{over_long_id}_to" => "2024-06-30" + } + + filters = DateFilter.from_params(params, [date_custom_field(known_id)]) + + refute Map.has_key?(filters, over_long_id) + refute Map.has_key?(filters, "#{over_long_id}_from") + refute Map.has_key?(filters, "#{over_long_id}_to") + end + end +end + +defmodule MvWeb.MemberLive.Index.DateFilterIntegrationTest do + @moduledoc """ + Integration tests for the date filter URL → query → result-set pipeline. + + Covers §1.7, §1.8, §1.9, §1.10, §1.11, §1.12, §1.15, §1.16, §1.17, §1.18, + §1.19. Custom date field filters are covered in the dedicated custom-field + integration test. + """ + # async: false: mutates the global member table. + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + today = Date.utc_today() + + {:ok, alice} = + Mv.Membership.create_member( + %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com", + join_date: ~D[2020-01-01] + }, + actor: system_actor + ) + + {:ok, bob} = + Mv.Membership.create_member( + %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com", + join_date: ~D[2022-06-15] + }, + actor: system_actor + ) + + {:ok, carla} = + Mv.Membership.create_member( + %{ + first_name: "Carla", + last_name: "Carter", + email: "carla@example.com", + join_date: ~D[2024-03-20] + }, + actor: system_actor + ) + + {:ok, dan} = + Mv.Membership.create_member( + %{ + first_name: "Dan", + last_name: "Dixon", + email: "dan@example.com" + # no join_date — should be excluded by any join_date range filter + }, + actor: system_actor + ) + + {:ok, former} = + Mv.Membership.create_member( + %{ + first_name: "Frida", + last_name: "Former", + email: "frida@example.com", + join_date: Date.add(today, -1000), + exit_date: Date.add(today, -10) + }, + actor: system_actor + ) + + %{alice: alice, bob: bob, carla: carla, dan: dan, former: former, today: today} + end + + describe "join_date filters" do + test "jd_from includes members with join_date >= bound; excludes nil (§1.7, §1.17)", + %{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01") + refute html =~ alice.first_name + assert html =~ bob.first_name + assert html =~ carla.first_name + refute html =~ dan.first_name + end + + test "jd_to excludes members with join_date > bound (§1.8)", + %{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_to=2022-12-31") + assert html =~ alice.first_name + assert html =~ bob.first_name + refute html =~ carla.first_name + refute html =~ dan.first_name + end + + test "jd_from and jd_to combine into an inclusive range (§1.9)", + %{conn: conn, alice: alice, bob: bob, carla: carla} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01&jd_to=2023-12-31") + refute html =~ alice.first_name + assert html =~ bob.first_name + refute html =~ carla.first_name + end + + test "no active filter imposes no constraint on the join_date field (§1.18)", + %{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do + # Nil join_date members are still visible when no join_date filter is active. + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ alice.first_name + assert html =~ bob.first_name + assert html =~ carla.first_name + assert html =~ dan.first_name + end + end + + describe "exit_date filters" do + test "ed_mode=inactive_only shows only former members (§1.10)", + %{conn: conn, alice: alice, former: former} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?ed_mode=inactive_only") + refute html =~ alice.first_name + assert html =~ former.first_name + end + + test "ed_mode=all shows all members regardless of exit_date (§1.11)", + %{conn: conn, alice: alice, former: former} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?ed_mode=all") + assert html =~ alice.first_name + assert html =~ former.first_name + end + + test "ed_mode=custom range hides members outside the range (§1.12)", + %{conn: conn, alice: alice, former: former, today: today} do + conn = conn_with_oidc_user(conn) + from = Date.add(today, -30) |> Date.to_iso8601() + to = Date.to_iso8601(today) + + {:ok, _view, html} = live(conn, "/members?ed_mode=custom&ed_from=#{from}&ed_to=#{to}") + refute html =~ alice.first_name + assert html =~ former.first_name + end + end + + describe "filter combination and URL persistence" do + test "join_date filter combined with the default (active-only) shows only active members in range (§1.15)", + %{conn: conn, alice: alice, bob: bob, former: former} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_from=2020-01-01") + # Default active-only hides the former member even though they match join_date range. + refute html =~ former.first_name + assert html =~ alice.first_name + assert html =~ bob.first_name + end + + test "date filter survives reload via URL params (§1.16)", + %{conn: conn, alice: alice, bob: bob, carla: carla} do + conn = conn_with_oidc_user(conn) + url = "/members?jd_from=2022-01-01&jd_to=2023-12-31" + {:ok, _view, html1} = live(conn, url) + {:ok, _view, html2} = live(conn, url) + # Same URL → same visible result set. + for member <- [alice, carla] do + refute html1 =~ member.first_name + refute html2 =~ member.first_name + end + + assert html1 =~ bob.first_name + assert html2 =~ bob.first_name + end + + test "malformed jd_from is silently ignored (§1.19)", + %{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_from=notadate") + # The filter is dropped, so default behavior (no join_date filter) applies + # and every member shows up. + assert html =~ alice.first_name + assert html =~ bob.first_name + assert html =~ carla.first_name + assert html =~ dan.first_name + end end end diff --git a/test/mv_web/live/member_live/index/custom_field_value_lookup_test.exs b/test/mv_web/live/member_live/index/custom_field_value_lookup_test.exs new file mode 100644 index 0000000..1c4bbe4 --- /dev/null +++ b/test/mv_web/live/member_live/index/custom_field_value_lookup_test.exs @@ -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 diff --git a/test/mv_web/live/member_live/index/filter_params_test.exs b/test/mv_web/live/member_live/index/filter_params_test.exs new file mode 100644 index 0000000..a73d17b --- /dev/null +++ b/test/mv_web/live/member_live/index/filter_params_test.exs @@ -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 From d6671daf1a2dc0026e252de22023960c364d9f49 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:32:29 +0200 Subject: [PATCH 04/26] feat(member-filter): add date filter sections with active-count badge and reset support --- .../components/member_filter_component.ex | 278 +++++++++++++- lib/mv_web/live/member_live/index.html.heex | 2 + priv/gettext/de/LC_MESSAGES/default.po | 75 ++++ priv/gettext/default.pot | 75 ++++ priv/gettext/en/LC_MESSAGES/default.po | 75 ++++ .../member_filter_component_test.exs | 338 ++++++++++++++++++ .../member_live/date_filter_property_test.exs | 9 +- 7 files changed, 834 insertions(+), 18 deletions(-) diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index 71227be..99ee2c5 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -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). - `:boolean_custom_fields` - List of boolean custom fields to display - `: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) - `: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 `{: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 `{: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 + alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.FilterParams @group_filter_prefix Mv.Constants.group_filter_prefix() @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() + @custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix() @impl true def mount(socket) do @@ -50,19 +60,42 @@ defmodule MvWeb.Components.MemberFilterComponent do socket |> assign(:id, assigns.id) |> assign(:cycle_status_filter, assigns[:cycle_status_filter]) - |> assign(:groups, assigns[:groups] || []) - |> assign(:group_filters, assigns[:group_filters] || %{}) - |> assign(:group_filter_prefix, @group_filter_prefix) - |> assign(:fee_types, assigns[:fee_types] || []) - |> 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_group_assigns(assigns) + |> assign_fee_type_assigns(assigns) + |> assign_boolean_assigns(assigns) + |> assign_date_assigns(assigns) |> assign(:member_count, assigns[:member_count] || 0) {:ok, socket} 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 def render(assigns) do ~H""" @@ -81,7 +114,8 @@ defmodule MvWeb.Components.MemberFilterComponent do "gap-2", (@cycle_status_filter || map_size(@group_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" ]} phx-click="toggle_dropdown" @@ -99,7 +133,8 @@ defmodule MvWeb.Components.MemberFilterComponent do @fee_types, @fee_type_filters, @boolean_custom_fields, - @boolean_filters + @boolean_filters, + @date_filters )} <.badge @@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do <.badge :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 } variant="primary" @@ -329,6 +366,163 @@ defmodule MvWeb.Components.MemberFilterComponent do + +
+
+ {gettext("Dates")} +
+
+ + {gettext("Exit date")} + +
+ + + + +
+
+ <.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)} + /> +
+
+
+ + {gettext("Join date")} + +
+ <.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)} + /> +
+
+
+ + +
0} class="mb-4"> +
+ {gettext("Custom date fields")} +
+
5, do: "max-h-60 overflow-y-auto pr-2", else: "" + }> +
+ + {field.name} + +
+ <.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)} + /> +
+
+
+
+
0} class="mb-2">
@@ -452,11 +646,13 @@ defmodule MvWeb.Components.MemberFilterComponent do ) 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_group_filter_changes(socket, group_filters_parsed) dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) + dispatch_date_filters_change(socket, new_date_filters) {:noreply, socket} end @@ -540,6 +736,12 @@ defmodule MvWeb.Components.MemberFilterComponent do 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 defp button_label( cycle_status_filter, @@ -548,14 +750,16 @@ defmodule MvWeb.Components.MemberFilterComponent do fee_types, fee_type_filters, boolean_custom_fields, - boolean_filters + boolean_filters, + date_filters ) do active_count = count_active_filter_categories( cycle_status_filter, group_filters, fee_type_filters, - boolean_filters + boolean_filters, + date_filters ) if active_count >= 2 do @@ -576,6 +780,9 @@ defmodule MvWeb.Components.MemberFilterComponent do map_size(boolean_filters) > 0 -> boolean_filter_label(boolean_custom_fields, boolean_filters) + date_filters_active?(date_filters) -> + gettext("Dates") + true -> gettext("Apply filters") end @@ -586,17 +793,27 @@ defmodule MvWeb.Components.MemberFilterComponent do cycle_status_filter, group_filters, fee_type_filters, - boolean_filters + boolean_filters, + date_filters ) do [ cycle_status_filter, map_size(group_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) 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, do: gettext("All") @@ -765,4 +982,35 @@ defmodule MvWeb.Components.MemberFilterComponent do "#{base_classes} btn-outline" 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 diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index efc1eb7..13dc89e 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -54,6 +54,8 @@ fee_type_filters={@fee_type_filters} boolean_custom_fields={@boolean_custom_fields} boolean_filters={@boolean_custom_field_filters} + date_custom_fields={@date_custom_fields} + date_filters={@date_filters} member_count={length(@members)} /> <.tooltip diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index a85b4cf..f03daf0 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3897,3 +3897,78 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}" #, elixir-autogen, elixir-format 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." + +#: 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" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index b995b1a..50ceff8 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3897,3 +3897,78 @@ msgstr "" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index f4526d1..9ec230f 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3897,3 +3897,78 @@ msgstr "" #, elixir-autogen, elixir-format 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." + +#: 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 "" diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs index d32993c..bb55fa5 100644 --- a/test/mv_web/components/member_filter_component_test.exs +++ b/test/mv_web/components/member_filter_component_test.exs @@ -209,6 +209,57 @@ defmodule MvWeb.Components.MemberFilterComponentTest do # Button should still contain some text (truncated version or indicator) assert String.length(button_html) > 0 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 describe "badge" do @@ -268,6 +319,293 @@ defmodule MvWeb.Components.MemberFilterComponentTest do refute dropdown_html =~ "String Field" 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 `` 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 + # `
` `