Add filter for date fields closes #340 #497

Merged
moritz merged 5 commits from issue/mitgliederverwaltung-340 into main 2026-06-01 14:42:14 +02:00
7 changed files with 834 additions and 18 deletions
Showing only changes of commit d6671daf1a - Show all commits

View file

@ -23,6 +23,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
- `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All).
- `: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
)}
</span>
<.badge
@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
</.badge>
<.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
</div>
</div>
<!-- Dates Group (built-in fields: exit_date with mode selector, join_date range) -->
<div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Dates")}
</div>
<fieldset class="border-0 p-0 m-0 min-w-0 mb-3">
<legend class="text-sm font-medium mb-1">
{gettext("Exit date")}
</legend>
<div class="join w-full">
<label
class={"#{exit_mode_label_class(@date_filters, :active_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-active-only"
>
<input
type="radio"
id="ed-mode-active-only"
name="ed_mode"
value="active_only"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :active_only}
/>
<span class="text-xs">{gettext("Active only")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :all)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-all"
>
<input
type="radio"
id="ed-mode-all"
name="ed_mode"
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :all}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :inactive_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-inactive-only"
>
<input
type="radio"
id="ed-mode-inactive-only"
name="ed_mode"
value="inactive_only"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :inactive_only}
/>
<span class="text-xs">{gettext("Inactive only")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :custom)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-custom"
>
<input
type="radio"
id="ed-mode-custom"
name="ed_mode"
value="custom"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :custom}
/>
<span class="text-xs">{gettext("Range")}</span>
</label>
</div>
<div
:if={exit_mode(@date_filters) == :custom}
class="mt-2 flex gap-3 items-end flex-wrap"
>
<.input
type="date"
id="ed-from"
name="ed_from"
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("Exit date from")}
value={date_value_for_input(@date_filters, :exit_date, :from)}
/>
<.input
type="date"
id="ed-to"
name="ed_to"
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("Exit date to")}
value={date_value_for_input(@date_filters, :exit_date, :to)}
/>
</div>
</fieldset>
<fieldset class="border-0 p-0 m-0 min-w-0">
<legend class="text-sm font-medium mb-1">
{gettext("Join date")}
</legend>
<div class="flex gap-3 items-end flex-wrap">
<.input
type="date"
id="jd-from"
name="jd_from"
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("Join date from")}
value={date_value_for_input(@date_filters, :join_date, :from)}
/>
<.input
type="date"
id="jd-to"
name="jd_to"
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("Join date to")}
value={date_value_for_input(@date_filters, :join_date, :to)}
/>
</div>
</fieldset>
</div>
<!-- Custom Date Fields Group (in-memory filter; one row per :date custom field) -->
<div :if={length(@date_custom_fields) > 0} class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Custom date fields")}
</div>
<div class={
if length(@date_custom_fields) > 5, do: "max-h-60 overflow-y-auto pr-2", else: ""
}>
<fieldset
:for={field <- @date_custom_fields}
class="border-0 border-b border-base-200 last:border-b-0 p-0 m-0 min-w-0 py-2"
>
<legend class="text-sm font-medium mb-1">
{field.name}
</legend>
<div class="flex gap-3 items-end flex-wrap">
<.input
type="date"
id={"cdf-#{field.id}-from"}
name={"#{@custom_date_filter_prefix}#{field.id}_from"}
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("%{field} from", field: field.name)}
value={custom_date_value_for_input(@date_filters, field.id, :from)}
/>
<.input
type="date"
id={"cdf-#{field.id}-to"}
name={"#{@custom_date_filter_prefix}#{field.id}_to"}
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("%{field} to", field: field.name)}
value={custom_date_value_for_input(@date_filters, field.id, :to)}
/>
</div>
</fieldset>
</div>
</div>
<!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 `<input>` tags carrying
# DaisyUI component classes (e.g. `input input-sm input-bordered`)
# directly in HEEX. `<.input>` is the project's single source of truth
# for input styling; bypassing it splits styling across many call sites.
#
# The recognizable structural fingerprint of `<.input>` is a wrapping
# `<fieldset class="mb-2 fieldset">` `<label>` chain immediately
# preceding the `<input>`. The raw inline form has no such wrapper —
# the input sits directly inside a sibling `<label>`/`<input>` flex row.
# We assert that fingerprint on each of the date inputs.
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
for name <- ["jd_from", "jd_to", "ed_from", "ed_to"] do
# Match `<fieldset class="mb-2 fieldset">` followed (within a short
# window of HTML) by an `<input>` carrying the expected `name`. The
# window prevents the regex from spanning unrelated `mb-2` /
# `fieldset` occurrences scattered across the dropdown. The wrapper
# is the canonical fingerprint of `MvWeb.CoreComponents.input/1`
# (see `lib/mv_web/components/core_components.ex` — every input
# branch starts with `<fieldset class="mb-2 fieldset">`).
assert Regex.match?(
~r/<fieldset[^>]*class="mb-2 fieldset"[^>]*>\s*<label[^>]*>(?:\s*<span[^>]*>.*?<\/span>)?\s*<input[^>]*name="#{name}"/s,
dropdown_html
),
"expected date input #{name} to be wrapped by MvWeb.CoreComponents.input " <>
"(class=\"mb-2 fieldset\" fieldset wrapper), not a raw inline " <>
"<input type=\"date\"> element"
end
end
test "exit_date defaults to :active_only in the rendered radio", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~
~r/name="ed_mode"[^>]*value="active_only"[^>]*checked|checked[^>]*name="ed_mode"[^>]*value="active_only"/
end
test "Custom date fields section is non-scrollable with 5 or fewer fields (§3.4)", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
for i <- 1..5 do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create!(actor: system_actor)
end
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
section_html = custom_date_section_html(view)
# With ≤ 5 fields the section must NOT carry the scrollable wrapper.
refute section_html =~ "max-h-60"
refute section_html =~ "overflow-y-auto"
end
test "Custom date fields section becomes scrollable with more than 5 fields (§3.4)", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
for i <- 1..6 do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create!(actor: system_actor)
end
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
section_html = custom_date_section_html(view)
# With more than 5 fields the section is wrapped in the scrollable container.
assert section_html =~ "max-h-60"
assert section_html =~ "overflow-y-auto"
end
# Extract the HTML of the rendered "Custom date fields" section. Returns
# "" if the section is not rendered. Used by the threshold tests to avoid
# picking up scrollable classes from sibling sections.
defp custom_date_section_html(view) do
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
label = gettext("Custom date fields")
case String.split(dropdown_html, label, parts: 2) do
[_before, after_label] ->
# Up to the next group header label, or the footer.
after_label
|> String.split(["text-xs font-semibold opacity-70 mb-2 uppercase"], parts: 2)
|> List.first()
_ ->
""
end
end
test "Custom date fields section appears only when date custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view_no_field, _} = live(conn, "/members")
view_no_field
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view_no_field
|> element("#member-filter div[role='dialog']")
|> render()
refute dropdown_html =~ gettext("Custom date fields")
# Add a date-typed custom field and re-load: the section appears.
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Birthday-#{System.unique_integer([:positive])}",
value_type: :date
})
|> Ash.create(actor: system_actor)
{:ok, view, _} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
assert dropdown_html =~ gettext("Custom date fields")
assert dropdown_html =~ field.name
assert dropdown_html =~ "cdf_#{field.id}_from"
assert dropdown_html =~ "cdf_#{field.id}_to"
end
test "update_filters event dispatches a date_filters_changed patch with the new jd_from", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
view
|> form("#member-filter form", %{
"jd_from" => "2024-01-15",
"payment_filter" => "all"
})
|> render_change()
# Parent LiveView receives {:date_filters_changed, ...} and patches the URL.
path = assert_patch(view)
assert path =~ "jd_from=2024-01-15"
end
test "selecting ed_mode=all updates the URL and reveals former members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
today = Date.utc_today()
unique_name = "Zarquon-#{System.unique_integer([:positive])}"
{:ok, former} =
Mv.Membership.create_member(
%{
first_name: unique_name,
last_name: "Exited",
email: "ex-#{System.unique_integer([:positive])}@example.com",
join_date: Date.add(today, -1000),
exit_date: Date.add(today, -30)
},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Fresh load hides the former member.
refute html =~ former.first_name
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
view
|> form("#member-filter form", %{
"ed_mode" => "all",
"payment_filter" => "all"
})
|> render_change()
path = assert_patch(view)
assert path =~ "ed_mode=all"
# Now Eve appears in the rendered list.
assert render(view) =~ former.first_name
end
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)

View file

@ -4,11 +4,14 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
* `to_params/1` `from_params/2` must be the identity for all valid
built-in date filter states (§2.3).
* `apply_in_memory/3` matches the inclusive range predicate
`(from == nil or value >= from) and (to == nil or value <= to)`
for any custom date field value and bound pair (§2.4).
Custom date field entries are not part of this property because
Custom date field round-trip is not part of the URL codec property because
`from_params/2` needs the caller-supplied `date_custom_fields` list to
validate UUIDs; the standalone property for the in-memory predicate (§2.4)
is covered in S8 after the predicate exists.
validate UUIDs; that interaction is covered by example tests in
`MvWeb.MemberLive.Index.DateFilterTest`.
"""
use ExUnit.Case, async: true
use ExUnitProperties