Add boolean custom field filters to member overview closes #309 #362

Merged
simon merged 15 commits from feature/filter-boolean-custom-fields into main 2026-01-23 14:53:08 +01:00
11 changed files with 2725 additions and 433 deletions

View file

@ -19,6 +19,12 @@ defmodule Mv.Constants do
@custom_field_prefix "custom_field_"
@boolean_filter_prefix "bf_"
@max_boolean_filters 50
@max_uuid_length 36
@email_validator_checks [:html_input, :pow]
def member_fields, do: @member_fields
@ -33,6 +39,42 @@ defmodule Mv.Constants do
"""
def custom_field_prefix, do: @custom_field_prefix
@doc """
Returns the prefix used for boolean custom field filter URL parameters.
## Examples
iex> Mv.Constants.boolean_filter_prefix()
"bf_"
"""
def boolean_filter_prefix, do: @boolean_filter_prefix
@doc """
Returns the maximum number of boolean custom field filters allowed per request.
This limit prevents DoS attacks by restricting the number of filter parameters
that can be processed in a single request.
## Examples
iex> Mv.Constants.max_boolean_filters()
50
"""
def max_boolean_filters, do: @max_boolean_filters
@doc """
Returns the maximum length of a UUID string (36 characters including hyphens).
UUIDs in standard format (e.g., "550e8400-e29b-41d4-a716-446655440000") are
exactly 36 characters long. This constant is used for input validation.
## Examples
iex> Mv.Constants.max_uuid_length()
36
"""
def max_uuid_length, do: @max_uuid_length
@doc """
Returns the email validator checks used for EctoCommons.EmailValidator.

View file

@ -0,0 +1,444 @@
defmodule MvWeb.Components.MemberFilterComponent do
@moduledoc """
Provides the MemberFilter Live-Component.
A DaisyUI dropdown filter for filtering members by payment status and boolean custom fields.
Uses radio inputs in a segmented control pattern (join + btn) for tri-state boolean filters.
## Design Decisions
- Uses `div` panel instead of `ul.menu/li` structure to avoid DaisyUI menu styles
(padding, display, hover, font sizes) that would interfere with form controls.
- Filter controls are form elements (fieldset with legend, radio inputs), not menu items.
Uses semantic `<fieldset>` and `<legend>` for proper accessibility and form structure.
- Dropdown stays open when clicking filter segments to allow multiple filter changes.
- Uses `phx-change` on form for radio inputs instead of individual `phx-click` events.
## Props
- `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid`
- `:boolean_custom_fields` - List of boolean custom fields to display
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when payment filter changes
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
tabindex="0"
class={[
"btn gap-2",
(@cycle_status_filter || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter members")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">
{button_label(@cycle_status_filter, @boolean_custom_fields, @boolean_filters)}
</span>
<span
:if={active_boolean_filters_count(@boolean_filters) > 0}
class="badge badge-primary badge-sm"
>
{active_boolean_filters_count(@boolean_filters)}
</span>
<span
:if={@cycle_status_filter && active_boolean_filters_count(@boolean_filters) == 0}
class="badge badge-primary badge-sm"
>
{@member_count}
</span>
</button>
<!--
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
(padding, display, hover, font sizes) that would interfere with our form controls.
Filter controls are form elements (fieldset with legend, radio inputs), not menu items.
We use semantic fieldset/legend structure for proper accessibility.
We use relative/absolute positioning instead of DaisyUI dropdown classes to have
full control over the open/close state via LiveView.
-->
<div
:if={@open}
tabindex="0"
class="absolute left-0 mt-2 w-[28rem] rounded-box border border-base-300 bg-base-100 p-4 shadow-xl z-[100]"
phx-click-away="close_dropdown"
phx-target={@myself}
role="dialog"
aria-label={gettext("Member filter")}
>
<form phx-change="update_filters" phx-target={@myself}>
<!-- Payment Filter Group -->
<div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Payments")}
</div>
<fieldset class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-0 p-0 m-0 min-w-0">
<legend class="text-sm font-medium col-start-1 float-left w-auto">
{gettext("Payment Status")}
</legend>
<div class="join col-start-2">
<label
class={"#{payment_filter_label_class(@cycle_status_filter, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="payment-filter-all"
>
<input
type="radio"
id="payment-filter-all"
name="payment_filter"
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={@cycle_status_filter == nil}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{payment_filter_label_class(@cycle_status_filter, :paid)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="payment-filter-paid"
>
<input
type="radio"
id="payment-filter-paid"
name="payment_filter"
value="paid"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={@cycle_status_filter == :paid}
/>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Paid")}</span>
</label>
<label
class={"#{payment_filter_label_class(@cycle_status_filter, :unpaid)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="payment-filter-unpaid"
>
<input
type="radio"
id="payment-filter-unpaid"
name="payment_filter"
value="unpaid"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={@cycle_status_filter == :unpaid}
/>
<.icon name="hero-x-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Unpaid")}</span>
</label>
</div>
</fieldset>
</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">
{gettext("Custom Fields")}
</div>
<div class="max-h-60 overflow-y-auto pr-2">
<fieldset
:for={custom_field <- @boolean_custom_fields}
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-b border-base-200 last:border-0 border-0 p-0 m-0 min-w-0"
>
<legend class="text-sm font-medium col-start-1 float-left w-auto">
{custom_field.name}
</legend>
<div class="join col-start-2">
<label
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"custom-boolean-filter-#{custom_field.id}-all"}
>
<input
type="radio"
id={"custom-boolean-filter-#{custom_field.id}-all"}
name={"custom_boolean[#{custom_field.id}]"}
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == nil}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, true)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"custom-boolean-filter-#{custom_field.id}-true"}
aria-label={gettext("Yes")}
title={gettext("Yes")}
>
<input
type="radio"
id={"custom-boolean-filter-#{custom_field.id}-true"}
name={"custom_boolean[#{custom_field.id}]"}
value="true"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == true}
/>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Yes")}</span>
</label>
<label
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, false)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"custom-boolean-filter-#{custom_field.id}-false"}
aria-label={gettext("No")}
title={gettext("No")}
>
<input
type="radio"
id={"custom-boolean-filter-#{custom_field.id}-false"}
name={"custom_boolean[#{custom_field.id}]"}
value="false"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == false}
/>
<.icon name="hero-x-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("No")}</span>
</label>
</div>
</fieldset>
</div>
</div>
<!-- Footer -->
<div class="mt-4 flex justify-between pt-3 border-t border-base-200">
<button
type="button"
phx-click="reset_filters"
phx-target={@myself}
class="btn btn-sm"
>
{gettext("Reset")}
</button>
<button
type="button"
phx-click="close_dropdown"
phx-target={@myself}
class="btn btn-primary btn-sm"
>
{gettext("Close")}
</button>
</div>
</form>
</div>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("update_filters", params, socket) do
# Parse payment filter
payment_filter =
case Map.get(params, "payment_filter") do
"paid" -> :paid
"unpaid" -> :unpaid
_ -> nil
end
# Parse boolean custom field filters (including nil values for "all")
custom_boolean_filters_parsed =
params
|> Map.get("custom_boolean", %{})
|> Enum.reduce(%{}, fn {custom_field_id_str, value_str}, acc ->
filter_value = parse_tri_state(value_str)
Map.put(acc, custom_field_id_str, filter_value)
end)
# Update payment filter if changed
if payment_filter != socket.assigns.cycle_status_filter do
send(self(), {:payment_filter_changed, payment_filter})
end
# Update boolean filters - send events for each changed filter
current_filters = socket.assigns.boolean_filters
# Process all custom field filters from form (including those set to "all"/nil)
# Radio buttons in a group always send a value, so all active filters are in the form
Enum.each(custom_boolean_filters_parsed, fn {custom_field_id_str, new_value} ->
current_value = Map.get(current_filters, custom_field_id_str)
# Only send event if value actually changed
if current_value != new_value do
send(self(), {:boolean_filter_changed, custom_field_id_str, new_value})
end
end)
# Don't close dropdown - allow multiple filter changes
{:noreply, socket}
end
@impl true
def handle_event("reset_filters", _params, socket) do
# Send single message to reset all filters at once (performance optimization)
# This avoids N×2 load_members() calls when resetting multiple filters
send(self(), {:reset_all_filters, nil, %{}})
# Close dropdown after reset
{:noreply, assign(socket, :open, false)}
end
# Parse tri-state filter value: "all" | "true" | "false" -> nil | true | false
defp parse_tri_state("true"), do: true
defp parse_tri_state("false"), do: false
defp parse_tri_state("all"), do: nil
defp parse_tri_state(_), do: nil
# Get display label for button
defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do
# If payment filter is active, show payment filter label
if cycle_status_filter do
payment_filter_label(cycle_status_filter)
else
# Otherwise show boolean filter labels
boolean_filter_label(boolean_custom_fields, boolean_filters)
end
end
# Get payment filter label
defp payment_filter_label(nil), do: gettext("All")
defp payment_filter_label(:paid), do: gettext("Paid")
defp payment_filter_label(:unpaid), do: gettext("Unpaid")
# Get boolean filter label (comma-separated list of active filter names)
defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
when map_size(boolean_filters) == 0 do
gettext("All")
end
defp boolean_filter_label(boolean_custom_fields, boolean_filters) do
# Get names of active boolean filters
active_filter_names =
boolean_filters
|> Enum.map(fn {custom_field_id_str, _value} ->
Enum.find(boolean_custom_fields, fn cf -> to_string(cf.id) == custom_field_id_str end)
end)
|> Enum.filter(&(&1 != nil))
|> Enum.map(& &1.name)
# Join with comma and truncate if too long
label = Enum.join(active_filter_names, ", ")
truncate_label(label, 30)
end
# Truncate label if longer than max_length
defp truncate_label(label, max_length) when byte_size(label) <= max_length, do: label
defp truncate_label(label, max_length) do
String.slice(label, 0, max_length) <> "..."
end
# Count active boolean filters
defp active_boolean_filters_count(boolean_filters) do
map_size(boolean_filters)
end
# Get CSS classes for payment filter label based on current state
defp payment_filter_label_class(current_filter, expected_value) do
base_classes = "join-item btn btn-sm"
is_active = current_filter == expected_value
cond do
# All button (nil expected)
expected_value == nil ->
if is_active do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
# Paid button
expected_value == :paid ->
if is_active do
"#{base_classes} btn-success btn-active"
else
"#{base_classes} btn"
end
# Unpaid button
expected_value == :unpaid ->
if is_active do
"#{base_classes} btn-error btn-active"
else
"#{base_classes} btn"
end
true ->
"#{base_classes} btn-outline"
end
end
# Get CSS classes for boolean filter label based on current state
defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do
base_classes = "join-item btn btn-sm"
current_value = Map.get(boolean_filters, to_string(custom_field_id))
is_active = current_value == expected_value
cond do
# All button (nil expected)
expected_value == nil ->
if is_active do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
# True button
expected_value == true ->
if is_active do
"#{base_classes} btn-success btn-active"
else
"#{base_classes} btn"
end
# False button
expected_value == false ->
if is_active do
"#{base_classes} btn-error btn-active"
else
"#{base_classes} btn"
end
true ->
"#{base_classes} btn-outline"
end
end
end

View file

@ -1,147 +0,0 @@
defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by cycle payment status (paid/unpaid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
Filter is based on cycle status (last or current cycle, depending on cycle view toggle).
## Props
- `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
class={[
"btn gap-2",
@cycle_status_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@cycle_status_filter)}</span>
<span :if={@cycle_status_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
:if={@open}
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
role="menu"
aria-label={gettext("Payment filter")}
phx-click-away="close_dropdown"
phx-target={@myself}
>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == nil)}
class={@cycle_status_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == :paid)}
class={@cycle_status_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
>
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
{gettext("Paid")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == :unpaid)}
class={@cycle_status_filter == :unpaid && "active"}
phx-click="select_filter"
phx-value-filter="unpaid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Unpaid")}
</button>
</li>
</ul>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
filter = parse_filter(filter_str)
# Close dropdown and notify parent
socket = assign(socket, :open, false)
send(self(), {:payment_filter_changed, filter})
{:noreply, socket}
end
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("unpaid"), do: :unpaid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:unpaid), do: gettext("Unpaid")
end

View file

@ -28,6 +28,7 @@ defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
require Ash.Query
require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
@ -41,6 +42,15 @@ defmodule MvWeb.MemberLive.Index do
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix Mv.Constants.custom_field_prefix()
# Prefix used for boolean custom field filter URL parameters (e.g., "bf_<id>")
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
# Maximum number of boolean custom field filters allowed per request (DoS protection)
@max_boolean_filters Mv.Constants.max_boolean_filters()
# Maximum length of UUID string (36 characters including hyphens)
@max_uuid_length Mv.Constants.max_uuid_length()
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
# Note: :id is always included for member identification
@ -72,6 +82,12 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
# Load boolean custom fields (filtered and sorted from all_custom_fields)
boolean_custom_fields =
all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> Enum.sort_by(& &1.name, :asc)
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
@ -101,10 +117,12 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
|> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
|> assign(:boolean_custom_fields, boolean_custom_fields)
|> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> assign(
@ -218,7 +236,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
new_show_current
new_show_current,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@ -332,7 +351,8 @@ defmodule MvWeb.MemberLive.Index do
existing_field_query,
existing_sort_query,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
# Set the new path with params
@ -361,7 +381,77 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
@impl true
def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
# Update boolean filters map
updated_filters =
if filter_value == nil do
# Remove filter if nil (All option selected)
Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str)
else
# Add or update filter
Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value)
end
socket =
socket
|> assign(:boolean_custom_field_filters, updated_filters)
|> load_members()
|> update_selection_assigns()
# Build the URL with all params including new filter
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
updated_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
@impl true
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
# Reset all filters at once (performance optimization)
# This avoids N×2 load_members() calls when resetting multiple filters
socket =
socket
|> assign(:cycle_status_filter, cycle_status_filter)
|> assign(:boolean_custom_field_filters, boolean_filters)
|> load_members()
|> update_selection_assigns()
# Build the URL with all params including reset filters
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
cycle_status_filter,
socket.assigns.show_current_cycle,
boolean_filters
)
new_path = ~p"/members?#{query_params}"
@ -448,6 +538,9 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def handle_params(params, _url, socket) do
# Build signature BEFORE updates to detect if anything actually changed
prev_sig = build_signature(socket)
# Parse field selection from URL
url_selection = FieldSelection.parse_from_url(params)
@ -471,23 +564,68 @@ defmodule MvWeb.MemberLive.Index do
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
# Apply all updates
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
# Build signature AFTER updates
next_sig = build_signature(socket)
# Only load members if signature changed (optimization: avoid duplicate loads)
# OR if members haven't been loaded yet (first handle_params call after mount)
socket =
if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
# Nothing changed AND members already loaded, skip expensive load_members() call
socket
|> prepare_dynamic_cols()
|> update_selection_assigns()
else
# Signature changed OR members not loaded yet, reload members
socket
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
end
{:noreply, socket}
end
# Builds a signature tuple representing all filter/sort parameters that affect member loading.
#
# This signature is used to detect if member data needs to be reloaded when handle_params
# is called. If the signature hasn't changed, we can skip the expensive load_members() call.
#
# Returns a tuple containing all relevant parameters:
# - query: Search query string
# - sort_field: Field to sort by
# - sort_order: Sort direction (:asc or :desc)
# - cycle_status_filter: Payment filter (:paid, :unpaid, or nil)
# - show_current_cycle: Whether to show current cycle
# - boolean_custom_field_filters: Map of active boolean filters
# - user_field_selection: Map of user's field visibility selections
# - visible_custom_field_ids: List of visible custom field IDs (affects which custom fields are loaded)
defp build_signature(socket) do
{
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection,
socket.assigns[:visible_custom_field_ids] || []
}
end
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
#
# Creates a list of column definitions, each containing:
@ -586,7 +724,8 @@ defmodule MvWeb.MemberLive.Index do
field_str,
Atom.to_string(order),
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@ -616,7 +755,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
@ -634,12 +774,14 @@ defmodule MvWeb.MemberLive.Index do
# Builds URL query parameters map including all filter/sort state.
# Converts cycle_status_filter atom to string for URL.
# Adds boolean custom field filters as bf_<id>=true|false.
defp build_query_params(
query,
sort_field,
sort_order,
cycle_status_filter,
show_current_cycle
show_current_cycle,
boolean_filters
) do
field_str =
if is_atom(sort_field) do
@ -670,11 +812,19 @@ defmodule MvWeb.MemberLive.Index do
end
# Add show_current_cycle if true
base_params =
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
else
base_params
end
# Add boolean custom field filters
Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
param_value = if filter_value == true, do: "true", else: "false"
Map.put(acc, param_key, param_value)
end)
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
@ -704,9 +854,32 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
# Load custom field values for visible custom fields (based on user selection)
# Load custom field values for visible custom fields AND active boolean filters
# This ensures boolean filters work even when the custom field is not visible in overview
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
query = load_custom_field_values(query, visible_custom_field_ids)
# Get IDs of active boolean filters (whitelisted against boolean_custom_fields)
# Convert boolean_custom_fields list to map for efficient lookup (consistent with maybe_update_boolean_filters)
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 ->
# Validate UUID format and check against whitelist
String.length(id_str) <= @max_uuid_length &&
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
Map.has_key?(boolean_custom_fields_map, id_str)
end)
# Union of visible IDs and active filter IDs
ids_to_load =
(visible_custom_field_ids ++ active_boolean_filter_ids)
|> Enum.uniq()
query = load_custom_field_values(query, ids_to_load)
# Load membership fee cycles for status display
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
@ -726,7 +899,9 @@ defmodule MvWeb.MemberLive.Index do
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
members = Ash.read!(query, actor: actor)
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
time_milliseconds = time_microseconds / 1000
Logger.info("Ash.read! in load_members/1 took #{time_milliseconds} ms")
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
@ -739,6 +914,14 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle
)
# Apply boolean custom field filters if set
members =
apply_boolean_custom_field_filters(
members,
socket.assigns.boolean_custom_field_filters,
socket.assigns.all_custom_fields
)
# Sort in memory if needed (for custom fields)
members =
if sort_after_load do
@ -1133,6 +1316,142 @@ defmodule MvWeb.MemberLive.Index do
defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil
# Updates boolean custom field filters from URL parameters if present.
#
# Parses all URL parameters with prefix @boolean_filter_prefix and validates them:
# - Extracts custom field ID from parameter name (explicitly removes prefix)
# - Validates filter value using determine_boolean_filter/1
# - Whitelisting: Only custom field IDs that exist and have value_type: :boolean
# - Security: Limits to maximum @max_boolean_filters filters to prevent DoS attacks
# - Security: Validates UUID length (max @max_uuid_length characters)
#
# Returns socket with updated :boolean_custom_field_filters assign.
defp maybe_update_boolean_filters(socket, params) do
# Get all boolean custom fields for whitelisting (keyed by ID as string for consistency)
boolean_custom_fields =
socket.assigns.all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
# Parse all boolean filter parameters
# Security: Use reduce_while to abort early after @max_boolean_filters to prevent DoS attacks
# This protects CPU/Parsing costs, not just memory/state
# We count processed parameters (not just valid filters) to protect against parsing DoS
prefix_length = String.length(@boolean_filter_prefix)
{filters, total_processed} =
params
|> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end)
|> Enum.reduce_while({%{}, 0}, fn {key, value_str}, {acc, count} ->
if count >= @max_boolean_filters do
{:halt, {acc, count}}
else
new_acc =
process_boolean_filter_param(
key,
value_str,
prefix_length,
boolean_custom_fields,
acc
)
# Increment counter for each processed parameter (DoS protection)
# Note: We count processed params, not just valid filters, to protect parsing costs
{:cont, {new_acc, count + 1}}
end
end)
# Log warning if we hit the limit
if total_processed >= @max_boolean_filters do
Logger.warning(
"Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})"
)
end
assign(socket, :boolean_custom_field_filters, filters)
end
# Processes a single boolean filter parameter from URL params.
#
# Validates the parameter and adds it to the accumulator if valid.
# Returns the accumulator unchanged if validation fails.
defp process_boolean_filter_param(
key,
value_str,
prefix_length,
boolean_custom_fields,
acc
) do
# Extract custom field ID from parameter name (explicitly remove prefix)
# This is more secure than String.replace_prefix which only removes first occurrence
custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)
# Validate custom field ID length (UUIDs are max @max_uuid_length characters)
# This provides an additional security layer beyond UUID format validation
if String.length(custom_field_id_str) > @max_uuid_length do
acc
else
validate_and_add_boolean_filter(
custom_field_id_str,
value_str,
boolean_custom_fields,
acc
)
end
end
# Validates UUID format and custom field existence, then adds filter if valid.
defp validate_and_add_boolean_filter(
custom_field_id_str,
value_str,
boolean_custom_fields,
acc
) do
case Ecto.UUID.cast(custom_field_id_str) do
{:ok, _custom_field_id} ->
add_boolean_filter_if_valid(
custom_field_id_str,
value_str,
boolean_custom_fields,
acc
)
:error ->
acc
end
end
# Adds boolean filter to accumulator if custom field exists and value is valid.
defp add_boolean_filter_if_valid(
custom_field_id_str,
value_str,
boolean_custom_fields,
acc
) do
if Map.has_key?(boolean_custom_fields, custom_field_id_str) do
case determine_boolean_filter(value_str) do
nil -> acc
filter_value -> Map.put(acc, custom_field_id_str, filter_value)
end
else
acc
end
end
# Determines valid boolean filter value from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "true" and "false"
# are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to filter functions.
#
# Returns:
# - `true` for "true" string
# - `false` for "false" string
# - `nil` for any other value
defp determine_boolean_filter("true"), do: true
defp determine_boolean_filter("false"), do: false
defp determine_boolean_filter(_), do: nil
# Updates show_current_cycle from URL parameters if present.
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
assign(socket, :show_current_cycle, true)
@ -1166,7 +1485,166 @@ defmodule MvWeb.MemberLive.Index do
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(cfv.custom_field && cfv.custom_field.id == custom_field.id)
(match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
end)
_ ->
nil
end
end
# Extracts the boolean value from a member's custom field value.
#
# Handles different value formats:
# - `%Ash.Union{value: value, type: :boolean}` - Extracts value from union
# - Map format with `"type"` and `"value"` keys - Extracts from map
# - Map format with `"_union_type"` and `"_union_value"` keys - Extracts from map
#
# Returns:
# - `true` if the custom field value is boolean true
# - `false` if the custom field value is boolean false
# - `nil` if no custom field value exists, value is nil, or value is not boolean
#
# Examples:
# get_boolean_custom_field_value(member, boolean_field) -> true
# get_boolean_custom_field_value(member, non_existent_field) -> nil
def get_boolean_custom_field_value(member, custom_field) do
case get_custom_field_value(member, custom_field) do
nil ->
nil
cfv ->
extract_boolean_value(cfv.value)
end
end
# Extracts boolean value from custom field value, handling different formats.
#
# Handles:
# - `%Ash.Union{value: value, type: :boolean}` - Union struct format
# - Map with `"type"` and `"value"` keys - JSONB map format
# - Map with `"_union_type"` and `"_union_value"` keys - Alternative map format
# - Direct boolean value - Primitive boolean
#
# Returns `true`, `false`, or `nil`.
defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}) do
extract_boolean_value(value)
end
defp extract_boolean_value(value) when is_map(value) do
# Handle map format from JSONB
type = Map.get(value, "type") || Map.get(value, "_union_type")
val = Map.get(value, "value") || Map.get(value, "_union_value")
if type == "boolean" or type == :boolean do
extract_boolean_value(val)
else
nil
end
end
defp extract_boolean_value(value) when is_boolean(value), do: value
defp extract_boolean_value(nil), do: nil
defp extract_boolean_value(_), do: nil
# Applies boolean custom field filters to a list of members.
#
# Filters members based on boolean custom field values. Only members that match
# ALL active filters (AND logic) are returned.
#
# Parameters:
# - `members` - List of Member resources with loaded custom_field_values
# - `filters` - Map of `%{custom_field_id_string => true | false}`
# - `all_custom_fields` - List of all CustomField resources (for validation)
#
# Returns:
# - Filtered list of members that match all active filters
# - All members if filters map is empty
# - Filters with non-existent custom field IDs are ignored
#
# Examples:
# apply_boolean_custom_field_filters(members, %{"uuid-123" => true}, all_custom_fields) -> [member1, ...]
# apply_boolean_custom_field_filters(members, %{}, all_custom_fields) -> members
def apply_boolean_custom_field_filters(members, filters, _all_custom_fields)
when map_size(filters) == 0 do
members
end
def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do
# Build a map of valid boolean custom field IDs (as strings) for quick lookup
valid_custom_field_ids =
all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> MapSet.new(fn cf -> to_string(cf.id) end)
# Filter out invalid custom field IDs from filters
valid_filters =
Enum.filter(filters, fn {custom_field_id_str, _value} ->
MapSet.member?(valid_custom_field_ids, custom_field_id_str)
end)
|> Enum.into(%{})
# If no valid filters remain, return all members
if map_size(valid_filters) == 0 do
members
else
Enum.filter(members, fn member ->
matches_all_filters?(member, valid_filters)
end)
end
end
# Checks if a member matches all active boolean filters.
#
# A member matches a filter if:
# - The filter value is `true` and the member's custom field value is `true`
# - The filter value is `false` and the member's custom field value is `false`
#
# Members without a custom field value or with `nil` value do not match any filter.
#
# Returns `true` if all filters match, `false` otherwise.
defp matches_all_filters?(member, filters) do
Enum.all?(filters, fn {custom_field_id_str, filter_value} ->
matches_filter?(member, custom_field_id_str, filter_value)
end)
end
# Checks if a member matches a specific boolean filter.
#
# Finds the custom field value by ID and checks if the member's boolean value
# matches the filter value.
#
# Returns:
# - `true` if the member's boolean value matches the filter value
# - `false` if no custom field value exists (member is filtered out)
# - `false` if value is nil or values don't match
defp matches_filter?(member, custom_field_id_str, filter_value) do
case find_custom_field_value_by_id(member, custom_field_id_str) do
nil ->
false
cfv ->
boolean_value = extract_boolean_value(cfv.value)
boolean_value == filter_value
end
end
# Finds a custom field value by custom field ID string.
#
# Searches through the member's custom_field_values to find one matching
# the given custom field ID.
#
# Returns the CustomFieldValue or nil.
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)
_ ->
@ -1221,8 +1699,11 @@ defmodule MvWeb.MemberLive.Index do
#
# Note: Mailto URLs have length limits that vary by email client.
# For large selections, consider using export functionality instead.
#
# Handles case where members haven't been loaded yet (e.g., when signature didn't change in handle_params).
defp update_selection_assigns(socket) do
members = socket.assigns.members
# Handle case where members haven't been loaded yet (e.g., when signature didn't change)
members = socket.assigns[:members] || []
selected_members = socket.assigns.selected_members
selected_count =

View file

@ -37,9 +37,11 @@
placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
module={MvWeb.Components.MemberFilterComponent}
id="member-filter"
cycle_status_filter={@cycle_status_filter}
boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}
/>
<button

View file

@ -144,7 +144,7 @@ msgstr "Hausnummer"
msgid "Notes"
msgstr "Notizen"
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -182,6 +182,7 @@ msgstr "Speichern..."
msgid "Street"
msgstr "Straße"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -196,6 +197,7 @@ msgstr "Nein"
msgid "Show Member"
msgstr "Mitglied anzeigen"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -578,6 +580,7 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden."
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -739,21 +742,11 @@ msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr "Nach Zahlungsstatus filtern"
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr "Zahlungsfilter"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
@ -786,6 +779,7 @@ msgstr "Nr."
msgid "Payment Data"
msgstr "Beitragsdaten"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Payments"
@ -920,7 +914,7 @@ msgstr "Status"
msgid "Suspended"
msgstr "Pausiert"
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -1926,6 +1920,31 @@ msgstr "Validierung fehlgeschlagen: %{field} %{message}"
msgid "Validation failed: %{message}"
msgstr "Validierung fehlgeschlagen: %{message}"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Close"
msgstr "Schließen"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter members"
msgstr "Mitglieder filtern"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Member filter"
msgstr "Mitgliedsfilter"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Status"
msgstr "Bezahlstatus"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Reset"
msgstr "Zurücksetzen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"

View file

@ -145,7 +145,7 @@ msgstr ""
msgid "Notes"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -183,6 +183,7 @@ msgstr ""
msgid "Street"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -197,6 +198,7 @@ msgstr ""
msgid "Show Member"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -579,6 +581,7 @@ msgstr ""
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -740,21 +743,11 @@ msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
@ -787,6 +780,7 @@ msgstr ""
msgid "Payment Data"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Payments"
@ -921,7 +915,7 @@ msgstr ""
msgid "Suspended"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -1927,6 +1921,31 @@ msgstr ""
msgid "Validation failed: %{message}"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Close"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter members"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Member filter"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment Status"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Reset"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"

View file

@ -145,7 +145,7 @@ msgstr ""
msgid "Notes"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -183,6 +183,7 @@ msgstr ""
msgid "Street"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -197,6 +198,7 @@ msgstr ""
msgid "Show Member"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
@ -579,6 +581,7 @@ msgstr ""
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
@ -740,21 +743,11 @@ msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
@ -787,6 +780,7 @@ msgstr ""
msgid "Payment Data"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Payments"
@ -921,7 +915,7 @@ msgstr ""
msgid "Suspended"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
@ -1927,7 +1921,339 @@ msgstr ""
msgid "Validation failed: %{message}"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Close"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter members"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member filter"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Status"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Reset"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"
msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Use this form to manage Custom Field Value records in your database."
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Choose a custom field"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Joining year - reduced to 0"
#~ msgstr ""
#~ #: lib/mv_web/components/layouts/sidebar.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Admin"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Regular"
#~ msgstr ""
#~ #: lib/mv_web/live/components/member_filter_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Payment"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Current"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Paid via bank transfer"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Mark as Unpaid"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Half-yearly contribution for supporting members"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Reduced fee for unemployed, pensioners, or low income"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom field value not found"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Supporting Member"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Monthly fee for students and trainees"
#~ msgstr ""
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Filter by payment status"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Custom field value %{action} successfully"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Total Contributions"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Manage contribution types for membership fees."
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Change Contribution Type"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "New Contribution Type"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Time Period"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom field value deleted successfully"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "You do not have permission to access this custom field value"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Cannot delete - members assigned"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Preview Mockup"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contribution Types"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "This page is not functional and only displays the planned features."
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Member since"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unsupported value type: %{type}"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Custom field"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Mark as Paid"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contribution type"
#~ msgstr ""
#~ #: lib/mv_web/components/layouts/sidebar.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contributions"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Reduced"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "No fee for honorary members"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "You do not have permission to delete this custom field value"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "%{count} period selected"
#~ msgid_plural "%{count} periods selected"
#~ msgstr[0] ""
#~ msgstr[1] ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Mark as Suspended"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Choose a member"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Suspend"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Reopen"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Value"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Why are not all contribution types shown?"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contribution Start"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Standard membership fee for regular members"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Save Custom Field Value"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Honorary"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contributions for %{name}"
#~ msgstr ""
#~ #: lib/mv_web/live/components/member_filter_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Payment status filter"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Family"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "You do not have permission to view custom field values"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Student"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly fee for family memberships"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Please select a custom field first"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Open Contributions"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_period_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Member Contributions"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_type_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "About Contribution Types"
#~ msgstr ""
#~ #: lib/mv_web/live/components/member_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Filter by %{name}"
#~ msgstr ""

View file

@ -0,0 +1,300 @@
defmodule MvWeb.Components.MemberFilterComponentTest do
@moduledoc """
Unit tests for the MemberFilterComponent.
Tests cover:
- Rendering Payment Filter and Boolean Custom Fields
- Boolean filter selection and event emission
- Button label and badge logic
- Filtering to show only boolean custom fields
"""
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.CustomField
# Helper to create a boolean custom field
defp create_boolean_custom_field(attrs \\ %{}) do
default_attrs = %{
name: "test_boolean_#{System.unique_integer([:positive])}",
value_type: :boolean
}
attrs = Map.merge(default_attrs, attrs)
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a non-boolean custom field
defp create_string_custom_field(attrs \\ %{}) do
default_attrs = %{
name: "test_string_#{System.unique_integer([:positive])}",
value_type: :string
}
attrs = Map.merge(default_attrs, attrs)
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "rendering" do
test "renders boolean custom fields when present", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Active Member"})
{:ok, view, _html} = live(conn, "/members")
# Should show the boolean custom field name in the dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
assert html =~ boolean_field.name
end
test "renders payment and custom fields groups when boolean fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
_boolean_field = create_boolean_custom_field()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Should have both "Payments" and "Custom Fields" group labels
assert html =~ gettext("Payments") || html =~ "Payment"
assert html =~ gettext("Custom Fields")
end
test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create a non-boolean field to ensure it's not shown
_string_field = create_string_custom_field()
{:ok, view, _html} = live(conn, "/members")
# Component should exist with correct ID
assert has_element?(view, "#member-filter")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Should show payment filter options (check both English and translated)
assert html =~ "All" || html =~ gettext("All")
assert html =~ "Paid" || html =~ gettext("Paid")
assert html =~ "Unpaid" || html =~ gettext("Unpaid")
# Should not show any boolean field names (since none exist)
# We can't easily check this without knowing field names, but the structure should be correct
end
end
describe "boolean filter selection" do
test "selecting boolean filter sends correct event", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
# Select "True" option for the boolean field using radio input
# Radio inputs trigger phx-change on the form, so we use render_change on the form
view
|> form("#member-filter form", %{
"custom_boolean" => %{to_string(boolean_field.id) => "true"}
})
|> render_change()
# The event should be sent to the parent LiveView
# We verify this by checking that the URL is updated
assert_patch(view)
end
test "payment filter still works after component extension", %{conn: conn} do
conn = conn_with_oidc_user(conn)
_boolean_field = create_boolean_custom_field()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option using radio input
# Radio inputs trigger phx-change on the form, so we use render_change on the form
view
|> form("#member-filter form", %{"payment_filter" => "paid"})
|> render_change()
# URL should be updated with cycle_status_filter=paid
path = assert_patch(view)
assert path =~ "cycle_status_filter=paid"
end
end
describe "button label" do
test "shows active boolean filter names in button label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
boolean_field2 = create_boolean_custom_field(%{name: "Newsletter"})
# Set filters via URL
{:ok, view, _html} =
live(
conn,
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
# Component should exist
assert has_element?(view, "#member-filter")
# Button label should contain the custom field names
# The exact format depends on implementation, but should show active filters
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
assert button_html =~ boolean_field1.name || button_html =~ boolean_field2.name
end
test "truncates long custom field names in button label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create field with very long name (>30 characters)
long_name = String.duplicate("A", 50)
boolean_field = create_boolean_custom_field(%{name: long_name})
# Set filter via URL
{:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
# Component should exist
assert has_element?(view, "#member-filter")
# Get button label text
button_html =
view
|> element("#member-filter button[aria-haspopup='true']")
|> render()
# Button label should be truncated - full name should NOT appear in button
# (it may appear in dropdown when opened, but not in the button label itself)
# Check that button doesn't contain the full 50-character name
refute button_html =~ long_name
# Button should still contain some text (truncated version or indicator)
assert String.length(button_html) > 0
end
end
describe "badge" do
test "shows total count of active boolean filters in badge", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
# Set two filters via URL
{:ok, view, _html} =
live(
conn,
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
# Component should exist
assert has_element?(view, "#member-filter")
# Badge should be visible when boolean filters are active
assert has_element?(view, "#member-filter .badge")
# Badge should show count of active boolean filters (2 in this case)
badge_html =
view
|> element("#member-filter .badge")
|> render()
assert badge_html =~ "2"
end
end
describe "filtering" do
test "only boolean custom fields are displayed, not string or integer fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Boolean Field"})
_string_field = create_string_custom_field(%{name: "String Field"})
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
# Should show boolean field in the dropdown panel
# Extract only the dropdown panel HTML to check
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
# Should show boolean field in dropdown
assert dropdown_html =~ boolean_field.name
# Should not show string field name in the filter dropdown
# (String fields should not appear in boolean filter section)
refute dropdown_html =~ "String Field"
end
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create 15 boolean custom fields (more than typical, should trigger scrollbar)
boolean_fields =
Enum.map(1..15, fn i ->
create_boolean_custom_field(%{name: "Field #{i}"})
end)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#member-filter button[aria-haspopup='true']")
|> render_click()
# Extract dropdown panel HTML
dropdown_html =
view
|> element("#member-filter div[role='dialog']")
|> render()
# Should have scrollbar classes: max-h-60 overflow-y-auto pr-2
# Check for the scrollable container (the div with max-h-60 and overflow-y-auto)
assert dropdown_html =~ "max-h-60"
assert dropdown_html =~ "overflow-y-auto"
# Verify all fields are present in the dropdown
Enum.each(boolean_fields, fn field ->
assert dropdown_html =~ field.name
end)
end
end
end

View file

@ -1,183 +0,0 @@
defmodule MvWeb.Components.PaymentFilterComponentTest do
@moduledoc """
Unit tests for the PaymentFilterComponent.
Tests cover:
- Rendering in all 3 filter states (nil, :paid, :unpaid)
- Event emission when selecting options
- ARIA attributes for accessibility
- Dropdown open/close behavior
"""
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
describe "rendering" do
test "renders with no filter active (nil)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Should show "All" text and no badge
assert has_element?(view, "#payment-filter")
refute has_element?(view, "#payment-filter .badge")
end
test "renders with paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
test "renders with unpaid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=unpaid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
end
describe "dropdown behavior" do
test "dropdown opens on button click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially dropdown is closed
refute has_element?(view, "#payment-filter ul[role='menu']")
# Click to open
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Dropdown should be visible
assert has_element?(view, "#payment-filter ul[role='menu']")
end
test "dropdown closes after selecting an option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
assert has_element?(view, "#payment-filter ul[role='menu']")
# Select an option - this should close the dropdown
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# After selection, dropdown should be closed
# Note: The dropdown closes via assign, which is reflected in the next render
refute has_element?(view, "#payment-filter ul[role='menu']")
end
end
describe "filter selection" do
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "All" option
view
|> element("#payment-filter button[phx-value-filter='']")
|> render_click()
# URL should not contain cycle_status_filter param - wait for patch
assert_patch(view)
end
test "selecting 'Paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# Wait for patch and check URL contains cycle_status_filter=paid
path = assert_patch(view)
assert path =~ "cycle_status_filter=paid"
end
test "selecting 'Unpaid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Unpaid" option
view
|> element("#payment-filter button[phx-value-filter='unpaid']")
|> render_click()
# Wait for patch and check URL contains cycle_status_filter=unpaid
path = assert_patch(view)
assert path =~ "cycle_status_filter=unpaid"
end
end
describe "accessibility" do
test "has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Main button should have aria-haspopup and aria-expanded
assert html =~ ~s(aria-haspopup="true")
assert html =~ ~s(aria-expanded="false")
assert html =~ ~s(aria-label=)
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Check aria-expanded is now true
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
# Options should have role="menuitemradio"
assert html =~ ~s(role="menuitemradio")
end
test "has aria-checked on selected option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# "Paid" option should have aria-checked="true"
# Check both possible orderings of attributes
assert html =~ "aria-checked=\"true\"" and html =~ "phx-value-filter=\"paid\""
end
end
end

File diff suppressed because it is too large Load diff