diff --git a/config/test.exs b/config/test.exs
index b48c408..b47c764 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -12,7 +12,10 @@ config :mv, Mv.Repo,
port: System.get_env("TEST_POSTGRES_PORT", "5000"),
database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
- pool_size: System.schedulers_online() * 4
+ pool_size: System.schedulers_online() * 8,
+ queue_target: 5000,
+ queue_interval: 1000,
+ timeout: 30_000
# We don't run a server during test. If one is required,
# you can enable the server option below.
diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex
index 73bfcd9..4ef355d 100644
--- a/lib/mv/constants.ex
+++ b/lib/mv/constants.ex
@@ -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.
diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex
new file mode 100644
index 0000000..9286ace
--- /dev/null
+++ b/lib/mv_web/live/components/member_filter_component.ex
@@ -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 `
` and `` 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"""
+
+
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" />
+
+ {button_label(@cycle_status_filter, @boolean_custom_fields, @boolean_filters)}
+
+ 0}
+ class="badge badge-primary badge-sm"
+ >
+ {active_boolean_filters_count(@boolean_filters)}
+
+
+ {@member_count}
+
+
+
+
+
+
+ """
+ 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
diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex
deleted file mode 100644
index 9caaa1f..0000000
--- a/lib/mv_web/live/components/payment_filter_component.ex
+++ /dev/null
@@ -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"""
-
-
- <.icon name="hero-funnel" class="h-5 w-5" />
- {filter_label(@cycle_status_filter)}
- {@member_count}
-
-
-
-
-
- <.icon name="hero-users" class="h-4 w-4" />
- {gettext("All")}
-
-
-
-
- <.icon name="hero-check-circle" class="h-4 w-4 text-success" />
- {gettext("Paid")}
-
-
-
-
- <.icon name="hero-x-circle" class="h-4 w-4 text-error" />
- {gettext("Unpaid")}
-
-
-
-
- """
- 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
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index 2cf7392..50b0cfa 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -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_")
@custom_field_prefix Mv.Constants.custom_field_prefix()
+ # Prefix used for boolean custom field filter URL parameters (e.g., "bf_")
+ @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))
- |> load_members()
- |> prepare_dynamic_cols()
- |> update_selection_assigns()
+
+ # 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_=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
- if show_current_cycle do
- Map.put(base_params, "show_current_cycle", "true")
- else
- base_params
- end
+ 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 =
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index b2af205..394db2c 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -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)}
/>
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
diff --git a/test/mv_web/components/payment_filter_component_test.exs b/test/mv_web/components/payment_filter_component_test.exs
deleted file mode 100644
index 7987efa..0000000
--- a/test/mv_web/components/payment_filter_component_test.exs
+++ /dev/null
@@ -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
diff --git a/test/mv_web/live/membership_fee_type_live/index_test.exs b/test/mv_web/live/membership_fee_type_live/index_test.exs
index 58be2d3..302814d 100644
--- a/test/mv_web/live/membership_fee_type_live/index_test.exs
+++ b/test/mv_web/live/membership_fee_type_live/index_test.exs
@@ -15,9 +15,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
# No custom setup needed
# Helper to create a membership fee type
- defp create_fee_type(attrs) do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
+ # Uses admin_user to test permissions (UI-/Permissions-nah)
+ defp create_fee_type(attrs, admin_user) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@@ -28,7 +27,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: system_actor)
+ |> Ash.create!(actor: admin_user)
end
# Helper to create a member
@@ -50,12 +49,21 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end
describe "list display" do
- test "displays all membership fee types with correct data", %{conn: conn} do
+ test "displays all membership fee types with correct data", %{
+ conn: conn,
+ current_user: admin_user
+ } do
_fee_type1 =
- create_fee_type(%{name: "Regular", amount: Decimal.new("60.00"), interval: :yearly})
+ create_fee_type(
+ %{name: "Regular", amount: Decimal.new("60.00"), interval: :yearly},
+ admin_user
+ )
_fee_type2 =
- create_fee_type(%{name: "Reduced", amount: Decimal.new("30.00"), interval: :yearly})
+ create_fee_type(
+ %{name: "Reduced", amount: Decimal.new("30.00"), interval: :yearly},
+ admin_user
+ )
{:ok, _view, html} = live(conn, "/membership_fee_types")
@@ -67,7 +75,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end
test "member count column shows correct count", %{conn: conn, current_user: admin_user} do
- fee_type = create_fee_type(%{interval: :yearly})
+ fee_type = create_fee_type(%{interval: :yearly}, admin_user)
# Create 3 members with this fee type
Enum.each(1..3, fn _ ->
@@ -90,8 +98,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
assert to == "/membership_fee_types/new"
end
- test "edit button per row navigates to edit form", %{conn: conn} do
- fee_type = create_fee_type(%{interval: :yearly})
+ test "edit button per row navigates to edit form", %{conn: conn, current_user: admin_user} do
+ fee_type = create_fee_type(%{interval: :yearly}, admin_user)
{:ok, view, _html} = live(conn, "/membership_fee_types")
@@ -106,7 +114,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
describe "delete functionality" do
test "delete button disabled if type is in use", %{conn: conn, current_user: admin_user} do
- fee_type = create_fee_type(%{interval: :yearly})
+ fee_type = create_fee_type(%{interval: :yearly}, admin_user)
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
{:ok, _view, html} = live(conn, "/membership_fee_types")
@@ -115,8 +123,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
assert html =~ "disabled" || html =~ "cursor-not-allowed"
end
- test "delete button works if type is not in use", %{conn: conn} do
- fee_type = create_fee_type(%{interval: :yearly})
+ test "delete button works if type is not in use", %{conn: conn, current_user: admin_user} do
+ fee_type = create_fee_type(%{interval: :yearly}, admin_user)
# No members assigned
{:ok, view, _html} = live(conn, "/membership_fee_types")
@@ -126,9 +134,12 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|> element("button[phx-click='delete'][phx-value-id='#{fee_type.id}']")
|> render_click()
- # Type should be deleted
+ # Type should be deleted - use admin_user to test permissions
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
- Ash.get(MembershipFeeType, fee_type.id, domain: Mv.MembershipFees)
+ Ash.get(MembershipFeeType, fee_type.id,
+ domain: Mv.MembershipFees,
+ actor: admin_user
+ )
end
end
diff --git a/test/mv_web/member_live/form_membership_fee_type_test.exs b/test/mv_web/member_live/form_membership_fee_type_test.exs
index a4d3673..911a4ce 100644
--- a/test/mv_web/member_live/form_membership_fee_type_test.exs
+++ b/test/mv_web/member_live/form_membership_fee_type_test.exs
@@ -12,9 +12,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
require Ash.Query
# Helper to create a membership fee type
- defp create_fee_type(attrs) do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
+ # Uses admin_user to test permissions (UI-/Permissions-nah)
+ defp create_fee_type(attrs, admin_user) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@@ -25,13 +24,12 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: system_actor)
+ |> Ash.create!(actor: admin_user)
end
# Helper to create a member
- defp create_member(attrs) do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
+ # Uses admin_user to test permissions (UI-/Permissions-nah)
+ defp create_member(attrs, admin_user) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@@ -42,7 +40,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
- |> Ash.create!(actor: system_actor)
+ |> Ash.create!(actor: admin_user)
end
describe "membership fee type dropdown" do
@@ -54,9 +52,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
html =~ "Beitragsart"
end
- test "shows available types", %{conn: conn} do
- _fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
- _fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
+ test "shows available types", %{conn: conn, current_user: admin_user} do
+ _fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly}, admin_user)
+ _fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly}, admin_user)
{:ok, _view, html} = live(conn, "/members/new")
@@ -64,11 +62,14 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
assert html =~ "Type 2"
end
- test "filters to same interval types if member has type", %{conn: conn} do
- yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
- _monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
+ test "filters to same interval types if member has type", %{
+ conn: conn,
+ current_user: admin_user
+ } do
+ yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly}, admin_user)
+ _monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly}, admin_user)
- member = create_member(%{membership_fee_type_id: yearly_type.id})
+ member = create_member(%{membership_fee_type_id: yearly_type.id}, admin_user)
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
@@ -77,11 +78,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
refute html =~ "Monthly Type"
end
- test "shows warning if different interval selected", %{conn: conn} do
- yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
- monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
+ test "shows warning if different interval selected", %{conn: conn, current_user: admin_user} do
+ yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly}, admin_user)
+ monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly}, admin_user)
- member = create_member(%{membership_fee_type_id: yearly_type.id})
+ member = create_member(%{membership_fee_type_id: yearly_type.id}, admin_user)
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
@@ -92,11 +93,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
assert html =~ yearly_type.id
end
- test "warning cleared if same interval selected", %{conn: conn} do
- yearly_type1 = create_fee_type(%{name: "Yearly Type 1", interval: :yearly})
- yearly_type2 = create_fee_type(%{name: "Yearly Type 2", interval: :yearly})
+ test "warning cleared if same interval selected", %{conn: conn, current_user: admin_user} do
+ yearly_type1 = create_fee_type(%{name: "Yearly Type 1", interval: :yearly}, admin_user)
+ yearly_type2 = create_fee_type(%{name: "Yearly Type 2", interval: :yearly}, admin_user)
- member = create_member(%{membership_fee_type_id: yearly_type1.id})
+ member = create_member(%{membership_fee_type_id: yearly_type1.id}, admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
@@ -109,8 +110,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
refute html =~ "Warning" || html =~ "Warnung"
end
- test "form saves with selected membership fee type", %{conn: conn} do
- fee_type = create_fee_type(%{interval: :yearly})
+ test "form saves with selected membership fee type", %{conn: conn, current_user: admin_user} do
+ fee_type = create_fee_type(%{interval: :yearly}, admin_user)
{:ok, view, _html} = live(conn, "/members/new")
@@ -126,29 +127,26 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|> form("#member-form", form_data)
|> render_submit()
- # Verify member was created with fee type
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
+ # Verify member was created with fee type - use admin_user to test permissions
member =
Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
- |> Ash.read_one!(actor: system_actor)
+ |> Ash.read_one!(actor: admin_user)
assert member.membership_fee_type_id == fee_type.id
end
- test "new members get default membership fee type", %{conn: conn} do
+ test "new members get default membership fee type", %{conn: conn, current_user: admin_user} do
# Set default fee type in settings
- fee_type = create_fee_type(%{interval: :yearly})
+ fee_type = create_fee_type(%{interval: :yearly}, admin_user)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
- |> Ash.update!(actor: system_actor)
+ |> Ash.update!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/new")
@@ -163,9 +161,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
conn: conn,
current_user: admin_user
} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- # Create custom field
+ # Create custom field - use admin_user to test permissions
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
@@ -173,11 +169,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :string,
required: false
})
- |> Ash.create!(actor: system_actor)
+ |> Ash.create!(actor: admin_user)
# Create two fee types with same interval
- fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
- fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
+ fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly}, admin_user)
+ fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly}, admin_user)
# Create member with fee type 1 and custom field value
member =
@@ -212,9 +208,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
end
test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- # Create date custom field
+ # Create date custom field - use admin_user to test permissions
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
@@ -222,9 +216,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :date,
required: false
})
- |> Ash.create!(actor: system_actor)
+ |> Ash.create!(actor: admin_user)
- fee_type = create_fee_type(%{interval: :yearly})
+ fee_type = create_fee_type(%{interval: :yearly}, admin_user)
# Create member with date custom field value
member =
@@ -261,9 +255,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
end
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- # Create custom field
+ # Create custom field - use admin_user to test permissions
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
@@ -271,9 +263,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :string,
required: false
})
- |> Ash.create!(actor: system_actor)
+ |> Ash.create!(actor: admin_user)
- fee_type = create_fee_type(%{interval: :yearly})
+ fee_type = create_fee_type(%{interval: :yearly}, admin_user)
# Create member with custom field value
member =
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index c3cd48c..0f3d03b 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -3,6 +3,49 @@ defmodule MvWeb.MemberLive.IndexTest do
import Phoenix.LiveViewTest
require Ash.Query
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.MembershipFees.MembershipFeeCycle
+
+ # Helper to create a membership fee type (shared across all tests)
+ defp create_fee_type(attrs, actor) do
+ default_attrs = %{
+ name: "Test Fee Type #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!(actor: actor)
+ end
+
+ # Helper to create a cycle (shared across all tests)
+ defp create_cycle(member, fee_type, attrs, actor) do
+ # Delete any auto-generated cycles first to avoid conflicts
+ existing_cycles =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!(actor: actor)
+
+ Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end)
+
+ default_attrs = %{
+ cycle_start: ~D[2023-01-01],
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :unpaid
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!(actor: actor)
+ end
+
test "shows translated title in German", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
@@ -479,25 +522,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
describe "cycle status filter" do
- alias Mv.MembershipFees.MembershipFeeType
- alias Mv.MembershipFees.MembershipFeeCycle
-
- # Helper to create a membership fee type
- defp create_fee_type(attrs, actor) do
- default_attrs = %{
- name: "Test Fee Type #{System.unique_integer([:positive])}",
- amount: Decimal.new("50.00"),
- interval: :yearly
- }
-
- attrs = Map.merge(default_attrs, attrs)
-
- MembershipFeeType
- |> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: actor)
- end
-
- # Helper to create a member
+ # Helper to create a member (only used in this describe block)
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
@@ -512,31 +537,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.create!(actor: actor)
end
- # Helper to create a cycle
- defp create_cycle(member, fee_type, attrs, actor) do
- # Delete any auto-generated cycles first to avoid conflicts
- existing_cycles =
- MembershipFeeCycle
- |> Ash.Query.filter(member_id == ^member.id)
- |> Ash.read!(actor: actor)
-
- Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end)
-
- default_attrs = %{
- cycle_start: ~D[2023-01-01],
- amount: Decimal.new("50.00"),
- member_id: member.id,
- membership_fee_type_id: fee_type.id,
- status: :unpaid
- }
-
- attrs = Map.merge(default_attrs, attrs)
-
- MembershipFeeCycle
- |> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: actor)
- end
-
test "filter shows only members with paid status in last cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
@@ -746,4 +746,1091 @@ defmodule MvWeb.MemberLive.IndexTest do
assert path =~ "show_current_cycle=true"
end
end
+
+ describe "boolean custom field filters" do
+ 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
+
+ test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ state = :sys.get_state(view.pid)
+ assert state.socket.assigns.boolean_custom_field_filters == %{}
+ end
+
+ test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{
+ conn: conn
+ } do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ state = :sys.get_state(view.pid)
+ assert state.socket.assigns.boolean_custom_fields == []
+ end
+
+ test "mount loads and filters boolean custom fields correctly", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Create boolean and non-boolean custom fields
+ boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
+ boolean_field2 = create_boolean_custom_field(%{name: "Newsletter Subscription"})
+ _string_field = create_string_custom_field(%{name: "Phone Number"})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ state = :sys.get_state(view.pid)
+ boolean_custom_fields = state.socket.assigns.boolean_custom_fields
+
+ # Should only contain boolean fields
+ assert length(boolean_custom_fields) == 2
+ assert Enum.all?(boolean_custom_fields, &(&1.value_type == :boolean))
+ assert Enum.any?(boolean_custom_fields, &(&1.id == boolean_field1.id))
+ assert Enum.any?(boolean_custom_fields, &(&1.id == boolean_field2.id))
+ end
+
+ test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Create boolean fields with specific names to test sorting
+ _boolean_field_z = create_boolean_custom_field(%{name: "Zebra Field"})
+ _boolean_field_a = create_boolean_custom_field(%{name: "Alpha Field"})
+ _boolean_field_m = create_boolean_custom_field(%{name: "Middle Field"})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ state = :sys.get_state(view.pid)
+ boolean_custom_fields = state.socket.assigns.boolean_custom_fields
+
+ # Should be sorted by name ascending
+ names = Enum.map(boolean_custom_fields, & &1.name)
+ assert names == ["Alpha Field", "Middle Field", "Zebra Field"]
+ end
+
+ test "handle_params parses bf_ values correctly", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Test true value
+ {:ok, view1, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ state1 = :sys.get_state(view1.pid)
+ filters1 = state1.socket.assigns.boolean_custom_field_filters
+ assert filters1[boolean_field.id] == true
+ refute filters1[boolean_field.id] == "true"
+
+ # Test false value
+ {:ok, view2, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=false")
+
+ state2 = :sys.get_state(view2.pid)
+ filters2 = state2.socket.assigns.boolean_custom_field_filters
+ assert filters2[boolean_field.id] == false
+ refute filters2[boolean_field.id] == "false"
+ end
+
+ test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ fake_id = Ecto.UUID.generate()
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{fake_id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Filter should not be added for non-existent custom field
+ refute Map.has_key?(filters, fake_id)
+ assert filters == %{}
+ end
+
+ test "handle_params ignores non-boolean custom fields", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ string_field = create_string_custom_field()
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{string_field.id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Filter should not be added for non-boolean custom field
+ refute Map.has_key?(filters, string_field.id)
+ assert filters == %{}
+ end
+
+ test "handle_params ignores invalid filter values", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Test various invalid values
+ invalid_values = ["1", "0", "yes", "no", "True", "False", "", "invalid", "null"]
+
+ for invalid_value <- invalid_values do
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Invalid values should not be added to filters
+ refute Map.has_key?(filters, boolean_field.id),
+ "Invalid value '#{invalid_value}' should not be added to filters"
+ end
+ end
+
+ test "handle_params handles multiple boolean filters simultaneously", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field1 = create_boolean_custom_field()
+ boolean_field2 = create_boolean_custom_field()
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
+ )
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ assert filters[boolean_field1.id] == true
+ assert filters[boolean_field2.id] == false
+ assert map_size(filters) == 2
+ end
+
+ test "build_query_params includes active boolean filters and excludes nil filters", %{
+ conn: conn
+ } do
+ conn = conn_with_oidc_user(conn)
+ boolean_field1 = create_boolean_custom_field()
+ boolean_field2 = create_boolean_custom_field()
+
+ # Test with active filters
+ {:ok, view1, _html} =
+ live(
+ conn,
+ "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
+ )
+
+ # Trigger a search to see if filters are preserved in URL
+ view1
+ |> element("[data-testid='search-input']")
+ |> render_change(%{value: "test"})
+
+ # Check that the patch includes boolean filters
+ path1 = assert_patch(view1)
+ assert path1 =~ "bf_#{boolean_field1.id}=true"
+ assert path1 =~ "bf_#{boolean_field2.id}=false"
+
+ # Test without filters (nil filters should not appear in URL)
+ {:ok, view2, _html} = live(conn, "/members")
+
+ # Trigger a search
+ view2
+ |> element("[data-testid='search-input']")
+ |> render_change(%{value: "test"})
+
+ # Check that no bf_ params are in URL
+ path2 = assert_patch(view2)
+ refute path2 =~ "bf_"
+ end
+
+ test "boolean filters are preserved during navigation actions", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ # Test sort toggle preserves filter
+ view
+ |> element("[data-testid='email']")
+ |> render_click()
+
+ path1 = assert_patch(view)
+ assert path1 =~ "bf_#{boolean_field.id}=true"
+
+ # Test search change preserves filter
+ view
+ |> element("[data-testid='search-input']")
+ |> render_change(%{value: "test"})
+
+ path2 = assert_patch(view)
+ assert path2 =~ "bf_#{boolean_field.id}=true"
+ end
+
+ test "boolean filters work together with cycle_status_filter", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
+ )
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Both filters should be set
+ assert filters[boolean_field.id] == true
+ assert state.socket.assigns.cycle_status_filter == :paid
+
+ # Both should be in URL when triggering search
+ view
+ |> element("[data-testid='search-input']")
+ |> render_change(%{value: "test"})
+
+ path = assert_patch(view)
+ assert path =~ "cycle_status_filter=paid"
+ assert path =~ "bf_#{boolean_field.id}=true"
+ end
+
+ test "handle_params removes filter when custom field is deleted", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Set up filter via URL
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ state_before = :sys.get_state(view.pid)
+ filters_before = state_before.socket.assigns.boolean_custom_field_filters
+ assert filters_before[boolean_field.id] == true
+
+ # Delete the custom field
+ Ash.destroy!(boolean_field)
+
+ # Navigate again - filter should be removed since custom field no longer exists
+ {:ok, view2, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ state_after = :sys.get_state(view2.pid)
+ filters_after = state_after.socket.assigns.boolean_custom_field_filters
+
+ # Filter should not be present for deleted custom field
+ refute Map.has_key?(filters_after, boolean_field.id)
+ assert filters_after == %{}
+ end
+
+ test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # URL-encode the custom field ID (though UUIDs shouldn't need encoding normally)
+ encoded_id = URI.encode(boolean_field.id)
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{encoded_id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Filter should work with URL-encoded ID
+ # Phoenix should decode it automatically, so we check with original ID
+ assert filters[boolean_field.id] == true
+ end
+
+ test "handle_params ignores malformed prefix (bf_bf_)", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Try to send parameter with double prefix
+ {:ok, view, _html} =
+ live(conn, "/members?bf_bf_#{boolean_field.id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Should not parse as valid filter (UUID validation should fail)
+ refute Map.has_key?(filters, boolean_field.id)
+ assert filters == %{}
+ end
+
+ test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Create 60 boolean custom fields (more than the limit)
+ boolean_fields = Enum.map(1..60, fn _ -> create_boolean_custom_field() end)
+
+ # Build URL with all 60 filters
+ filter_params =
+ Enum.map_join(boolean_fields, "&", fn cf -> "bf_#{cf.id}=true" end)
+
+ {:ok, view, _html} = live(conn, "/members?#{filter_params}")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Should limit to maximum 50 filters
+ assert map_size(filters) <= 50
+ # All filters in the result should be valid
+ Enum.each(filters, fn {id, value} ->
+ assert value in [true, false]
+ # Verify the ID corresponds to one of our boolean fields
+ assert id in Enum.map(boolean_fields, &to_string(&1.id))
+ end)
+ end
+
+ test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Create a fake ID that's way too long (UUIDs are max 36 chars)
+ fake_long_id = String.duplicate("a", 100)
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{fake_long_id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Should not accept the extremely long ID
+ refute Map.has_key?(filters, fake_long_id)
+ # Valid boolean field should still work
+ refute Map.has_key?(filters, boolean_field.id)
+ assert filters == %{}
+ end
+
+ # Helper to create a member with a boolean custom field value
+ defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do
+ {:ok, member} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(
+ :create_member,
+ %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+ |> Map.merge(member_attrs)
+ )
+ |> Ash.create(actor: actor)
+
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: custom_field.id,
+ value: %{"_union_type" => "boolean", "_union_value" => value}
+ })
+ |> Ash.create(actor: actor)
+
+ # Reload member with custom field values
+ member
+ |> Ash.load!(:custom_field_values, actor: actor)
+ end
+
+ # Tests for get_boolean_custom_field_value/2
+ test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field = create_boolean_custom_field()
+ member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
+
+ # Test the function (will fail until implemented)
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == true
+ end
+
+ test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field = create_boolean_custom_field()
+ member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
+
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == false
+ end
+
+ test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
+ %{conn: _conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field = create_boolean_custom_field()
+
+ {:ok, member} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: boolean_field.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Reload member with custom field values
+ member = member |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == true
+ end
+
+ test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
+ conn: _conn
+ } do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field = create_boolean_custom_field()
+
+ {:ok, member} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Member has no custom field value for this field
+ member = member |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == nil
+ end
+
+ test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
+ conn: _conn
+ } do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field = create_boolean_custom_field()
+
+ {:ok, member} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Create CustomFieldValue with nil value (edge case)
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: boolean_field.id,
+ value: nil
+ })
+ |> Ash.create(actor: system_actor)
+
+ member = member |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == nil
+ end
+
+ test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
+ conn: _conn
+ } do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ string_field = create_string_custom_field()
+ boolean_field = create_boolean_custom_field()
+
+ {:ok, member} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Create string custom field value (not boolean)
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: string_field.id,
+ value: %{"_union_type" => "string", "_union_value" => "test"}
+ })
+ |> Ash.create(actor: system_actor)
+
+ member = member |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ # Try to get boolean value from string field - should return nil
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == nil
+ end
+
+ # Tests for apply_boolean_custom_field_filters/2
+ test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
+ %{conn: _conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field = create_boolean_custom_field()
+
+ member_with_true =
+ create_member_with_boolean_value(
+ %{first_name: "TrueMember"},
+ boolean_field,
+ true,
+ system_actor
+ )
+
+ member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ {:ok, member_without_value} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "NoValue",
+ last_name: "Member",
+ email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ member_without_value = member_without_value |> Ash.load!(:custom_field_values)
+
+ members = [member_with_true, member_with_false, member_without_value]
+ filters = %{to_string(boolean_field.id) => true}
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ assert length(result) == 1
+ assert List.first(result).id == member_with_true.id
+ refute Enum.any?(result, &(&1.id == member_with_false.id))
+ refute Enum.any?(result, &(&1.id == member_without_value.id))
+ end
+
+ test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
+ %{conn: _conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field = create_boolean_custom_field()
+
+ member_with_true =
+ create_member_with_boolean_value(
+ %{first_name: "TrueMember"},
+ boolean_field,
+ true,
+ system_actor
+ )
+
+ member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ {:ok, member_without_value} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "NoValue",
+ last_name: "Member",
+ email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ member_without_value =
+ member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ members = [member_with_true, member_with_false, member_without_value]
+ filters = %{to_string(boolean_field.id) => false}
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ assert length(result) == 1
+ assert List.first(result).id == member_with_false.id
+ refute Enum.any?(result, &(&1.id == member_with_true.id))
+ refute Enum.any?(result, &(&1.id == member_without_value.id))
+ end
+
+ test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
+ conn: _conn
+ } do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field = create_boolean_custom_field()
+
+ member1 =
+ create_member_with_boolean_value(
+ %{first_name: "Member1"},
+ boolean_field,
+ true,
+ system_actor
+ )
+
+ member2 =
+ create_member_with_boolean_value(
+ %{first_name: "Member2"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ members = [member1, member2]
+ filters = %{}
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ assert length(result) == 2
+
+ assert Enum.all?([member1.id, member2.id], fn id ->
+ Enum.any?(result, &(&1.id == id))
+ end)
+ end
+
+ test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
+ conn: _conn
+ } do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
+ boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
+
+ # Member with both fields = true
+ {:ok, member_both_true} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "BothTrue",
+ last_name: "Member",
+ email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv1} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_both_true.id,
+ custom_field_id: boolean_field1.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv2} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_both_true.id,
+ custom_field_id: boolean_field2.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ member_both_true = member_both_true |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ # Member with field1 = true, field2 = false
+ {:ok, member_mixed} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Mixed",
+ last_name: "Member",
+ email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv3} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_mixed.id,
+ custom_field_id: boolean_field1.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv4} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_mixed.id,
+ custom_field_id: boolean_field2.id,
+ value: %{"_union_type" => "boolean", "_union_value" => false}
+ })
+ |> Ash.create(actor: system_actor)
+
+ member_mixed = member_mixed |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ members = [member_both_true, member_mixed]
+
+ filters = %{
+ to_string(boolean_field1.id) => true,
+ to_string(boolean_field2.id) => true
+ }
+
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ # Only member_both_true should match (both fields = true)
+ assert length(result) == 1
+ assert List.first(result).id == member_both_true.id
+ end
+
+ test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
+ conn: _conn
+ } do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ boolean_field = create_boolean_custom_field()
+ fake_id = Ecto.UUID.generate()
+
+ member =
+ create_member_with_boolean_value(
+ %{first_name: "Member"},
+ boolean_field,
+ true,
+ system_actor
+ )
+
+ members = [member]
+ filters = %{fake_id => true}
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ # Should return all members since fake_id doesn't match any custom field
+ assert length(result) == 1
+ end
+
+ # Integration tests for boolean custom field filters in load_members
+ test "boolean filter integration filters members by boolean custom field value via URL parameter",
+ %{conn: conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ _member_with_true =
+ create_member_with_boolean_value(
+ %{first_name: "TrueMember"},
+ boolean_field,
+ true,
+ system_actor
+ )
+
+ _member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ {:ok, _member_without_value} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "NoValue",
+ last_name: "Member",
+ email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Test true filter
+ {:ok, _view, html_true} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ assert html_true =~ "TrueMember"
+ refute html_true =~ "FalseMember"
+ refute html_true =~ "NoValue"
+
+ # Test false filter
+ {:ok, _view, html_false} =
+ live(conn, "/members?bf_#{boolean_field.id}=false")
+
+ assert html_false =~ "FalseMember"
+ refute html_false =~ "TrueMember"
+ refute html_false =~ "NoValue"
+ end
+
+ test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+ fee_type = create_fee_type(%{interval: :yearly}, system_actor)
+ today = Date.utc_today()
+ last_year_start = Date.new!(today.year - 1, 1, 1)
+
+ # Member with true boolean value and paid status
+ {:ok, member_paid_true} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "PaidTrue",
+ last_name: "Member",
+ email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
+ membership_fee_type_id: fee_type.id
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_paid_true.id,
+ custom_field_id: boolean_field.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ create_cycle(
+ member_paid_true,
+ fee_type,
+ %{cycle_start: last_year_start, status: :paid},
+ system_actor
+ )
+
+ # Member with true boolean value but unpaid status
+ {:ok, member_unpaid_true} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "UnpaidTrue",
+ last_name: "Member",
+ email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
+ membership_fee_type_id: fee_type.id
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv2} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_unpaid_true.id,
+ custom_field_id: boolean_field.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ create_cycle(
+ member_unpaid_true,
+ fee_type,
+ %{cycle_start: last_year_start, status: :unpaid},
+ system_actor
+ )
+
+ # Test both filters together
+ {:ok, _view, html} =
+ live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
+
+ # Only member_paid_true should match both filters
+ assert html =~ "PaidTrue"
+ refute html =~ "UnpaidTrue"
+ end
+
+ test "boolean filter integration works together with search query", %{conn: conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ _member_with_true =
+ create_member_with_boolean_value(
+ %{first_name: "TrueMember"},
+ boolean_field,
+ true,
+ system_actor
+ )
+
+ _member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ # Test search + boolean filter
+ {:ok, _view, html} =
+ live(conn, "/members?query=TrueMember&bf_#{boolean_field.id}=true")
+
+ # Only member_with_true should match both search and filter
+ assert html =~ "TrueMember"
+ refute html =~ "FalseMember"
+ end
+
+ test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ conn = conn_with_oidc_user(conn)
+
+ # Create boolean field with show_in_overview: false
+ boolean_field = create_boolean_custom_field(%{show_in_overview: false})
+
+ _member_with_true =
+ create_member_with_boolean_value(
+ %{first_name: "TrueMember"},
+ boolean_field,
+ true,
+ system_actor
+ )
+
+ _member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ {:ok, _member_without_value} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "NoValue",
+ last_name: "Member",
+ email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Test that filter works even though field is not visible in overview
+ {:ok, _view, html_true} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ assert html_true =~ "TrueMember"
+ refute html_true =~ "FalseMember"
+ refute html_true =~ "NoValue"
+
+ # Test false filter
+ {:ok, _view, html_false} =
+ live(conn, "/members?bf_#{boolean_field.id}=false")
+
+ assert html_false =~ "FalseMember"
+ refute html_false =~ "TrueMember"
+ refute html_false =~ "NoValue"
+ end
+
+ test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Start with no boolean custom fields
+ {:ok, view, _html} = live(conn, "/members")
+
+ state_before = :sys.get_state(view.pid)
+ boolean_fields_before = state_before.socket.assigns.boolean_custom_fields
+ assert boolean_fields_before == []
+
+ # Create a new boolean custom field
+ new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
+
+ # Navigate again - the new field should appear
+ {:ok, view2, _html} = live(conn, "/members")
+
+ state_after = :sys.get_state(view2.pid)
+ boolean_fields_after = state_after.socket.assigns.boolean_custom_fields
+
+ # New boolean field should be present
+ assert length(boolean_fields_after) == 1
+ assert Enum.any?(boolean_fields_after, &(&1.id == new_boolean_field.id))
+ assert Enum.any?(boolean_fields_after, &(&1.name == "Newly Added Field"))
+ end
+
+ test "boolean filter performance with 150 members", %{conn: conn} do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Create 150 members - 75 with true, 75 with false
+ members_with_true =
+ Enum.map(1..75, fn i ->
+ create_member_with_boolean_value(
+ %{
+ first_name: "TrueMember#{i}",
+ email: "truemember#{i}@example.com"
+ },
+ boolean_field,
+ true,
+ system_actor
+ )
+ end)
+
+ members_with_false =
+ Enum.map(1..75, fn i ->
+ create_member_with_boolean_value(
+ %{
+ first_name: "FalseMember#{i}",
+ email: "falsemember#{i}@example.com"
+ },
+ boolean_field,
+ false,
+ system_actor
+ )
+ end)
+
+ # Verify all members were created
+ assert length(members_with_true) == 75
+ assert length(members_with_false) == 75
+
+ # Test filter performance - should complete in reasonable time (< 1 second)
+ start_time = System.monotonic_time(:millisecond)
+
+ {:ok, _view, html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ end_time = System.monotonic_time(:millisecond)
+ duration = end_time - start_time
+
+ # Should complete in less than 1 second (1000ms)
+ assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
+
+ # Verify filtering worked correctly - should show all true members
+ Enum.each(1..75, fn i ->
+ assert html =~ "TrueMember#{i}"
+ end)
+
+ # Should not show false members
+ Enum.each(1..75, fn i ->
+ refute html =~ "FalseMember#{i}"
+ end)
+ end
+ end
end