diff --git a/config/test.exs b/config/test.exs
index b47c764..b48c408 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -12,10 +12,7 @@ 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() * 8,
- queue_target: 5000,
- queue_interval: 1000,
- timeout: 30_000
+ pool_size: System.schedulers_online() * 4
# 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 4ef355d..73bfcd9 100644
--- a/lib/mv/constants.ex
+++ b/lib/mv/constants.ex
@@ -19,12 +19,6 @@ 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
@@ -39,42 +33,6 @@ 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
deleted file mode 100644
index 9286ace..0000000
--- a/lib/mv_web/live/components/member_filter_component.ex
+++ /dev/null
@@ -1,444 +0,0 @@
-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
new file mode 100644
index 0000000..9caaa1f
--- /dev/null
+++ b/lib/mv_web/live/components/payment_filter_component.ex
@@ -0,0 +1,147 @@
+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 50b0cfa..2cf7392 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -28,7 +28,6 @@ defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
require Ash.Query
- require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
@@ -42,15 +41,6 @@ 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
@@ -82,12 +72,6 @@ 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
@@ -117,12 +101,10 @@ 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(
@@ -236,8 +218,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
- new_show_current,
- socket.assigns.boolean_custom_field_filters
+ new_show_current
)
new_path = ~p"/members?#{query_params}"
@@ -351,8 +332,7 @@ defmodule MvWeb.MemberLive.Index do
existing_field_query,
existing_sort_query,
socket.assigns.cycle_status_filter,
- socket.assigns.show_current_cycle,
- socket.assigns.boolean_custom_field_filters
+ socket.assigns.show_current_cycle
)
# Set the new path with params
@@ -381,77 +361,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
- 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
+ socket.assigns.show_current_cycle
)
new_path = ~p"/members?#{query_params}"
@@ -538,9 +448,6 @@ 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)
@@ -564,68 +471,23 @@ defmodule MvWeb.MemberLive.Index do
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
- # Apply all updates
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
- |> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
-
- # Build signature AFTER updates
- next_sig = build_signature(socket)
-
- # Only load members if signature changed (optimization: avoid duplicate loads)
- # OR if members haven't been loaded yet (first handle_params call after mount)
- socket =
- if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
- # Nothing changed AND members already loaded, skip expensive load_members() call
- socket
- |> prepare_dynamic_cols()
- |> update_selection_assigns()
- else
- # Signature changed OR members not loaded yet, reload members
- socket
- |> load_members()
- |> prepare_dynamic_cols()
- |> update_selection_assigns()
- end
+ |> load_members()
+ |> prepare_dynamic_cols()
+ |> update_selection_assigns()
{: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:
@@ -724,8 +586,7 @@ defmodule MvWeb.MemberLive.Index do
field_str,
Atom.to_string(order),
socket.assigns.cycle_status_filter,
- socket.assigns.show_current_cycle,
- socket.assigns.boolean_custom_field_filters
+ socket.assigns.show_current_cycle
)
new_path = ~p"/members?#{query_params}"
@@ -755,8 +616,7 @@ 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.boolean_custom_field_filters
+ socket.assigns.show_current_cycle
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
@@ -774,14 +634,12 @@ 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,
- boolean_filters
+ show_current_cycle
) do
field_str =
if is_atom(sort_field) do
@@ -812,19 +670,11 @@ defmodule MvWeb.MemberLive.Index do
end
# Add show_current_cycle if true
- base_params =
- if show_current_cycle do
- Map.put(base_params, "show_current_cycle", "true")
- else
- base_params
- end
-
- # Add boolean custom field filters
- Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
- param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
- param_value = if filter_value == true, do: "true", else: "false"
- Map.put(acc, param_key, param_value)
- end)
+ if show_current_cycle do
+ Map.put(base_params, "show_current_cycle", "true")
+ else
+ base_params
+ end
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
@@ -854,32 +704,9 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
- # 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
+ # Load custom field values for visible custom fields (based on user selection)
visible_custom_field_ids = socket.assigns[: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)
+ query = load_custom_field_values(query, visible_custom_field_ids)
# Load membership fee cycles for status display
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
@@ -899,9 +726,7 @@ defmodule MvWeb.MemberLive.Index do
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
- {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")
+ members = Ash.read!(query, actor: actor)
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
@@ -914,14 +739,6 @@ 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
@@ -1316,142 +1133,6 @@ 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)
@@ -1485,166 +1166,7 @@ defmodule MvWeb.MemberLive.Index do
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
- (match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
- end)
-
- _ ->
- nil
- end
- 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)
+ (cfv.custom_field && cfv.custom_field.id == custom_field.id)
end)
_ ->
@@ -1699,11 +1221,8 @@ 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
- # Handle case where members haven't been loaded yet (e.g., when signature didn't change)
- members = socket.assigns[:members] || []
+ 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 394db2c..b2af205 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -37,11 +37,9 @@
placeholder={gettext("Search...")}
/>
<.live_component
- module={MvWeb.Components.MemberFilterComponent}
- id="member-filter"
+ module={MvWeb.Components.PaymentFilterComponent}
+ id="payment-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
new file mode 100644
index 0000000..7987efa
--- /dev/null
+++ b/test/mv_web/components/payment_filter_component_test.exs
@@ -0,0 +1,183 @@
+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 302814d..58be2d3 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,8 +15,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
# No custom setup needed
# Helper to create a membership fee type
- # Uses admin_user to test permissions (UI-/Permissions-nah)
- defp create_fee_type(attrs, admin_user) do
+ defp create_fee_type(attrs) do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@@ -27,7 +28,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: admin_user)
+ |> Ash.create!(actor: system_actor)
end
# Helper to create a member
@@ -49,21 +50,12 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end
describe "list display" do
- test "displays all membership fee types with correct data", %{
- conn: conn,
- current_user: admin_user
- } do
+ test "displays all membership fee types with correct data", %{conn: conn} do
_fee_type1 =
- create_fee_type(
- %{name: "Regular", amount: Decimal.new("60.00"), interval: :yearly},
- admin_user
- )
+ create_fee_type(%{name: "Regular", amount: Decimal.new("60.00"), interval: :yearly})
_fee_type2 =
- create_fee_type(
- %{name: "Reduced", amount: Decimal.new("30.00"), interval: :yearly},
- admin_user
- )
+ create_fee_type(%{name: "Reduced", amount: Decimal.new("30.00"), interval: :yearly})
{:ok, _view, html} = live(conn, "/membership_fee_types")
@@ -75,7 +67,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}, admin_user)
+ fee_type = create_fee_type(%{interval: :yearly})
# Create 3 members with this fee type
Enum.each(1..3, fn _ ->
@@ -98,8 +90,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
assert to == "/membership_fee_types/new"
end
- 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)
+ test "edit button per row navigates to edit form", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
{:ok, view, _html} = live(conn, "/membership_fee_types")
@@ -114,7 +106,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}, admin_user)
+ fee_type = create_fee_type(%{interval: :yearly})
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
{:ok, _view, html} = live(conn, "/membership_fee_types")
@@ -123,8 +115,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, current_user: admin_user} do
- fee_type = create_fee_type(%{interval: :yearly}, admin_user)
+ test "delete button works if type is not in use", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
# No members assigned
{:ok, view, _html} = live(conn, "/membership_fee_types")
@@ -134,12 +126,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|> element("button[phx-click='delete'][phx-value-id='#{fee_type.id}']")
|> render_click()
- # Type should be deleted - use admin_user to test permissions
+ # Type should be deleted
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
- Ash.get(MembershipFeeType, fee_type.id,
- domain: Mv.MembershipFees,
- actor: admin_user
- )
+ Ash.get(MembershipFeeType, fee_type.id, domain: Mv.MembershipFees)
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 911a4ce..a4d3673 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,8 +12,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
require Ash.Query
# Helper to create a membership fee type
- # Uses admin_user to test permissions (UI-/Permissions-nah)
- defp create_fee_type(attrs, admin_user) do
+ defp create_fee_type(attrs) do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@@ -24,12 +25,13 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: admin_user)
+ |> Ash.create!(actor: system_actor)
end
# Helper to create a member
- # Uses admin_user to test permissions (UI-/Permissions-nah)
- defp create_member(attrs, admin_user) do
+ defp create_member(attrs) do
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
default_attrs = %{
first_name: "Test",
last_name: "Member",
@@ -40,7 +42,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
- |> Ash.create!(actor: admin_user)
+ |> Ash.create!(actor: system_actor)
end
describe "membership fee type dropdown" do
@@ -52,9 +54,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
html =~ "Beitragsart"
end
- 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)
+ 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})
{:ok, _view, html} = live(conn, "/members/new")
@@ -62,14 +64,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
assert html =~ "Type 2"
end
- 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)
+ 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})
- member = create_member(%{membership_fee_type_id: yearly_type.id}, admin_user)
+ member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
@@ -78,11 +77,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
refute html =~ "Monthly Type"
end
- 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)
+ 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})
- member = create_member(%{membership_fee_type_id: yearly_type.id}, admin_user)
+ member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
@@ -93,11 +92,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
assert html =~ yearly_type.id
end
- 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)
+ 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})
- member = create_member(%{membership_fee_type_id: yearly_type1.id}, admin_user)
+ member = create_member(%{membership_fee_type_id: yearly_type1.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
@@ -110,8 +109,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
refute html =~ "Warning" || html =~ "Warnung"
end
- test "form saves with selected membership fee type", %{conn: conn, current_user: admin_user} do
- fee_type = create_fee_type(%{interval: :yearly}, admin_user)
+ test "form saves with selected membership fee type", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
{:ok, view, _html} = live(conn, "/members/new")
@@ -127,26 +126,29 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|> form("#member-form", form_data)
|> render_submit()
- # Verify member was created with fee type - use admin_user to test permissions
+ # Verify member was created with fee type
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
member =
Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
- |> Ash.read_one!(actor: admin_user)
+ |> Ash.read_one!(actor: system_actor)
assert member.membership_fee_type_id == fee_type.id
end
- test "new members get default membership fee type", %{conn: conn, current_user: admin_user} do
+ test "new members get default membership fee type", %{conn: conn} do
# Set default fee type in settings
- fee_type = create_fee_type(%{interval: :yearly}, admin_user)
+ fee_type = create_fee_type(%{interval: :yearly})
+ 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: admin_user)
+ |> Ash.update!(actor: system_actor)
{:ok, view, _html} = live(conn, "/members/new")
@@ -161,7 +163,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
conn: conn,
current_user: admin_user
} do
- # Create custom field - use admin_user to test permissions
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ # Create custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
@@ -169,11 +173,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :string,
required: false
})
- |> Ash.create!(actor: admin_user)
+ |> Ash.create!(actor: system_actor)
# Create two fee types with same interval
- fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly}, admin_user)
- fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly}, admin_user)
+ fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
+ fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
# Create member with fee type 1 and custom field value
member =
@@ -208,7 +212,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
end
test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do
- # Create date custom field - use admin_user to test permissions
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ # Create date custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
@@ -216,9 +222,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :date,
required: false
})
- |> Ash.create!(actor: admin_user)
+ |> Ash.create!(actor: system_actor)
- fee_type = create_fee_type(%{interval: :yearly}, admin_user)
+ fee_type = create_fee_type(%{interval: :yearly})
# Create member with date custom field value
member =
@@ -255,7 +261,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
end
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
- # Create custom field - use admin_user to test permissions
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ # Create custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
@@ -263,9 +271,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :string,
required: false
})
- |> Ash.create!(actor: admin_user)
+ |> Ash.create!(actor: system_actor)
- fee_type = create_fee_type(%{interval: :yearly}, admin_user)
+ fee_type = create_fee_type(%{interval: :yearly})
# 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 0f3d03b..c3cd48c 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -3,49 +3,6 @@ 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")
@@ -522,7 +479,25 @@ defmodule MvWeb.MemberLive.IndexTest do
end
describe "cycle status filter" do
- # Helper to create a member (only used in this describe block)
+ 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
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
@@ -537,6 +512,31 @@ 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,1091 +746,4 @@ 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