From d6671daf1a2dc0026e252de22023960c364d9f49 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:32:29 +0200 Subject: [PATCH] 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 + # `
` `